“分布式事务 2PC, 3PC, TCC”

数据库事务的概念

在讲述分布式事务的概念之前,我们先来回顾下事务相关的一些概念。

事务的基本概念:

就是一个程序执行单元,里面的操作要么全部执行成功,要么全部执行失败,不允许只成功一半另外一半执行失败的事情发生。例如一段事务代码做了两次数据库更新操作,那么这两次数据库操作要么全部执行成功,要么全部回滚。

事务的基本特性:

我们知道事务有4个非常重要的特性,即我们常说的 (ACID) 。

Atomicity (原子性) :是说事务是一个不可分割的整体,所有操作要么全做,要么全不做;只要事务中有一个操作出错,回滚到事务开始前的状态的话,那么之前已经执行的所有操作都是无效的,都应该回滚到开始前的状态。

Consistency (一致性) : 是说事务执行前后,数据从一个状态到另一个状态必须是一致的,比如A向B转账 (A、B的总金额就是一个一致性状态) ,不可能出现A扣了钱,B却没收到的情况发生。

Isolation (隔离性) : 多个并发事务之间相互隔离,不能互相干扰。关于事务的隔离性,可能不是特别好理解,这里的并发事务是指两个事务操作了同一份数据的情况;而对于并发事务操作同一份数据的隔离性问题,则是要求不能出现脏读、幻读的情况,即事务A不能读取事务B还没有提交的数据,或者在事务A读取数据进行更新操作时,不允许事务B率先更新掉这条数据。而为了解决这个问题,常用的手段就是加锁了,对于数据库来说就是通过数据库的相关锁机制来保证。

Durablity (持久性) : 事务完成后,对数据库的更改是永久保存的,不能回滚。

关于数据库事务的基本概念大家可以去网上搜一下,这里只是给大家回顾下事务的基本概念及特性,诸如事务并发问题、事务隔离级别等大家如有遗忘可以去回顾下 (tips: 面试经常会问到的问题哦) 。

3

什么是分布式事务

以上内容我们回顾了下事务的基本概念,那么分布式事务又是个什么概念呢?它与数据库事务之间又有什么区别呢?

其实分布式事务从实质上看与数据库事务的概念是一致的,既然是事务也就需要满足事务的基本特性 (ACID) ,只是分布式事务相对于本地事务而言其表现形式有很大的不同。举个例子,在一个JVM进程中如果需要同时操作数据库的多条记录,而这些操作需要在一个事务中,那么我们可以通过数据库提供的事务机制 (一般是数据库锁) 来实现。

而随着这个JVM进程 (应用) 被拆分成了微服务架构,原本一个本地逻辑执行单元被拆分到了多个独立的微服务中,这些微服务又分别操作不同的数据库和表,服务之间通过网络调用。

举个例子: 服务A收到一笔购物下单请求后,需要调用服务B去支付,支付成功则处理购物订单为待发货状态,否则就需要将购物订单处理为失败状态。 (如图所示)

640?wx_fmt=png

在上面这个例子中会不会出现服务B支付成功了,但是由于网络调用的问题没有通知到服务A,导致用户付了钱,但是购物订单无法显示支付成功的状态呢?

答案是这种情况是普遍存在的,因为服务B在处理成功后需要向服务A发送网络请求,而这个过程是极有可能失败的。那么如何确保“服务A->服务B”这个过程能够组成一个事务,要么全部成功、要么全部失败呢?而这就是典型的需要通过分布式事务解决的问题。

分布式事务是为了解决微服务架构 (形式都是分布式系统) 中不同节点之间的数据一致性问题。这个一致性问题本质上解决的也是传统事务需要解决的问题,即一个请求在多个微服务调用链中,所有服务的数据处理要么全部成功,要么全部回滚。当然分布式事务问题的形式可能与传统事务会有比较大的差异,但是问题本质是一致的,都是要求解决数据的一致性问题。

而分布式事务的实现方式有很多种,最具有代表性的是由Oracle Tuxedo系统提出的XA分布式事务协议。XA协议包括两阶段提交 (2PC) 和三阶段提交 (3PC) 两种实现,接下来我们分别来介绍下这两种实现方式的原理。

两阶段提交 (2PC)

分布式事务的解决方案 两阶段提交/XA 两阶段提交,顾名思义就是要分两步提交。存在一个负责协调各个本地资源管理器的事务管理器,本地资源管理器一般是由数据库实现,事务管理器在第一阶段的时候询问各个资源管理器是否都就绪?如果收到每个资源的回复都是 yes,则在第二阶段提交事务,如果其中任意一个资源的回复是 no, 则回滚事务。 大致的流程:

第一阶段 (prepare) : 事务管理器向所有本地资源管理器发起请求,询问是否是 ready 状态,所有参与者都将本事务能否成功的信息反馈发给协调者; 第二阶段 (commit/rollback): 事务管理器根据所有本地资源管理器的反馈,通知所有本地资源管理器,步调一致地在所有分支上提交或者回滚。

存在的问题:

同步阻塞: 当参与事务者存在占用公共资源的情况,其中一个占用了资源,其他事务参与者就只能阻塞等待资源释放,处于阻塞状态。

单点故障: 一旦事务管理器出现故障,整个系统不可用

数据不一致: 在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。

不确定性: 当协事务管理器发送 commit 之后,并且此时只有一个参与者收到了 commit,那么当该参与者与事务管理器同时宕机之后,重新选举的事务管理器无法确定该条消息是否提交成功。


两阶段提交又称2PC (two-phase commit protocol) ,2pc是一个非常经典的强一致、中心化的原子提交协议。这里所说的中心化是指协议中有两类节点: 一个是中心化协调者节点 (coordinator) 和N个参与者节点 (partcipant) 。

下面我们就以一个尽量贴近实际业务场景的操作来举例: “假设在一个分布式架构的系统中事务的发起者通过分布式事务协调者 (如RocketMQ,在早期RocketMQ版本不提供事务消息特性时,有些公司会自己研发一个基于MQ的可靠消息服务来实现一定的分布式事务的特性) 分别向应用服务A、应用服务B发起处理请求,二者在处理的过程中会分别操作自身服务的数据库,现在要求应用服务A、应用服务B的数据处理操作要在一个事务里”?

在上面这个例子中如果采用两阶段提交来实现分布式事务,那么其运行原理应该是个什么样的呢? (如?) :

第一阶段: 请求/表决阶段 (点击放大)

640?wx_fmt=png

既然称为两阶段提交,说明在这个过程中是大致存在两个阶段的处理流程。第一个阶段如?图所示,这个阶段被称之为请求/表决阶段。是个什么意思呢?

就是在分布式事务的发起方在向分布式事务协调者 (Coordinator) 发送请求时,Coordinator首先会分别向参与者 (Partcipant) 节点A、参与这节点 (Partcipant) 节点B分别发送事务预处理请求,称之为Prepare,有些资料也叫"Vote Request”。

说的直白点就是问一下这些参与节点"这件事你们能不能处理成功了”,此时这些参与者节点一般来说就会打开本地数据库事务,然后开始执行数据库本地事务,但在执行完成后并不会立马提交数据库本地事务,而是先向Coordinator报告说: “我这边可以处理了/我这边不能处理”。

如果所有的参与这节点都向协调者作了“Vote Commit”的反馈的话,那么此时流程就会进入第二个阶段了。

第二阶段: 提交/执行阶段 (正常流程)

640?wx_fmt=png

如果所有参与者节点都向协调者报告说“我这边可以处理”,那么此时协调者就会向所有参与者节点发送“全局提交确认通知 (global_commit) ”,即你们都可以进行本地事务提交了,此时参与者节点就会完成自身本地数据库事务的提交,并最终将提交结果回复“ack”消息给Coordinator,然后Coordinator就会向调用方返回分布式事务处理完成的结果。

第二阶段: 提交/执行阶段 (异常流程)

640?wx_fmt=png

相反,在第二阶段除了所有的参与者节点都反馈“我这边可以处理了”的情况外,也会有节点反馈说“我这边不能处理”的情况发生,此时参与者节点就会向协调者节点反馈“Vote_Abort”的消息。此时分布式事务协调者节点就会向所有的参与者节点发起事务回滚的消息 (“global_rollback”) ,此时各个参与者节点就会回滚本地事务,释放资源,并且向协调者节点发送“ack”确认消息,协调者节点就会向调用方返回分布式事务处理失败的结果。

以上就是两阶段提交的基本过程了,那么按照这个两阶段提交协议,分布式系统的数据一致性问题就能得到满足吗?

实际上分布式事务是一件非常复杂的事情,两阶段提交只是通过增加了事务协调者 (Coordinator) 的角色来通过2个阶段的处理流程来解决分布式系统中一个事务需要跨多个服务节点的数据一致性问题。但是从异常情况上考虑,这个流程也并不是那么的无懈可击。

假设如果在第二个阶段中Coordinator在接收到Partcipant的"Vote_Request"后挂掉了或者网络出现了异常,那么此时Partcipant节点就会一直处于本地事务挂起的状态,从而长时间地占用资源。当然这种情况只会出现在极端情况下,然而作为一套健壮的软件系统而言,异常Case的处理才是真正考验方案正确性的地方。

以下几点是XA-两阶段提交协议中会遇到的一些问题:

性能问题。从流程上我们可以看得出,其最大缺点就在于它的执行过程中间,节点都处于阻塞状态。各个操作数据库的节点此时都占用着数据库资源,只有当所有节点准备完毕,事务协调者才会通知进行全局提交,参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大。

协调者单点故障问题。事务协调者是整个XA模型的核心,一旦事务协调者节点挂掉,会导致参与者收不到提交或回滚的通知,从而导致参与者节点始终处于事务无法完成的中间状态。

丢失消息导致的数据不一致问题。在第二个阶段,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就会导致节点间数据的不一致问题。

既然两阶段提交有以上问题,那么有没有其他的方案来解决呢?

三阶段提交 (3PC)

三阶段提交又称3PC,其在两阶段提交的基础上增加了CanCommit阶段,并引入了超时机制。一旦事务参与者迟迟没有收到协调者的Commit请求,就会自动进行本地commit,这样相对有效地解决了协调者单点故障的问题。

但是性能问题和不一致问题仍然没有根本解决。下面我们还是一起看下三阶段流程的是什么样的?

第一阶段: CanCommit阶段

640?wx_fmt=png

这个阶段类似于2PC中的第二个阶段中的Ready阶段,是一种事务询问操作,事务的协调者向所有参与者询问“你们是否可以完成本次事务?”,如果参与者节点认为自身可以完成事务就返回“YES”,否则“NO”。而在实际的场景中参与者节点会对自身逻辑进行事务尝试,其实说白了就是检查下自身状态的健康性,看有没有能力进行事务操作。

第二阶段: PreCommit阶段

640?wx_fmt=png

在阶段一中,如果所有的参与者都返回Yes的话,那么就会进入PreCommit阶段进行事务预提交。此时分布式事务协调者会向所有的参与者节点发送PreCommit请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后 (此时属于未提交事务的状态) ,就会向协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。

否则,如果阶段一中有任何一个参与者节点返回的结果是No响应,或者协调者在等待参与者节点反馈的过程中超时 (2PC中只有协调者可以超时,参与者没有超时机制) 。整个分布式事务就会中断,协调者就会向所有的参与者发送“abort”请求。

第三阶段: DoCommit阶段

640?wx_fmt=png

在阶段二中如果所有的参与者节点都可以进行PreCommit提交,那么协调者就会从“预提交状态”-》“提交状态”。然后向所有的参与者节点发送"doCommit"请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈“Ack”消息,协调者收到所有参与者的Ack消息后完成事务。

相反,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。

看到这里,你是不是会疑惑"3PC相对于2PC而言到底优化了什么地方呢?”

相比较2PC而言,3PC对于协调者 (Coordinator) 和参与者 (Partcipant) 都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了一个什么问题呢?这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯 (协调者挂掉了) 的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。

另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。

以上就是3PC相对于2PC的一个提高 (相对缓解了2PC中的前两个问题) ,但是3PC依然没有完全解决数据不一致的问题。

补偿事务 (TCC)

TCC 关于 TCC (Try-Confirm-Cancel) 的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。 TCC 事务机制相比于上面介绍的 XA,解决了其几个缺点:

解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。 同步阻塞: 引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。 数据一致性,有了补偿机制之后,由业务活动管理器控制一致性 TCC(Try Confirm Cancel) Try 阶段: 尝试执行,完成所有业务检查 (一致性) , 预留必须业务资源 (准隔离性) Confirm 阶段: 确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足幂等性。要求具备幂等设计,Confirm 失败后需要进行重试。 Cancel 阶段: 取消执行,释放 Try 阶段预留的业务资源 Cancel 操作满足幂等性 Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。

在 Try 阶段,是对业务系统进行检查及资源预览,比如订单和存储操作,需要检查库存剩余数量是否够用,并进行预留,预留操作的话就是新建一个可用库存数量字段,Try 阶段操作是对这个可用库存数量进行操作。 基于 TCC 实现分布式事务,会将原来只需要一个接口就可以实现的逻辑拆分为 Try、Confirm、Cancel 三个接口,所以代码实现复杂度相对较高。


说起分布式事务的概念,不少人都会搞混淆,似乎好像分布式事务就是TCC。实际上TCC与2PC、3PC一样,只是分布式事务的一种实现方案而已。

TCC (Try-Confirm-Cancel) 又称补偿事务。其核心思想是: “针对每个操作都要注册一个与其对应的确认和补偿 (撤销操作) “。它分为三个操作:

Try阶段: 主要是对业务系统做检测及资源预留。

Confirm阶段: 确认执行业务操作。

Cancel阶段: 取消执行业务操作。

TCC事务的处理流程与2PC两阶段提交类似,不过2PC通常都是在跨库的DB层面,而TCC本质上就是一个应用层面的2PC,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。

而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口还必须实现幂等。

TCC的具体原理图如?:

640?wx_fmt=png

消息队列MQ事务

在前面介绍2PC、3PC的时候我们说没有根本解决性能问题,而如果通过MQ的事务消息来进行异步解耦,并实现系统的数据的最终一致性的话会不会好很多呢?实际上这就是我们下一篇文章要继续讲述的《分布式事务之如何基于RocketMQ的事务消息特性实现分布式系统的最终一致性?》。敬请期待!

本地消息表

本地消息表这个方案最初是 ebay 架构师 Dan Pritchett 在 2008 年发表给 ACM 的文章。该方案中会有消息生产者与消费者两个角色,假设系统 A 是消息生产者,系统 B 是消息消费者,其大致流程如下: 当系统 A 被其他系统调用发生数据库表更操作,首先会更新数据库的业务表,其次会往相同数据库的消息表中插入一条数据,两个操作发生在同一个事务中 系统 A 的脚本定期轮询本地消息往 mq 中写入一条消息,如果消息发送失败会进行重试 系统 B 消费 mq 中的消息,并处理业务逻辑。如果本地事务处理失败,会在继续消费 mq 中的消息进行重试,如果业务上的失败,可以通知系统 A 进行回滚操作 本地消息表实现的条件:

消费者与生成者的接口都要支持幂等 生产者需要额外的创建消息表 需要提供补偿逻辑,如果消费者业务失败,需要生产者支持回滚操作 容错机制:

步骤 1 失败时,事务直接回滚 步骤 2、3 写 mq 与消费 mq 失败会进行重试 步骤 3 业务失败系统 B 向系统 A 发起事务回滚操作 此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。

可靠消息最终一致性

A 系统先向 mq 发送一条 prepare 消息,如果 prepare 消息发送失败,则直接取消操作 如果消息发送成功,则执行本地事务 如果本地事务执行成功,则想 mq 发送一条 confirm 消息,如果发送失败,则发送回滚消息 B 系统定期消费 mq 中的 confirm 消息,执行本地事务,并发送 ack 消息。如果 B 系统中的本地事务失败,会一直不断重试,如果是业务失败,会向 A 系统发起回滚请求 5.mq 会定期轮询所有 prepared 消息调用系统 A 提供的接口查询消息的处理情况,如果该 prepare 消息本地事务处理成功,则重新发送 confirm 消息,否则直接回滚该消息

该方案与本地消息最大的不同是去掉了本地消息表,其次本地消息表依赖消息表重试写入 mq 这一步由本方案中的轮询 prepare 消息状态来重试或者回滚该消息替代。其实现条件与余容错方案基本一致。目前市面上实现该方案的只有阿里的 RocketMq。

尽最大努力通知

最大努力通知是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果 不影响主动方的处理结果。

这个方案的大致意思就是:

系统 A 本地事务执行完之后,发送个消息到 MQ; 这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口; 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。


https://xiaomi-info.github.io/2020/01/02/distributed-transaction/
https://blog.csdn.net/bjweimengshu/article/details/86698036