在分布式架构大行其道的当下,如何解决分布式系统中各子系统事务一致性的问题也成了一个核心难题,由此诞生了诸多分布式事务的解决方案,本文也将围绕分布式事务这个核心主题向大家介绍分布式事务概念、原因及常见的解决方案如 2PC、3PC、TCC、本地消息表、MQ 事务消息、Saga 事务。
1. 分布式事务概念
在介绍分布式事务之前,我们必须要明确:什么是事务?
事务是指逻辑上的一组操作,组成这组操作的各个单元,要么都成功,要么都失败,同时事务应该满足 ACID 四个特性。这四个特性分别是:
- A, 原子性(Atomicity):一个事务是一个不可分割的工作单位,事务中的操作要么都成功,要么都失败。
- C, 一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。
- I, 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
- D, 持久性(Durability):已被提交的事务对数据库的修改应该永久保存在数据库中。
在分布式系统中,一个流程可能是由多个系统共同完成的,这些系统部署在不同的服务器上,同时可能连接多个数据库。在这样的情况下,如果其中一个系统出现了故障,就应该导致整个流程失败,所有已更新的操作全部回滚,只有所有系统都成功了,这次操作才能够被持久化。
而分布式事务,就是为了保证在分布式系统中数据的 ACID 四个特性。
本段话摘自笔者之前的博客《RocketMQ.4-基于事务消息解决分布式事务》
2. 分布式事务产生的原因
我们就以用户下单场景为例来说明为什么需要分布式事务。
整个电商平台是一个分布式系统,其中有很多子系统如用户中心、商品中心、支付中心、订单中心、库存中心、营销中心、会员中心等等。假设下单场景涉及的子系统只有库存中心和订单中心,在用户下单需要检查商品库存是否充足,满足购买件数才能成功创建订单,否则创建订单就会因为库存不足而失败。
上述业务的流程图可用下图表示:
订单中心和库存中心是部署在不同服务器,连接不同数据库的两个服务,这就需要保证锁定库存成功后才能创建订单、同时若创建订单失败,锁定的库存也需要回滚。
在上述的整个流程中,基于锁定库存和创建订单这两个步骤一共可能会有四种情况发生。
- 锁定库存成功,创建订单成功。
- 锁定库存失败,创建订单失败。
- 锁定库存成功,创建订单失败。
- 锁定库存失败,创建订单成功。
情况1和2自然不必多说,这正是我们期望看到的结果,而情况3和4则是我们需要进一步分析原因的场景。
先来说说第三种情况:锁定库存成功,创建订单失败。当用户下单后,首先调用库存中心提供的 RPC 接口进行库存的锁定,当接口响应成功后,订单中心进行后续创建订单的逻辑,但是在后续操作中由于某些原因出错了,最终导致了订单创建失败。
如果没有分布式事务,那么库存中心的事务已提交并持久化,而订单并未创建,这就导致了没有用户下单,但是商品库存却莫名其妙减少了,这显然是不合理的。
如果使用分布式事务,这种情况将不会出现,因为在整个下单过程中,订单中心和库存中心是处于同一个大事务中的,如果订单中心创建订单失败,那么库存中心已经锁定的商品库存也将被回滚,保证了数据的一致性,使用分布式事务的第三种情况整体流程如下:
接下来是第四种情况:锁定库存失败,创建订单成功。在当前同步的下单场景中并不会出现,因为在当前的场景下只有锁定库存成功,才能够继续创建订单。如果锁定库存失败了,那么订单中心并不会继续创建订单,而是直接返回确认订单失败了。
3. 常见解决方案
接下来我们就来讨论分布式事务的常见解决方案:2PC、3PC、TCC、本地消息表、MQ 事务消息、Saga 事务。
3.1 2PC(强一致性)
二阶段提交协议(Two-phase Commit, 2PC)将分布式事务的提交过程分为准备和提交两个阶段。事务的发起者称事务协调者,事务的执行者称事务参与者。
2PC的整体流程如下:
- 准备阶段
- 事务协调者开启一个事务,它会各个事务参与者发送 canCommit 请求,询问是否可以提交事务,同时等待所有事务参与者的响应。
- 事务参与者收到事务协调者的 canCommit 后,开始执行本地事务操作,它会记录 redo log 和 undo log 日志,但是并不做提交操作,然后将执行结果返回给事务协调者。
- 提交阶段
- 若所有事务参与者都返回成功响应,则发送提交事务指令;只要有一个事务参与者返回失败响应,则会发送回滚指令。
- 事务参与者收到事务协调者发送的指令后,开始执行具体的指令:
- 若收到提交事务指令,则事务参与者提交本地事务、释放事务期间占用的资源并返回提交完成响应,当事务协调者收到所有参与者都提交完成的响应后,本次事务提交完成。
- 若收到回滚事务指令,则事务参与者根据步骤2中记录的 undo log 日志进行回滚操作、释放事务期间占用的资源并返回回滚完成响应,当事务协调者收到所有参与者都回滚完成的响应后,本次事务回滚完成。
- 在整个流程中,事务协调者有超时机制,若长时间未收到事务参与者的响应,则默认事务失败,进行回滚。
2PC 的整体流程图如下:
3.1.1 优点
2PC 模式的优点是:
- 保证强一致性,因为在整个事务运行过程中,事务协调者与事务参与者之前是同步阻塞的,只有在所有事务参与者都提交或回滚成功后,整个事务才结束。
- 对业务无侵入,因为它是基于数据库自身的特性来实现事务的提交和回滚的。
3.1.2 缺点
2PC 的实际应用场景较少,是由于它有一些比较大的问题:
- 性能问题,正是由于 2PC 模式的强一致性,事务协调者与事务参与者之前是同步阻塞的,所以在准备阶段,所有事务参与者的资源都是被锁定的状态,如果这时候有其他事务想要修改这些资源会被阻塞,只有等到事务提交/回滚后才能继续进行,所以 2PC 模式的性能较低。
- 单点故障问题,从 2PC 的流程描述来看,事务的流程整体是由事务协调者来把控的,如果事务协调者宕机,则分布式事务就无法正常运行。如果某个事务参与者在返回 canCommit 响应后,事务协调者宕机了,则这个事务参与者的资源就会一直处于锁定状态。
- 数据不一致问题,在 2PC 模式的提交阶段下,部分事务参与者收到了提交指令,而另一部分事务参与者则由于网络问题没有收到指令,会出现数据不一致的情况,当然这种情况可以通过重试并检查的手段来避免。
3.2 3PC(强一致性)
为了解决 2PC 模式中存在的问题,三阶段提交协议(Three-phase Commit, 3PC)应运而生,它在 2PC 基础上新增了一个预提交阶段,这是为了解决在准备完成之后由于事务协调者宕机,事务参与者不确定自身是提交还是回滚,从而产生一个较长时间的等待的情况。
同时 3PC 还在事务协调者和事务参与者中都引入了超时机制,用来防止一些异常情况发生:
- 预提交阶段,若事务协调者在等待超时时间后,也没有收到事务参与者的响应,则中断事务
- 提交阶段,若事务协调者在等待超时时间后,也没有收到事务参与者的 ACK 响应,则中断事务
- 提交阶段,若事务参与者在等待超时时间后,也没有收到事务协调者的指令通知,则自动提交事务
3PC 的整体流程如下:
- 准备阶段
- 事务协调者开启一个事务,它会各个事务参与者发送 canCommit 请求,询问是否可以提交事务,同时等待所有事务参与者的响应。
- 事务参与者收到事务协调者的 canCommit 后,会检查是否可以执行事务操作,并返回给事务协调者。
- 预提交阶段
- 事务协调者根据事务参与者对 canCommit 请求的响应发送不同的指令:
- 若事务参与者全部返回可以提交,则事务协调者向事务参与者发送 preCommit 指令,事务参与者收到该指令后执行本地事务操作,它会记录 redo log 和 undo log 日志,但是并不做提交操作,然后将执行结果返回给事务协调者。
- 若存在一个事务参与者返回的是不可提交事务,或者超过超时时间未返回响应,则事务协调者向事务参与者发送中断事务指令,事务结束
- 事务协调者根据事务参与者对 canCommit 请求的响应发送不同的指令:
- 提交阶段
- 若发送 preCommit 指令,就会进入真正的提交阶段,若所有事务参与者在执行本地事务后都返回成功响应,则发送提交事务指令;只要有一个事务参与者返回失败响应,则会发送回滚指令。
- 事务参与者收到事务协调者发送的指令后,开始执行具体的指令:
- 若收到提交事务指令,则事务参与者提交本地事务、释放事务期间占用的资源并返回提交完成响应,当事务协调者收到所有参与者都提交完成的响应后,本次事务提交完成。
- 若收到回滚事务指令,则事务参与者根据步骤3.1中记录的 undo log 日志进行回滚操作、释放事务期间占用的资源并返回回滚完成响应,当事务协调者收到所有参与者都回滚完成的响应后,本次事务回滚完成。
- 需要注意的是:若事务协调者出现宕机情况而未发送提交或回滚指令时,事务参与者会在等待超时时间后默认执行提交指令。
3PC 的整体流程图如下:
3.2.1 优点
3PC 模式是 2PC 的升级版,2PC 有的优点 3PC 都有,同时它还有其他优点:
- 一定程度上解决了 2PC 的单点故障问题,防止由于事务协调者宕机导致的资源长时间被锁定
- 准备阶段和预提交阶段事务协调者和事务参与者若在超时时间后未收到响应会中断事务
- 提交阶段参与者会在等待超时时间后默认提交事务
- 与 2PC 相比降低了同步阻塞的范围,因为事务协调者和事务参与者都有超时中断机制。
3.2.2 缺点
3PC 通过引入超时机制和预提交阶段一定程度上解决了 2PC 存在的问题,同时也存在新的问题:
- 数据不一致问题依旧存在,在提交阶段,若事务协调者由于网络原因未将中断事务指令成功发送给某一个事务参与者,而该事务参与者由于超时机制自动提交了事务,就会导致数据不一致情况发生。
- 性能问题更加严重,3PC 增加了预提交阶段,也增加了一次交互,让原本并不好看的性能指标更加烂。
所以 3PC 实际上并没有给 2PC 带来实质上的根本改变,故此 3PC 的应用也寥寥无几。
3.3 TCC(最终一致性)
尝试-确认-取消协议(Try-Confirm-Cancel, TCC)有别于 2PC 和 3PC,后两者都是依赖于数据库提供的能力进行事务的提交和回滚的,对业务侵入性较小,而 TCC 模式中事务的提交和回滚都是依赖于业务代码实现的,它是一种在业务层面实现的分布式事务二阶段提交模式。
TCC 全称是 Try-Confirm-Cancel,每个单词代表的就是整个处理流程中的不同阶段(后两者是同一阶段的不同分支):
- Try:第一个阶段,也就是 2PC 中的准备阶段,在 TCC 模式中以业务代码的形式完成资源的检查和预留
- Confirm:第二个阶段,也就是 2PC 中提交阶段,在 TCC 模式中以业务代码的形式完成真正业务的执行并提交事务
- Cancel:第二个阶段失败时的回滚分支,也属于 2PC 中的提交阶段,在 TCC 模式中以业务代码的形式完成预留资源的取消
TCC 的整体流程如下:
- Try 阶段
- 主业务服务开启一个事务,向从业务服务发送 Try 请求
- 从业务服务收到 Try 请求后,执行 Try 操作,完成资源的检查和预留,并返回 Try 操作执行结果
- 若主业务服务收到的 Try 操作响应都是成功,则进入 Confirm 阶段
- 主业务服务发送 Confirm 指令确认提交事务
- 从业务服务收到 Confirm 指令后,完成事务的提交,若提交事务失败,会不断重试直到成功
- 当主业务服务收到所有从业务服务提交事务成功的响应后,事务提交成功,事务结束
- 若主业务服务收到的 Try 操作响应有一个是失败,则进入 Cancel 阶段
- 主业务服务发送 Cancel 指令取消事务
- 从业务服务收到 Cancel 指令后,完成事务的回滚,若取消事务失败,会不断重试直到成功
- 当主业务服务收到所有从业务服务取消事务成功的响应后,事务取消成功,事务结束
TCC 的整体流程图如下:
3.3.1 优点
- 相比于 2PC/3PC,TCC 的性能较好,TCC 通过业务代码来实现具体资源的锁定,锁粒度更小
- 数据最终一致性,在 Confirm 和 Cancel 阶段,从业务服务会有因失败而不断重试的过程,保证了数据的最终一致性
- 这里就需要 TCC 的业务代码能够保证幂等性,否则数据会因重复执行而错误
3.3.2 缺点
TCC 的缺点是:
- 业务侵入性强,因为 TCC 的三个阶段都需要业务代码来实现,对业务代码的侵入性比较高
3.4 本地消息表(最终一致性)
本地消息表模式其实就是采用了最大努力通知的思想,它会在数据库中建立一张本地事务消息表,事务主动方执行本地事务时囊括了写入本地事务消息表的业务,本地事务成功以后会将消息发送给事务被动方并执行,若事务被动方执行成功,则调用事务主动方提供的 RPC 接口修改本地事务消息表中数据的状态,代表事务执行成功。
若事务被动方执行事务失败也没关系,这时候会有一个后台线程对本地事务消息表进行扫描,若发现事务状态未成功的消息,会进行重试,若超过一定次数还是未重试成功,则需要人工介入进行处理。
本地消息表的具体流程如下:
- 事务主动方开启事务,处理本地事务,同时往本地消息表中写入一条数据
- 若本地事务执行成功,将消息发送到消息中间件,消息中间件将该消息通知给事务被动方进行处理
- 事务被动方接收到消息后,执行本地事务
- 若事务被动方本地事务执行失败,则发送回滚消息,消息中间件将回滚消息通知事务主动方,进行事务回滚,若此时有其他事务被动方已成功执行本地事务,事务主动方还需要将回滚消息通知其他执行成功的事务被动方进行事务回滚
- 若事务被动方本地事务执行成功,将通过消息中间件通知事务主动方,然后将本地消息表中的消息状态更新为已完成
本地消息表的整体流程图如下:
3.4.1 优点
本地消息表的优点:
- 实现了数据的最终一致性
3.4.2 缺点
本地消息表的缺点:
- 由于本地消息表的存在,可能会占用一定的数据库资源,影响数据库性能
- 业务侵入性高,事务的提交回滚与业务耦合度大
3.5 MQ 事务消息(最终一致性)
MQ 事务消息其实就是本地消息表模型的一种实现方式,这里采用 RocketMQ 提供的事务消息进行陈述。我在之前的博客中对 RocketMQ 事务消息有详细的介绍,有兴趣的同学可以直接转战《RocketMQ.4-基于事务消息解决分布式事务》,下面关于 MQ 事务消息的内容也大多摘抄自这篇博客。
MQ 事务消息的具体流程如下:
- 消息生产者发送一个半消息到 Broker,如果半消息发送成功,Broker 将返回一个发送成功的标识,生产者开始执行本地事务。如果半消息发送失败,整个消息也就崩于起始,发送失败。
- 在生产者执行本地事务时或断网等特殊情况下,Broker 未收到本地事务的最终执行状态,Broker 会定时发起消息回查来检查本地事务的执行状态。
- 当生产者执行本地事务结束后,生产者会发送一个二次确认的标识给 Broker,如果是 Commit 那代表本地事务执行成功,半消息将被标记为可投递,消费者将收到该消息;如果是 Rollback 则代表本地事务执行失败,半消息将被删除,消费者也就不会收到这条消息。
- 消费者最终将收到被标记为可投递的半消息进行消费。
MQ 事务消息的整体流程图如下:
3.5.1 优点
MQ 事务消息的优点:
- 与本地事务表相比,MQ 事务消息的存储是在 RocketMQ 中,与业务数据库隔离,并不会影响数据库性能
3.5.2 缺点
MQ 事务消息的缺点:
- 事务提交与回滚对业务代码侵入性强,同时还需要提供回查本地消息执行状态的接口
- 完全依赖于 RocketMQ 来实现分布式事务,可能存在单点故障,同时若发生消息丢失会比较麻烦
3.6 Saga(最终一致性)
Saga 模式的核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。这样做的目的是为了避免大事务长时间锁定数据库的资源。
Saga 模式的流程有两种模式:集中式实现和基于事件实现。
先来说说集中式实现,它通过事务协调器集中处理事件的决策和业务逻辑排序。具体流程如下:
- 主事务服务向 Saga 事务协调器提交开启事务请求,并执行本地短事务
- 事务协调器基于类似责任链模式的形式向从事务服务发起事务请求,每个从事务服务按顺序处理本地短事务并返回执行结果
Saga 模式集中式实现的流程图如下:
第二种实现方式是基于事件实现,每个服务监听其他服务的事件来执行本地短事务,同时发布自身事件。其流程如下:
- 主事务服务开启事务,执行本地短事务,并发布自身事件
- 从事务服务1监听主事务服务的事件,执行本地短事务,并发布自身事件
- 直到最后一个从事务服务执行完本地短事务后不再发布事件时,本次分布式事务结束
Saga 模式基于事件实现的流程图如下:
上述的这两种实现方式还没有涉及异常流程,因为在 Saga 模式中如果某个步骤失败,会根据相反顺序一次调用补偿操作,这里的补偿操作有两种策略:
第一种是向前恢复策略(forward recovery),这种策略适用于必须要成功的场景,它会对失败的节点进行重试,直到成功,这里就不需要进行额外的补偿操作。
第二种是向后恢复策略(backward recovery),这种策略会将当前失败节点前每一个节点都进行回滚,从而保证整体事回滚成功。向后恢复策略的流程图如下:
3.6.1 优点
Saga 模式的优点:
- 锁定资源的粒度较小,且是本地事务,性能较高
- 基于事件实现的 Saga 模式采用异步执行,吞吐量高
3.6.2 缺点
Saga 模式的缺点:
- 仅能保证弱一致性,由于其向后恢复策略,可能产生事务已提交又回滚的情况,引起业务上的歧义
- 存在数据隔离性问题,当多个 Saga 事务操作同一个资源时,可能产生更新丢失、脏数据问题
- 基于事件实现的 Saga 模式可能产生服务间依赖较复杂的长事务,遇到问题难以追踪
4. 参考资料
- 理解分布式事务
[20 张图搞懂「分布式事务」 🏆 技术专题第五期征文](https://juejin.cn/post/6874788280378851335#heading-2)
最后,本文收录于个人语雀知识库: 我所理解的后端技术,欢迎来访。