摘要
分布式事务是分布式系统中非常重要的一部分,最典型的例子是银行转账和扣款,A 和 B 的账户信息在不同的服务器上,A 给 B 转账 100 元,要完成这个操作,需要两个步骤,从 A 的账户上扣款,以及在 B 的账户上增加金额,两个步骤必须全部执行成功;否则如果有一个失败,那么另一个操作也不能执行。
分布式事务关注的是分布式场景下如何处理事务,是指事务的参与者、支持事务操作的服务器、存储等资源分别位于分布式系统的不同节点之上。简单来说,分布式事务就是一个业务操作,是由多个细分操作完成的,而这些细分操作又分布在不同的服务器上;事务,就是这些操作要么全部成功执行,要么全部不执行。
事务在分布式系统的尤为重要,特别是涉及到一些数据安全和数据一致性的问题的时候,处理好分布式系统事务对整个系统具有重要的影响。同时很多人对事务或者是分布式事务没有系统和提体系化的学习和了解,同时对于绝大多数同学来说很难接触到分布式事务的实战。本文将系列的介绍从单一数据库的分事务到分布式事务的原理与相关实例。供大家参考和学习。
一、数据库事务原理
1.1 数据库的ACID原则
数据库事务的特性包括原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durabilily),简称 ACID。在数据库执行中,多个并发执行的事务如果涉及到同一份数据的读写就容易出现数据不一致的情况,不一致的异常现象有以下几种。
脏读:是指一个事务中访问到了另外一个事务未提交的数据。例如事务 T1 中修改的数据项在尚未提交的情况下被其他事务(T2)读取到,如果 T1 进行回滚操作,则 T2 刚刚读取到的数据实际并不存在。
不可重复读:是指一个事务读取同一条记录 2 次,得到的结果不一致。例如事务 T1 第一次读取数据,接下来 T2 对其中的数据进行了更新或者删除,并且 Commit 成功。这时候 T1 再次读取这些数据,那么会得到 T2 修改后的数据,发现数据已经变更,这样 T1 在一个事务中的两次读取,返回的结果集会不一致。
幻读:是指一个事务读取 2 次,得到的记录条数不一致。例如事务 T1 查询获得一个结果集,T2 插入新的数据,T2 Commit 成功后,T1 再次执行同样的查询,此时得到的结果集记录数不同。
脏读、不可重复读和幻读有以下的包含关系,如果发生了脏读,幻读和不可重复读都有可能出现。
1.2 数据库的隔离级别
SQL 标准根据三种不一致的异常现象,将隔离性定义为四个隔离级别(Isolation Level),隔离级别和数据库的性能呈反比,隔离级别越低,数据库性能越高;而隔离级别越高,数据库性能越差,具体如下:
Read uncommitted 读未提交:在该级别下,一个事务对数据修改的过程中,不允许另一个事务对该行数据进行修改,但允许另一个事务对该行数据进行读,不会出现更新丢失,但会出现脏读、不可重复读的情况。
Read committed 读已提交:在该级别下,未提交的写事务不允许其他事务访问该行,不会出现脏读,但是读取数据的事务允许其他事务访问该行数据,因此会出现不可重复读的情况。
Repeatable read 可重复读:在该级别下,在同一个事务内的查询都是和事务开始时刻一致的,保证对同一字段的多次读取结果都相同,除非数据是被本身事务自己所修改,不会出现同一事务读到两次不同数据的情况。因为没有约束其他事务的新增Insert操作,所以 SQL 标准中可重复读级别会出现幻读。值得一提的是,可重复读是 MySQL InnoDB 引擎的默认隔离级别,但是在 MySQL 额外添加了间隙锁(Gap Lock),可以防止幻读。
Serializable 序列化:该级别要求所有事务都必须串行执行,可以避免各种并发引起的问题,效率也最低。
对不同隔离级别的解释,其实是为了保持数据库事务中的隔离性(Isolation),目标是使并发事务的执行效果与串行一致,隔离级别的提升带来的是并发能力的下降,两者是负相关的关系。
1.3 数据库事务的实现原理
二、常见的分布式事务解决方案
2.1 分布式事务的产生的原因
分布式事务是伴随着系统拆分出现的,前面我们说过,分布式系统解决了海量数据服务对扩展性的要求,但是增加了架构上的复杂性,在这一点上,分布式事务就是典型的体现。在实际开发中,分布式事务产生的原因主要来源于存储和服务的拆分。分布式事务的解决方案,典型的有两阶段和三阶段提交协议、 TCC 分段提交,和基于消息队列的最终一致性设计。
2.1.1 存储层拆分
存储层拆分,最典型的就是数据库分库分表,一般来说,当单表容量达到千万级,就要考虑数据库拆分,从单一数据库变成多个分库和多个分表。在业务中如果需要进行跨库或者跨表更新,同时要保证数据的一致性,就产生了分布式事务问题。在后面的课程中,也会专门来讲解数据库拆分相关的内容。
2.1.2 服务层拆分
服务层拆分也就是业务的服务化,系统架构的演进是从集中式到分布式,业务功能之间越来越解耦合。
比如电商网站系统,业务初期可能是一个单体工程支撑整套服务,但随着系统规模进一步变大,参考康威定律,大多数公司都会将核心业务抽取出来,以作为独立的服务。商品、订单、库存、账号信息都提供了各自领域的服务,业务逻辑的执行散落在不同的服务器上。用户如果在某网站上进行一个下单操作,那么会同时依赖订单服务、库存服务、支付扣款服务,这几个操作如果有一个失败,那下单操作也就完不成,这就需要分布式事务来保证了。
2.2 2PC 两阶段提交
在分布式系统中,各个节点之间在物理上相互独立,通过网络进行沟通和协调。在关系型数据库中,由于存在事务机制,可以保证每个独立节点上的数据操作满足 ACID。但是,相互独立的节点之间无法准确的知道其他节点中的事务执行情况,所以在分布式的场景下,如果不添加额外的机制,多个节点之间理论上无法达到一致的状态。在分布式事务中,两阶段和三阶段提交是经典的一致性算法。
两阶段提交(2PC,Two-phase Commit Protocol)是非常经典的强一致性、中心化的原子提交协议,在各种事务和一致性的解决方案中,都能看到两阶段提交的应用。
二阶段提交算法的成立是基于以下假设的:
- 在该分布式系统中,存在一个节点作为协调者(Coordinator),其他节点作为参与者(Participants),且节点之间可以进行网络通信;
- 所有节点都采用预写式日志,日志被写入后被保存在可靠的存储设备上,即使节点损坏也不会导致日志数据的丢失;
- 所有节点不会永久性损坏,即使损坏后仍然可以恢复。
两阶段提交中的两阶段,指的是Commit-request 阶段和Commit 阶段,两阶段提交流程如下:
提交请求阶段:在提交请求阶段,协调者将通知事务参与者准备提交事务,然后进入表决过程。在表决过程中,参与者将告知协调者自己的决策:同意(事务参与者本地事务执行成功)或取消(本地事务执行故障),在第一阶段,参与节点并没有进行Commit操作。
提交阶段:在提交阶段,协调者将基于第一个阶段的投票结果进行决策:提交或取消这个事务。这个结果的处理和前面基于半数以上投票的一致性算法不同,必须当且仅当所有的参与者同意提交,协调者才会通知各个参与者提交事务,否则协调者将通知各个参与者取消事务。
参与者在接收到协调者发来的消息后将执行对应的操作,也就是本地 Commit 或者 Rollback。
两阶段提交协议有几个明显的问题,下面列举如下。
资源被同步阻塞:在执行过程中,所有参与节点都是事务独占状态,当参与者占有公共资源时,那么第三方节点访问公共资源会被阻塞。
协调者可能出现单点故障:一旦协调者发生故障,参与者会一直阻塞下去。
在 Commit 阶段出现数据不一致:在第二阶段中,假设协调者发出了事务 Commit 的通知,但是由于网络问题该通知仅被一部分参与者所收到并执行 Commit,其余的参与者没有收到通知,一直处于阻塞状态,那么,这段时间就产生了数据的不一致性。
2.3 3PC 三阶段提交
三阶段提交协议(3PC,Three-phase_commit_protocol)是在 2PC 之上扩展的提交协议,主要是为了解决两阶段提交协议的阻塞问题,从原来的两个阶段扩展为三个阶段,增加了超时机制。
CanCommit 阶段:3PC 的 CanCommit 阶段其实和 2PC 的准备阶段很像。协调者向参与者发送 Can-Commit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。
PreCommit 阶段:
协调者根据参与者的反应情况来决定是否可以继续事务的 PreCommit 操作。根据响应情况,有以下两种可能。
A. 假如协调者从所有的参与者获得的反馈都是 Yes 响应,那么就会进行事务的预执行:
- 发送预提交请求,协调者向参与者发送 PreCommit 请求,并进入 Prepared 阶段;
- 事务预提交,参与者接收到 PreCommit 请求后,会执行事务操作;
- 响应反馈,如果参与者成功执行了事务操作,则返回 ACK 响应,同时开始等待最终指令。
B. 假如有任何一个参与者向协调者发送了 No 响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就中断事务:
- 发送中断请求,协调者向所有参与者发送 abort 请求;
- 中断事务,参与者收到来自协调者的 abort 请求之后,执行事务的中断。
DoCommit 阶段
该阶段进行真正的事务提交,也可以分为以下两种情况。
A.执行提交
- 发送提交请求。协调者接收到参与者发送的 ACK 响应后,那么它将从预提交状态进入到提交状态,并向所有参与者发送 doCommit 请求。
- 事务提交。参与者接收到 doCommit 请求之后,执行正式的事务提交,并在完成事务提交之后释放所有事务资源。
- 响应反馈。事务提交完之后,向协调者发送 ACK 响应。
- 完成事务。协调者接收到所有参与者的 ACK 响应之后,完成事务。
B. 中断事务 协调者没有接收到参与者发送的 ACK 响应,可能是因为接受者发送的不是 ACK 响应,也有可能响应超时了,那么就会执行中断事务。
C.超时提交 参与者如果没有收到协调者的通知,超时之后会执行 Commit 操作。
2.3.1 三阶段提交做了哪些改进
引入超时机制:在2PC中,只有协调者拥有超时机制,如果在一定时间内没有收到参与者的消息则默认失败,3PC同时在协调者和参与者中都引入超时机制。
添加预提交阶段:在2PC的准备阶段和提交阶段之间,插入一个准备阶段,使 3PC拥有 CanCommit、PreCommit、DoCommit 三个阶段,PreCommit 是一个缓冲,保证了在最后提交阶段之前各参与节点的状态是一致的。
三阶段提交协议存在的问题:三阶段提交协议同样存在问题,具体表现为,在阶段三中,如果参与者接收到了 PreCommit 消息后,出现了不能与协调者正常通信的问题,在这种情况下,参与者依然会进行事务的提交,这就出现了数据的不一致性。
2.3.2 两阶段和三阶段提交的应用
两阶段提交是一种比较精简的一致性算法/协议,很多关系型数据库都是采用两阶段提交协议来完成分布式事务处理的,典型的比如MySQL的XA规范。
在事务处理、数据库和计算机网络中,两阶段提交协议提供了分布式设计中的数据一致性的保障,整个事务的参与者要么一致性全部提交成功,要么全部回滚。MySQL Cluster 内部数据的同步就是用的 2PC 协议。
MySQL的主从复制
在MySQL中,二进制日志是server层,主要用来做主从复制和即时点恢复时使用的;而事务日志(Redo Log)是 InnoDB 存储引擎层,用来保证事务安全的。在数据库运行中,需要保证 Binlog 和 Redo Log 的一致性,如果顺序不一致, 则意味着 Master-Slave 可能不一致。
在开启 Binlog 后,如何保证 Binlog 和 InnoDB redo 日志的一致性呢?MySQL 使用的就是二阶段提交,内部会自动将普通事务当做一个 XA 事务(内部分布式事务)来处理:
- Commit会被自动的分成 Prepare 和 Commit 两个阶段;
- Binlog 会被当做事务协调者(Transaction Coordinator),BinlogEvent会被当做协调者日志。
2.4 TCC 分段提交
TCC 是一个分布式事务的处理模型,将事务过程拆分为 Try、Confirm、Cancel 三个步骤,在保证强一致性的同时,最大限度提高系统的可伸缩性与可用性。
两阶段、三阶段以及 TCC 协议在后面的课程中我会详细介绍,接下来介绍几种系统设计中常用的一致性解决方案。
2.5 基于消息补偿的最终一致性
异步化在分布式系统设计中随处可见,基于消息队列的最终一致性就是一种异步事务机制,在业务中广泛应用。在具体实现上,基于消息补偿的一致性主要有本地消息表和第三方可靠消息队列等。
下面介绍一下本地消息表,本地消息表的方案最初是由 ebay 的工程师提出,核心思想是将分布式事务拆分成本地事务进行处理,通过消息日志的方式来异步执行。本地消息表是一种业务耦合的设计,消息生产方需要额外建一个事务消息表,并记录消息发送状态,消息消费方需要处理这个消息,并完成自己的业务逻辑,另外会有一个异步机制来定期扫描未完成的消息,确保最终一致性。
- 系统收到下单请求,将订单业务数据存入到订单库中,并且同时存储该订单对应的消息数据,比如购买商品的 ID 和数量,消息数据与订单库为同一库,更新订单和存储消息为一个本地事务,要么都成功,要么都失败。
- 库存服务通过消息中间件收到库存更新消息,调用库存服务进行业务操作,同时返回业务处理结果。
- 消息生产方,也就是订单服务收到处理结果后,将本地消息表的数据删除或者设置为已完成。
- 设置异步任务,定时去扫描本地消息表,发现有未完成的任务则重试,保证最终一致性。
除了上述几种,还有一种不保证最终一致性的柔性事务,也称为尽最大努力通知,这种方式适合可以接受部分不一致的业务场景。
三、分布式事务解决方案实践
3.1 两阶段(2PC)实践方案
3.2 三阶段实践方案
3.3 TCC实践方案(seata)
分布式事务开源组件应用比较广泛的是蚂蚁金服开源的 Seata,也就是 Fescar,前身是阿里中间件团队发布的 TXC(Taobao Transaction Constructor)和升级后的 GTS(Global Transaction Service)。
Seata 的设计思想是把一个分布式事务拆分成一个包含了若干分支事务(Branch Transaction)的全局事务(Global Transaction)。分支事务本身就是一个满足 ACID 的 本地事务,全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。
在 Seata 中,全局事务对分支事务的协调基于两阶段提交协议,类似数据库中的 XA 规范,XA 规范定义了三个组件来协调分布式事务,分别是 AP 应用程序、TM 事务管理器、RM 资源管理器、CRM 通信资源管理器。