分布式事务方案
Java 架构师 · 服务治理
跨服务的写操作无法依赖单机 ACID(一个事务跨多个数据库/服务,没有共享的锁与 undo/redo)。分布式事务的目标,是在"一致性"与"可用性/性能/复杂度"之间取得与业务价值匹配的平衡。架构师最该建立的认知是:绝大多数场景不需要强一致,用最终一致(可靠消息 + 幂等 + 对账)就能覆盖 80%,只有资金、库存等临界资源才上 TCC/Seata。本章逐一拆解 2PC、TCC、Saga、本地消息表、事务消息、最大努力通知的适用边界与工程陷阱。
一、CAP 视角:为什么强一致代价高
跨服务强一致需要"锁住多个资源的写"并协调提交,意味着:
- 持有锁时间长 → 并发骤降;
- 任一参与者宕机 → 整个事务阻塞(可用性丧失);
- 网络分区 → 无法达成(或牺牲可用)。
所以工业界几乎不在长链路追求强一致,转而用最终一致 + 补偿 + 对账。
二、方案全景与选型边界
| 方案 | 一致性 | 性能 | 复杂度 | 典型适用 |
|---|---|---|---|---|
| 2PC / XA | 强一致 | 差(长锁) | 中 | 短事务、同机房、强一致(少用) |
| 本地消息表 | 最终一致 | 高 | 中 | 异步解耦、可接受延迟 |
| 事务消息(RocketMQ) | 最终一致 | 高 | 中 | 写本地 + 发消息需原子 |
| TCC | 准实时一致 | 中高 | 高(三接口) | 资金、库存临界 |
| Saga | 最终一致 | 高 | 高(补偿链) | 长流程业务(下单→风控→发货) |
| 最大努力通知 | 弱一致 | 高 | 低 | 通知类、跨机构对账 |
选型第一原则
一致性强度应与业务价值成正比。能用"同库单事务 + 本地消息表异步通知"解决的,就不要引入 TCC 的复杂度;只有"少收/多扣会出资损"的临界资源,才上 TCC/Seata AT 并严格压测。
三、2PC:理论清晰但工程脆弱
两阶段提交(XA):
- Prepare:协调者问所有参与者"能否提交",参与者锁资源、写 redo/undo 并返回 Yes/No。
- Commit/Rollback:全 Yes 则发 Commit,否则发 Rollback。
致命缺陷:
- 同步阻塞:Prepare 后资源一直锁到 Commit,长事务拖垮并发。
- 单点(协调者):协调者宕机,参与者陷入"不确定状态"无限等待(需超时 + 补偿恢复)。
- 数据不一致风险:Commit 阶段部分参与者收到、部分没收到(网络分区)→ 数据分裂。
因此 2PC 只适合短事务、同机房、参与者少的场景(如单应用内多数据源 XA),不宜做跨服务长链路。
四、本地消息表:最简单的最终一致
核心思想:本地事务与"消息记录"写在同一个本地库事务里,保证"业务写成 = 消息落表"原子;再由后台任务轮询消息表,投递到 MQ,下游消费后回调删除/标记。
优点:不依赖 MQ 的事务特性,纯本地事务即可保证"不丢消息"。缺点:消息表与业务耦合,需自己实现发送/重试/清理。
五、RocketMQ 事务消息:半消息 + 回查
RocketMQ 把"本地事务 + 发消息"的原子性做成中间件能力,避免自己维护消息表:
// 1. 发半消息(对消费者不可见)
TransactionSendResult r = producer.sendMessageInTransaction(msg, biz);
// 2. 执行本地事务
@Transactional
public LocalTransactionState executeLocal(biz) {
orderMapper.insert(order); // 本地事务
return LocalTransactionState.COMMIT_MESSAGE; // 半消息转可消费
}
// 3. 回查: 防止本地事务状态未知(Broker 没收到 Commit/Rollback)
public LocalTransactionState checkLocal(MessageExt msg) {
return orderExists(msg) ? COMMIT_MESSAGE : ROLLBACK_MESSAGE;
}为什么需要回查? 本地事务执行完,Broker 还没收到 Commit 就宕机/网络断,消息状态悬而未决。Broker 会主动回调 checkLocalTransaction 问"到底成没成",从而决定提交还是回滚。这是事务消息可靠性的关键——补偿由中间件主动发起,而非靠下游对账。
六、TCC:业务层两阶段(资金级强一致)
TCC = Try(预留资源)/ Confirm(确认)/ Cancel(取消)。它把"数据库锁"换成"业务锁"(如冻结库存而非锁行):
- Try:检查并预留资源(扣减可用额、冻结库存),不真正提交。
- Confirm:真正提交预留(幂等,必定成功)。
- Cancel:释放预留。
代价:每个业务要写 Try/Confirm/Cancel 三个接口,开发量是 3 倍;且 Confirm/Cancel 必须幂等、空回滚安全、防悬挂。
TCC 的两大坑:空回滚与防悬挂
- 空回滚:Cancel 先于 Try 到达(网络乱序/Try 超时未达)。此时业务没预留过资源,Cancel 不能真的去释放,否则会误释放。解法:Try 阶段先记"事务状态=已发起",Cancel 发现没 Try 记录则记"空回滚"标记并直接返回成功。
- 防悬挂:Try 在 Cancel 之后才到达(因为 Cancel 已空回滚,Try 又到了),若执行预留会留下"永远不被 Confirm/Cancel 的脏预留"。解法:Try 执行前检查是否已有"空回滚"标记,有则拒绝执行 Try(悬挂)。状态机:空回滚标记 > Try 执行。
七、Saga:长事务的补偿链
长业务流程(下单 → 风控 → 扣库存 → 发货)不适合 TCC(锁太久)。Saga 把流程拆为有序的本地事务,每个步骤有对应的补偿动作;任一步失败,按逆序执行已成功步骤的补偿。
注意:Saga 不保证隔离性(补偿前中间状态对外可见),可能需要"重试 + 补偿"或"语义锁"缓解。补偿逻辑必须能处理"部分成功",否则留下脏数据。
八、最大努力通知与对账兜底
最大努力通知:上游尽最大努力把结果推给下游(多次重试),下游需能主动查询对账(如支付宝异步通知 + 商户主动查单)。它一致性最弱,但实现最简单,适合"通知类、可对账"的场景。本质是把一致性交给定期对账任务最终修复——这也是所有最终一致方案的最后一道保险。
幂等:分布式事务的必选项
无论哪种方案,下游都可能重复收到消息/调用(重试、回查、MQ at-least-once)。必须按业务键幂等:唯一约束、去重表、版本号。没有幂等,消息重复 = 重复扣款。幂等不是可选项,是分布式写的前提。
九、最佳实践
- 优先最终一致:可靠消息(本地消息表或事务消息)+ 幂等 + 对账,覆盖 80% 场景。
- 强一致只用于资金/库存临界,用 TCC 或 Seata AT,并严格压测与混沌演练。
- 所有补偿/回滚路径必须测试,含"部分失败""网络乱序"用例。
- 建对账任务,最终兜底修复不一致;监控"悬挂事务""未确认消息"数量。
本节小结
分布式事务没有银弹,本质是"一致性 vs 可用性/性能/复杂度"的阶梯式取舍:2PC 强一致但长锁脆弱、仅适合短事务;本地消息表/事务消息用最终一致覆盖绝大多数异步场景(事务消息靠半消息+回查保可靠);TCC 用业务锁换资金级准实时一致,但必须解决空回滚与防悬挂;Saga 用补偿链解长流程但牺牲隔离性;最大努力通知+对账是最后兜底。架构师要按业务一致性要求分级选型,并把幂等、补偿、对账作为必选项——一致性强度必须与业务价值成正比。
📚 官方文档与参考资料
- Spring Cloud Alibaba(Seata 分布式事务):https://sca.aliyun.com/
- Seata 官方文档(AT/TCC/Saga):https://seata.apache.org/zh-cn/docs/
- RocketMQ 事务消息文档:https://rocketmq.apache.org/zh/docs/
- Spring Cloud:https://spring.io/projects/spring-cloud
✏️ 练习与自测
- 设计题:电商"下单减库存"用可靠消息还是 TCC?给出理由与关键实现点(含幂等与回查)。
- 画图题:画出 TCC 防悬挂方案——Cancel 先于 Try 到达时如何处理、空回滚标记如何产生、Try 执行前如何检查并拒绝悬挂。
- 设计题:设计一笔"跨行转账"的 Saga 流程,列出每个正向步骤及其补偿动作,并说明如何防止补偿期间的中间状态被用户看到。
- 辨析题:本地消息表与 RocketMQ 事务消息都能保证"本地写+发消息"原子,二者实现差异与适用边界是什么?什么场景必须用事务消息而非本地消息表?