先说结论
分布式事务没有通用最优解。强一致且参与者支持协议时可评估 2PC;业务可提供预留、确认、取消接口时可用 TCC;长流程适合 Saga 补偿;可接受最终一致的事件驱动业务常用本地事务加 Outbox 或可靠消息。
首选仍是调整服务和数据边界,让一次事务在单库或单聚合内完成,避免不必要的跨服务原子性。
方案比较
2PC 语义直接但协调、阻塞和可用性成本高;TCC 控制精细却侵入业务且要处理空回滚、悬挂和幂等;Saga 每步提交本地事务,失败执行语义补偿,但补偿不一定能完全恢复外部世界。
先画清业务边界
订单服务:创建订单、锁定订单状态
库存服务:预占库存、确认扣减、释放库存
支付服务:支付授权、支付确认或退款
履约服务:创建发货任务
如果一个请求需要这些服务同时提交,先问能否把“订单 + 库存预占”归入一个聚合,或把支付和履约改成异步状态流。服务拆分后再强行追求跨服务 ACID,往往把网络、重试和不可控外部副作用引入核心事务。
方案对比细化
| 方案 | 一致性 | 可用性成本 | 业务侵入 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 较强 | 协调者故障可能阻塞 | 中 | 参与者少且都支持协议 |
| TCC | 业务可控 | 需要预留与补偿 | 高 | 资金、库存等可拆三阶段动作 |
| Saga | 最终一致 | 分步提交,可补偿 | 中高 | 长流程、每步可逆或可补偿 |
| Outbox/可靠消息 | 最终一致 | 高可用、异步 | 中 | 事件驱动、允许延迟收敛 |
选择不能只看“强/弱一致”,还要看补偿是否可定义、参与者是否支持事务、用户能否看到中间状态和失败后是否需要人工介入。
TCC 的三个边界
Try 要预留资源且可重复调用,Confirm 真正提交,Cancel 释放预留。必须处理空回滚、悬挂和重复调用:空回滚是 Try 实际未成功却收到 Cancel;悬挂是旧 Try 晚于 Cancel 到达;重复 Confirm/Cancel 则来自重试和超时。每一步都要用事务 ID 和状态表做幂等,不能只靠调用方“应该只调用一次”。
Try -> 预占库存(status=TRY)
Confirm -> status=CONFIRMED
Cancel -> status=CANCELED
状态迁移要用条件更新,禁止 Confirm 后再被 Cancel 覆盖。TCC 的代码量和测试量很大,适合价值高且动作确实可拆的领域。
工程闭环
每个步骤使用全局事务或业务操作 ID,状态机只允许合法迁移。重试有退避与上限,失败进入人工或自动补偿队列;对账从事实源重新计算差异并支持可追溯修复。
Saga 与补偿设计
Saga 每一步本地提交并记录状态,失败后按逆序执行补偿。补偿不是数据库 rollback:已发出的短信、已扣的第三方款项、已送出的商品可能只能退款、重发或人工处理。补偿接口也要幂等、可重试、可观测,并记录“补偿失败待处理”而不是无限阻塞主流程。
Outbox 的故障时序
业务表和 outbox 事件在同一本地事务提交;投递器读未发送事件发布消息,收到确认后标记 sent。发布超时可能导致消息已发但状态未标记,下一次会重复发,因此消费者必须幂等。Outbox 表需要索引、分批清理、分区和积压监控,不能让事件永久堆积拖慢主库。
对账和状态机
每个跨服务操作都有全局 operation_id,状态只允许合法迁移:CREATED -> RESERVED -> PAID -> COMPLETED,失败进入 COMPENSATING/FAILED。定时对账从事实源或各服务状态重算差异,生成可追踪修复命令。没有状态机和对账的“最终一致”只是把错误藏到日志里。
容易踩坑的地方
“最终一致”不是不做保证,而是保证在明确条件和时间内收敛。补偿也不是数据库回滚,例如已发送短信无法撤回,只能记录并采取后续业务动作。
常见问题
追问:数据库成功、消息发送失败怎么办?
在同一本地事务中写业务数据和 Outbox 事件,后台可靠投递并标记状态;消费者幂等,投递失败可重试和对账。
追问:TCC 的 Cancel 一定会执行吗?
不能假设。协调器、网络和服务都可能故障,Cancel 也需要可靠重试、超时告警和人工补偿;Try 预留必须有过期释放机制。
追问:为什么不把所有服务加入 XA?
跨服务锁和协调会降低可用性、吞吐和故障恢复能力,且外部系统不支持 XA。只有参与者少、事务短、强一致收益明显时才评估。
追问:最终一致如何向用户解释?
用明确状态表达“处理中/待确认/失败可重试”,提供查询和补偿入口,不把尚未完成的异步流程伪装成成功。