前言
上一篇文章我们将整个交易系统进行了微服务化,拆分为了多个相互独立的业务组件,每个业务组件不只是包含自己业务的微服务,还包括了独立管理的数据库。那么,我们来考虑下单的场景,用户下委托单的时候,主要有三步操作:一是冻结金额,二是新增订单,三是投递给到撮合引擎。这三步需要保证事务的一致性。在服务和数据库都不拆分的情况下,是很容易满足的。但拆分之后,这几个步骤的操作也分开到不同业务组件了,服务是分开的,数据库也是分开的。在这种分布式的环境下,又要如何保证事务的一致性,这就是分布式事务问题了。
那分布式事务问题都有哪些解决方案?怎么选型?如何落地?本篇文章我们就来一一解答这些问题。
从 ACID 说起
ACID 是数据库事务正常执行的四个特性,分别指:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
原子性要求一个事务的一系列操作要么全部完成,要么全部不完成,不能停滞在中间某个环节。如果中间发生错误,应该回滚到事务开始前的状态。
持久性则要求事务结束后,其结果应该是持久化的。在数据库层面,普遍都是用 WAL(Write-Ahead Logging) 技术来保证原子性和持久性的。
隔离性是为了应对并发事务的,要求并发执行的各个事务之间是相互隔离的,防止多个事务并发执行时由于交叉执行导致数据的不一致。如果不考虑隔离,则可能会出现脏读、不可重复读、幻读等问题。本质上,隔离的实现其实就是并发控制。SQL 标准中,定义了 4 种隔离级别,由低到高分别为:未提交读(Read Uncommitted)、已提交读(Read Committed)、可重复读(Repeatable Read)、串行化(Serializable)。隔离级别越低的事务,并发性更好,但一致性更低。而标准定义的这 4 种隔离级别,其实只适用于基于锁的事务并发控制。后来,出现了基于 MVCC(多版本并发控制) 机制的隔离方案,该机制相对于基于锁的并发控制主要特点是读不上锁,这种特性对于读多写少的场景,大大提高了系统的并发性能,因此大部分数据库都实现了 MVCC。
一致性很容易和 CAP 中的 C 混淆,但其实两者是不同概念。CAP 中的一致性,具体到数据库上,指的是在分布式数据库中,每一个节点对于同一个数据必须有相同的拷贝。而事务的一致性,确保事务只能将数据库从一种有效状态转移到另一种有效状态,并保持数据库不变性,不存在可感知的中间状态。所谓有效状态就是满足预定的约束,包括数据库层面的各种约束,也包括业务逻辑上的约束。
解释事务一致性最常用的例子就是转账,假设 A 向 B 转账 100 元。如果 A 的账户余额只剩下 90 元,而数据库对账户余额的约束条件是不能小于 0,那如果还能转账成功的话,A 的账户余额将变成负数,不符合约束条件,也就不满足一致性。如果 A 的账户余额充足,那就需要分两个步骤,先扣减 A 的账户余额 100 元,再给 B 的账户余额增加 100 元,如果只完成了第一步,事务就结束了,那业务逻辑上就是不正确的,即整个事务也不满足一致性。只有 A 的账户余额扣减了 100 元,同时 B 的账户余额增加了 100 元,两步都一起成功,且不受其他并发事务的干扰,这时整个事务才保证了一致性。
从本质上来说,原子性、隔离性、持久性,最终目的都是为了保证一致性。即一致性是最终目标,原子性、隔离性、持久性可以说都是为了实现这一目标的手段。
所以,不管是本地事务,还是分布式事务,最终的目标都是为了保证一致性。只是针对不同场景,有着不同的实现方案,且对一致性的强弱程度有所取舍。
XA 规范
分布式事务的解决方案有很多种,XA 规范是其中一种有代表性的标准方案,是由 X/Open 组织在 1991 年提出来的,该规范的文档为:《Distributed Transaction Processing: The XA Specification》。
XA 规范里描述了一个 DTP 模型,这是一个实现分布式事务处理系统的概念模型。其实不只是 X/Open,OSI 其实也有正式文档对 DTP 模型进行了描述。XA 规范里描述了该模型包含三类角色:
- AP:Application Pragram,应用程序,定义了事务的边界,以及指定了组成一个事务的行为,可以理解为就是事务发起的某个微服务。
- RMs:Resource Managers,资源管理器,有多个,可以理解为就是分布式数据库中的每一个数据库实例。
- TM:Transaction Manager,事务管理器,负责协调和管理事务,是一个控制全局事务的协调者。
之所以引入 TM,是因为整个全局事务被分散到多个节点之后,每个节点虽然可以知道自己操作是否成功,但是却无法得知其他节点上操作是否成功,靠分散的节点自身并无法保证全局事务的一致性,因此需要引入一个协调者来管理全局,进而才能保证全局事务的 ACID。
这三者的关系图如下:
XA 规范里还定义了一系列接口,称为 XA 接口,用于 TM 和 RM 之间通讯的接口,主要包含了以下这些接口:
TM 与 RM 之间实现事务的完成和回滚,是使用了 2PC(Two-Phase Commit) 协议——即两阶段提交协议来实现的。2PC 协议的提出时间相比 XA 规范早得多,最早是分布式事务的专家 Jim Gray 在 1977 年的一篇文章 《Notes on Database Operating Systems》 中提及。
2PC 协议
2PC = Two-Phase Commit,两阶段提交,将事务分割成先后两个阶段:Prepare 阶段和 Commit 阶段。
在开始两阶段提交之前,涉及的 RM 是需要先注册到 TM 的。然后,AP 向 TM 发起一个全局事务,之后,就开始进入该事务的两阶段提交了。
Prepare 阶段,由 TM 向涉及的每个 RM 都发送 prepare 请求,并等待 RMs 的响应。RM 接收到请求之后,执行本地事务但不会提交,并记录下事务日志,即 undo 和 redo 日志。RM 的本地事务如果执行成功,则返回给 TM ok 的响应,如果本地事务执行失败,则响应 error。在这一阶段,RM 执行本地事务成功的话,因为没有提交,就会一直锁定事务资源,并等待 TM 的下一步指令。
Prepare 阶段结束后,会存在三种可能性:
- 所有的 RM 都响应 ok
- 一个或多个 RM 响应 error
- TM 等待 RM 的响应超时
Commit 阶段,根据以上三种不同结果,会执行不一样的操作。如果是所有 RM 都响应 ok,那 TM 就向所有 RM 发送 commit 请求。如果出现其他两种情况,则由 TM 向所有 RM 发送 rollback 请求。RM 收到 commit 请求的话,就会将上一阶段未提交的本地事务进行提交操作,如果收到 rollback 请求,那就回滚本地事务。
整个流程大致如下图:
流程上虽然简单,但分布式系统,随时可能发生网络超时、网络重发、服务器宕机等问题,因此也会给分布式事务带来一些问题。主要有幂等处理、空回滚、资源悬挂这几个问题。
当 TM 向 RM 发送 commit/rollback 请求时,如果出现网络抖动等原因,导致请求超时或中断,那 TM 就需要向 RM 重复发送 commit/rollback 请求。对 RM 来说,第一次 commit/rollback 请求可能已经接收到了,且已经处理过了,但因为网络原因导致 TM 没收到响应。那 RM 再次收到同样的 commit/rollback 请求,肯定不能再处理一次本地事务。正确的做法就是 RM 的 commit/rollback 接口需要保证幂等性。
如果 RM 没收到 prepare 请求,但收到了 rollback 请求,那这个 rollback 请求其实是无效的,即本次 rollback 就属于空回滚。要解决空回滚的问题,那 rollback 时需要识别到前一阶段的 prepare 是否已经执行。
如果 prepare 请求因为网络拥堵而超时,之后 TM 发起了 rollback,而最终 RM 又收到了超时的 prepare 请求,但 rollback 比 prepare 先到达 RM。这种情况下,收到 prepare 的时候,整个全局事务其实已经结束了,如果再执行 prepare 请求,就会锁定相关资源,但事务已经结束,锁定的资源将无法释放。至此,就形成了资源悬挂。
解决这三个问题的方案,普遍都可以用事务状态控制表来解决,该表主要包含了全局事务ID、分支事务ID、分支事务状态。
XA/2PC 小结
XA/2PC 用在分布式事务,一般情况下能够保证事务的 ACID 特性,能利用数据库自身的实现进行本地事务的提交和回滚,对业务没有侵入。但 2PC 的缺点主要有以下几个:
- 同步阻塞:在执行过程中,所有 RM 都是事务阻塞型的,如果 RM 占有了公共资源,那其他第三方要访问公共资源时就会处于阻塞状态。
- TM单点故障:一旦 TM 发生故障,RMs 会由于等待 TM 的消息,而一直锁定事务资源,导致整个系统被阻塞。
- 数据不一致:在第二阶段中,当 TM 向 RMs 发送 commit 请求之后,发生了局部网络异常或者在发送 commit 请求过程中 TM 发生了故障,导致只有部分 RMs 接到了 commit 请求。这部分 RMs 接到 commit 请求之后就会执行 commit 操作,但是其他未接到 commit 请求的 RMs 则无法执行事务提交。于是整个分布式系统便出现了数据不一致的现象。
- 事务状态不确定:TM 发出 commit 消息之后宕机,而接收到这条消息的 RM 同时也宕机了,那么即使通过选举协议产生了新的 TM,这条事务的状态也是不确定的,集群中无法判断出事务是否已经被提交。
2PC 最大的问题其实是性能差,处理短事务可能还好,要是处理长事务,那资源锁定时间更长,性能更差,根本无法忍受。
为了改进 2PC,后来又提出了 3PC。3PC 给 RMs 也增加了超时机制,而且把整个事务拆成了三个阶段。不过,3PC 也只是解决了 2PC 的部分问题,并没有解决性能差的问题,而且因为多增加了一个阶段,导致性能更差了。因此,3PC 几乎没人使用,我也没找到落地实现,所以我也不打算深入去讲解 3PC。2PC 虽然有缺陷,反而还有落地实现,开源框架就有 Atomikos、Bitronix 以及 Seata 的 XA 模式,另外,大部分主流数据库厂商也落地实现了 XA/2PC。因此,对强一致性有要求的场景,2PC 依然还是最佳选择。
说到开源框架,我要补充一下,目前成熟的分布式事务框架,包括下面提到的,基本都是基于 Java 的,其他语言的落地实现都还不成熟。
柔性事务
符合 ACID 特性的事务,也可以称为刚性事务,主要保证强一致性。XA/2PC 就是解决刚性分布式事务的主要方案,但因为性能太差,并不适合高性能、高并发的互联网场景。为了解决性能问题,就有人基于 BASE 理论提出了柔性事务的概念。BASE 理论其实就是 Basically Available(基本可用)、Soft state(软状态)、Eventual consistency(最终一致性) 三个短语的缩写,其核心思想就是:
即使无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)
。
前面我们说过,事务的(强)一致性,确保事务只能将数据库从一种有效状态转移到另一种有效状态,不存在可感知的中间状态。柔性事务就允许数据存在中间状态(即软状态),只要经过一段时间后,能达到最终一致性即可。最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。跨行转账就是最好的一个示例,转账时,其实有个资金冻结的中间状态,且要经过一段时间才知道转账结果,即达到最终一致性的结果。
刚性事务在隔离性方面,主要是通过资源锁定的方式实现资源隔离的,在数据库层面自身就提供了这种隔离实现,不需要业务实现。而柔性事务则一般不用锁,而是通过资源预留(比如冻结金额)的方式实现隔离,且这种资源预留的隔离方式,是需要业务自己去实现并保证隔离性的。
柔性事务的解决方案主要分为补偿型和通知型两大类,补偿型的方案主要有 TCC 和 Saga 两种模式,通知型的方案则又分为事务消息型和最大努力通知型。补偿型事务一般是同步的,通知型事务则是异步的,所以也有同步事务和异步事务的划分。
TCC
TCC = Try-Confirm-Cancel,就是三个单词的缩写。TCC 最早的出现其实可以追溯到 2007 年的一篇论文:《Life beyond Distributed Transactions: an Apostate’s Opinion》,在该论文中,其实原本的三个单词是 Tentative-Confirmation-Cancellation,正式以 Try-Confirm-Cancel 作为名称的是 Atomikos 公司,且还注册了 TCC 商标。
TCC 其实也是基于 2PC 的设计思路演变过来的,也同样分两个阶段进行事务提交,第一阶段提交 Try 接口,第二阶段提交 Confirm 或 Cancel 接口。TCC 的 Try-Confirm-Cancel 虽然与 2PC 的 Prepare-Commit-Rollback 很相似,但实现却大不同。
2PC 其实是数据库层面或者说是资源层面的分布式事务方案,Prepare-Commit-Rollback,这几个操作其实都在数据库内部完成的,开发者层面是感知不到的。
TCC 则是业务层面的分布式事务方案,Try-Confirm-Cancel 都是在业务层面实现的操作,开发者是能感知到的,是需要开发者自己去实现这几个操作的。
Try 阶段,主要会完成所有业务检查,并对需要用到的业务资源转为中间状态,通过该方式实现资源预留。比如,转账时将金额冻结。
如果 Try 阶段,所有分支业务都回复 OK,第二阶段就对所有分支业务提交 Confirm,确认执行业务,这时候就无需再做业务检查了,而是直接将中间状态的资源转为最终状态。
如果 Try 阶段,并非所有分支业务都回复 OK,这时就要走补偿机制了,对那些 Try 操作 OK 的分支业务,执行 Cancel 补偿操作,回滚 Try 操作,将中间状态的资源恢复到事务前的状态。
TCC 的具体流程如下图所示:
注意,第一阶段的 Try 接口是由业务应用调用的(实线箭头),第二阶段的 Confirm 或 Cancel 接口则是事务协调器 TM 调用的(虚线箭头)。这就是 TCC 模型的二阶段异步化功能,分支业务服务的第一阶段执行成功,业务应用就可以提交完成,然后再由事务协调器异步地执行各分支业务服务的第二阶段。
TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段都需要支持幂等。如果重试失败,则需要人工介入进行恢复和处理了。TCC 除了需要支持幂等处理,前面提到的空回滚、资源悬挂的问题也同样需要解决。
TCC 相对 2PC,因为对资源不加锁,不会影响并发事务对资源的访问,所以性能得到大幅提高,就适合处理高并发的场景,也适合处理长事务。
但 TCC 的缺点就是对业务侵入性太强,需要大量开发工作进行业务改造,给业务升级和运维都带来困难。TCC 落地实现的开源框架主要有 ByteTCC、TCC-transaction、Himly、Seata TCC 模式等。
Saga
Saga 的起源比 TCC 早得多,起源于 1987 年的一篇论文《Sagas》,讲述的是如何处理 long lived transaction(长活事务)。Saga 的核心思想就是将一个长事务分解为多个短事务(也叫子事务),每个子事务都是能保证自身一致性的本地事务,且每个子事务都有相应的执行模块和补偿模块。当其中任意一个子事务出错了,就可以通过调用相关的补偿方法恢复到事务的初始状态,从而达到事务的最终一致性。
总的来说,Saga 的组成包含两部分:
- 每个 saga 事务由一系列子事务 Ti 所组成
- 每个 Ti 都有对应的补偿动作 Ci,用于撤销 Ti 造成的结果
和 TCC 相比,Saga 事务没有分两阶段提交,没有“预留”的动作,Ti 是直接提交到库的。因此,有人就会提问了:那 Saga 怎么保证隔离性的呢?其实,Saga 本身并不保证隔离性,需要业务自己控制并发,即在业务层自己实现对资源的加锁或预留。
最佳情况就是整个子事务序列 T1, T2, ..., Tn 全部都执行成功,整个 Saga 事务也就执行成功了。
如果执行到某一子事务失败了,那有两种恢复方式:向前恢复和向后恢复。
- 向前恢复:重试失败的事务,假设每个子事务最终都会成功
- 向后恢复:补偿所有已完成的事务,本质就是所有已完成的本地事务进行回滚操作
显然,向前恢复就没必要提供补偿方法。如果你的业务中,子事务最终总会成功,或补偿方法难以定义或不可能,向前恢复更符合你的需求。
向后恢复的话,如果出现子事务失败,会立即将失败信息响应给 AP,之后的补偿操作则是异步执行的。
理论上,补偿方法是必须要成功的,如果执行补偿操作时,因为服务器宕机或网络抖动等原因导致补偿失败,那就需要对补偿方法也进行重试,如果重试依然失败,那就需要人工介入进行处理了。
Saga 的实现方式,主要分集中式和非集中式两种。
集中式的实现需要依赖中心化的协调器(TM)负责服务调用和事务协调,主要是基于 AOP Proxy 的设计实现,华为的 ServiceComb Saga 就用这种实现方式。集中式的实现方式比较直观并且容易控制,开发简单、学习成本低,缺点就是业务耦合程度会比较高。
非集中式的实现,也称为分布式的实现,不依赖于中心化的 TM,而是通过事件驱动的机制进行事务协调,Seata Saga 就采用了这种机制,实现了一个状态机。非集中式的实现的优点: 采用事件源的方式降低系统复杂程度,提升系统扩展性, 处理模块通过订阅事件的方式降低系统的耦合程度。缺点则是: Saga 系统会涉及大量的业务事件,这样会对编码和调试带来一些问题;还有就是相关的业务逻辑处理是基于事件,相关事件处理模块可能会有循环依赖的问题。
Saga 因为没有两阶段提交,所以,Saga 处理事务请求所花费的时间可能只是 TCC 的一半, 因为 TCC 需要与每个服务至少进行两次通信,而 Saga 只需要通信一次。因此,理论上,Saga 的性能比 TCC 至少可以高一倍。且因为 Saga 对业务的侵入性较小,所以 Saga 是目前行业内落地较多的成功方案。
事务消息型
事务消息型,也称异步确保型,核心思路就是:用消息队列(MQ)来保证最终一致性。相比同步的补偿型方案,引入 MQ 的异步方案,主要有以下优点:
- 可以降低不同分支事务的微服务之间的耦合度
- 可以提高各服务的吞吐量
- 可以增强整体服务的可用性
那么,引入 MQ 之后,最核心的问题在于如何解决服务本地事务处理成功
与消息发送成功
两者的一致性问题。即 MQ 消息的上游服务处理完本地事务之后,如何才能保证消息可靠地传递给到下游服务。而目前业界解决该问题的方案有两种:
- 基于 MQ 自身的事务消息
- 基于 DB 的本地消息表
MQ 事务消息
基于 MQ 自身的事务消息方案,据了解,目前只有 RocketMQ 提供了支持,其他主流的 MQ 都还不支持,所以我们对该方案的解说都是基于 RocketMQ 的。该方案的设计思路是基于 2PC 的,事务消息交互流程如下图所示:
其中,涉及几个概念要说明一下:
- 事务消息:消息队列 MQ 提供类似 X/Open XA 的分布式事务功能,通过 MQ 事务消息能达到分布式事务的最终一致。
- 半事务消息:暂不能投递的消息,发送方已经成功地将消息发送到了 MQ 服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半事务消息。
- 消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,MQ 服务端通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit或是Rollback),该询问过程即消息回查。
事务消息发送步骤如下:
- 发送方将半事务消息发送至 MQ 服务端。
- MQ 服务端将消息持久化成功之后,向发送方返回 Ack 确认消息已经发送成功,此时消息为半事务消息。
- 发送方开始执行本地事务逻辑。
- 发送方根据本地事务执行结果向服务端提交二次确认(Commit 或是 Rollback),服务端收到 Commit 状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到 Rollback 状态则删除半事务消息,订阅方将不会接受该消息。
事务消息回查步骤如下:
- 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。
- 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行操作。
有一点需注意,如果发送方没有及时收到 MQ 服务端的 Ack 结果,那就可能造成 MQ 消息的重复投递,因此,订阅方必须对消息的消费做幂等处理,不能造成同一条消息重复消费的情况。
MQ 事务消息方案的最大缺点就是对业务具有侵入性,业务发送方需要提供回查接口。
本地消息表
本地消息表方案最初是由 ebay 提出的,后来通过支付宝等公司的布道,在国内被广泛使用。对于不支持事务消息的 MQ 则可以采用此方案,其核心的设计思路就是将事务消息存储到本地数据库中,并且消息数据的记录与业务数据的记录必须在同一个事务内完成。将消息数据保存到 DB 之后,就可以通过一个定时任务到 DB 中去轮询查出状态为待发送
的消息,然后将消息投递给 MQ,成功收到 MQ 的 ACK 确认之后,再将 DB 中消息的状态更新或者删除消息。交互流程如下图所示:
处理步骤如下:
- 消息生产者在本地事务中处理业务更新操作,并写一条事务消息到本地消息表,该消息的状态为
待发送
,业务操作和写消息表都在同一个本地事务中完成。 - 定时任务不断轮询从本地消息表中查询出状态为
待发送
状态的消息,并将查出的所有消息投递到 MQ Server。 - MQ Server 接收到消息之后,就会将消息进行持久化,然后返回 ACK 给到消息生产者。
- 消息生产者收到了 MQ Server 的 ACK 之后,再从本地消息表中查询出对应的消息记录,将消息的状态更新为
已发送
,或者直接删除消息记录。 - MQ Server 返回 ACK 给到消息生产者之后,接着就会将消息发送给消息消费者。
- 消息消费者接收到消息之后,执行本地事务,最后返回 ACK 给到 MQ Server。
因为 MQ 宕机或网络中断等原因,生产者有可能会向 MQ 发送重复消息,因此,消费者接收消息后的处理需要支持幂等。
该方案,相比 MQ 事务消息方案,其优点就是弱化了对 MQ 的依赖,因为消息数据的可靠性依赖于本地消息表,而不依赖于 MQ。还有一个优点就是容易实现。缺点则是本地消息表与业务耦合在一起,难以做成通用性,且消息数据与业务数据同个数据库,占用了业务系统资源。本地消息表是基于数据库来做的,而数据库是要读写磁盘 I/O 的,因此在高并发下是有性能瓶颈的。
最大努力通知型
最大努力通知型也是基于事务消息型扩展而来的,其应用场景主要用于通知外部的第三方系统。即是说,最大努力通知型方案,主要解决的其实是跨平台、跨企业的系统间的业务交互问题。而事务消息型方案则适用于同个网络体系的内部服务间的分布式事务。
最大努力通知型一般会引入一个通知服务,由通知服务向第三方系统发送通知消息。其简化的流程如下图:
通知服务与第三方系统之间的交互一般是通知服务通过调用第三方系统的接口完成的,发送的通知消息可以允许丢失。
通知服务会提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用第三方系统的接口。在通知 N 次之后就不再通知,这就是所谓的最大努力通知,N 次通知之后都失败的话,那就需要报警+记日志+人工介入了。
因为通知服务可能会多次调用第三方系统,所以第三方系统提供的接口就需要做幂等处理。
通知服务还需要有定期校验机制,对业务数据进行兜底,防止第三方无法履行责任时进行业务回滚,确保数据最终一致性。
如何选型
至此,可以解决分布式事务问题的方案我们基本都讲了个遍,那要把分布式事务落地到我们的交易系统中,应该如何选型呢?我们将每种方案先做个对比吧,看下表:
属性 | XA/2PC | TCC | Saga | MQ事务消息 | 本地消息表 | 最大努力通知型 |
事务一致性 | 强 | 中 | 弱 | 弱 | 弱 | 弱 |
性能 | 低 | 中 | 高 | 高 | 高 | 高 |
业务侵入性 | 小 | 大 | 小 | 中 | 中 | 中 |
复杂性 | 中 | 高 | 中 | 低 | 低 | 低 |
维护成本 | 低 | 高 | 中 | 中 | 低 | 中 |
而具体如何选型,其实还是需要根据场景而定。在第一篇文章就说过,我们应该由场景驱动架构,离开场景谈架构就是耍流氓。
如果是要解决和外部第三方系统的业务交互,比如交易系统对接了第三方支付系统,那我们就只能选择最大努力通知型。
如果对强一致性有刚性要求的短事务,对高性能和高并发则没要求的场景,那可以考虑用 XA/2PC,如果是用 Java 的话,那落地实现可以直接用 Seata 框架的 XA 模式。
如果对一致性要求高,实时性要求也高,执行时间确定且较短的场景,就比较适合用 TCC,比如用在互联网金融的交易、支付、账务事务。落地实现如果是 Java 也建议可以直接用 Seata 的 TCC 模式。
Saga 则适合于业务场景事务并发操作同一资源较少的情况,因为 Saga 本身不能保证隔离性。而且,Saga 没有预留资源的动作,所以补偿动作最好也是容易处理的场景。
MQ 事务消息和本地消息表方案适用于异步事务,对一致性的要求比较低,业务上能容忍较长时间的数据不一致,事务涉及的参与方和参与环节也较少,且业务上还有对账/校验系统兜底。如果系统中用到了 RocketMQ,那就可以考虑用 MQ 事务消息方案,因为 MQ 事务消息方案目前只有 RocketMQ 支持。否则,那就考虑用本地消息表方案。
其实,还有一个选型,就是业务规避,意思就是说可以从业务上稍作调整,从而规避掉分布式事务,这是解决分布式事务问题最优雅的方案,没有之一。业务规避,本质上就是消灭掉问题本身,需要换位思考,跳出惯性思维从不同维度去思考解决问题的方案,有时候可能还会牺牲一些业务特性。不过,现实情况却是,大部分场景下很难做到业务规避,那只能老老实实地解决分布式事务问题。
另外,有些现实场景还具有特殊性,这时候就不能直接套用上面的说法,而要根据具体场景而调整方案。比如,具体到我们的交易系统,我们来看看下单这个业务的分布式事务处理方案。
下单其实存在三个步骤:
- 创建订单;
- 冻结用户的资产账户余额;
- 将订单投递给到撮合引擎进行撮合。
下单事务的发起方是交易服务,第一步也是在交易服务完成的,而第二步应该是在公共服务完成的——因为我们还没有将账户服务抽离出来——第三步则是通过 MQ 将订单投递给到撮合引擎。
从上面提到的场景分类来说,我们的交易场景属于互联网金融的交易事务,那比较适合用 TCC,但最后一步又是异步事务,这又该怎么选呢?其实,前两步用 TCC 保证同步事务的一致性,而第三步用本地消息表来异步确保消息的可靠投递,这样的处理是可以的。但必须前两步的事务执行成功后,才把消息写入消息表。而撮合引擎作为 MQ 的消费者,就需要做幂等处理了。
因此,一个事务有时候并非就只能用一种单一的方案,可以组合,可以演变的。分布式事务问题之所以复杂,最根本的原因也在此,现实场景远比理论复杂多变。
总结
我们用了很长的篇幅讲解了分布式事务的各种解决方案,从 ACID 讲起,再讲了 XA、2PC、TCC、Saga、MQ 事务消息、本地消息表、最大努力通知型,最后对所有方案做了汇总的对比,以及描述了适用的场景。不过,分布式事务的话题还远不是这一篇文章就能说得完的,因为篇幅所限,很多设计细节也没展开,而且更具体的落地实现还会更复杂。另外,如果系统的开发语言不是 Java 的话,那大概率是需要自己来实现解决分布式事务的。
最后,我上面所说的不一定都是对的,如果有讲错或遗漏的地方,欢迎留言并指出。