概述
分布式事务是架构师面试的"分水岭",也是高并发系统设计中最棘手的难题。本文从经典的MySQL Redo/Binlog 2PC 讲起,一针见血地指出其同步阻塞、单点故障的致命伤;随后深入最终一致性 的两种主流玩法(本地消息表 + RocketMQ事务消息),并详解TCC 在应用层的补偿实践。最后,针对极端异常场景,分享大厂必备的状态表补偿 与异步对账兜底方案。一文带你走完分布式事务从"理论"到"落地"的全路径。
2PC 两阶段提交
执行流程
在之前的Mysql 的Binlog 和 RedoLog 的一致性问题时,就已经用到了2PC,当然Mysql的场景只是内部的分布式问题,只涉及单机的两个日志文件之间的数据一致性,2PC主要应用在两个数据库之间。
2PC有两个角色:协调者、参与者。具体到数据库的实现来说,每一个数据库就是一个参与者,调用方,2PC是指将事务的提交分为两个阶段。
阶段1:准备阶段:协调者向各个参与者发起询问,说要执行一个事务,各参与者可能回复YES、NO或者超时。
阶段2:提交阶段:
- 如果所有参与者都回复的是Yes,则协调者向所有参与者发起事务提交( Commit )操作,所有参与者各自执行事务,然后发送确认消息( ACK )
- 如果有一个参与者在阶段1回复的是No或者超时,则协调者向所有向所有参与者发起事务回滚( Rollback )操作,所有参与者各自回滚事务,然后发送ACK。
无论事务最终是提交还是回滚,都经过了两个阶段,所以这个思路叫两阶段提交。
致命缺点
- 同步阻塞,性能差 参与者第一阶段 PREPARE 之后,会持有数据库锁,阻塞业务,等待协调者指令;高并发场景性能很差,互联网很少使用。
- 协调者单点故障问题
- 如果准备阶段完成后,协调者宕机,所有参与者全部卡在 prepare 状态,资源锁一直占用,数据库被阻塞。
- 协调者恢复后才能继续二阶段;如果永久宕机,事务悬置,需要人工处理
XA RECOVER。
- 存在数据不一致风险 协调者发出 commit 指令,网络问题导致部分参与者收到 commit、部分没收到;收到的提交,没收到的一直 prepare,出现数据不一致。
- 故障恢复复杂 prepare 状态残留事务,数据库不会自动处理,需要运维手动检测并提交 / 回滚悬置 XA 事务。
- 不适合 PHP‑FPM 无状态场景 PHP 请求结束进程销毁,如果二阶段还没完成,协调者逻辑直接丢失,事务卡住。
最终一致性
本地消息表
先解决一个跨数据库DB1和DB2转账扣钱的分布式数据库的实践,问题如下:
系统A收到用户的转账请求,系统A先自己扣钱,也就是更新DB1;然后通过消息中间件给系统B发送一条加钱的消息,系统B收到此消息,对自己的账户加钱,也就是更新DB2。
这里面有一个关键的技术问题,是两次网络调用,两个操作不是原子性的,所以无论谁先谁后,都是有问题的,解决问题的思路方案是:消息中间件实现最终一致性,具体操作的流程图如下图:

第一步:系统A添加一张消息表,系统A不再直接给消息中间件发送消息(如果业务场景不复杂,可以使用Redis的队列进行存储),而是把消息写入消息表中,把DB1的扣钱操作(表1)和写消息表(表2)这两个操作放在一个数据库事务中,保证两者的原子性。
第二步:系统A准备一个后台程序,源源不断的把消息表中的消息传输给消息中间件,如果失败了,也不断尝试重传。系统A发送给消息中间件的消息网络超时了,消息中间件可能已经收到了消息,可可能没收到,系统A会再次发送该消息,直到消息中间件返回成功,所以,系统A允许消息重复,但消息不会丢失,纯血也不会打乱。
通过上面的两个步骤,系统A保证了消息不丢失,但消息可能重复,系统B对消息的消费解决下面的两个问题:
-
丢失消费。系统B从消息中间件取出消息(此时还在内存中),如果处理了一半,系统B宕机并再次重启,此时这条消息未处理成功,怎么办?
通过消息中间件的ACK机制,凡是发送ACK的消息,重启系统B之后消息中间件不会再次推送,凡是没有发送ACK的消息,系统B重启之后消息中间件会再次推送。
-
重复消费。系统A的后台任务也可能给消息中间件重复发送消息。
为了解决重复消息的问题,系统B增加一个判重表,判重表记录了处理成功的消息ID和消息中间件对应的offset,系统B宕机重启,可以定位到offset位置,从这之后开始继续消费。
每次接收到新消息,先通过判重表进行判重,实现业务的幂等操作。同样,DB2的加钱操作和写判重表两个操作要在一个数据库事务中完成。
要补充说明的是,消息的判重不止判重表一种方法。如果业务本身就有业务数据,可以判断消息是否重复了,就不需要判重表了。
通过上面的三个步骤,实现了消息在发送方的不丢失、在接收方的不重复,联合起来就是消息的不漏不重,严格的实现了系统A和系统B的最终一致性。
MQ 事务消息
为了能通过消息中间件解决最终一致性问题,同时又不和业务逻辑耦合,阿里巴巴的RockrtMQ提出了事务消息的概念,RockrtMQ不是提供一个单一的发送接口,而是把消息的发送拆成两个阶段,Prepare阶段 和 Confirm阶段,步骤如下:

步骤1:系统A调用Prepare接口,预发送消息,此时消息保存在消息中间件中,但消息中间件不会把消息给消费方进行消费,消息只是暂存在哪。
步骤2:系统A更新数据库,进行扣钱操作。
步骤3:系统A调用Confirm接口,确认发送消息。此时消息中间件才会发消息给消费方进行消费。
显然,这里有以下两种异常场景。
场景1:步骤1成功,步骤2成功,步骤3失败或超时。
场景2:步骤1成功,步骤2失败或超时,步骤3不会执行。
这就涉及到RockrtMQ的关键点:RockrtMQ会定期(默认是1min)扫描所有的预发送但没还没有确认的消息,回调给发送发,询问这条消息要是发出去,还是要取消。根据业务的实际需要进行处理即可。
对比最终一致性的两种实现方案会发现,RockrtMQ最大的改变其实是把"扫描消息表"这件事不让业务方做,而是让消息中间件完成。
如果发送发把消息成功放入了队列中,如果消费方失败怎么办? 如果消费失败先尝试失败,如果重试3次,还是一直失败,要人工介入,对于这种发生概率极低的事件,采取人工处理会比现实一个高复杂的自动化回滚系统更加可靠,也更加简单。
TCC
现在企业采用的是各式各样的SOA服务,需要解决两个服务之间的分布式事务问题,提出这个解决思路的是支付宝技术团队,其实是一个应用层面的2PC协议,Confirm对应的是2PC中的事务提交,Cancel对应的是2PC中的事务回滚操作。

阶段1:准备阶段。调用方调用所有服务方提供的try接口,该阶段各调用方做资源检查和资源锁定,为接下来的阶段2做准备。
阶段2:提交阶段。如果所有服务方都返回Yes,则进入提交阶段,调用方调用各服务方的Confirm接口,各服务方提交事务。如果有一个服务方在阶段1返回No或超时,则调用方各服务的Cancel接口。
在阶段2调用方发生宕机,或者某个服务超时了,如何处理呢?答案是不断尝试,要就要求Confirm和Cancel都必须是幂等操作,注意,这里的重试是由TCC的框架来执行的,而不是让业务方自己去做。
事务状态表 + 事务补偿
还有一种方法是业务方自己实现的,调用方维护一张事务状态表(或者事务日志、日志流水),在每次调用之前,将一条事务流水落盘,生成一个全局的事务ID。
方案如下:
可以有中间状态,也可以没有,这个无所谓,但必须有Begin和End,事务开始前的状态是Begin,全部结束的状态是End。如果某个事务一直停留在Begin状态,则说明该事务没有执行完毕。
然后有一个后台任务,扫描事务状态表,在过了某段时间后(假设一次事务执行成功通常最多花费30s),状态没有编程最终状态End,说明这个事务没有执行成功。于是重新调用系统A、B、C根据全局的事务ID做幂等操作,所以即使重复调用也没有关系。
补充说明:
1、如果后台任务重试多次仍然不能成功,那么要为事务状态表加一个Error状态,通过人工进行干预。
2、对于调用方的同步调用,如果部分成功,那么此时给客户端返回什么呢?答案不确定,或者说暂时未知,只能告诉用户该笔钱转账超时,请稍后再来确认。
3、对于同步调用,当调用方A或B失败时,可以重试3次。如果重试3次还不成功,则放弃操作,在交由后台任务后续继续处理。
同步双写 + 异步对账
先抛出场景和问题:微博关注的关系,需要存两张表,一张是关注表,一张是粉丝表,这两张表各自都是分库分表的。假设A关注了B,需要先以A为主键进行分库,存入关注表,再以B为主键进行分库,存入粉丝表。也就是说,一次业务操作,要向两个数据库写入两条数据,那如何保证原子性呢?
除两个数据库的数据一致性外,为了查询性能可能还会为数据加上<K,V>缓存。两个数据库 + 一个<K,V>缓存,那么,如何保持数据一致性呢?
在前面,我们把注意力都放在了过程中,而在对账的思路中,将把注意力转移到结果中,下面介绍一下对账的实现思路。
对账有个关键点:要有对账的基准,也就是说,需要知道用谁去对谁。业务双写/多写时,采取的策略就是:其中一个数据库写成功了,另外的数据库、缓存不管是否成功,都给客户端返回成功,然后以该数据库为基准校对另外一个数据库和缓存。
方案思路:
1、异步消息对账:每一次业务采用双写操作,如果部分成功,就抛出一条消息到中间件,然后由一个消费者消费这条消息,对两个数据库中的数据进行对比,用正确的一方补齐缺的那一方,当然消息可能丢失,无法百分之百保证,需要下面的全量后台任务对账兜底。
2、全量后台任务对账。例如,每个晚上运行一个定时任务,对比两个数据库。
3、增量后台任务对账。基于数据库的更新时间,美格一段时间只对账增量的数据库。
还有一些隐性场景:
一旦发现系统中的某个数据对象超过了一个限定时间而生命周期没有结束,仍然处于某个中间状态,说明系统不一致了,要进行某种补偿操作(重试或发出警报)