一文让你彻底搞懂什么是分布式事务
楔子
无论使用什么样的开发语言,无论软件运行在何种操作系统上,无论架构采用的是单体应用架构还是分布式微服务架构,只要我们开发复杂的交易型业务系统,必然就有一个困扰诸多开发人员的技术难题无法绕开,那就是事务。
而随着互联网的不断发展,互联网企业的业务在飞速变化,推动着系统架构也在不断地发生变化。总体来说,系统架构大致经历了单体应用架构 → 垂直应用架构 → 分布式架构 → SOA架构 → 微服务架构的演变。如今微服务技术越来越成熟,很多企业都采用微服务架构来支撑内部及对外的业务,尤其是在高并发大流量的电商业务场景下,微服务更是企业首选的架构模式。
但微服务的普及也带来了新的问题,原本单一的应用架构只需要连接一台数据库实例即可完成所有业务操作,业务方法的逻辑在一个事务中即可完成,涉及的所有数据库操作要么全部提交,要么全部不提交,很容易实现数据的一致性。而在微服务架构下,原本单一的应用被拆分为一个个很小的服务,每个服务都有其独立的业务和数据库,服务与服务之间的交互通过接口或者远程过程调用(RemoteProcedure Call,RPC)的方式进行,此时服务与服务之间的数据一致性问题就变得棘手了。
因为微服务这种架构模式本质上就是多个应用连接多个数据库共同完成一组业务逻辑,所以数据一致性问题就凸显出来了。除此之外,多个应用连接同一个数据库和单个应用连接多个数据库也会产生数据一致性问题。可以这么说,在互联网行业,任何企业都会或多或少地遇到数据一致性问题,业界将这种数据一致性问题称为分布式事务问题。为了解决分布式事务问题,业界提出了一些著名的理论,比如 CAP 理论和 Base 理论,并针对这些理论提出了很多解决方案,比如解决强一致性分布式事务的 DTP 模型、XA 事务、2PC 模型、3PC 模型,解决最终一致性分布式事务的 TCC、可靠消息最终一致性、最大努力通知型等模型。不少企业和开源组织,甚至个人都基于这些模型实现了比较通用的分布式事务框架。
深入掌握分布式事务已然成为互联网行业中每个中高级开发人员和架构师必须掌握的技能,而熟练掌握分布式事务产生的各种场景和解决方案也成为各大互联网公司对应聘者的基本要求。
事务的基本概念
事务一般指的是逻辑上的一组操作,或者作为单个逻辑单元执行的一系列操作。同属于一个事务的操作会作为一个整体提交给系统,这些操作要么全部执行成功,要么全部执行失败,下面就简单地介绍一下事务的基本概念。
事务的特性
总体来说,事务存在四大特性,分别是原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),如下图所示,因此事务的四大特性又被称为 ACID。
原子性
事务的原子性指的是构成事务的所有操作要么全部执行成功,要么全部执行失败,不可能出现部分执行成功,部分执行失败的情况。例如在转账业务中,张三向李四转账 100 元,于是张三的账户余额减少 100 元,李四的账户余额增加 100 元。在开启事务的情况下,这两个操作要么全部执行成功,要么全部执行失败,不可能出现只将张三的账户余额减少 100 元的操作,也不可能出现只将李四的账户余额增加 100 元的操作。
一致性
事务的一致性指的是在事务执行之前和执行之后,数据库中已存在的约束不会被打破。比如余额必须大于等于 0 就是一个约束,而张三余额只有 90 元,这个时候如果转账 100 元给李四,那么之后它的余额就变成了 -10,此时就破坏了数据库的约束,所以数据库认为这个事务是不合法的,因此执行失败。
隔离性
事务的隔离性指的是并发执行的两个事务之间互不干扰。也就是说,一个事务在执行过程中不能看到其他事务运行过程的中间状态。例如,在张三向李四转账的业务场景中,存在两个并发执行的事务 A 和事务 B,事务 A 执行扣减张三账户余额的操作和增加李四账户余额的操作,事务 B 执行查询张三账户余额的操作。在事务 A 完成之前,事务 B 读取的张三的账户余额仍然为扣减之前的账户余额,不会读取到扣减后的账户余额。
MySQL 通过锁和 MVCC 机制来保证事务的隔离性。
持久性
事务的持久性指的是事务提交完成后,此事务对数据的更改操作会被持久化到数据库中,并且不会被回滚。例如,在张三向李四转账的业务场景中,在同一事务中执行扣减张三账户余额和增加李四账户余额的操作,事务提交完成后,这种对数据的修改操作就会被持久化到数据库中,且不会被回滚,因为已经被提交了。
数据库的事务在实现时,会将一次事务中包含的所有操作全部封装成一个不可分割的执行单元,这个单元中的所有操作要么全部执行成功,要么全部执行失败。只要其中任意一个操作执行失败,整个事务就会执行回滚操作。但执行成功之后,就无法再回滚了,因为事务已经结束了。
事务的类型
事务主要分为五大类,分别为扁平事务、带有保存点的扁平事务、链式事务、嵌套事务和分布式事务。本节就简单介绍一下事务的五大类型。
扁平事务
扁平事务是事务操作中最常见,也是最简单的事务。在数据库中,扁平事务通常由 begin 或者 starttransaction 字段开始,由 commit 或者 rollback 字段结束。在这之间的所有操作要么全部执行成功,要么全部执行失败(回滚),当今主流的数据库都支持扁平事务。
扁平事务虽然是最常见、最简单的事务,但是无法提交或者回滚整个事务中的部分事务,只能把整个事务全部提交或者回滚。为了解决这个问题,带有保存点的扁平事务出现了。
带有保存点的扁平事务
通俗地讲,内部设置了保存点的扁平事务,就是带有保存点的扁平事务。带有保存点的扁平事务通过在事务内部的某个位置设置保存点(savepoint),达到将当前事务回滚到此位置的目的,示例如下。
-- 例如设置一个名称为 save_user_point 的保存点,在 MySQL 中通过如下方式
savepoint save_user_point;
-- 通过如下命令将当前事务回滚到定义的保存点位置
rollback to save_user_point;
-- 通过如下命令删除保存点
release savepoint save_user_point
从本质上讲,普通的扁平事务也是有保存点的,只是普通的扁平事务只有一个隐式的保存点,并且这个隐式的保存点会在事务启动的时候,自动设置为当前事务的开始位置。也就是说,普通的扁平事务具有保存点,而且默认是事务的开始位置。
链式事务
链式事务是在带有保存点的扁平事务的基础上,自动将当前事务的上下文隐式地传递给下一个事务。也就是说,一个事务的提交操作和下一个事务的开始操作具备原子性,上一个事务的处理结果对下一个事务是可见的,事务与事务之间就像链条一样传递下去。
注意:每一个事务在提交的时候,会释放要提交的事务中的所有锁和保存点,也就是说,链式事务的回滚操作只能回滚到当前所在事务的保存点,而不能回滚到已提交事务的保存点。
嵌套事务
顾名思义,嵌套事务就是有多个事务处于嵌套状态,共同完成一项任务的处理,整个任务具备原子性。嵌套事务最外层有一个顶层事务,这个顶层事务控制着所有的内部子事务,内部子事务提交完成后,整体事务并不会提交,只有最外层的顶层事务提交完成后,整体事务才算提交完成。
关于嵌套事务需要注意以下几点:
1. 回滚嵌套事务内部的子事务时,会将事务回滚到外部顶层事务的开始位置
2. 嵌套事务的提交是从内部的子事务向外依次进行的,直到最外层的顶层事务提交完成
3. 回滚嵌套事务最外层的顶层事务时,会回滚嵌套事务包含的所有事务,包括已提交的内部子事务
在主流的关系型数据库中,MySQL 不支持原生的嵌套事务,而 SQL Server 支持。但说实话,还是不建议使用嵌套事务。
分布式事务
分布式事务指的是事务的参与者、事务所在的服务器、涉及的资源服务器以及事务管理器等分别位于不同分布式系统的不同服务或数据库节点上。简单来说,分布式事务就是一个在不同环境(比如不同的数据库、不同的服务器)下运行的整体事务。这个整体事务包含一个或者多个分支事务,并且整体事务中的所有分支事务要么全部提交成功,要么全部提交失败。
例如,在电商系统的下单减库存业务中,订单业务所在的数据库为事务 A 的节点,库存业务所在的数据库为事务 B 的节点。事务 A 和事务 B 组成了一个具备 ACID 特性的分布式事务,要么全部提交成功,要么全部提交失败。
本地事务
在常见的计算机系统和应用系统中,很多事务是通过关系型数据库进行控制的。这种控制事务的方式是利用数据库本身的事务特性来实现,而在这种实现方式中,数据库和应用通常会被放在同一台服务器中,因此,这种基于关系型数据库的事务也可以称作本地事务或者传统事务。
本地事务使用常见的执行模式,可以使用如下伪代码来表示。
begin
insert into table(col1, col2, ...) values (v1, v2, ...)
update table set col = value where id = id_value
delete from table where id > id_value
commit / rollback
另外,本地事务也具有一些特征。以下列举几个本地事务具有的典型特征。
一次事务过程中只能连接一个支持事务的数据库,这里的数据库一般指的是关系型数据库
事务的执行结果必须满足 ACID 特性
事务的执行过程会用到数据库本身的锁机制
本地事务的执行流程
本地事务的执行流程如下图所示:
- 1. 客户端开始事务操作之前,需要开启一个连接会话;
- 2. 开始会话后,客户端发起开启事务的指令;
- 3. 事务开启后,客户端发送各种 SQL 语句处理数据;
- 4. 正常情况下,客户端会发起提交事务的指令,如果发生异常情况,客户端会发起回滚事务的指令;
- 5. 上述流程完成后,关闭会话;
本地事务是由资源管理器在本地进行管理的。
本地事务的优缺点
本地事务的优点总结如下:
支持严格的ACID特性,这也是本地事务得以实现的基础
事务可靠,一般不会出现异常情况
本地事务的执行效率比较高
事务的状态可以只在数据库中进行维护,上层的应用不必理会事务的具体状态
应用的编程模型比较简单,不会涉及复杂的网络通信
本地事务的缺点总结如下:
不具备分布式事务的处理能力
一次事务过程中只能连接一个支持事务的数据库,即不能用于多个事务性数据库
MySQL 事务基础
在互联网领域,MySQL 数据库是使用最多的关系型数据库之一,也是一种典型的支持事务的关系型数据库,因此我们有必要对 MySQL 数据库的事务进行简单介绍。
并发事务带来的问题
数据库一般会并发执行多个事务,而多个事务可能会并发地对相同的数据进行增加、删除、修改和查询操作,进而导致并发事务问题。并发事务带来的问题包括更新丢失(脏写)、脏读、不可重复读和幻读,如下图所示。
1. 更新丢失(脏写)
当两个或两个以上的事务选择数据库中的同一行数据,并基于最初选定的值更新该行数据时,因为每个事务之间都无法感知彼此的存在,所以会出现最后的更新操作覆盖之前由其他事务完成的更新操作的情况。也就是说,对于同一行数据,一个事务对该行数据的更新操作覆盖了其他事务对该行数据的更新操作。
例如,张三的账户余额是 100 元,当前有事务 A 和事务 B 两个事务,事务 A 是将张三的账户余额增加 100 元,事务 B 是将张三的账户余额增加 200 元。起初,事务 A 和事务 B 同时读取到张三的账户余额为 100 元。然后,事务 A 和事务 B 将分别更新张三的银行账户余额,假设事务 A 先于事务 B 提交,但事务 A 和事务 B 都提交后的结果是张三的账户余额是 300 元。本来应该有 400 元的,因为 A 增加 100、B 增加 200,加上原本的 100,也就是说,后提交的事务 B 覆盖了事务 A 的更新操作,A 的更新操作无效了。
更新丢失(脏写)本质上是写操作的冲突,解决办法是让每个事务按照串行的方式执行,按照一定的顺序依次进行写操作。
2. 脏读
一个事务正在对数据库中的一条记录进行修改操作,在这个事务完成并提交之前,当有另一个事务来读取正在修改的这条数据记录时,如果没有对这两个事务进行控制,则第二个事务就会读取到没有被提交的脏数据,并根据这些脏数据做进一步的处理,此时就会产生未提交的数据依赖关系。我们通常把这种现象称为脏读,也就是一个事务读取了另一个事务未提交的数据。
例如,当前有事务 A 和事务 B 两个事务,事务 A 是向张三的银行账户转账 100 元,事务 B 是查询张三的账户余额。事务 A 执行转账操作,在事务 A 未提交时,事务 B 查询到张三的银行账户多了 100 元,后来事务A由于某些原因,例如服务超时、系统异常等因素进行回滚操作,但事务 B 查询到的数据并没有改变。此时,事务 B 查询到的数据就是脏数据。
脏读本质上是读写操作的冲突,解决办法是先写后读,也就是写完之后再读。
3. 不可重复读
一个事务读取了某些数据,在一段时间后,这个事务再次读取之前读过的数据,此时发现读取的数据发生了变化,或者其中的某些记录已经被删除,这种现象就叫作不可重复读。即同一个事务,使用相同的查询语句,在不同时刻读取到的结果不一致。
例如,当前有事务 A 和事务 B 两个事务,事务 A 是向张三的银行账户转账 100 元,事务 B 是查询张三的账户余额。第一次查询时,事务 A 还没有转账,第二次查询时,事务 A 已经转账成功,此时,就会导致事务 B 两次查询结果不一致。
不可重复读本质上也是读写操作的冲突,解决办法是先读后写,也就是读完之后再写。
4. 幻读
一个事务按照相同的查询条件重新读取之前读过的数据,此时发现其他事务插入了满足当前事务查询条件的新数据,这种现象叫作幻读。即一个事务两次读取一个范围的数据记录,两次读取到的结果不同。
例如,当前有事务 A 和事务 B 两个事务,事务 A 是两次查询张三的转账记录,事务 B 是向张三的银行账户转账 100 元。事务 A 第一次查询时,事务 B 还没有转账,事务 A 第二次查询时,事务 B 已经转账成功,此时,就会导致事务 A 两次查询的转账数据不一致。
幻读本质上是读写操作的冲突,解决办法是先读后写,也就是读完之后再写。话说很多人不清楚不可重复读和幻读到底有何区别。这里,我们简单介绍一下。
不可重复读的重点在于更新和删除操作,而幻读的重点在于插入操作
使用锁机制实现事务隔离级别时,在可重复读隔离级别中,SQL 语句第一次读取到数据后,会将相应的数据加锁,使得其他事务无法修改和删除这些数据,此时可以实现可重复读。但这种方法无法对新插入的数据加锁,如果事务 A 读取了数据,或者修改和删除了数据,此时事务 B 还可以进行插入操作,导致事务 A 莫名其妙地多了一条之前没有的数据,这就是幻读
幻读无法通过行级锁来避免,需要使用串行化的事务隔离级别,但是这种事务隔离级别会极大降低数据库的并发能力
从本质上讲,不可重复读和幻读最大的区别在于如何通过锁机制解决问题
另外,除了可以使用悲观锁来避免不可重复读和幻读的问题外,我们也可以使用乐观锁来处理,例如,MySQL、Oracle 和 PostgreSQL 等数据库为了提高整体性能,就使用了基于乐观锁的MVCC(多版本并发控制)机制来避免不可重复读和幻读。
MySQL 的事务隔离级别
MySQL 中的 InnoDB 储存引擎提供 SQL 标准所描述的 4 种事务隔离级别,分别为读未提交(Read Uncommitted)、读已提交(ReadCommitted)、可重复读(Repeatable Read)和串行化(Serializable),如下图所示。
MySQL 默认的隔离级别为可重复读,当然我们也可以手动指定隔离级别。
例如,可以在 my.cnf 或者 my.ini 文件中的 mysqld 节点下面配置如下选项。
transaction-isolation = {READ-UNCOMMITTED │ READ-COMMITTED │ REPEATABLE-READ │ SERIALIZABLE}
也可以使用 SET TRANSACTION 命令改变单个或者所有新连接的事务隔离级别,基本语法如下所示。
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED │ READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
如果使用SET TRANSACTION命令来设置事务隔离级别,需要注意以下几点。
不带 SESSION 或 GLOBAL 关键字设置事务隔离级别,指的是为下一个(还未开始的)事务设置隔离级别
使用 GLOBAL 关键字指的是对全局设置事务隔离级别,也就是设置后的事务隔离级别对所有新产生的数据库连接生效
使用 SESSION 关键字指的是对当前的数据库连接设置事务隔离级别,此时的事务隔离级别只对当前连接的后续事务生效
任何客户端都能自由改变当前会话的事务隔离级别,可以在事务中间改变,也可以改变下一个事务的隔离级别
使用如下命令可以查询全局级别和会话级别的事务隔离级别。
SELECT @@global.tx_isolation;
SELECT @@session.tx_isolation;
SELECT @@tx_isolation;
MySQL 中各种事务隔离级别的区别
4 种事务隔离级别对于并发事务带来的问题的解决程度不一样,具体如下表所示。
- 读未提交允许脏读,即在读未提交的事务隔离级别下,可能读取到其他会话未提交事务修改的数据。这种事务隔离级别下存在脏读、不可重复读和幻读的问题。
- 读已提交只能读取到已经提交的数据,Oracle 等数据库使用的默认事务隔离级别就是读已提交。这种事务隔离级别存在不可重复读和幻读的问题
- 可重复读就是在同一个事务内,无论何时查询到的数据都与开始查询到的数据一致,这是 MySQL 中 InnoDB 存储引擎默认的事务隔离级别。这种事务隔离级别下存在幻读的问题。
- 串行化是指完全串行地读,每次读取数据库中的数据时,都需要获得表级别的共享锁,读和写都会阻塞。这种事务隔离级别解决了并发事务带来的问题,但完全的串行化操作使得数据库失去了并发特性,所以这种隔离级别往往在互联网行业中不太常用。
接下来,为了更好地理解 MySQL 的事务隔离级别,列举几个实际案例。
MySQL 事务隔离级别最佳实践
在 MySQL 中创建一个 test 数据库,在 test 数据库中创建一个 account 数据表作为测试使用的账户数据表,然后写入几条数据,如下所示。
此时 account 数据表中有张三、李四和王五的账户信息,账户余额分别为 300 元、350 元和 500 元。准备工作完成了,接下来我们一起来看 MySQL 中每种事务隔离级别下数据的处理情况。
1)读未提交
第一步:打开第一个服务器终端,登录 MySQL,将当前终端的事务隔离级别设置为 read uncommitted,也就是读未提交,然后查询数据表。
第二步:在第一个终端的事务提交之前,再打开服务器的另一个终端(后续就简化为终端 1、终端 2),连接 MySQL,将当前事务模式设置为 read uncommitted 并更新 account 表的数据,将张三的账户余额加 100 元。
可以看到,在终端 2 中,当前事务未提交时,张三的账户余额变为更新后的值,即 400 元。
第三步:在终端 1 查看 account 数据表的数据。
可以看到,虽然终端 2 的事务并未提交,但是终端 1 可以查询到终端 2 已经更新的数据。
第四步:如果终端 2 的事务由于某种原因执行了回滚操作,那么终端 2 中执行的所有操作都会被撤销。也就是说,终端 1 查询到的数据其实就是脏数据。下面我们执行回滚:
可以看到,在终端 2 执行了事务的回滚操作后,张三的账户余额重新变为 300 元。
第五步:在终端 1 查询张三的账户余额,发现也变成了 300 元。
以上便是脏读的问题,因为你不知道另一个事务的操作最终是提交还是回滚,就好比原来张三的余额有 300,但是事务 1 发现变成了 400,因为事务 2 给它加了 100,于是呢准备减去 100 只留 300。但问题是事务 2 的操作还没提交呢?如果事务 2 回滚了自己的操作,那么事务 1 再减去 100 的话,张三的余额就变成 200 了。所以万恶之源还是「读未提交」这个隔离级别允许一个事务读取另一个事务在执行过程中所做的变更,因此这个隔离级别基本不会在生产环境上使用。
2)读已提交
第一步:在终端 1 中将事务隔离级别设置为 read committed,也就是读已提交。
和之前一样,张三、李四和王五的账户余额分别为 300 元、350 元和 500 元。
第二步:在终端 1 的事务提交之前,打开终端 2,并将事务隔离级别设置为 read committed,开启事务并更新 account 数据表中的数据,将张三的账户余额增加 100 元。
可以看到,在终端 2 的查询结果中,张三的账户余额已经由原来的 300 元变成 400 元。
第三步:在终端 2 的事务提交之前,在终端 1 中查询 account 数据表中的数据,如下所示。
可以看到,在终端 1 查询出来的张三的账户余额仍为 300 元,说明此时已经解决了脏读的问题。
第四步:在终端 2 提交事务,如下所示。
mysql> commit;
Query OK, 0 rows affected (0.03 sec)
第五步:在终端 2 提交事务后,在终端 1 再次查询 account 数据表中的数据,如下所示。
可以看到,此时就不会出现脏读的问题了,在「读已提交」隔离级别下,一个事务必须提交之后,所做的修改才能被另一个事务读取。但是此时又产生了一个问题,终端 1 在终端 2 的事务提交前和提交后读取到的 account 数据表中的数据不一致,产生了不可重复读的问题。而有时我们希望在一个事务内,不管什么读取,读到的数据是不变的,要想解决这个问题,就需要使用可重复读的事务隔离级别。
3)可重复读
第一步:在终端 1 中将事务隔离级别设置为 read committed,也就是读已提交。
可以看到,此时张三、李四、王五的账户余额分别为 400 元、350 元、500 元。
第二步:在终端 1 的事务提交之前,打开终端 2,登录 MySQL,将当前终端的事务隔离级别设置为可重复读。开启事务,将张三的账户余额增加 100 元,随后提交事务,如下所示。
可以看到,在终端 2 查询的结果中,张三的账户余额已经由原来的 400 元变成 500 元。
第三步:在终端 1 查询 account 数据表中的数据,如下所示。
可以看到,在终端 1 查询的结果中,张三的账户余额仍为 400 元,并没有出现不可重复读的问题,说明可重复读的事务隔离级别解决了不可重复读的问题。
第四步:在终端 1 为张三的账户增加 100元,如下所示。
可以看到,事务 1 查询的结果是 400 元,然后增加 100 元,但此时张三的账户余额却变成 600 元,而不是 500 元,说明数据的一致性没有遭到破坏。这是因为在终端 1 为张三的账户余额增加 100 元之前,终端 2 已经为张三的账户余额增加了 100 元,共计增加了 200 元,所以最终张三的账户余额是 600 元。
可重复读的隔离级别使用了MVCC(Multi-Version Concurrency Control,多版本并发控制)机制,数据库中的查询(select)操作不会更新版本号,是快照读,而操作数据表中的数据(insert、update、delete)则会更新版本号,是当前读。
第五步:在终端 2 开启事务,插入一条数据后提交事务,如下所示。
可以看到,在终端 2 查询的结果中,已经显示出新插入的赵六的账户信息了。
第六步:在终端 1 查询 account 数据表的数据,如下所示。
可以看到,在终端 1 查询的数据中,并没有赵六的账户信息,说明没有出现幻读。
第七步:在终端 1 中 为 id = 4 的账户增加 100 元,按理说由于没有 id = 4 的记录,所以应该什么也不会发生。
可以看到,在终端 1 执行完数据更新操作后,查询到赵六的账户信息,出现了幻读的问题。如何解决该问题呢?答案是使用可串行化的事务隔离级别或者间隙锁和临键锁。
4)串行化
第一步:在终端 1 中将事务隔离级别设置为 serializable,也就是串行化。
第二步:打开终端 2,登录 MySQL,将当前终端的事务隔离级别设置为 serializable,开启事务,修改 account 数据表中 id 为 1 的数据,如下所示。
可以看到,在终端 2 中对 account 数据表中 id 为 1 的数据执行更新操作时,会发生阻塞,因为所有事务操作都是串行的,终端 1 的事务执行完毕之前,终端 2 的事务是无法执行的。MySQL 背后的做法是通过锁来保证串行的,因此终端 2 的事务想要执行必须获取锁,但锁此时被终端 1 的事务持有,因此终端 2 的事务只能陷入等待,如果锁超时,会抛出 "ERROR 1205(HY000): Lock wait timeout exceeded: try restarting transaction" 错误。因此采用串行化的方式可以避免幻读,但它是最高的隔离级别,此时完全丧失了并发性,生产环境也很少使用。
另外,在可重复的事务隔离级别下,如果终端 1 执行的是一个范围查询,那么该范围内的所有行(包括每行记录所在的间隙区间范围,如果某行记录还未被插入数据,这行记录也会被加锁,这是一种间隙锁,下面会详细讲解)都会被加锁。此时终端 2 在此范围内插入数据,就会被阻塞,从而也可以避免幻读。
MySQL 中锁的分类
从本质上讲,锁是一种协调多个进程或多个线程对某一资源的访问的机制,MySQL 使用锁和 MVCC 机制实现了事务隔离级别。接下来简单介绍 MySQL 中锁的分类。
MySQL 中的锁可以从以下几个方面进行分类,如下图所示。
1)悲观锁和乐观锁
悲观锁对于数据库中数据的读写持悲观态度,即在整个数据处理的过程中,它会将相应的数据锁定。在数据库中,悲观锁的实现需要依赖数据库提供的锁机制,以保证对数据库加锁后,其他应用系统无法修改数据库中的数据。在悲观锁机制下,读取数据库中的数据时需要加锁,此时不能对这些数据进行修改操作;修改数据库中的数据时也需要加锁,此时不能对这些数据进行读取操作。
悲观锁会极大地降低数据库的性能,特别是对长事务而言,性能的损耗往往是无法承受的,乐观锁则在一定程度上解决了这个问题。乐观锁对于数据库中数据的读写持乐观态度,即在整个数据处理的过程中,大多数情况下它是通过数据版本记录机制实现的。
实现乐观锁的一种常用做法是为数据增加一个版本标识,如果是通过数据库实现,往往会在数据表中增加一个类似 version 的版本号字段。在查询数据表中的数据时,会将版本号字段的值一起读取出来,当更新数据时,会令版本号字段的值加 1。将提交数据的版本与数据表对应记录的版本进行对比,如果提交的数据版本号大于数据表中当前要修改的数据的版本号,则对数据进行修改操作。否则,不修改数据表中的数据。
2)读锁和写锁
读锁又称为共享锁或 S 锁(Shared Lock),针对同一份数据,可以加多个读锁而互不影响;写锁又称为排他锁或 X 锁(Exclusive Lock),如果当前写锁未释放,它会阻塞其他的写锁和读锁。
因此对同一份数据,如果加了读锁,则可以继续为其加读锁,且多个读锁之间互不影响,但此时不能为数据增加写锁。一旦加了写锁,则不能再增加写锁和读锁。因为读锁具有共享性,而写锁具有排他性。
3)表锁、行锁和页面锁
表锁也称为表级锁,就是在整个数据表上对数据进行加锁和释放锁。典型特点是开销比较小,加锁速度快,一般不会出现死锁,锁定的粒度比较大,发生锁冲突的概率最高,并发度最低。
在 MySQL 中,有两种表级锁模式:一种是表共享锁(Table Shard Lock);另一种是表独占写锁(Table Write Lock)。当一个线程获取到一个表的读锁后,其他线程仍然可以对表进行读操作,但是不能对表进行写操作。当一个线程获取到一个表的写锁后,只有持有锁的线程可以对表进行更新操作,其他线程对数据表的读写操作都会被阻塞,直到写锁被释放为止。
在 MySQL 中可以通过命令行的方式手动增加锁,举个栗子:
-- 为 account 增加表级读锁
lock table account read;
-- 为 account 增加表级写锁
lock table account write;
-- 查看数据表上增加的锁
show open tables;
-- 删除添加的表锁
unlock tables;
行锁也称为行级锁,就是在数据行上对数据进行加锁和释放锁。典型特点是开销比较大,加锁速度慢,可能会出现死锁,但锁定的粒度最小,发生锁冲突的概率最小,并发度最高。
在 InnoDB 存储引擎中,有两种类型的行锁:一种是共享锁,另一种是排他锁。共享锁允许一个事务读取一行数据,但不允许一个事务对加了共享锁的当前行增加排他锁。排他锁只允许当前事务对数据行进行增删改查操作,不允许其他事务对增加了排他锁的数据行增加共享锁和排他锁。因此特点和表锁是类似的,只不过作用范围不同,另外使用行锁时,需要注意以下几点:
行锁主要加在索引上,如果对非索引的字段设置条件进行更新,行锁可能会变成表锁
InnoDB 的行锁是针对索引加锁,不是针对记录加锁,并且加锁的索引不能失效,否则行锁可能会变成表锁
锁定某一行时,可以使用 lock in share mode 命令来指定共享锁,使用 for update 命令来指定排他锁,例如这个 SQL 语句:select * from account where id = 1 for update
页面锁也称为页级锁,就是在页面级别对数据进行加锁和释放锁。对数据的加锁开销介于表锁和行锁之间,可能会出现死锁,锁定的粒度大小介于表锁和行锁之间,并发度一般。
接下来,我们总结一下表锁、行锁和页面锁的特点,如下表所示。
4)间隙锁和临键锁
在 MySQL 中使用范围查询时,如果请求共享锁或排他锁,InnoDB 会给符合条件的已有数据的索引项加锁。如果键值在条件范围内,而这个范围内并不存在记录,则认为此时出现了"间隙(也就是GAP)"。InnoDB 存储引擎会对这个「间隙」加锁,而这种加锁机制就是间隙锁(GAP Lock)。
说得简单点,间隙锁就是对两个值之间的间隙加锁。MySQL 的默认隔离级别是可重复读,在可重复读隔离级别下会存在幻读的问题,而间隙锁在某种程度下可以解决幻读的问题。
此时,account 数据表中的间隙包括 id 为 (3,15]、(15,20]、(20,正无穷] 三个区间。如果执行如下命令,将符合条件的用户的账户余额增加 100 元:
update account set balance = balance + 100 where id > 5;
则其他事务无法在 (3,20] 这个区间内插入或者修改任何数据,此时也可以避免幻读,但需要注意:间隙锁只有在可重复度事务隔离级别下才会生效。
而临键锁(Next-Key Lock)就比较简单了,它是行锁和间隙锁的组合,例如上面例子中的区间 (3,20] 就可以称为临键锁。
死锁的产生和预防
虽然锁在一定程度上能够解决并发问题,但稍有不慎,就可能造成死锁。发生死锁的必要条件有 4 个,分别为互斥条件、不可剥夺条件、请求与保持条件和循环等待条件,如下图所示。
1)互斥条件
在一段时间内,计算机中的某个资源只能被一个进程占用,此时,如果其他进程请求该资源,则只能等待。
2)不可剥夺条件
某个进程获得的资源在使用完毕之前,不能被其他进程强行夺走,只能由获得资源的进程主动释放。
3)请求与保持条件
进程已经获得了至少一个资源,又要请求其他资源,但请求的资源已经被其他进程占有,此时请求的进程就会被阻塞,并且不会释放自己已获得的资源。
4)循环等待条件
系统中的进程之间相互等待,同时各自占用的资源又会被下一个进程所请求。例如有进程 A、进程 B 和进程 C 三个进程,进程 A 请求的资源被进程 B 占用,进程 B 请求的资源被进程 C 占用,进程 C 请求的资源被进程 A 占用,于是形成了循环等待条件。
但需要注意的是,只有 4 个必要条件都满足时,才会发生死锁。处理死锁有 4 种方法,分别为预防死锁、避免死锁、检测死锁和解除死锁。
- 预防死锁:处理死锁最直接的方法就是破坏造成死锁的 4 个必要条件中的一个或多个,以防止死锁的发生。
- 避免死锁:在系统资源的分配过程中,使用某种策略或者方法防止系统进入不安全状态,从而避免死锁的发生。
- 检测死锁:这种方法允许系统在运行过程中发生死锁,但是能够检测死锁的发生,并采取适当的措施清除死锁。
- 解除死锁:当检测出死锁后,采用适当的策略和方法将进程从死锁状态解脱出来。
在实际工作中,通常采用有序资源分配法和银行家算法这两种方式来避免死锁,可自行了解。
MySQL 中的死锁问题
在 MySQL 5.5.5 及以上版本中,MySQL 的默认存储引擎是 InnoDB。该存储引擎使用的是行级锁,在某种情况下会产生死锁问题,所以 InnoDB 存储引擎采用了一种叫作等待图(wait-for graph)的方法来自动检测死锁,如果发现死锁,就会自动回滚一个事务。
第一步:在终端 1 中将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id 为 1 的数据添加排他锁。
第二步:在终端 2 中将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id 为 2 的数据添加排他锁。
第三步:在终端 1 中为 account 数据表中 id 为 2 的数据添加排他锁。
mysql> select * from account where id = 2 for update;
此时事务 1 会阻塞住,因为它在等待事务 2 释放 id = 2 的排他锁。
第四步:在终端 2 中为 account 数据表中 id 为 1 的数据添加排他锁。
我们看到死锁了,因为事务 1 因为事务 2 已经处于阻塞了,但此时事务 2 又因事务 1 陷入阻塞,因此出现了循环等待,所以事务 2 直接报错、并且终止。而一旦事务 2 终止,那么它施加的行锁就会失效,那么事务 1 此时就会给 id = 2 施加行锁成功,不再阻塞。
我们可以通过如下命令可以查看死锁的日志信息:show engine innodb status \G,或者通过配置 innodb_print_all_deadlocks(MySQL 5.6.2 版本开始提供)参数为 ON,将死锁相关信息打印到 MySQL 错误日志中。
因此在 MySQL 中,通常通过以下几种方式来避免死锁:
尽量让数据表中的数据检索都通过索引来完成,避免无效索引导致行锁升级为表锁
合理设计索引,尽量缩小锁的范围
尽量减少查询条件的范围,尽量避免间隙锁或缩小间隙锁的范围
尽量控制事务的大小,减少一次事务锁定的资源数量,缩短锁定资源的时间
如果一条 SQL 语句涉及事务加锁操作,则尽量将其放在整个事务的最后执行
尽可能使用低级别的事务隔离机制
InnoDB 中的 MVCC 原理
上面提到了可重复读隔离级别使用了 MVCC 机制,下面将具体介绍 InnoDB 存储引擎中的 MVCC 原理。
在 MVCC 机制中,每个连接到数据库的读操作,在某个瞬间看到的都是数据库中数据的一个快照,而写操作的事务提交之前,读操作是看不到这些数据的变化的。MVCC 机制能够大大提升数据库的读写性能,很多数据库厂商的事务性存储引擎都实现了 MVCC 机制,包含 MySQL、Oracle、PostgreSQL 等。虽然不同数据库实现 MVCC 机制的细节不同,但大多实现了非阻塞的读操作,写操作也只会锁定必要的数据行。
从本质上讲,MVCC 机制保存了数据库中数据在某个时间点上的数据快照,这意味着同一个读操作的事务,按照相同的条件查询数据,无论查询多少次,结果都是一样的。从另一个角度来讲,这也意味着不同的事务在同一时刻看到的同一张表的数据可能不同。
在 InnoDB 存储引擎中,MVCC 机制是通过在每行数据表记录后面保存两个隐藏的列来实现的,一个列用来保存行的创建版本号,另一个列用来保存行的过期版本号。每当有一个新的事务执行时,版本号就会自动递增。事务开始时刻的版本号作为事务的版本号,用于和查询到的每行记录的版本号做对比。接下来,我们来看在可重复读事务隔离级别下,MVCC 机制是如何完成增删改查操作的。
1)查询操作
在查询操作中,InnoDB 存储引擎会根据下面两个条件检查每行记录。
InnoDB 存储引擎只会查找不晚于当前事务版本的数据行,也就是说,InnoDB 存储引擎只会查找版本号小于或者等于当前事务版本的数据行。这些数据行要么在事务开始前就已经存在,要么就是事务本身插入或者更新的数据行。
数据行删除的版本要么还没有被定义,要么大于当前事务的版本号,只有这样才能确保事务读取到的行,在事务开始之前没有被删除。
这里需要注意的是,只有符合上面两个条件的数据行,才会被返回作为查询的结果数据。
例如,存在事务 A 和事务 B 两个事务,事务 A 中存在两条相同的 select 语句,事务 B 中存在一条 update 语句。事务A中的第一条 select 语句在事务 B 提交之前执行,第二条 select 语句在事务B提交之后执行。如果不使用 MVCC 机制,则事务 A 中的第一条 select 语句读取的数据是修改前的数据,而第二条 select 语句读取的是修改后的数据,两次读取的数据不一致。如果使用了 MVCC 机制,则无论事务 B 如何修改数据,事务 A 中的两条 select 语句查询出来的结果始终是一致的。
2)插入操作
在插入操作中,InnoDB 存储引擎会将新插入的每一行记录的当前系统版本号保存为行版本号。
例如向 account 数据表中插入一条数据,同时假设 MVCC 的两个版本号分别为 create_version 和 delete_version:create_version 代表创建行的版本号,delete_version 代表删除行的版本号。
+------+--------+---------+----------+----------------+----------------+
| id | name | balance | trans_id | create_version | delete_version |
+------+--------+---------+----------+----------------+----------------+
| 1001 | satori | 100 | 1 | 1 | undefined |
+------+--------+---------+----------+----------------+----------------+
为了更好地展示效果,我们增加一个描述事务的版本号 trans_id,当向数据库新增记录时,需要设置创建行的版本号,而删除行的版本号未定义。
3)更新操作
在更新操作中,InnoDB 存储引擎会插入一行新记录,并保存当前系统的版本号作为新记录行的版本号,同时保存当前系统的版本号到原来的数据行作为删除标识。
update account set balance = balance + 100 where id = 1001;
执行 SQL 语句成功后,再次查询 account 数据表中的数据,存在版本号和事务编号不同的两条记录。
+------+--------+---------+----------+----------------+----------------+
| id | name | balance | trans_id | create_version | delete_version |
+------+--------+---------+----------+----------------+----------------+
| 1001 | satori | 100 | 1 | 1 | 2 |
| 1001 | satori | 200 | 2 | 2 | undefined |
+------+--------+---------+----------+----------------+----------------+
执行更新操作时,MVCC 机制是先将原来的数据复制一份,将 balance 字段的值增加 100 后,再将 create_version 字段的值设置为当前系统的版本号,而 delete_version 字段的值未定义。除此之外,MVCC 机制还会将原来行的 delete_version 字段的值设置为当前的系统版本号,以标识原来行被删除。
这里需要注意的是,原来的行会被复制到 undo log 中。
4)删除操作
在删除操作中,InnoDB 存储引擎会保存删除的每一个行记录当前的系统版本号,作为行删除标识。例如删除 account 数据表中 id 为 1001 的数据:
delete from account where id = 1001;
对应的版本号信息如下表所示:
+------+--------+---------+----------+----------------+----------------+
| id | name | balance | trans_id | create_version | delete_version |
+------+--------+---------+----------+----------------+----------------+
| 1001 | satori | 200 | 3 | 2 | 3 |
+------+--------+---------+----------+----------------+----------------+
当删除数据表中的数据行时,MVCC 机制会将当前系统的版本号写入被删除数据行的删除版本字段 delete_version 中,以此来标识当前数据行已经被删除。
MySQL 事务的实现原理
MySQL 作为互联网行业使用最多的关系型数据库之一,其 InnoDB 存储引擎本身就支持事务,而事务的实现离不开 Redo log 和 Undo Log。从某种程度上说,事务的隔离性是由锁和 MVCC 机制实现的,原子性和持久性是由 Redo Log 实现的,一致性是由 Undo Log 实现的。
下面就简单地介绍下 MySQL 事务的实现原理,涉及的内容如下:
Redo Log
Undo Log
BinLog
MySQL 事务的流程
MySQL 的 XA 事务
redo log
MySQL 中事务的原子性和持久性是由 Redo Log 实现的,从这句话就可以看出,Redo Log在 MySQL 事务的实现中起着至关重要的作用,它确保 MySQL 事务提交后,事务所涉及的所有操作要么全部执行成功,要么全部执行失败。
Redo Log 基本概念
Redo Log 也被称作重做日志,它是在 InnoDB 存储引擎中产生的,用来保证事务的原子性和持久性。Redo Log 主要记录的是物理日志,也就是对磁盘上的数据进行的修改操作,往往用来恢复提交后的物理数据页,不过只能恢复到最后一次提交的位置。
Redo Log 通常包含两部分:一部分是内存中的日志缓冲,称作 Redo Log Buffer,这部分日志比较容易丢失;另一分是存放在磁盘上的 Redo Log 文件,称作 Redo Log File,这部分日志是持久化到磁盘上的,不容易丢失。
Redo Log 基本原理
Redo Log 能够保证事务的原子性和持久性,在 MySQL 发生故障时,尽力避免内存中的脏页数据写入数据表的 IBD 文件。在重启 MySQL 服务时,可以根据 Redo Log 恢复事务已经提交但是还未写入 IBD 文件中的数据,从而对事务提交的数据进行持久化操作。
例如,在商城系统的下单业务中,用户提交订单时,系统会创建一条新的订单记录并保存到订单数据表(假设叫 order )中。在 MySQL 内部,Redo Log 的基本原理可以使用下图表示。
用户下单后系统创建订单记录,MySQL 在提交事务时,会将数据写入 Redo Log Buffer,而 Redo Log Buffer 中的数据会根据一定的规则写入 Redo Log 文件,具体规则将在后续介绍。当 MySQL 发生故障重启时,会通过 Redo Log 中的数据对订单表中的数据进行恢复,也就是将 Redo Log 文件中的数据恢复到 ibd 文件中。系统可以根据需要,查询并加载订单表中的数据(也就是加载 ibd 文件中的数据),也可以向订单表写入数据(也就是持久化数据到 ibd 文件中)。
Redo Log 刷盘规则
在 MySQL 的 InnoDB 存储引擎中,通过提交事务时强制执行写日志操作机制实现事务的持久化。InnoDB 存储引擎为了保证在事务提交时,将日志提交到事务日志文件中,默认每次将 Redo Log Buffer 中的日志写入日志文件时,都调用一次操作系统的 fsync() 操作。因为 MySQL 进程和其占用的内存空间都工作在操作系统的用户空间中,所以 MySQL 的 Log Buffer 也工作在操作系统的用户空间中。默认情况下,如果想要将 Log Buffer 中的数据持久化到磁盘的日志文件中,还需要经过操作系统的内核空间缓冲区,也就是 OS Buffer。从 Redo Log Buffer 中将数据持久化到磁盘的日志文件中的大致流程如下图所示。
可以看出,Redo Log 从用户空间的 Log Buffer 写入磁盘的 Redo Log 文件时需要经过内核空间的 OS Buffer。这是因为在打开日志文件时,没有使用 O_DIRECT 标志位,而 O_DIRECT 标志位可以不经过操作系统内核空间的 OS Buffer,直接向磁盘写数据。
在 InnoDB 存储引擎中,Redo Log 具有以下几种刷盘规则:
1. 开启事务,发出提交事务指令后是否刷新日志由变量 innodb_flush_log_at_trx_commit 决定
2. 每秒刷新一次,刷新日志的频率由变量 innodb_flush_log_at_timeout 的值决定,默认是 1s。需要注意的是,刷新日志的频率和是否执行了 commit 操作无关
3. 当 Log Buffer 中已经使用的内存超过一半时,也会触发刷盘操作
4. 当事务中存在 checkpoint(检查点)时,在一定程度上代表了刷写到磁盘时日志所处的 LSN 的位置,其中 LSN(Log Sequence Number)表示日志的逻辑序列号
接下来,对第 1 条规则进行简单介绍。
当事务提交时,需要先将事务日志写入 Log Buffer,这些写入 Log Buffer 的日志并不是随着事务的提交立刻写入磁盘的,而是根据一定的规则将 Log Buffer 中的数据刷写到磁盘,从而保证了 Redo Log 文件中数据的持久性。这种刷盘规则可以通过 innodb_flush_log_at_trx_commit 变量控制,该变量可取的值有 0、1 和 2,默认为 1。
- 如果该变量设置为 0,则每次提交事务时,不会将 Log Buffer 中的日志写入 OS Buffer,而是通过一个单独的线程,每秒写入 OS Buffer 并调用 fsync() 函数写入磁盘的 Redo Log 文件。这种方式不是实时写磁盘的,而是每隔 1s 写一次日志,如果系统崩溃,可能会丢失 1s 的数据。
- 如果该变量设置为 1,则每次提交事务都会将 Log Buffer 中的日志写入 OS Buffer,并且会调用 fsync() 函数将日志数据写入磁盘的 Redo Log 文件中。这种方式虽然在系统崩溃时不会丢失数据,但是性能比较差。如果没有设置 innodb_flush_log_at_trx_commit 变量的值,则默认为 1。
- 如果该变量设置为 2,则每次提交事务时,都只是将数据写入 OS Buffer,之后每隔 1s,通过 fsync() 函数将 OS Buffer 中的日志数据同步写入磁盘的 Redo Log 文件中。
需要注意的是,在 MySQL 中,有一个变量 innodb_flush_log_at_timeout 的值为 1,这个变量表示刷新日志的频率。另外,在 InnoDB 存储引擎中,刷新数据页到磁盘和刷新 Undo Log 页到磁盘就只有一种检查点规则。
从上面的分析还可以看出,不同的 Redo Log 刷盘规则,对 MySQL 数据库性能的影响也不同。当 innodb_flush_log_at_trx_commit 变量的值设置为 0 或者 2 时,在插入数据方面的性能要比设置为 1 要好,但缺点是在系统发生故障时,可能会丢失 1s 的数据,而这 1s 内可能会产生大量的数据。也就是说,可能会造成大量数据丢失。MySQL 内部默认设置为 1,必须将写入 Log Buffer 的日志写入 OS Buffer,然后调用 fsync() 函数将日志持久化到磁盘(Redo Log File)中,然后再提交事务。
Redo Log 写入规则
Redo Log 主要记录的是物理日志,其文件内容是以顺序循环的方式写入的,一个文件写满时会写入另一个文件,最后一个文件写满时,会向第一个文件写数据,并且是覆盖写。
- Wirte Pos 是数据表中当前记录所在的位置,随着不断地向数据表中写数据,这个位置会向后移动,当移动到最后一个文件的最后一个位置时,又会回到第一个文件的开始位置进行写操作;
- CheckPoint 是当前要擦除的位置,这个位置也是向后移动的,移动到最后一个文件的最后一个位置时,也会回到第一个文件的开始位置进行擦除。只不过在擦除记录之前,需要把记录更新到数据文件中;
- Write Pos 和 CheckPoint 之间存在间隔时,中间的间隔表示还可以记录新的操作。如果 Write Pos 移动的速度较快,追上了 CheckPoint,则表示数据已经写满,不能再向 Redo Log 文件中写数据了。此时,需要停止写入数据,擦除一些记录;
Redo Log 的 LSN 机制
LSN(Log Sequence Number)表示日志的逻辑序列号,在 InnoDB 存储引擎中,LSN 占用 8 字节的存储空间,并且 LSN 的值是单调递增的。一般可以从LSN中获取如下信息:
Redo Log 写入数据的总量
检查点位置
数据页版本相关的信息
LSN 除了存在于 Redo Log 中外,还存在于数据页中。在每个数据页的头部,有一个fill_page_lsn 参数记录着当前页最终的 LSN 值。将数据页中的 LSN 值和 Redo Log 中的 LSN 值进行比较,如果数据页中的 LSN 值小于 Redo Log 中的 LSN 值,则表示丢失了一部分数据,此时,可以通过 Redo Log 的记录来恢复数据,否则不需要恢复数据。
在 MySQL 的命令行通过如下命令可以查看 LSN 值,show engine innodb status \G
Log sequence number 18179721
Log buffer assigned up to 18179721
Log buffer completed up to 18179721
Log written up to 18179721
Log flushed up to 18179721
Added dirty pages up to 18179721
Pages flushed up to 18179721
Last checkpoint at 18179721
258 log i/o's done, 0.00 log i/o's/second
省略了部分输出,挑出来的部分就是 LSN 相关的,含义分别如下:
Log sequence number:表示当前内存缓冲区中的 Redo Log 的 LSN
Log flushed up to:表示刷新到磁盘上的 Redo Log 文件中的 LSN
Pages flushed up to:表示已经刷新到磁盘数据页上的 LSN
Last checkpoint at:表示上一次检查点所在位置的 LSN
Redo Log 相关参数
在 MySQL 中,输入如下命令可以查看与 Redo Log 相关的参数:show variables like '%innodb_log%'
+------------------------------------+----------+
| Variable_name | Value |
+------------------------------------+----------+
| innodb_log_buffer_size | 16777216 |
| innodb_log_checksums | ON |
| innodb_log_compressed_pages | ON |
| innodb_log_file_size | 50331648 |
| innodb_log_files_in_group | 2 |
| innodb_log_group_home_dir | ./ |
| innodb_log_spin_cpu_abs_lwm | 80 |
| innodb_log_spin_cpu_pct_hwm | 50 |
| innodb_log_wait_for_flush_spin_hwm | 400 |
| innodb_log_write_ahead_size | 8192 |
| innodb_log_writer_threads | ON |
+------------------------------------+----------+
介绍一下里面的几个参数:
innodb_log_buffer_size:表示 log buffer 的大小,默认为 8MB
innodb_log_file_size:表示事务日志的大小,默认为 5MB
innodb_log_files_group=2:表示事务日志组中的事务日志文件个数,默认为 2 个
innodb_log_group_home_dir=./:表示事务日志组所在的目录,当前目录表示 MySQL 数据所在的目录
Undo Log
Undo Log 在 MySQL 事务的实现中也起着至关重要的作用,MySQL 中事务的一致性是由 Undo Log 实现的。下面对 MySQL 中的 Undo Log 进行介绍,主要包括 Undo Log 文件的基本概念、存储方式、基本原理、MVCC 机制和 Undo Log 文件的常见参数配置。
Undo Log 基本概念
Undo Log 在 MySQL 事务的实现中主要起到两方面的作用:回滚事务和多版本并发事务,也就是常说的 MVCC 机制。在 MySQL 启动事务之前,会先将要修改的数据记录存储到 Undo Log 中,如果数据库的事务回滚或者 MySQL 数据库崩溃,可以利用 Undo Log 对数据库中未提交的事务进行回滚操作,从而保证数据库中数据的一致性。
Undo Log 会在事务开始前产生,当事务提交时,并不会立刻删除相应的 Undo Log。此时,InnoDB 存储引擎会将当前事务对应的 Undo Log 放入待删除的列表,接下来,通过一个后台线程 purge thread 进行删除处理。
与 Redo Log 不同,Undo Log 记录的是逻辑日志,可以这样理解:当数据库执行一条 insert 语句时,Undo Log 会记录一条对应的 delete 语句;当数据库执行一条 delete 语句时,Undo Log 会记录一条对应的 insert 语句;当数据库执行一条 update 语句时,Undo Log 会记录一条相反的 update 语句。当数据库崩溃重启或者执行回滚事务时,可以从 Undo Log 中读取相应的数据记录进行回滚操作。
MySQL 中的多版本并发控制也是通过 Undo Log 实现的,当 select 语句查询的数据被其他事务锁定时,可以从 Undo Log 中分析出当前数据之前的版本,从而向客户端返回之前版本的数据。但需要注意的是,因为 MySQL 事务执行过程中产生的 Undo Log 也需要进行持久化操作,所以 Undo Log 也会产生 Redo Log。由于 Undo Log 的完整性和可靠性需要 Redo Log 来保证,因此数据库崩溃时需要先做 Redo Log 数据恢复,然后做 Undo Log 回滚。
Undo Log 存储方式
在 MySQL 中,InnoDB 存储引擎对于 Undo Log 的存储采用段的方式进行管理,在 InnoDB 存储引擎的数据文件中存在一种叫作 rollback segment 的回滚段,这个回滚段内部有 1024 个 undo log segment 段。
Undo Log 默认存放在共享数据表空间中,默认为 ibdata1 文件,如果开启了innodb_file_per_table参数,就会将 Undo Log 存放在每张数据表的 .ibd 文件中。默认情况下,InnoDB 存储引擎会将回滚段全部写在同一个文件中,也可以通过 innodb_undo_tablespaces 变量将回滚段平均分配到多个文件中。innodb_undo_tablespaces 变量的默认值为 0,表示将 rollback segment 回滚段全部写到同一个文件中。
需要注意的是,innodb_undo_tablespaces 变量只能在停止 MySQL 服务的情况下修改,重启 MySQL 服务后生效,但是不建议修改这个变量的值。
Undo Log 基本原理
Undo Log 写入磁盘时和 Redo Log 一样,默认情况下都需要经过内核空间的 OS Buffer。同样,如果在打开日志文件时设置了 O_DIRECT 标志位,就可以不经过操作系统内核空间的 OS Buffer,直接向磁盘写入数据,这点和 Redo Log 也是一样的。
这里依然以商城系统的下单业务为例来简单说明 Undo Log 的基本原理,如下图所示。
MySQL 数据库事务提交之前,InnoDB 存储引擎会将数据表中修改前的数据保存到 Undo Log Buffer。Undo Log Buffer 中的数据会持久化到磁盘的 Undo Log 文件中。当数据库发生故障重启或者事务回滚时,InnoDB 存储引擎会读取 Undo Log 中的数据,将事务还未提交的数据回滚到最初的状态。同时,系统可以根据需要查询并加载订单表中的数据,也就是加载 order.ibd 文件中的数据,也可以向订单表写入数据,也就是持久化数据到 order.ibd 文件中。
Undo Log 实现 MVCC 机制
在 MySQL 中,Undo Log 除了实现事务的回滚操作外,另一个重要的作用就是实现多版本并发控制,也就是 MVCC 机制。在事务提交之前,向 Undo Log 保存事务当前的数据,这些保存到 Undo Log 中的旧版本数据可以作为快照供其他并发事务进行快照读。
Undo Log 的回滚段中,undo logs 分为 insert undo log 和 update undo log。
insert undo log:事务对插入新记录产生的 Undo Log,只在事务回滚时需要,在事务提交后可以立即丢弃
update undo log:事务对记录进行删除和更新操作时产生的 Undo Log,不仅在事务回滚时需要,在一致性读时也需要,因此不能随便删除,只有当数据库所使用的快照不涉及该日志记录时,对应的回滚日志才会被 purge 线程删除
关于 InnoDB 实现 MVCC 机制,简单点理解就是 InnoDB 存储引擎在数据表的每行记录后面保存了两个隐藏列,一个隐藏列保存行的创建版本,另一个隐藏列保存行的删除版本。每开始一个新的事务,这些版本号就会递增。在可重复读隔离级别下,MVCC 机制在增删改查操作下分别按照如下方式实现:
1)当前操作是 select 操作时,InnoDB 存储引擎只会查找版本号小于或者等于当前事务版本号的数据行,这样可以保证事务读取的数据行要么之前就已经存在,要么是当前事务自身插入或者修改的记录。另外,行的删除版本号要么未定义,要么大于当前事务的版本号,这样可以保证事务读取的行在事务开始之前没有被删除
2)当前操作是 insert 操作时,将当前事务的版本号保存为当前行的创建版本号
3)当前操作是 delete 操作时,将当前事务的版本号保存为删除的数据行的删除版本号,作为行删除标识
4)当前操作是 update 操作时,InnoDB 存储引擎会将待修改的行复制为新的行,将当前事务的版本号保存为新数据行的创建版本号,同时保存当前事务的版本号为原来数据行的删除版本号
需要注意的是,将当前事务的版本号保存为行删除版本号时,相应的数据行并不会被真正删除,当事务提交时,会将这些行记录放入一个待删除列表,因此需要根据一定的策略对这些标识为删除的行进行清理。为此,InnoDB 存储引擎会开启一个后台线程进行清理工作,是否可以清理需要后台线程来判断。
其实为了便于理解 Undo Log 实现 MVCC 机制的原理,上面介绍的实现过程经过了简化。从本质上说,为实现 MVCC 机制,InnoDB 存储引擎在数据库每行数据的后面添加了 3 个字段:6 字节的事务 id(DB_TRX_ID)字段、7 字节的回滚指针(DB_ROLL_PTR)字段、6 字节的 DB_ROW_ID 字段。每个字段的作用如下所示:
DB_TRX_ID:用来标识最近一次对本行记录做修改(insert、update)的事务的标识符,即最后一次修改本行记录的事务 id。如果是 delete 操作,在 InnoDB 存储引擎内部也属于一次 update 操作,即更新行中的一个特殊位,将行标识为已删除,并非真正删除
DB_ROLL_PTR:主要指向上一个版本的行记录,能够从最新版本的行记录逐级向上,找到要查找的行版本记录
DB_ROW_ID:这个字段包含一个随着新数据行的插入操作而单调递增的行 id,当由 InnoDB 存储引擎自动产生聚集索引时,聚集索引会包含这个行 id,否则这个行 id 不会出现在任何索引中
假设有事务 A 和事务 B 两个事务,事务 A 对商品数据表中的库存字段进行更新,同时事务 B 读取商品的信息。Undo Log 实现的 MVCC 机制流程如下图所示:
手动开启事务 A 后,更新商品数据表中 id 为 1 的数据,首先会把更新命令中的数据写入 Undo Buffer 中。在事务 A 提交之前,事务 B 手动开启事务,查询商品数据表中 id 为 1 的数据,此时的事务 B 会读取 Undo Log 中的数据并返回给客户端。
Undo Log 相关参数
在 MySQL 命令行输入如下命令可以查看 Undo Log 相关的参数。
show variables like '%undo%'
说一下里面比较重要的参数:
innodb_max_undo_log_size:表示 Undo Log 空间的最大值,当超过这个阈值(默认是 1GB),会触发 truncate 回收(收缩)操作,回收操作后,Undo Log 空间缩小到 10MB
innodb_undo_directory:表示 Undo Log 的存储目录
innodb_undo_log_encrypt:MySQL 8 中新增的参数,表示 Undo Log 是否加密,OFF 表示不加密,ON 表示加密,默认为 OFF
innodb_undo_log_truncate:表示是否开启在线回收 Undo Log 文件操作,支持动态设置,ON 表示开启,OFF 表示关闭,默认为 OFF
innodb_undo_tablespaces:此参数必须大于或等于 2,即回收一个 Undo Log 时,要保证另一个 Undo Log 是可用的
innodb_undo_logs:表示 Undo Log 的回滚段数量,此参数的值至少大于或等于 35,默认为 128
innodb_purge_rseg_truncate_frequency:用于控制回收 Undo Log 的频率。Undo Log 空间在回滚段释放之前是不会回收的,要想增加释放回滚区间的频率,就要降低 innodb_purge_rseg_truncate_frequency 参数的值
BinLog
Redo Log 是 InnoDB 存储引擎特有的日志,MySQL 也有其自身的日志,这个日志就是 BinLog,即二进制日志,或者叫逻辑日志。它位于 Server 层,所以不管 MySQL 使用的什么存储引擎,都有 BinLog 日志。
BinLog 基本概念
BinLog 是一种记录所有 MySQL 数据库表结构变更以及表数据变更的二进制日志,BinLog 中不会记录诸如 select 和 show 这类查询操作的日志,同时 BinLog 是以事件形式记录相关变更操作的,并且包含语句执行所消耗的时间。BinLog 有以下两个最重要的使用场景:
主从复制:在主数据库上开启 BinLog,主数据库通过 dump 线程把 BinLog 发送至从数据库,从数据库获取 BinLog 后通过 I/O 线程将日志写到中继日志,也就是 Relay Log 中。然后,通过 SQL 线程将 Relay Log 中的数据同步至从数据库(BinLog 回放),从而达到主从数据库数据的一致性。
数据恢复:当 MySQL 数据库发生故障或者崩溃时,可以通过 BinLog 进行数据恢复,例如可以使用 mysqlbinlog 等工具进行数据恢复。
BinLog 记录模式
BinLog 文件中主要有 3 种记录模式,分别为 Row、Statement 和 Mixed。
1)Row 模式
Row 模式下的 BinLog 文件会记录每一行数据被修改的情况,然后在 MySQL 从数据库中对相同的数据进行修改。
Row 模式的优点是能够非常清楚地记录每一行数据的修改情况,完全实现主从数据库的同步和数据的恢复;缺点是如果主数据库中发生批量操作,尤其是大批量的操作,会产生大量的二进制日志。比如,使用 alter table 操作修改拥有大量数据的数据表结构时,会使二进制日志的内容暴涨,产生大量的二进制日志,从而大大影响主从数据库的同步性能。
2)Statement 模式
Statement 模式下的 BinLog 文件会记录每一条修改数据的 SQL 语句,MySQL 从数据库在复制 SQL 语句的时候,会通过 SQL 线程将 BinLog 中的 SQL 语句解析成和 MySQL 主数据库上执行过的 SQL 语句相同的 SQL 语句,然后在从数据库上执行 SQL 线程解析出来的 SQL 语句。
Statement 模式的优点是由于不记录数据的修改细节,只是记录数据表结构和数据变更的 SQL 语句,因此产生的二进制日志数据量比较小,这样能够减少磁盘的 I/O 操作,提升数据存储和恢复的效率;缺点是在某些情况下,可能会导致主从数据库中的数据不一致。例如在 MySQL 主数据库中使用了 last_insert_id() 和 now() 等函数,会导致 MySQL 主从数据库中的数据不一致。
3)Mixed 模式
Mixed 模式下的 BinLog 是 Row 模式和 Statement 模式的混用,在这种模式下,一般会使用 Statement 模式保存 BinLog,如果存在 Statement 模式无法复制的操作,例如在 MySQL 主数据库中使用了 last_insert_id() 和 now() 等函数,MySQL 会使用 Row 模式保存 BinLog。也就是说,如果将 BinLog 的记录模式设置为 Mixed,MySQL 会根据执行的 SQL 语句选择写入的记录模式。
BinLog 文件结构
MySQL 的 BinLog 文件中保存的是对数据库、数据表和数据表中的数据的各种更新操作,用来表示修改操作的数据结构叫作日志事件(Log Event),不同的修改操作对应着不同的日志事件。在 MySQL 中,比较常用的日志事件包括 Query Event、Row Event、Xid Event 等。从某种程度上说,BinLog 文件的内容就是各种日志事件的集合。
具体细节可以查看 https://dev.mysql.com/doc/internals/en/event-header-fields.html 。
BinLog 写入机制
MySQL 事务在提交的时候,会记录事务日志(也叫重做日志、物理日志)和二进制日志(也叫逻辑日志),也就是 Redo Log 和 BinLog。这里就存在一个问题:对于事务日志和二进制日志,MySQL 会先记录哪种呢?这里买个关子,一会再说。
简单点理解就是 MySQL 在写 BinLog 文件时,会按照如下规则进行写操作:
根据记录的模式(Row、Statement 和 Mixed)和操作(create、drop、alter、insert、update 等)触发事件生成日志事件(事件触发执行机制)
将事务执行过程中产生的日志事件写入相应的缓冲区,注意,这里是每个事务线程都有一个缓冲区。日志事件保存在数据结构 binlog_cache_mngr 中,这个数据结构中有两个缓冲区:一个是 stmt_cache,用于存放不支持事务的信息;另一个是 trx_cache,用于存放支持事务的信息
事务在 Commit 阶段会将产生的日志事件写入磁盘的 BinLog 文件中,因为不同的事务会以串行的方式将日志事件写入 BinLog 文件中,所以一个事务中包含的日志事件信息在 BinLog 文件中是连续的,中间不会插入其他事务的日志事件
综上,一个事务的 BinLog 是完整的,并且中间不会插入其他事务的 BinLog。
BinLog 组提交机制
为了提高 MySQL 中日志刷盘的效率,MySQL 数据库提供了组提交(group commit)功能。通过组提交功能,调用一次 fsync() 函数能够将多个事务的日志刷新到磁盘的日志文件中,而不用将每个事务的日志单独刷新到磁盘的日志文件中,从而大大提升了日志刷盘的效率。
在 InnoDB 存储引擎中,提交事务时,一般会进行两个阶段的操作。
1)修改内存中事务对应的信息,并将日志写入相应的 Redo Log Buffer
2)调用 fsync() 函数将 Redo Log Buffer 中的日志信息刷新到磁盘的 Redo Log 文件中
其中,步骤 2 因为存在写磁盘的操作,所以比较耗时。事务提交后,先将日志信息写入内存中的 Redo Log Buffer,然后调用 fsync() 函数将多个事务的日志信息从内存中的 Redo Log Buffer 刷新到磁盘的 Redo Log 文件中,这样能够大大提升事务日志的写入效率,尤其对于写入和更新操作比较频繁的业务,性能提升更加明显。
在 MySQL 5.6 之前的版本中,如果开启了 BinLog,则 InnoDB 存储引擎的组提交功能就会失效,导致事务性能下降。这是因为在 MySQL 中需要保证 BinLog 和 Redo Log 的一致性,为了保证二者的一致性,使用了两阶段提交。两阶段提交的步骤如下所示:
数据是先在内存中更新的,也就是先将数据写入 Redo Log Buffer 中,然后在 Redo Log Buffer 中更新数据。更新完成后要写入 Redo Log 文件中,注意此时是要落盘的,但是落盘之后并不代表万事大吉了,落盘之后会先处于 prepare 阶段
MySQL 上层会将数据库、数据表和数据表中的数据的更新操作写入 BinLog 文件
提交事务,Redo Log 处于 commit 状态。如果后续数据库宕机重新恢复时,先看 Redo Log 是否是 commit,如果是直接提交事务,因为此时 BinLog 已经写完了,两者是一致的;如果 Redo Log 不是 commit,则判断 BinLog 是否是完整的,BinLog 完整则提交事务,不完整则借助 Undo Log 回滚事务。所以两阶段提交就是为了保证 Redo Log 和 BinLog 的一致性
所以为了保证 BinLog 和 Redo Log 的一致性,在步骤 1 的 prepare 阶段会启用一个 prepare_commit_mutex 锁,这样会导致开启二进制日志后组提交功能失效。但这个问题在 MySQL 5.6 中得到了解决,在 MySQL 5.6 中,提交事务时会在 InnoDB 存储引擎的上层将事务按照一定的顺序放入一个队列,队列中的第一个事务称为 leader,其他事务称为 follower。在执行顺序上,虽然还是先写 BinLog,再写 Redo Log,但是写日志的机制发生了变化:移除了 prepare_commit_mutex 锁。开启 BinLog 后,组提交功能不会失效,BinLog 的写入和 InnoDB 的事务日志写入都是通过组提交功能进行的。
MySQL 5.6 中,这种实现方式称为二进制日志组提交(Binary Log GroupCommit,BLGC)。BLGC 的实现主要分为 Flush、Sync 和 Commit 三个阶段。
Flush 阶段:将每个事务的 BinLog 写入对应的内存缓冲区
Sync 阶段:将内存缓冲区中的 BinLog 写入磁盘的 BinLog 文件,如果队列中存在多个事务,则此时只执行一次刷盘操作就可以将多个事务的 BinLog 刷新到磁盘的 BinLog 文件中,这就是 BLGC 操作
Commit 阶段:leader 事务根据队列中事务的顺序调用存储引擎层事务的提交操作,由于 InnoDB 存储引擎本身就支持组提交功能,因此解决了 prepare_commit_mutex 锁导致的组提交功能失效的问题
在 Flush 阶段,将 BinLog 写入内存缓冲区时,不是写完就立刻进入 Sync 阶段,而是等待一定时间,多积累几个事务的 BinLog 再一起进入 Sync 阶段。这个等待时间由变量 binlog_max_fush_queue_time 决定,默认值为 0。除非有大量的事务不断地进行写入和更新操作,否则不建议修改这个变量的值,这是因为修改后可能会导致事务的响应时间变长。
进入 Sync 阶段后,会将内存缓冲区中多个事务的 BinLog 刷新到磁盘的 BinLog 文件中,和刷新一个事务的 BinLog 一样,也是由 sync_binlog 变量进行控制的。
一组事务正在执行 Commit 阶段的操作时,其他新产生的事务可以执行 Flush 阶段的操作,Commit 阶段的事务和 Flush 阶段的事务不会互相阻塞,这样组提交功能就会持续生效。此时,组提交功能的性能和队列中的事务数量有关,如果队列中只存在一个事务,组提交功能和单独提交一个事务的效果差不多,有时甚至会更差。提交的事务越多,组提交功能的性能提升就越明显。
BinLog 与 Redo Log 的区别
BinLog 和 Redo Log 在一定程度上都能恢复数据,但是二者有着本质的区别,具体内容如下。
- BinLog 是 MySQL 本身就拥有的,不管使用何种存储引擎,BinLog 都存在,而 Redo Log 是 InnoDB 存储引擎特有的,只有 InnoDB 存储引擎才会输出 Redo Log。
- BinLog 是一种逻辑日志,记录的是对数据库的所有修改操作,而 Redo Log 是一种物理日志,记录的是每个数据页的修改。
- Redo Log 具有幂等性,多次操作的前后状态是一致的,而 BinLog 不具有幂等性,记录的是所有影响数据库的操作。例如插入一条数据后再将其删除,则 Redo Log 前后的状态未发生变化,而 BinLog 就会记录插入操作和删除操作。
- BinLog 只会在事务提交时,一次性写入 BinLog,其日志的记录方式与事务的提交顺序有关,并且一个事务的 BinLog 中间不会插入其他事务的 BinLog。而 Redo Log 记录的是物理页的修改,最后一个提交的事务记录会覆盖之前所有未提交的事务记录,并且一个事务的 Redo Log 中间会插入其他事务的 Redo Log
- BinLog 是追加写入,写完一个日志文件再写下一个日志文件,不会覆盖使用,而 Redo Log 是循环写入,日志空间的大小是固定的,会覆盖使用。
- BinLog 一般用于主从复制和数据恢复,并且不具备崩溃自动恢复的能力,而 Redo Log 是在服务器发生故障后重启 MySQL,用于恢复事务已提交但未写入数据表的数据。
BinLog 相关参数
在 MySQL 中,输入如下命令可以查看与 BinLog 相关的参数。
show variables like '%log_bin%';
show variables like '%binlog%';
其中,几个重要的参数如下所示:
log_bin:表示开启二进制日志,未指定 BinLog 的目录时,会在 MySQL 的数据目录下生成 BinLog,指定 BinLog 的目录时,会在指定的目录下生成 BinLog
log_bin_index:设置此参数可以指定二进制索引文件的路径与名称
binlog_do_db:表示只记录指定数据库的二进制日志
binlog_ignore_db:表示不记录指定数据库的二进制日志
max_binlog_size:表示 BinLog 的最大值,默认值为 1GB
sync_binlog:这个参数会影响 MySQL 的性能和数据的完整性,取值为 0 时,事务提交后,MySQL 将 binlog_cache 中的数据写入 BinLog 文件的同时,不会执行 fsync() 函数刷盘。当取值为大于 0 的数字 N 时,在进行 N 次事务提交操作后,MySQL 将执行一次 fsync() 函数,将多个事务的 BinLog 刷新到磁盘中
max_binlog_cache_size:表示 BinLog 占用的最大内存
binlog_cache_size:表示 BinLog 使用的内存大小
binlog_cache_use:表示使用 BinLog 缓存的事务数量
binlog_cache_disk_use:表示使用 BinLog 缓存但超过 binlog_cache_size 的值,并且使用临时文件来保存 SQL 语句中的事务数量
需要注意的是,MySQL 中默认不会开启 BinLog。如果需要开启 BinLog,要修改 my.cnf 或 my.ini 配置文件,在 mysqld 下面增加 log_bin=mysql_bin_log 命令,重启 MySQL 服务,如下所示。
binlog-format=ROW
log-bin=mysqlbinlog
MySQL 事务流程
MySQL 的事务流程分为 MySQL 事务执行流程和 MySQL 事务恢复流程,本节对 MySQL 的事务流程进行简单的介绍。
MySQL 事务执行流程
MySQL 事务执行的过程中,主要是通过 Redo Log 和 Undo Log 实现的。
可以看出,MySQL 在事务执行的过程中,会记录相应 SQL 语句的 UndoLog 和 Redo Log,然后在内存中更新数据并形成数据脏页。接下来 Redo Log 会根据一定的规则触发刷盘操作,Undo Log 和数据脏页则通过检查点机制刷盘。事务提交时,会将当前事务相关的所有 Redo Log 刷盘,只有当前事务相关的所有 RedoLog 刷盘成功,事务才算提交成功。
MySQL 事务恢复流程
如果一切正常,则 MySQL 事务会按照下图中的顺序执行。实际上,MySQL 事务的执行不会总是那么顺利。如果 MySQL 由于某种原因崩溃或者宕机,则需要进行数据的恢复或者回滚操作。
如果事务在执行第 8 步,即事务提交之前,MySQL 崩溃或者宕机。那么重启之后会先使用 Redo Log 恢复数据,然后使用 Undo Log 回滚数据。如果在执行第 8 步之后 MySQL 崩溃或者宕机,此时会使用 Redo Log 恢复数据。
因此 MySQL 发生崩溃或者宕机时,需要重启 MySQL。MySQL 重启之后,会获取日志检查点信息,随后根据日志检查点信息使用 Redo Log 恢复数据。如果在 MySQL 崩溃或者宕机时,事务未提交,则接下来使用 Undo Log 回滚数据。如果在 MySQL 崩溃或者宕机时,事务已经提交,则用 Redo Log 恢复数据即可。
MySQL 中的 XA 事务
MySQL 5.0.3 版本开始支持 XA 分布式事务,并且只有 InnoDB 存储引擎支持 XA 事务,MySQL Connector/J 5.0.0 版本之后开始提供对 XA 事务的支持,本节对 MySQL 中的 XA 事务进行简单的介绍。
XA 事务的基本原理
XA 事务支持不同数据库之间实现分布式事务,这里的不同数据库,可以是不同的 MySQL 实例,也可以是不同的数据库类型,比如 MySQL 数据库和 Oracle 数据库。
XA 事务本质上是一种基于两阶段提交的分布式事务,分布式事务可以简单理解为多个数据库事务共同完成一个原子性的事务操作,参与操作的多个事务要么全部提交成功,要么全部提交失败。在使用 XA 分布式事务时,InnoDB 存储引擎的事务隔离级别需要设置为串行化。
XA 事务由一个事务管理器(Transaction Manager)、一个或者多个资源管理器(Resource Manager)和一个应用程序(Application Program)组成,组成模型如下图所示。
- 1)事务管理器:主要对参与全局事务的各个分支事务进行协调,并与资源管理器进行通信。
- 2)资源管理器:主要提供对对事务资源的访问能力。实际上,一个数据库就可以看作一个资源管理器。
- 3)应用程序:主要用来明确全局事务和各个分支事务,指定全局事务中的各个操作。
因为 XA 事务是基于两阶段提交的分布式事务,所以 XA 事务也被拆分为 Prepare 阶段和 Commit 阶段。
在 Prepare 阶段,事务管理器向资源管理器发送准备指令,资源管理器接收到指令后,执行数据的修改操作并记录相关的日志信息,然后向事务管理器返回可以提交或者不可以提交的结果信息。
在 Commit 阶段,事务管理器接收所有资源管理器返回的结果信息,如果某一个或多个资源管理器向事务管理器返回的结果信息为不可以提交,或者超时,则事务管理器向所有的资源管理器发送回滚指令。如果事务管理器收到的所有资源管理器返回的结果信息为可以提交,则事务管理器向所有的资源管理器发送提交事务的指令。
XA 事务用的实际上不是很多,有兴趣的话,可以参考官网自行了解 https://dev.mysql.com/doc/refman/8.0/en/xa-states.html 。
分布式事务的基本概念
随着互联网的不断发展,企业积累的数据越来越多,当单台数据库难以存储海量数据时,人们便开始探索如何将这些数据分散地存储到多台服务器的多台数据库中,逐渐形成了分布式数据库。如果将数据分散存储,对于数据的增删改查操作就会变得更加复杂,尤其是难以保证数据的一致性问题,这就涉及了常说的分布式事务。
接下来将对分布式事务的基本概念进行介绍,涉及的内容如下:
分布式系统架构原则
分布式系统架构演进
分布式事务场景
数据一致性
分布式系统架构
随着互联网的快速发展,传统的单体系统架构已不能满足海量用户的需求。于是更多的互联网企业开始对原有系统进行改造和升级,将用户产生的大规模流量进行分解,分而治之,在不同的服务器上为用户提供服务,以满足用户的需求。慢慢地,由原来的单体系统架构演变为分布式系统架构。
产生的背景
在互联网早期,互联网企业的业务并不是很复杂,用户量也不大,一般使用单体系统架构快速实现业务。此时,系统处理的流量入口更多来自 PC 端。
随着用户量爆发式增长,此时的流量入口不再只有 PC 端,更多来自移动端 App、H5、微信小程序、自主终端机、各种物联网设备和网络爬虫等,用户和企业的需求也开始变得越来越复杂。在不断迭代升级的过程中,单体系统变得越来越臃肿,系统的业务也变得越来越复杂,甚至难以维护。修改一个很小的功能可能会导致整个系统的变动,并且系统需要经过严格测试才能上线,一个很小的功能就要发布整个系统,直接影响了系统中其他业务的稳定性与可用性。
此时开发效率低下,升级和维护系统成本很高,测试周期越来越长,代码的冲突率也会变得越来越高。最让人头疼的是,一旦有开发人员离职,新入职的人需要很长的时间来熟悉整个系统,并且单体系统架构已经无法支撑大流量和高并发的场景。
面对单体系统架构的种种问题,解决方案是对复杂、臃肿的系统进行水平拆分,把共用的业务封装成独立的服务,供其他业务调用,把各相关业务封装成子系统并提供接口,供其他系统或外界调用,以此达到降低代码耦合度,提高代码复用率的目的。此时,由于各个子系统之间进行了解耦,因此对每个子系统内部的修改不会影响其他子系统的稳定性。这样一来降低了系统的维护和发布成本,测试时也不需要把整个系统再重新测试一遍,提高了测试效率。在代码维护上,各个子系统的代码单独管理,降低了代码的冲突率,提高了系统的研发效率。
架构目标和架构原则
好的分布式系统架构并不是一蹴而就的,而是随着企业和用户的需求不断迭代演进的,能够解决分布式系统当前最主要的矛盾,同时对未来做出基本的预测,使得系统架构具备高并发、高可用、高可扩展性、高可维护性等非功能性需求,能够快速迭代,以适应不断变化的需求。
分布式系统架构的设计虽然比较复杂,但是也有一些业界遵循的原则。其中一些典型的架构原则来自 The Art of Scalability 一书,作者马丁L.阿伯特和迈克尔T.费舍尔分别是 eBay 和 PayPal 的 CTO。他们在书中总结了15项架构原则,分别如下所示。
N+1设计
回滚设计
禁用设计
监控设计
设计多活数据中心
使用成熟的技术
异步设计
无状态系统
水平扩展而非垂直升级
设计时至少要有两步前瞻性
非核心则购买
使用商品化硬件
小构建、小发布和快试错
隔离故障
自动化
分布式系统架构演进
互联网企业的业务飞速发展,促使系统架构不断变化。总体来说,系统架构大致经历了 单体应用架构—垂直应用架构—分布式架构—SOA架构—微服务架构 的演变,很多互联网企业的系统架构已经向服务化网格(Service Mesh)演变。接下来简单介绍一下系统架构的发展历程。
单体应用架构
在企业发展的初期,一般公司的网站流量比较小,只需要一个应用将所有的功能代码打包成一个服务并部署到服务器上,就能支撑公司的业务需求。这种方式能够减少开发、部署和维护的成本。比如大家很熟悉的电商系统,里面涉及的业务主要有用户管理、商品管理、订单管理、支付管理、库存管理、物流管理等模块。企业发展初期,我们将所有的模块写到一个 Web 项目中,再统一部署到一个 Web 服务器中,这就是单体应用架构,系统架构如下图所示。
这种架构的优点如下:
架构简单,项目开发和维护成本低
所有项目模块部署在一起,对于小型项目来说,方便维护
但是其缺点也是比较明显的:
所有模块耦合在一起,对于大型项目来说,不易开发和维护
项目各模块之间过于耦合,一旦有模块出现问题,整个项目将不可用
无法针对某个具体模块来提升性能
无法对项目进行水平扩展
正是由于单体应用架构存在诸多缺点,才逐渐演变为垂直应用架构。
垂直应用架构
随着企业业务的不断发展,单节点的单体应用无法满足业务需求,于是企业将单体应用部署多份,分别放在不同的服务器上。然而不是所有的模块都有比较大的访问量,如果想针对项目中的某些模块进行优化和性能提升,对于单体应用来说,是做不到的,于是垂直应用架构诞生了。
垂直应用架构就是将原来的项目应用拆分为互不相干的几个应用,以此提升系统的整体性能。同样以电商系统为例,在垂直应用架构下,我们可以将整个电商项目拆分为电商交易系统、后台管理系统、数据分析系统,系统架构如下图所示。
将单体应用架构拆分为垂直应用架构之后,一旦访问量变大,只需要针对访问量大的业务增加服务器节点,无须针对整个项目增加服务器节点。
这种架构的优点如下:
对系统进行拆分,可根据不同系统的访问情况,有针对性地进行优化
能够实现应用的水平扩展
各系统能够分担整体访问流量,解决了并发问题
子系统发生故障,不影响其他子系统的运行情况,提高了整体的容错率
缺点如下:
拆分后的各系统之间相对独立,无法进行互相调用
各系统难免存在重叠的业务,会存在重复开发的业务,后期维护比较困难
分布式架构
将系统演变为垂直应用架构之后,当垂直应用越来越多时,重复编写的业务代码就会越来越多。此时,我们需要将重复的代码抽象出来,形成统一的服务,供其他系统或者业务模块调用,这就是分布式架构。
在分布式架构中,我们会将系统整体拆分为服务层和表现层。服务层封装了具体的业务逻辑供表现层调用,表现层则负责处理与页面的交互操作。分布式系统架构如下图所示:
这种架构的优点如下:
将重复的业务代码抽象出来,形成公共的访问服务,提高了代码的复用性
可以有针对性地对系统和服务进行性能优化,以提升整体的访问性能
缺点如下:
系统之间的调用关系变得复杂
系统之间的依赖关系变得复杂
系统维护成本高
SOA架构
在分布式架构下,当部署的服务越来越多时,重复的代码就会变得越来越多,不利于代码的复用和系统维护。为此,我们需要增加一个统一的调度中心对集群进行实时管理,这就是 SOA(面向服务)架构。SOA 系统架构如下图所示:
这种架构的优点是通过注册中心解决了各个服务之间服务依赖和调用关系的自动注册与发现。缺点如下:
各服务之间存在依赖关系,如果某个服务出现故障,可能会造成服务器崩溃
服务之间的依赖与调用关系复杂,增加了测试和运维的成本
微服务架构
微服务架构是在 SOA 架构的基础上进行进一步的扩展和拆分,在微服务架构下,一个大的项目拆分为一个个小的可独立部署的微服务,每个微服务都有自己的数据库。微服务系统架构如图所示:
这种架构的优点如下:
服务彻底拆分,各服务独立打包、独立部署和独立升级
每个微服务负责的业务比较清晰,利于后期扩展和维护
微服务之间可以采用 REST 和 RPC 协议进行通信
缺点如下:
开发成本比较高
涉及各服务的容错性问题
涉及数据的一致性问题
涉及分布式事务问题
分布式事务场景
将一个大的应用系统拆分为多个可以独立部署的应用服务,需要各个服务远程协作才能完成某些事务操作,这就涉及分布式事务的问题。总的来讲,分布式事务会在 3 种场景下产生,分别是跨进程、跨数据库实例和多服务访问单数据库。
跨进程
将单体项目拆分为分布式、微服务项目之后,各个服务之间通过远程 REST 或者 RPC 调用来协同完成业务操作。典型的场景是商城系统的订单微服务和库存微服务,用户在下单时会访问订单微服务。订单微服务在生成订单记录时,会调用库存微服务来扣减库存。各个微服务部署在不同的服务进程中,此时会产生因跨进程而导致的分布式事务问题。商城系统中跨进程产生分布式事务的场景如下图所示:
跨数据库实例
单体系统访问多个数据库实例,也就是跨数据源访问时会产生分布式事务。例如,系统中的订单数据库和交易数据库放在不同的数据库实例中,当用户发起退款时,会同时操作用户的订单数据库和交易数据库(在交易数据库中执行退款操作,在订单数据库中将订单的状态变更为已退款)。由于数据分布在不同的数据库实例中,需要通过不同的数据库连接会话来操作数据库中的数据,因此产生了分布式事务。商城系统中跨数据库实例产生分布式事务场景如下图所示:
多服务访问单数据库
多个微服务访问同一个数据库,例如订单微服务和交易微服务访问同一个数据库就会产生分布式事务,原因是多个微服务访问同一个数据库,本质上也是通过不同的数据库会话来操作数据库,此时就会产生分布式事务。商城系统中多服务访问单数据库产生分布式事务的场景如下图所示:
跨数据库实例场景和多服务访问单数据库场景,在本质上都会产生不同的数据库会话来操作数据库中的数据,进而产生分布式事务。这两种场景是比较容易被忽略的。
数据的一致性
在分布式场景下,当网络、服务器或者系统软件出现故障,就可能会导致数据一致性的问题。接下来介绍数据一致性相关的问题及解决方案。
数据的一致性问题
总的来说,数据的一致性问题包含数据多副本、调用超时、缓存与数据库不一致、多个缓存节点数据不一致等场景。
数据多副本场景
如果数据的存储存在多副本的情况,当网络、服务器或者系统软件出现故障时,可能会导致一部分副本写入成功,一部分副本写入失败,造成各个副本之间数据的不一致。
调用超时场景
调用超时场景包含同步调用超时和异步调用超时。
同步调用超时往往是由于网络、服务器或者系统软件异常引起的,例如服务 A 同步调用服务 B 时出现超时现象,导致服务 A 与服务 B 之间的数据不一致;异步调用超时是指服务 A 异步调用服务 B,同样是由于网络、服务器或者系统软件异常导致调用失败,出现服务 A 与服务 B 之间的数据不一致的情况。一个典型的场景就是支付成功的异步回调通知。
缓存与数据库不一致场景
这种场景主要针对缓存与数据库,在高并发场景下,一些热数据会缓存到 Redis 或者其他缓存组件中。此时如果对数据库中的数据进行新增、修改和删除操作,缓存中的数据如果得不到及时更新,就会导致缓存与数据库中数据不一致。
多个缓存节点数据不一致场景
这种场景主要针对缓存内部各节点之间数据的不一致。例如在Redis集群中,由于网络异常等原因引起的脑裂问题,就会导致多个缓存节点数据不一致。
数据一致性解决方案
业界对于数据一致性问题提出了相应的解决方案,目前比较成熟的方案有 ACID 特性、CAP 理论、Base 理论、DTP 模型、2PC(两阶段提交)模型、3PC(三阶段提交)模型、TCC 模型、可靠消息最终一致性模型、最大努力通知模型等。
至于这些解决方案是怎么做的,我们单独开一片博客介绍。
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏