分布式事务的本质:2PC、TCC、Saga 与最终一致性如何选择
订单服务创建订单后调用库存服务扣减库存。订单提交成功、库存调用失败时,两个服务的数据出现分歧。这不是加一个 @Transactional 能解决的问题,因为本地事务管理器只控制当前数据库连接。
分布式事务的本质,是多个独立资源在无法共享单一本地事务时,如何处理部分成功、网络不确定和故障恢复。
一、先明确一致性目标
设计前要回答:
- 是否必须对外立即呈现原子结果?
- 最长允许不一致多久?
- 业务操作能否撤销?
- 失败后是回滚,还是继续向前补齐?
- 可用性和吞吐能牺牲多少?
没有这些答案,讨论具体框架没有意义。
二、2PC:准备与提交
两阶段提交由协调者组织多个参与者:
阶段一 Prepare:各参与者执行并锁定资源,报告能否提交
阶段二 Commit/Rollback:协调者统一下达提交或回滚
XA 是数据库资源常见的 2PC 实现。优点是对业务代码相对透明、一致性强;代价是参与者在等待决议时持有锁,协调者故障和网络分区会产生 in-doubt 事务并影响可用性,跨异构服务支持也有限。2PC 也不是“绝不会出错”的魔法:协调日志丢失、运维强制决议或资源管理器缺陷仍可能产生启发式提交/回滚,必须具备恢复与核对能力。
适合参与者少、数据库支持良好、事务短且强一致价值高的内部系统。不适合长流程和互联网高并发核心链路。
三、TCC:业务层的 Try、Confirm、Cancel
TCC 把操作显式拆成三步:
Try:检查并预留资源
Confirm:确认使用资源
Cancel:释放预留资源
例如冻结账户金额:
interface AccountTccService {
void tryFreeze(String txId, Long accountId, BigDecimal amount);
void confirm(String txId);
void cancel(String txId);
}
TCC 不依赖数据库长时间持锁,可实现较强业务一致性,但侵入性高。每个参与者都要处理:
- 空回滚:Try 未成功却收到 Cancel。
- 幂等:Confirm 或 Cancel 被重复调用。
- 悬挂:Cancel 先到,迟到的 Try 又执行。
- 资源预留超时与自动释放。
需要用全局事务 ID 和状态表约束合法状态迁移。
四、Saga:长事务的补偿链
Saga 把长流程拆成一系列本地事务,每一步都有补偿动作:
创建订单 T1 -> 扣库存 T2 -> 扣款 T3 -> 安排配送 T4
失败时:C3 退款 -> C2 返还库存 -> C1 取消订单
Saga 不要求持有跨服务锁,适合持续数秒甚至数天的业务流程。缺点是中间状态会对外可见,补偿不一定能完美恢复原状。
例如“发送邮件”的补偿不能让收件人忘记邮件;机票取消也可能产生手续费。因此补偿应被理解为业务上的反向动作,而不是数据库物理回滚。
Saga 有两种组织方式:
- 编排式:中央 Orchestrator 明确下达每一步,流程可视化和治理更容易。
- 协同式:服务通过事件互相触发,耦合表面更低,但链路和循环更难理解。
复杂核心流程通常更适合显式编排。
五、事务消息与 Outbox
当问题主要是“本地数据库提交后可靠发布事件”,Outbox 往往比完整分布式事务更简单:
BEGIN;
INSERT INTO orders(...);
INSERT INTO outbox(event_id, event_type, payload, status)
VALUES (..., 'ORDER_CREATED', ..., 'NEW');
COMMIT;
后台发布器扫描并投递消息,成功后更新状态。投递可能重复,因此消费者必须幂等。
多发布器并发扫描时,要通过 SELECT ... FOR UPDATE SKIP LOCKED、租约/版本字段或 CDC 可靠地认领记录,不能让所有实例读取同一批 NEW 后同时发送。直接更新成 SENT 也有“消息已发但状态未写回”的窗口,所以 Outbox 天然通常是至少一次发布;业务事件必须有稳定 event_id。高吞吐场景可用 CDC 读取事务日志,但仍要设计重复、乱序、Schema 演进和故障重建。
部分消息平台提供事务消息,以回查本地事务状态决定提交或回滚消息。它降低了自建发布器成本,但会绑定中间件能力,仍需要处理回查幂等和消费者重复。
六、为什么最终一致性仍需要状态机
最终一致不代表“过一会自然就好了”。必须有明确的中间状态、重试、超时和人工补偿:
PENDING -> CONFIRMED
PENDING -> CANCELLING -> CANCELLED
PENDING -> MANUAL_REVIEW
状态更新要使用条件更新,防止迟到消息把已取消订单改回成功:
UPDATE orders
SET status = 'CONFIRMED'
WHERE id = ? AND status = 'PENDING';
七、方案对比
| 方案 | 一致性 | 业务侵入 | 资源占用 | 典型场景 |
|---|---|---|---|---|
| XA/2PC | 强 | 中 | 高、可能长锁 | 短事务、少量数据库 |
| TCC | 较强 | 很高 | 预留业务资源 | 支付、库存等可冻结资源 |
| Saga | 最终一致 | 高 | 不持跨服务长锁 | 长流程、可补偿步骤 |
| Outbox | 最终一致 | 中 | 额外表与发布器 | 数据库到消息可靠双写 |
| 事务消息 | 最终一致 | 中 | 依赖中间件 | 本地事务结果通知 |
八、对账是最后一道防线
网络和程序故障不可能被完全消灭。资金、库存等关键业务应定期按业务单号核对参与方状态,发现“订单成功但支付未知”等差异后自动补偿或转人工。
对账不是方案失败后的补丁,而是分布式一致性设计的一部分。
九、常见误区
- 把跨服务调用放进
@Transactional,误以为远程服务会一起回滚。 - 所有场景都上重量级全局事务框架。
- 补偿接口不幂等,重试补偿反而造成二次损害。
- 只设计成功路径,没有超时、悬挂和人工状态。
- 用消息最终一致却没有唯一 eventId、重试和对账。
十、选型检查清单
- 一致性窗口和业务损失上限是什么?
- 参与资源是否支持 XA,能否承受锁和协调成本?
- 业务资源能否 Try 预留,Confirm/Cancel 是否可幂等?
- 每一步是否有现实可行的补偿动作?
- 是否只需要解决数据库与消息双写?
- 是否设计中间状态、超时扫描、重试和人工处理?
- 是否有稳定全局事务 ID 与端到端追踪?
- 是否建立周期性对账?
- 补偿是否经过授权、限额和审计,是否可能覆盖后续合法状态?
总结
不存在适用于所有业务的“分布式事务银弹”。2PC 用资源锁定换强一致,TCC 用高业务侵入换可控预留,Saga 用补偿和中间状态支撑长流程,Outbox 则专注解决数据库与消息双写。选型的核心不是框架名,而是业务能否预留、能否补偿、允许多久不一致,以及故障后如何恢复和对账。
相关文章
雪花算法在时钟回拨时会发生什么?分布式 ID 的边界与治理
拆解 Snowflake 64 位 ID 结构,分析同毫秒序列、机器号冲突、时钟回拨和纪元耗尽风险,并比较等待、拒绝、备用位与集中发号方案。
接口幂等不是加一个 Redis 锁那么简单:生产级设计指南
从幂等键、请求指纹、数据库唯一约束、状态机、处理中状态、结果复用和过期策略,设计可应对超时重试与并发重复的接口幂等方案。
Exactly Once 是真实能力还是营销概念?先定义处理边界
区分投递一次、处理一次与业务效果一次,分析 Kafka 幂等生产者、事务、read-process-write 链路及外部数据库边界,说明 Exactly Once 的真实适用范围。