跳过导航

分布式事务的本质:2PC、TCC、Saga 与最终一致性如何选择

约 7 分钟...次浏览
专栏分布式系统与消息队列第 6 篇

订单服务创建订单后调用库存服务扣减库存。订单提交成功、库存调用失败时,两个服务的数据出现分歧。这不是加一个 @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 则专注解决数据库与消息双写。选型的核心不是框架名,而是业务能否预留、能否补偿、允许多久不一致,以及故障后如何恢复和对账。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。