什么是分布式事务?
在介绍分布式事务之前,我们首先来看一下什么是事务。
事务(Transaction)提供一种机制,将包含一系列操作的工作序列纳入到一个不可分割的执行单元。只有所有操作均被正确执行才能提交事务;任意一个操作失败都会导致整个事务回滚(Rollback)到之前状态,即所有操作均被取消。简单来说,事务提供了一种机制,使得工作要么全部都不做,要么完全被执行,即 all or nothing。
通常情况下,我们所说的事务指的都是本地事务,也就是在单机上的事务。而事务具备四大基本特征 ACID,具体含义如下。
- A:原子性(Atomicity),即事务最终的状态只有两种,全部执行成功和全部不执行,不会停留在中间某个环节。若处理事务的任何一项操作不成功,就会导致整个事务失败。一旦操作失败,所有操作都会被取消(即回滚),使得事务仿佛没有被执行过一样。就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品在商家手中,消费者的钱也没花出去。
- C:一致性(Consistency),是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。比如,用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元,用户 A 给用户 B 转账 200 元,分为两个步骤,从 A 的账户扣除 200 元和对 B 的账户增加 200 元。一致性就是要求上述步骤操作后,最后的结果是用户 A 还有 600 元,用户 B 有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200 元,但用户 B 未增加的情况(该情况,用户 A 和 B 均为 600 元,总共 1200 元)。
- I:隔离性(Isolation),是指当系统内有多个事务并发执行时,多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。
- D:持久性(Durability),也被称为永久性,是指一个事务被执行后,那么它对数据库所做的更新就永久地保存下来了。即使发生系统崩溃或宕机等故障,重新启动数据库系统后,只要数据库能够重新被访问,那么一定能够将其恢复到事务完成时的状态。就像消费者在网站上的购买记录,即使换了手机,也依然可以查到。
只有在数据操作请求满足上述四个特性的条件下,存储系统才能保证处于正确的工作状态。因此,无论是在传统的集中式存储系统还是在分布式存储系统中,任何数据操作请求都必须满足 ACID 特性。
分布式事务,就是在分布式系统中运行的事务,由多个本地事务组合而成。在分布式场景下,对事务的处理操作可能来自不同的机器,甚至是来自不同的操作系统。文章开头提到的电商处理订单问题,就是典型的分布式事务。
分布式事务由多个事务组成,因此基本满足 ACID,其中的 C 是强一致性,也就是所有操作均执行成功,才提交最终结果,以保证数据一致性或完整性。但随着分布式系统规模不断扩大,复杂度急剧上升,达成强一致性所需时间周期较长,限定了复杂业务的处理。为了适应复杂业务,出现了 BASE 理论,该理论的一个关键点就是采用最终一致性代替强一致性。我会在“知识扩展”模块与你详细展开 BASE 理论这部分内容。
介绍完什么是事务和分布式事务,以及它们的基本特征后,就进入“怎么做”的阶段啦。所以接下来,我们就看看如何实现分布式事务吧。
如何实现分布式事务?
实际上,分布式事务主要是解决在分布式环境下,组合事务的一致性问题。实现分布式事务有以下 3 种基本方法:
- 基于 XA 协议的二阶段提交协议方法;
- 三阶段提交协议方法;
- 基于消息的最终一致性方法。
其中,基于 XA 协议的二阶段提交协议方法和三阶段提交协议方法,采用了强一致性,遵从 ACID。基于消息的最终一致性方法,采用了最终一致性,遵从 BASE 理论。下面,我将带你一起学习这三种方法。
基于 XA 协议的2PC
XA 是一个分布式事务协议,规定了事务管理器和资源管理器接口。因此,XA 协议包括事务管理器和本地资源管理器两个部分。
XA 实现分布式事务的原理,就类似于我在第 3 讲中与你介绍的集中式算法:事务管理器相当于协调者,负责各个本地资源的提交和回滚;而资源管理器就是分布式事务的参与者,通常由数据库实现,比如 Oracle、DB2 等商业数据库都实现了 XA 接口。
基于 XA 协议的二阶段提交方法中,二阶段提交协议(Two-phase Commit Protocol,2PC),用于保证分布式系统中事务提交时的数据一致性,是 XA 在全局事务中用于协调多个资源的机制。
那么,两阶段提交 协议如何保证分布在不同节点上的分布式事务的一致性呢? 为了保证它们的一致性,我们需要引入一个协调者来管理所有的节点,并确保这些节点正确提交操作结果,若提交失败则放弃事务。接下来,我们看看两阶段提交协议的具体过程。
两阶段提交协议的执行过程,分为投票 (Voting)和提交(Commit)两个阶段。
首先,我们看一下第一阶段投票:在这一阶段,协调者(Coordinator,即事务管理器)会向事务的参与者(Cohort,即本地资源管理器)发起执行操作的 CanCommit 请求,并等待参与者的响应。参与者接收到请求后,会执行请求中的事务操作,将操作信息记录到事务日志中但不提交(即不会修改数据库中的数据),待参与者执行成功,则向协调者发送“Yes”消息,表示同意操作;若不成功,则发送“No”消息,表示终止操作。
当所有的参与者都返回了操作结果(Yes 或 No 消息)后,系统进入了第二阶段提交阶段(也可以称为,执行阶段)。在提交阶段,协调者会根据所有参与者返回的信息向参与者发送 DoCommit(提交)或 DoAbort(取消)指令。具体规则如下:
- 若协调者从参与者那里收到的都是“Yes”消息,则向参与者发送“DoCommit”消息。参与者收到“DoCommit”消息后,完成剩余的操作(比如修改数据库中的数据)并释放资源(整个事务过程中占用的资源),然后向协调者返回“HaveCommitted”消息;
- 若协调者从参与者收到的消息中包含“No”消息,则向所有参与者发送“DoAbort”消息。此时投票阶段发送“Yes”消息的参与者,则会根据之前执行操作时的事务日志对操作进行回滚,就好像没有执行过请求操作一样,然后所有参与者会向协调者发送“HaveCommitted”消息;
- 协调者接收到来自所有参与者的“HaveCommitted”消息后,就意味着整个事务结束了。
二阶段提交的算法思路可以概括为:协调者向参与者下发请求事务操作,参与者接收到请求后,进行相关操作并将操作结果通知协调者,协调者根据所有参与者的反馈结果决定各参与者是要提交操作还是撤销操作。
虽然基于 XA 的二阶段提交算法尽量保证了数据的强一致性,而且实现成本低,但依然有些不足。主要有以下三个问题:
- 同步阻塞问题:二阶段提交算法在执行过程中,所有参与节点都是事务阻塞型的。也就是说,当本地资源管理器占有临界资源时,其他资源管理器如果要访问同一临界资源,会处于阻塞状态。因此,基于 XA 的二阶段提交协议不支持高并发场景。
- 单点故障 问题:该算法类似于集中式算法,一旦事务管理器发生故障,整个系统都处于停滞状态。尤其是在提交阶段,一旦事务管理器发生故障,资源管理器会由于等待管理器的消息,而一直锁定事务资源,导致整个系统被阻塞。
- 数据不一致问题:在提交阶段,当协调者向所有参与者发送“DoCommit”请求时,如果发生了局部网络异常,或者在发送提交请求的过程中协调者发生了故障,就会导致只有一部分参与者接收到了提交请求并执行提交操作,但其他未接到提交请求的那部分参与者则无法执行事务提交。于是整个分布式系统便出现了数据不一致的问题。
3PC
三阶段提交协议(Three-phase Commit Protocol,3PC),是对二阶段提交(2PC)的改进。为了更好地处理两阶段提交的同步阻塞和数据不一致问题,三阶段提交引入了超时机制和准备阶段。
- 与 2PC 只是在协调者引入超时机制不同,3PC 同时在协调者和参与者中引入了超时机制。如果协调者或参与者在规定的时间内没有接收到来自其他节点的响应,就会根据当前的状态选择提交或者终止整个事务,从而减少了整个集群的阻塞时间,在一定程度上减少或减弱了 2PC 中出现的同步阻塞问题。
- 在第一阶段和第二阶段中间引入了一个准备阶段,或者说把 2PC 的投票阶段一分为二,也就是在提交阶段之前,加入了一个预提交阶段。在预提交阶段尽可能排除一些不一致的情况,保证在最后提交之前各参与节点的状态是一致的。
三阶段提交协议就有 CanCommit、PreCommit、DoCommit 三个阶段,下面我们来看一下这个三个阶段。
第一,CanCommit 阶段。
协调者向参与者发送请求操作(CanCommit 请求),询问参与者是否可以执行事务提交操作,然后等待参与者的响应;参与者收到 CanCommit 请求之后,回复 Yes,表示可以顺利执行事务;否则回复 No。
3PC 的 CanCommit 阶段与 2PC 的 Voting 阶段相比:
- 类似之处在于:协调者均需要向参与者发送请求操作(CanCommit 请求),询问参与者是否可以执行事务提交操作,然后等待参与者的响应。参与者收到 CanCommit 请求之后,回复 Yes,表示可以顺利执行事务;否则回复 No。
- 不同之处在于,在 2PC 中,在投票阶段,若参与者可以执行事务,会将操作信息记录到事务日志中但不提交,并返回结果给协调者。但在 3PC 中,在 CanCommit 阶段,参与者仅会判断是否可以顺利执行事务,并返回结果。而操作信息记录到事务日志但不提交的操作由第二阶段预提交阶段执行。
CanCommit 阶段不同节点之间的事务请求成功和失败的流程,如下所示。
当协调者接收到所有参与者回复的消息后,进入预提交阶段(PreCommit 阶段)。
第二,PreCommit 阶段。
协调者根据参与者的回复情况,来决定是否可以进行 PreCommit 操作(预提交阶段)。
- 如果所有参与者回复的都是“Yes”,那么协调者就会执行事务的预执行:
- 协调者向参与者发送 PreCommit 请求,进入预提交阶段。
- 参与者接收到 PreCommit 请求后执行事务操作,并将 Undo 和 Redo 信息记录到事务日志中。
- 如果参与者成功执行了事务操作,则返回 ACK 响应,同时开始等待最终指令。
- 假如任何一个参与者向协调者发送了“No”消息,或者等待超时之后,协调者都没有收到参与者的响应,就执行中断事务的操作:
- 协调者向所有参与者发送“Abort”消息。
- 参与者收到“Abort”消息之后,或超时后仍未收到协调者的消息,执行事务的中断操作。
预提交阶段,不同节点上事务执行成功和失败的流程,如下所示。
预提交阶段保证了在最后提交阶段(DoCmmit 阶段)之前所有参与者的状态是一致的。
第三,DoCommit 阶段。
DoCmmit 阶段进行真正的事务提交,根据 PreCommit 阶段协调者发送的消息,进入执行提交阶段或事务中断阶段。
- 执行提交阶段:
- 若协调者接收到所有参与者发送的 Ack 响应,则向所有参与者发送 DoCommit 消息,开始执行阶段。
- 参与者接收到 DoCommit 消息之后,正式提交事务。完成事务提交之后,释放所有锁住的资源,并向协调者发送 Ack 响应。
- 协调者接收到所有参与者的 Ack 响应之后,完成事务。
- 事务中断阶段:
- 协调者向所有参与者发送 Abort 请求。
- 参与者接收到 Abort 消息之后,利用其在 PreCommit 阶段记录的 Undo 信息执行事务的回滚操作,释放所有锁住的资源,并向协调者发送 Ack 消息。
- 协调者接收到参与者反馈的 Ack 消息之后,执行事务的中断,并结束事务。
执行阶段不同节点上事务执行成功和失败 (事务中断) 的流程,如下所示。
3PC 协议在协调者和参与者均引入了超时机制。即当参与者在预提交阶段向协调者发送 Ack 消息后,如果长时间没有得到协调者的响应,在默认情况下,参与者会自动将超时的事务进行提交,从而减少整个集群的阻塞时间,在一定程度上减少或减弱了 2PC 中出现的同步阻塞问题。
但三阶段提交仍然存在数据不一致的情况。具体表现为,在阶段三中,如果参与者接收到了 PreCommit 消息后,出现了不能与协调者正常通信的问题,在这种情况下,参与者依然会进行事务的提交,这就出现了数据的不一致性。
基于分布式消息的最终一致性方案
事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。
回到订单和购物车这个例子,我们一起来看下如何用消息队列来实现分布式事务。
首先,订单系统在消息队列上开启一个事务。然后订单系统给消息服务器发送一个“半消息”,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。
半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。这样就基本实现了“要么都成功,要么都失败”的一致性要求。
如果你足够细心,可能已经发现了,这个实现过程中,有一个问题是没有解决的。如果在第四步提交事务消息时失败了怎么办?对于这个问题,Kafka 和 RocketMQ 给出了 2 种不同的解决方案。
Kafka 的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。RocketMQ 则给出了另外一种解决方案。
RocketMQ 中的分布式事务实现
在 RocketMQ 中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果 Producer 也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。
为了支撑这个事务反查机制,我们的业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。
在我们这个例子中,反查本地事务的逻辑也很简单,我们只要根据消息中的订单 ID,在订单库中查询这个订单是否存在即可,如果订单存在则返回成功,否则返回失败。RocketMQ 会自动根据事务反查的结果提交或者回滚事务消息。
这个反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ 依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。
综合上面讲的通用事务消息的实现和 RocketMQ 的事务反查机制,使用 RocketMQ 事务消息功能实现分布式事务的流程如下图:
对比
TCC
可靠消息队列的实现原理,虽然它也能保证最终的结果是相对可靠的,过程也足够简单(相对于 TCC 来说),但现在你已经知道,可靠消息队列的整个实现过程完全没有任何隔离性可言。
虽然在有些业务中,有没有隔离性不是很重要,比如说搜索系统。但在有些业务中,一旦缺乏了隔离性,就会带来许多麻烦。
比如说前几讲,我一直引用的 Fenix's Bookstore 在线书店的场景事例中,如果缺乏了隔离性,就会带来一个显而易见的问题:超售。
事例场景:Fenix's Bookstore 是一个在线书店。一份商品成功售出,需要确保以下三件事情被正确地处理:
- 用户的账号扣减相应的商品款项;
- 商品仓库中扣减库存,将商品标识为待配送状态;
- 商家的账号增加相应的商品款项。
也就是说,在书店的业务场景下,很有可能会出现这样的情况:两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和,却超过了库存。
如果这件事情是发生在刚性事务且隔离级别足够的情况下,其实是可以完全避免的。比如,我前面提到的“超售”场景,就需要“可重复读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败。但用可靠消息队列就无法保证这一点了。
所以,如果业务需要隔离,我们通常就应该重点考虑 TCC 方案,它天生适合用于需要强隔离性的分布式事务中。
在具体实现上,TCC 的操作其实有点儿麻烦和复杂,它是一种业务侵入性较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认 / 释放消费资源”两个子过程。另外,你看名字也能看出来,TCC 的实现过程分为了三个阶段:
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的所有业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。注意,Confirm 阶段可能会重复执行,因此需要满足幂等 性。
- Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。注意,Cancel 阶段也可能会重复执行,因此也需要满足幂等 性。
那么,根据 Fenix's Bookstore 在线书店的场景事例,TCC 的执行过程应该是这样的:
-
第一步,最终用户向 Fenix's Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
-
第二步,创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:用户服务:检查业务可行性,可行的话,把该用户的 100 元设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。仓库服务:检查业务可行性,可行的话,将该仓库的 1 本《深入理解 Java 虚拟机》设置为“冻结”状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。商家服务:检查业务可行性,不需要冻结资源。
-
第三步,如果第二步中所有业务都反馈业务可行,就将活动日志中的状态记录为 Confirm,进入 Confirm 阶段:
- 用户服务:完成业务操作(扣减被冻结的 100 元)。
- 仓库服务:完成业务操作(标记那 1 本冻结的书为出库状态,扣减相应库存)。
- 商家服务:完成业务操作(收款 100 元)。
-
第四步,如果第三步的操作全部完成了,事务就会宣告正常结束。而如果第三步中的任何一方出现了异常,不论是业务异常还是网络异常,都将会根据活动日志中的记录,来重复执行该服务的 Confirm 操作,即进行“最大努力交付”。
-
第五步,如果是在第二步,有任意一方反馈业务不可行,或是任意一方出现了超时,就将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
- 用户服务:取消业务操作(释放被冻结的 100 元)。
- 仓库服务:取消业务操作(释放被冻结的 1 本书)。
- 商家服务:取消业务操作(大哭一场后安慰商家谋生不易)。
-
第六步,如果第五步全部完成了,事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常,不论是业务异常还是网络异常,也都将会根据活动日志中的记录,来重复执行该服务的 Cancel 操作,即进行“最大努力交付”。
那么,你从上述的操作执行过程中可以发现,TCC 其实有点类似于 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。
另外,TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。
但是,由于 TCC 的业务侵入性比较高,需要开发编码配合,在一定程度上增加了不少工作量,也就给我们带来了一些使用上的弊端,那就是我们需要投入更高的开发成本和更换事务实现方案的替换成本。
所以,通常我们并不会完全靠裸编码来实现 TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。
对 tcc 预留资源的理解:
SAGA
好,现在你就已经知道了,TCC 事务具有较强的隔离性,能够有效避免“超售”的问题,而且它的性能可以说是包括可靠消息队列在内的几种柔性事务模式中最高的。但是,TCC 仍然不能满足所有的业务场景。
我在前面也提到了,TCC 最主要的限制是它的业务侵入性很强,但并不是指由此给开发编码带来的工作量,而是指它所要求的技术可控性上的约束。
比如说,我们把这个书店的场景事例修改一下:由于中国网络支付日益盛行,在书店系统中,现在用户和商家可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,而是允许在购物时,直接通过 U 盾或扫码支付,在银行账户中划转货款。
这个需求完全符合我们现在支付的习惯,但这也给系统的事务设计增加了额外的限制:如果用户、商家的账户余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义了,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以,在 TCC 的执行过程中,第一步 Try 阶段往往就已经无法施行了。
那么,我们就只能考虑采用另外一种柔性事务方案:SAGA 事务。
SAGA 事务基于数据补偿代替回滚的解决思路
SAGA 事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA 的意思是“长篇故事、长篇记叙、一长串事件”,它起源于 1987 年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)。
文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出 SAGA 的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。
SAGA 由两部分操作组成。
一部分是把大事务拆分成若干个小事务,将整个分布式事务 T 分解为 n 个子事务,我们命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该、或者能被看作是原子行为。如果分布式事务 T 能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。
另一部分是为每一个子事务设计对应的补偿动作,我们命名为 C1,C2,…,Ci,…,Cn。Ti 与 Ci 必须满足以下条件:
- Ti 与 Ci 都具备幂等性;
- Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;
- Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。
如果 T1 到 Tn 均成功提交,那么事务就可以顺利完成。否则,我们就要采取以下两种恢复策略之一:
- 正向恢复(Forward Recovery) :如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
- 反向恢复(Backward Recovery) :如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。
所以你能发现,与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。
我给你举个例子。我在前面提到的账户余额直接在银行维护的场景,从银行划转货款到 Fenix's Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是作为补偿措施,我们让 Fenix's Bookstore 系统将货款转回到用户账上,却是完全可行的。
SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。
另外你还要注意,尽管补偿操作通常比冻结 / 撤销更容易实现,但要保证正向、反向恢复过程能严谨地进行,也需要你花费不少的工夫。比如,你可能需要通过服务编排、可靠事件队列等方式来完成。所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。我前面提到的 Seata 就同样支持 SAGA 事务模式。
AT 事务
从整体上看,AT 事务是参照了 XA 两段提交协议来实现的,但针对 XA 2PC 的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务也设计了针对性的解决方案。
它大致的做法是在业务数据提交时,自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。
如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”。
所以,基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。
比如,当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。
所以,GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。
这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。
另外,在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,我们要付出的代价就非常大了,一般并不会这样做。
所以到这里,你其实能发现,分布式事务中并没有能一揽子包治百病的解决办法,你只有因地制宜地选用合适的事务处理方案,才是唯一有效的做法。