Contents

分布式事务一致性业界方案及实践经验总结

分布式事务讨论经常“讲概念很热闹,落地很痛苦”。这篇文章把常见方案放到一张地图里:你到底在追求什么一致性?要付出什么代价?工程上需要哪些兜底机制?

先从一个最小但足够典型的场景开始:账户 A 向账户 B 转账 100 元。你通常关心的是:

  • A 扣款与 B 入账要么都成功,要么都失败(原子性)
  • 转账前后 A+B 总额不变(业务一致性)
  • 多笔转账尽量并行,不互相阻塞(并发与隔离)
  • 结果可追溯、可对账、可恢复(持久性与可运营)

单机/单库事务依赖数据库提供的 ACID。多数互联网业务读多写少,常见实现会使用 MVCC,在 Repeatable Read 等隔离级别下获得较好的并发能力。

一旦进入分布式:服务拆分、库拆分、资源跨系统,你会立刻遇到两个事实:

  • 没有一个统一的锁与日志能覆盖所有参与方
  • 网络不可靠,调用会超时、重试、重复、乱序、丢失

因此,多数“跨系统业务流程”并不追求强 ACID,而是用 BASE(最终一致)+ 业务约束来换可用性与扩展性。

把方案按“你想要的保证”划分会更清晰:

  • 强一致倾向(代价高):XA/2PC/3PC、AT(框架托管的回滚/补偿)
  • 业务补偿(更可控):TCC、Saga(补偿与状态机由业务或编排器承担)
  • 可靠消息最终一致(工程最常用):本地消息表(Outbox)、事务消息、最大努力通知 + 查询兜底

另一个经常被忽略但很现实的选项是:把问题缩小到“同一个数据库组件内部”。例如 TiDB/Spanner/OceanBase 等分布式数据库在组件内部提供事务能力,可以把一部分一致性下沉到数据库层。但一旦跨出该组件(跨库、跨系统、跨公司),仍然要回到上面三类方案。

XA 规范定义了事务管理器(TM)与资源管理器(RM)的交互接口。多数关系型数据库都支持 XA。

2PC 分为 preparecommit/rollback:协调者先询问各参与者是否就绪(准备并锁资源),再统一提交或回滚。

202412041918201

优点:

  • 协议清晰,理解成本低

主要问题(这也是 2PC 在高并发业务里难用的原因):

  • 阻塞与锁持有时间长:prepare 后资源被锁住,吞吐下降明显
  • 协调者单点/共识成本:协调者故障会放大影响面
  • 不一致窗口:第二阶段如果消息只到了一部分参与者,会出现分支状态不一致

工程上常见的“补丁”是超时与互询:协调者超时触发回滚;参与者在 READY 状态超时后不能擅自回滚,需要向其他参与者查询最终决议。但注意:当所有参与者都卡在 READY,系统仍可能陷入长时间阻塞,这是协议本身的边界。

3PC 通过增加 can_commit(预询)与 pre_commit 来降低阻塞时间,并引入超时策略减少长时间锁资源的概率。

202412041918254

3PC 思路更“完备”,但实现与运维复杂度高、性能一般,实际落地远少于 2PC。

TCC(Try-Confirm-Cancel)把隔离与回滚从数据库层转移到业务层:先 Try 预留资源,再 Confirm 兑现;失败则 Cancel 释放。

  • Try:业务检查 + 资源预留(准隔离)
  • Confirm:只做兑现,不再做检查(必须幂等,可重试)
  • Cancel:释放预留(必须幂等,可重试)

它解决了 XA 的一个核心问题:不再用数据库长锁,而用“业务预留”把粒度缩小。但代价是:你需要为每个参与方写三套接口与状态机。

一个非常小但实用的落地结构(建议每个分支都记账):

create table tcc_branch (
  xid varchar(64) not null,
  branch varchar(64) not null,
  status varchar(16) not null, -- TRYING/CONFIRMED/CANCELED
  updated_at timestamp not null,
  primary key (xid, branch)
);

并且要把两个经典坑提前“钉死”:

  • 空回滚:Cancel 先到、Try 还没执行(需要根据状态表做幂等与防穿透)
  • 悬挂:Cancel 处理后 Try 才到(Try 需要识别并拒绝/直接返回)

这类问题在实践里通常靠“事务屏障(barrier)/幂等表”来解决,本质就是把时序问题收敛到一套通用状态机里。

Saga 把长事务拆成多个本地短事务,由编排器协调执行。正常则完成;某一步失败则按相反顺序调用补偿操作。

两种恢复策略:

  1. 向后恢复:失败后补偿已完成步骤
  2. 向前恢复:重试失败步骤,假设最终会成功(不依赖补偿,但对可重试性要求更高)

Saga 的关键点是:它通常不提供数据库级隔离(隔离要靠业务约束/锁/状态机)。适用场景是业务步骤多、流程长、允许中间态(例如机票/酒店/租车组合订单)。补偿不等于“回滚数据”,更多是“业务向前修正/取消状态”。

本地消息表的核心是把“业务落库”与“发消息”放进同一个本地事务里,保证原子性;然后异步投递到 MQ,消费者幂等处理。

典型流程:

  1. 生产者在一个本地事务里:写业务表 + 写 outbox 消息表
  2. 异步任务轮询 outbox,投递到 MQ,投递成功后标记已发送
  3. 消费者消费并执行业务(必须幂等),失败按策略重试/入死信

你至少需要一个像这样的消息表:

create table outbox (
  id bigint primary key,
  topic varchar(128) not null,
  payload json not null,
  status varchar(16) not null, -- NEW/SENT/FAILED
  next_retry_at timestamp null,
  created_at timestamp not null
);

这类方案的“难点不在表”,而在工程纪律:

  • 投递端要处理重复投递(至少一次语义)
  • 消费端要做幂等(按业务唯一键去重)
  • 要有对账与补偿:消息丢了/重复了/乱序了,都得能修
202412041918999

以 RocketMQ 的事务消息为代表,它本质上把“消息半状态 + 回查机制”做进了 MQ:

  • 发送 half 消息(对消费者不可见)
  • 执行本地事务
  • 根据本地事务结果提交/回滚 half 消息
  • 若长时间未决,Broker 回查生产者,本质是问“本地事务到底成功了吗?”

这减少了生产者侧轮询 outbox 的负担,但并不改变消费者必须幂等、必须可重试的现实。

注意:Kafka/Pulsar 的“事务”与 RocketMQ 的事务消息语义不同,选型时不要混用概念。

最大努力通知适用于“通知失败不影响主流程”的场景:主系统做完本地事务后发通知;通知系统按策略重试 N 次,最终失败则放弃;接收方需要提供查询接口做兜底。

它的核心设计点只有两个:

  • 重试策略:次数上限 + 指数退避 + 最终死信/人工处理
  • 查询兜底:回调不可靠时,靠查询把状态拉齐

AT(Seata 的一种事务模式)试图在“业务不用写补偿”和“最终一致可接受”之间找平衡:框架记录 undo log,在回滚时自动补偿。好处是接入相对轻;代价是会引入锁与额外日志,并可能遇到脏回滚等问题,性能与复杂度介于 XA 与业务补偿之间。

无论你选 XA/TCC/Saga/消息一致性,最后都会回到同一组工程动作:

要有贯穿全链路的唯一事务标识(订单号/支付单号/xid),并让每个参与方的接口满足:

  • 可重试(失败可重试)
  • 幂等(重复请求不重复扣款/不重复发货)
  • 可去重(按业务唯一键或幂等表判断是否已处理)

分布式事务的底层兜底通常就是重试,但暴力重试会在故障时制造雪崩。一个可用的默认策略:

  • RPC 重试次数尽量少(常见建议 ≤ 3)
  • 消息重放要有上限(例如 ≤ 50)并进入死信队列
  • 指数退避 + 抖动(1s、2s、4s、8s…),避免同一时刻打爆下游

只要你允许最终一致,就必须有对账与补偿:

  • 关键业务表要有可对账字段(状态机、时间戳、关联单号)
  • 对账任务要能找出“卡住的中间态”
  • 补偿要可审计、可回放、可人工介入

最终一致不是纯技术问题。你需要明确:

  • 中间态是否展示给用户(例如“处理中/待确认”)
  • 超时后的用户提示与客服路径
  • 资金/库存等高敏感状态的展示与冻结策略

一个经验原则:会给用户带来直接利益的动作尽量放到更靠后执行,但也要结合失败率与复杂度来排序,避免“先做简单的、后做复杂的,最后不得不补偿更多”。

  • 要强一致,就要付出锁与阻塞的代价(XA/2PC/AT)
  • 能接受最终一致,就把一致性收敛到“消息 + 幂等 + 对账”(Outbox/事务消息/最大努力通知)
  • 业务流程长且可补偿,就用 Saga/TCC,但要正视补偿与状态机的工程成本