做过几年电商后端,最常听到的一句话就是:“订单和库存又对不上了?”下单成功但扣减库存失败、支付完成但订单状态没更新、A服务提交了B服务却悄悄回滚——这些问题背后都是同一个词:分布式事务。而每次想系统学一学分布式事务,绕不开的又是三座大山:CAP定理、两阶段提交(2PC)、三阶段提交(3PC)。
这篇文章我就用做项目的思路,把这几个概念拆开揉碎讲清楚。我不打算搬运百科定义,而是结合真实业务里最典型的订单与库存场景,说人话、给方案、讲坑点。无论你是刚接触微服务的后端开发,还是正在为“订单和库存数据对不上”焦头烂额的负责人,这篇文章的目标都是让你看完之后能理解、能选型、能动手。
1. 分布式事务到底在解决什么问题
1.1 从本地事务说起
先把时间拨回单体应用时代。那时候所有表都在同一个数据库里,事务就是一串SQL的事:BEGIN、几条UPDATE、COMMIT或者ROLLBACK。数据库的ACID特性保证了整个过程要么全部成功、要么全部回滚,根本不需要你去关心“中间状态”这种东西。
但微服务拆完以后,事情就变味了。订单数据在订单服务的库里,库存数据在库存服务的库里,就连一个用户下单的动作都跨了两个数据库、两个进程、两台机器。以往那一套本地事务的手段瞬间失效——你没法在订单库里开一个事务,然后顺手把库存库的数据也包进去,因为两个库的“事务边界”根本不相通。
这时候就引出了分布式事务的核心定义:事务的参与者、支持事务的服务器、资源服务器以及事务管理器,分别位于分布式系统的不同节点之上,需要通过网络协调它们的事务行为,共同保证事务的原子性和一致性。
用大白话翻译就是一句话:多个服务要一起成功、一起失败,不能有的提交了、有的没提交。
1.2 用一个订单场景把问题具象化
不举抽象的例子,我们直接套一个电商系统。
- 用户下单,订单服务在订单库里插入一条订单记录,状态置为“待支付”。
- 扣减库存,库存服务在库存库里扣减对应商品的可售库存。
- 后续支付完成后,再更新订单状态为“已支付”。
用户点击“提交订单”的那一刻,订单服务和库存服务必须协同工作。如果订单写成功了,库存扣减却失败了(比如库存不足),那商品详情页明明显示“有货”,用户却下不了单;反过来,库存扣了、订单却没写成功,那就是凭空少了一件库存,对账的时候早晚对出问题。
所以订单服务和库存服务之间的操作,必须保证“要么一起成功、要么一起失败”,这就是分布式事务要解决的最典型场景。
1.3 分布式事务的核心难点:网络不可靠
可能有同学会想:那我写个API,让订单服务调库存服务,如果库存扣减失败就抛异常回滚,不就行了吗?
想法很自然,但漏了一个关键因素:这两个操作之间隔着一层网络,而网络是分布式系统里唯一确定不可靠的东西。
在这个前提下,有一连串问题会接踵而至:
- 第一个服务提交成功了,第二个服务执行失败,谁来通知第一个服务回滚?
- 即使通知到了,回滚动作本身也可能因为网络问题执行失败。
- 如果中间协调的人(协调者)也宕机了,怎么办?
- 网络超时了,到底这次操作是成功了还是没执行?应不应该重试?
这些问题,正是CAP定理要划定的理论边界,也是2PC、3PC等协议要解决的具体问题。所以,不要一上来就背概念,先理解“网络不可靠”这个根源,后面的所有机制理解起来才会顺。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CAP定理:分布式事务的理论天花板
2.1 先澄清一个最常见的误解
很多人记住的CAP是“三选二”:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance),最多只能选两个。这个说法害人不浅。
CAP三个字母的准确含义是:
- C(一致性):所有节点在同一时间看到的数据是相同的。写入一个节点后,立刻从任何节点读取,都能读到最新值。
- A(可用性):每个请求都能在合理时间内收到响应,不保证响应的是最新数据。
- P(分区容错性):分布式系统在遇到网络分区(节点之间无法通信)时,仍然能继续对外服务。
问题的关键在于P:只要系统是分布式的,P就是客观前提,根本不存在“选不选”的问题。 网络分区一定会发生——交换机重启、机房光缆被挖断、机器负载过高导致心跳超时,这些不是“万一”才出现的事故,而是“早晚会发生”的日常。
所以CAP真正的命题不是“三选二”,而是:当网络分区发生时,你选择牺牲C(保证可用性、允许短暂不一致),还是牺牲A(拒绝写入、保证一致性)?
2.2 为什么网络分区时不能同时满足C和A
用最简单的情况来解释。假设有两个节点A和B,它们之间断网了。此时客户端向A发了一个写请求“库存减1”,A接受了请求,本地库存变成9;而B仍然认为库存是10。
这时客户端又向B发了一个读请求“查询库存”,B该怎么回复?
- 如果B为了保证一致性,必须等和A通信恢复、同步数据之后再返回,那这个请求就会一直挂起直到超时——可用性被牺牲。
- 如果B为了保证可用性,直接返回自己当前的值10——那它返回的就是旧数据,和A已经不一致了——一致性被牺牲。
这就是CAP的铁律:分区发生时,你只能二选一。 不存在任何技术手段可以同时绕开这堵墙。
2.3 CP和AP怎么选
既然P是必选项,剩下的就是在C和A之间做选择。
- 选择CP(一致性优先):典型是银行转账、订单金额计算。宁可暂时不可用,也不能算错账。这种场景通常需要强一致性方案,比如2PC体系。
- 选择AP(可用性优先):典型是商品浏览、点赞数、动态流。短暂看到旧数据问题不大,只要最终一致就行。这种场景用异步消息、补偿机制就够了。
不少人对“订单+库存”的第一反应是“肯定要CP啊”。其实真实世界的电商订单往往是混合体——下单环节为了响应速度可以做AP,库存扣减环节为了不超卖做CP;极端场景比如秒杀,很多团队连库存扣减都改成异步了,用“最终一致”换更低的延迟。
这里有一个容易混淆的点必须说清楚:CAP里的C和分布式事务里的C,并不是同一个维度的概念。 CAP的一致性指多副本对外呈现的线性一致性,分布式事务的一致性指多个事务参与者的最终状态一致。两者有关联,但不完全等价。2PC/3PC本质上是在CP边界内做文章:为了保住全局强一致,付出阻塞、性能、可用性的代价。 理解了这一点,后面看2PC和3PC的取舍就会很清晰。
3. 两阶段提交(2PC):最经典的强一致性协议
3.1 两个角色和两个阶段
2PC把参与分布式事务的节点分成两类:
- 协调者(Coordinator):角色上相当于事务管理器,负责发起事务、收集投票、做出最终决策。
- 参与者(Participant):实际执行事务操作并持有资源的一方,比如订单库、库存库。
流程可以分成两个阶段:
阶段一:准备阶段(Prepare/投票阶段)
- 协调者向所有参与者发送Prepare请求,通知它们“准备提交事务T”。
- 参与者收到Prepare后,执行事务的写操作(比如插入订单、扣减库存),但不提交,把Undo/Redo日志写入磁盘。
- 参与者向协调者回复“OK(就绪)”或者“Abort(失败)”。
这个阶段的关键词是:本地事务已经执行完毕、只差最后的COMMIT。参与者的资源已经被锁住,等待最终的指令。
阶段二:提交阶段(Commit/执行阶段)
- 协调者收集所有参与者的投票。
- 如果所有参与者都回复OK,协调者广播Commit消息,所有参与者提交本地事务,释放锁。
- 只要有任何一个参与者回复Abort,或者超时未响应,协调者广播Rollback消息,所有参与者回滚本地事务。
用伪代码表示:
text复制// 协调者
1. broadcast prepare
2. wait for all participant votes
3. if all votes = YES:
broadcast commit
4. else:
broadcast rollback
// 参与者
1. receive prepare
2. execute local transaction, write undo/redo logs, lock resources
3. reply YES or NO
4. if receive commit: commit local txn, release locks
5. if receive rollback: rollback local txn, release locks
流程看起来很简单,逻辑也清晰,但真正落地的时候,问题一个接一个。
3.2 阻塞问题:2PC最大的硬伤
2PC的第一大问题是同步阻塞。
在准备阶段,参与者已经把资源锁住了。假设锁住的是库存表里的一行数据,在等待协调者最终指令的这段时间里,其他消费者想要操作这一行库存,只能被阻塞。这个等待可能只有几毫秒,也可能长达几分钟,极端情况下甚至可能是无限久。
更麻烦的是,系统吞吐量会因此急剧下降。想象一个热门商品的库存行,被一个迟迟不结束的2PC事务锁住,所有交易都堵在它后面。2PC本质上是在用锁换一致性,而锁的持有时间完全不可控,这是它在真实业务里最让人头疼的一点。
3.3 单点故障问题:协调者挂了,大家一起卡死
2PC的第二个问题是协调者单点故障。
如果协调者在发出Prepare之后、发出Commit之前宕机了,所有参与者都会处于“已准备、未提交、锁未释放”的状态。它们等不到协调者的指令,又不敢贸然提交或回滚(因为不知道其他参与者的状态),只能一直干等。
工程上,这意味着一次协调者宕机可能拖垮整条业务链路。严格来说这个问题有缓解手段——协调者可以持久化日志,恢复后根据日志继续决策。但这既增加了开发复杂度,也消除不了故障窗口期内的风险。
3.4 脑裂:Commit消息丢失怎么办
2PC的第三个问题叫脑裂,也叫数据不一致问题。
假设协调者发出了Commit消息,但网络抖动导致某个参与者一直没收到。其他参与者都已经提交了,这个参与者还停留在“已准备”状态。如果后续靠人工或自动恢复机制对它做了回滚,全局就会出现“有的节点提交、有的节点回滚”的不一致局面。
要明确一点:2PC协议本身并没有解决消息丢失场景下的数据不一致问题。 它只在“所有消息都能可靠送达”的前提假设下才能保证一致性。而真实网络环境里,这个假设本身就是不成立的。
3.5 工程实现:XA协议和Seata的AT模式
讲2PC不能只停留在协议层,工程上它到底怎么落地?
最标准的实现是XA协议。MySQL(InnoDB)、Oracle、PostgreSQL等主流数据库都实现了XA接口,分布式事务里的XA事务就是2PC的数据库原生实现。Java后端最熟悉的就是JTA + XA数据源。
但直接用XA的痛苦谁用谁知道:侵入性强、性能差、对数据库版本和配置要求高,而且是典型的强同步阻塞方案。所以后来社区里出现了Seata的AT模式,本质上是“改进版2PC”:
- 一阶段:业务SQL正常执行,事务管理器(TC)同时生成undo_log,记录数据快照和反向操作日志。
- 二阶段:如果所有分支都成功,TC异步删除undo_log;如果某分支失败,TC根据undo_log反向补偿,把数据恢复到执行前状态。
相比原生XA,AT模式最大的优势是业务代码几乎无侵入(一个@GlobalTransactional注解搞定),不需要业务方自己写回滚逻辑,而且一阶段会先提交本地事务,锁的持有时间大大缩短。代价是依赖undo_log做补偿,引入了分支事务和全局事务的管理开销,对热点数据的全局锁竞争仍然需要仔细设计。
如果你的技术栈是Spring Cloud,我个人的学习路径建议是先理解Seata AT模式,因为它的调试体验比直接写XA接口友好太多了。后面第6节的实战也会用它。
4. 三阶段提交(3PC):一次不太成功的改良
4.1 3PC想解决什么
2PC的痛点很明确:阻塞、单点、脑裂。3PC的提出者想从协议层面解决这些问题,核心思路有两个:
- 引入超时机制——无论协调者还是参与者都不能无限等待,超时就按预设规则处理。
- 增加一个额外的确认阶段(CanCommit)——让参与者在真正锁资源之前先探个底,避免大家都带着重活进入等待。
4.2 三个阶段逐个拆解
阶段一:CanCommit(询问阶段)
协调者向所有参与者发送CanCommit请求,问一句:“大家有空处理事务T吗?”参与者回复YES或NO。这个阶段不执行任何写操作,纯粹是探路。只要有参与者回复NO,事务直接中止;全部回复YES才进入下一阶段。
阶段二:PreCommit(预提交阶段)
协调者广播PreCommit请求,参与者收到后执行事务的写操作,写Undo/Redo日志,然后回复ACK,但仍然不提交。从这里开始,参与者的资源被锁住。
阶段三:DoCommit(最终提交阶段)
协调者收到所有参与者的ACK后,广播DoCommit消息,参与者正式提交本地事务并释放锁。
到这里为止,和2PC似乎差别不大,最关键的差异在下一节。
4.3 超时策略带来的连锁效果
2PC里,参与者处于“已准备”状态时,如果一直等不到协调者的指令,只能无限等待。3PC在其中加入了参与者超时自我保护机制:如果参与者在指定超时时间内没收到DoCommit或Abort指令,它不会继续呆等,而是默认提交。
设计者的逻辑是:既然所有参与者都已经进入PreCommit阶段并回复了ACK,说明大家都有能力完成这个事务;协调者迟迟不发指令,大概率是它挂了。与其无限阻塞,不如主动提交,把事务向前推进。
这个设计确实解决了2PC的部分阻塞问题——参与者不再无限等待,系统吞吐不会因为一次超时直接归零。
但它也带来了新的风险:如果协调者其实想发送Abort,只是消息丢了,而参与者超时自动提交,就会出现“协调者想取消、参与者已提交”的不一致。
换句话说,3PC用更复杂的三阶段交互,换来了概率上的一致性,而不是确定性的一致性。 它把2PC“要么全提交,要么全回滚”的强约束,降级成了“大多数情况下能一致”的软约束。
4.4 为什么生产环境几乎见不到3PC
聊到3PC,大家最常问的就是:这个方案看起来思路挺有意思,为什么生产环境没人用?
我总结了几条主要原因:
- 理论优势有限:3PC在缓解阻塞的同时引入了不确定性,一致性保证反而不如2PC强。
- 工程复杂度陡增:多一个阶段意味着多一轮网络交互,对消息可靠性和时序的要求更高,排查问题的面更广。
- 没有成熟的中间件生态:市面上几乎没有直接用3PC的成熟分布式事务中间件,自己实现一版成本高、风险大。
- 现实场景用不上:生产系统要么选2PC体系的强一致性方案(如XA、AT模式),要么选最终一致性方案(如事务消息、TCC),3PC两头不讨好。
所以3PC目前更多是作为理论进阶学习内容存在,属于“面试要懂、生产不用”的典型。单独研究它,对理解分布式事务的设计权衡非常有帮助,但别指望上线用它。
5. 2PC和3PC的差异对比与选型建议
5.1 一张表看明白
| 对比维度 | 两阶段提交(2PC) | 三阶段提交(3PC) |
|---|---|---|
| 阶段数量 | 准备、提交两个阶段 | CanCommit、PreCommit、DoCommit三个阶段 |
| 参与者超时行为 | 无超时机制,只能无限等待 | 超时后默认提交 |
| 协调者超时处理 | 无,需人工介入或日志恢复 | 相对减少,依靠超时和冗余阶段 |
| 一致性保证 | 强(在消息可靠送达前提下) | 概率性,存在不一致可能 |
| 阻塞风险 | 高,锁长期持有 | 较低,参与者可超时推进 |
| 单点故障影响 | 大,协调者挂了全员卡死 | 相对小,参与者可超时自救 |
| 工程实现成本 | 高,但中间件成熟(XA、Seata AT) | 极高,几乎没有成熟实现 |
| 实际应用 | 广泛,是强一致方案的基准 | 极少,主要存在于理论中 |
5.2 表格之外:我的工程选型理解
单纯看表格还不够,结合我的实际项目经验,再说几句掏心窝的话。
2PC和3PC都不是“问题的最优解”,而是“特定权衡下的产物”。
2PC把所有赌注押在协调者身上,协调者成了全场景里的“上帝节点”。而分布式系统的第一条原则恰恰是“不要有上帝”——上帝一旦挂了,全局一起遭殃。
3PC想化解这个矛盾,把赌注从“上帝永远在线”改成“参与者超时自动提交”,用一致性换取可用性。但因为参与者在不确定的状态下做了一个猜测性动作,又违背了“不确定时不要乱动”的谨慎原则。
所以我的结论是:要强一致性,老老实实用2PC体系(XA或AT模式),同时配套超时、重试、对账;能接受最终一致性,就别在2PC/3PC上死磕,直接上事务消息或TCC。 3PC的学术价值大于工程价值,了解思想即可,不要作为主要选型方向。
6. 实战:订单与库存的分布式事务落地方案
6.1 业务场景与核心诉求
拉一个最典型的电商场景:用户下单,生成订单,同时扣减库存。
需求拆解如下:
- 订单表里插入一条订单记录。
- 库存表里扣减对应商品的可售库存。
- 两个操作跨订单库和库存库,必须保证一致性。
但“一致性”还能再细化。不同的业务阶段,需求其实不同:
- 方案A:强一致——用户下单那一刻,订单和库存必须同时可见、同时变化。
- 方案B:最终一致——下单后可以先快速返回“下单成功”,库存扣减在后台异步完成,只要最终对上账就行。
这两种需求对应完全不同的技术方案,下面逐个展开。
6.2 强一致路线:用Seata AT模式落地2PC
如果业务确实需要强一致(比如后台库存严格管控、库存量小、并发量不高),用Seata AT模式落地2PC是个高性价比选择。
核心代码(简化示意):
java复制@GlobalTransactional(name = "order_create_transaction", timeoutMills = 30000)
public void createOrderAndReduceStock(Order order) {
// 1. 插入订单(订单库)
orderDao.insert(order);
// 2. 扣减库存(库存库)
stockDao.reduceStock(order.getProductId(), order.getQuantity());
}
一阶段: 业务SQL正常执行,订单库和库存库各自提交本地事务,同时在每个库的undo_log表里记录反向操作日志。一阶段就提交了本地事务,所以锁的持有时间极短。
二阶段: 如果所有分支都成功,TC发起全局提交,异步清理undo_log;如果某个分支失败,TC根据undo_log反向补偿,把数据恢复成执行前状态。
实操要点:
- 每一张参与分布式事务的业务表,都要有配套的undo_log表,结构必须与官方脚本一致。
- 数据库账号要有插入undo_log和查询回滚日志的权限。
- 一阶段提交后,分支事务之间的隔离依赖全局锁。热点数据竞争激烈时要重新评估是否适合AT模式。
我在这里踩过最大的坑就是:undo_log表结构建错,导致一阶段直接报错。 特别是snapshot和rollback_info字段的类型必须精确匹配,建议直接用Seata官方提供的建表脚本,别自己手写。
6.3 最终一致路线:RocketMQ事务消息方案
如果业务允许最终一致(大多数电商订单都允许,因为下单后本来就有一个支付和履约流程),我更推荐事务消息方案。它的优势是:对业务SQL几乎无侵入、吞吐高、可用性好,还有天然的削峰填谷能力。
RocketMQ事务消息的核心流程:
- 订单服务先向MQ发送一条半消息(half message,对消费者不可见)。
- 半消息发送成功后,订单服务执行本地事务——插入订单表。
- 本地事务成功后,向MQ提交Commit,把半消息转为正式消息。
- 库存服务监听正式消息,执行扣减库存操作。
- 如果本地事务失败,向MQ发送Rollback,半消息被丢弃。
那如果订单服务执行完本地事务后,还没来得及告诉MQ结果就宕机了呢?RocketMQ会回查事务状态——回调订单服务的接口询问“这笔订单的本地事务到底成功了没有”。所以订单表里必须有一个能标识事务状态和幂等状态的字段,供回查接口读取。
关键代码(简化示意):
java复制// 订单服务:实现 TransactionListener
public class OrderTransactionListener implements TransactionListener {
@Autowired
private OrderMapper orderMapper;
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 真正执行本地事务:插入订单
Order order = (Order) arg;
orderMapper.insert(order);
return LocalTransactionState.COMMIT_MESSAGE;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 回查:根据订单号查订单是否已存在
String orderId = msg.getUserProperty("orderId");
Order order = orderMapper.selectById(orderId);
if (order != null) {
return LocalTransactionState.COMMIT_MESSAGE;
}
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
这里要特别提醒:真正的生产实现里,半消息发送和本地事务执行的顺序、异常处理有很多细节。我强烈建议把回查逻辑做成独立的幂等服务,不要把业务逻辑和消息发送逻辑强行搅在一起,否则后面排查问题时想哭都哭不出来。
6.4 不用MQ:本地消息表方案
事务消息最大的麻烦是“公司没有RocketMQ,只有Kafka和RabbitMQ”。Kafka自带的事务消息落地成本和对版本、配置的要求偏高。这时候,本地消息表是最接地气的最终一致性方案,也是我最常推荐给团队的兜底方案。
思路一句话:把“发送消息”和“本地业务操作”放进同一个本地事务里。
实操步骤:
- 在订单库里建一张
t_message表,字段包括消息ID、消息内容、状态(待发送、已发送、失败)、重试次数。 - 用户下单时,在同一事务里插入订单记录和一条“扣减库存”的消息记录。
- 本地事务提交成功后,后台定时任务扫描
t_message表中状态为“待发送”的消息,投递给MQ或直接调用库存服务。 - 库存服务处理成功后,回调更新消息状态为“已发送”;定时任务扫描超时未确认的消息,重新投递。
这个方案的好处是:本地数据库事务不出问题,消息就不会丢;坏处是:要额外维护消息表、定时任务和重试机制,代码量稍多,但胜在可控、不绑定特定中间件。我做过好几个项目,都在没有RocketMQ的环境里用这套打过底,稳定性很靠谱。
6.5 TCC方案与适用场景
最后提一下TCC(Try-Confirm-Cancel)。TCC把每个分布式操作拆成三步:
- Try:预留资源。比如冻结库存100件,不真正扣减。
- Confirm:确认执行。真正扣减冻结的库存。
- Cancel:撤销。释放冻结的库存。
TCC最大的优势是业务控制力最强,锁的粒度可以做得非常细,性能比数据库级2PC好不少。缺点也极其明显:每个业务接口都要写Try、Confirm、Cancel三套逻辑,开发和测试成本高得惊人。
以我的经验,TCC适合那种性能要求高、又需要准实时一致的核心链路,比如支付和资金类。普通订单+库存场景用它有点杀鸡用牛刀,别给自己找事。
见过不少团队徒手写TCC,最后Cancel逻辑写不对,出现“冻结库存没释放”“超卖”之类的事故。如果没有成熟的TCC中间件和严格的上线流程,慎用。
7. 常见问题排查与实操经验
7.1 协调者或TC宕机了怎么办
只要用了分布式事务方案,你一定要提前想明白“全局事务状态存在哪里”。
Seata AT模式下,TC会把全局事务状态持久化到存储介质(默认是数据库或文件)。TC恢复后,能根据状态日志继续推进事务。所以:一定要给TC配置持久化存储,并且TC要集群多节点部署。 TC一旦单点宕机,所有在途的全局事务全部处于中间状态,恢复成本极高。
我生产环境遇到过TC所在机器磁盘故障。因为当初偷懒没做TC高可用,恢复时只能靠人工对账。那次之后,我再也不信“TC不会挂”这句话。
7.2 参与者超时了怎么处理
2PC体系里,参与者已准备但迟迟等不到最终指令,超时怎么办?标准协议没写死,工程上的常见做法是:
- 协调者超时未收到投票,先把该分支标记为异常或回滚,同时报警让人工介入。
- 参与者超时未收到最终指令,不要自行决定提交或回滚,而是向协调者查询事务状态;实在无法查询时,保留资源快照,等人工处理。
这里有一条我反复强调的原则:不要为了图省事,让参与者在未知状态下盲目提交或回滚,那是拿数据一致性赌博。 宁可让事务卡住并报警,由对账任务兜底,也不能自作主张。
7.3 数据不一致了,怎么排查
不管用哪种方案,数据最终都可能出现暂时不一致(消息重复、消息丢失、并发竞争都会引发)。所以接入分布式事务的团队,建议准备两样东西:一张幂等表,一套对账脚本。
排查流程我一般按四步走:
- 确认不一致的时间窗口,导出两个系统的数据快照。
- 按业务主键(比如订单号)做关联比对,找出差异记录清单。
- 分析差异原因:是消息没送达?是处理逻辑异常?还是幂等没做好?
- 根据差异类型,手动补偿或触发重放逻辑。
手动补偿最怕的是补偿操作本身没做幂等。这是我踩过最深的坑:补账脚本执行两次,直接把数据改错。补偿操作必须设计成可重复执行且结果一致。
7.4 幂等、空回滚、悬挂:TCC的三个大坑
如果你使用TCC,除了要写三套逻辑之外,还必然处理三个经典问题:
- 空回滚:Try没执行成功,但Cancel被调用了。Cancel要能识别这种状态,直接返回成功,不做多余操作。
- 幂等:Confirm和Cancel可能被多次调用,接口必须幂等。
- 悬挂:Cancel先于Try到达,导致Try无法继续执行。Cancel时就要记录状态,防止Try后补到达。
解决这些问题的通用手法是:在事务操作记录表里增加状态字段,用状态机控制Try/Confirm/Cancel的执行顺序,所有接口做幂等控制。
这些坑不是理论推导,是我和团队接支付系统时真实填过的坑。没有状态机的TCC,就像没有红绿灯的十字路口,迟早出事。
8. 一致性出问题后的修复与补偿套路
8.1 对账体系是底线
很多团队会把“不出问题”当成目标,这个出发点没错,但不够。分布式系统里真正可靠的底线,是出现问题之后能发现、能定位、能修复。这也是为什么我前面反复说“对账脚本是底线”。
不管你是用了2PC强一致,还是用了最终一致消息方案,我都建议实现以下几个动作:
- 每天定时对账:订单库里已支付金额,和支付系统里成功流水核对。
- 库存定期盘差:每天对比库存系统的扣减流水与实际出库记录。
- 消息投递监控:对
t_message或MQ消息的重试次数、堆积量做告警。
补齐对账体系,分布式事务从“出了问题靠运气”变成“出了问题5分钟定位”,这是每一个经历过大事故的团队都会认同的投入产出比。
8.2 补偿操作必须幂等且可审计
修复和补偿动作中,最重要的一点是幂等。之前提过多次,这里再集中讲一下设计要点:
- 每个补偿任务带上唯一的业务ID(订单号、流水号),DB表用业务ID做唯一索引。
- 补偿执行前后都记录日志,方便回溯“谁动了数据、改了什么、为什么改”。
- 补偿脚本支持“先行试跑”(核对预览),确认影响行数符合预期后再实际执行。
我在实际项目里见过不少事故,都是在“赶时间修复”的阶段,因为补偿脚本缺乏幂等保护,把本应修正的数据又改错了。慢一点、稳一点,永远比快而错更划算。
最后再分享一个小经验。很多人学分布式事务,喜欢把所有方案从上到下背一遍,然后纠结“到底哪个最好”。我的体会是:没有最好的分布式事务方案,只有当前业务约束下最合适的方案。 需要强一致、并发又不高的场景,2PC体系(XA或Seata AT)依然能打;需要高吞吐、允许短暂不一致的场景,事务消息或本地消息表才是主菜;TCC和3PC,一个留给资金级核心链路,一个留给理论学习和面试。
真到了设计阶段,先问自己三个问题:这个操作能接受短暂不一致吗?不一致最坏会带来多少业务损失?团队有没有能力维护复杂的补偿逻辑?想清楚这三个问题,你的选型基本就出来了。
