分布式事务一致性业界方案及实践经验总结
分布式事务讨论经常“讲概念很热闹,落地很痛苦”。这篇文章把常见方案放到一张地图里:你到底在追求什么一致性?要付出什么代价?工程上需要哪些兜底机制?
先从一个最小但足够典型的场景开始:账户 A 向账户 B 转账 100 元。你通常关心的是:
- A 扣款与 B 入账要么都成功,要么都失败(原子性)
- 转账前后 A+B 总额不变(业务一致性)
- 多笔转账尽量并行,不互相阻塞(并发与隔离)
- 结果可追溯、可对账、可恢复(持久性与可运营)
1. 先说清楚:为什么“单库事务”不等于“分布式事务”
单机/单库事务依赖数据库提供的 ACID。多数互联网业务读多写少,常见实现会使用 MVCC,在 Repeatable Read 等隔离级别下获得较好的并发能力。
一旦进入分布式:服务拆分、库拆分、资源跨系统,你会立刻遇到两个事实:
- 没有一个统一的锁与日志能覆盖所有参与方
- 网络不可靠,调用会超时、重试、重复、乱序、丢失
因此,多数“跨系统业务流程”并不追求强 ACID,而是用 BASE(最终一致)+ 业务约束来换可用性与扩展性。
2. 方案地图:强一致、业务补偿、消息最终一致
把方案按“你想要的保证”划分会更清晰:
- 强一致倾向(代价高):XA/2PC/3PC、AT(框架托管的回滚/补偿)
- 业务补偿(更可控):TCC、Saga(补偿与状态机由业务或编排器承担)
- 可靠消息最终一致(工程最常用):本地消息表(Outbox)、事务消息、最大努力通知 + 查询兜底
另一个经常被忽略但很现实的选项是:把问题缩小到“同一个数据库组件内部”。例如 TiDB/Spanner/OceanBase 等分布式数据库在组件内部提供事务能力,可以把一部分一致性下沉到数据库层。但一旦跨出该组件(跨库、跨系统、跨公司),仍然要回到上面三类方案。
3. XA:数据库参与的分布式事务(2PC/3PC)
XA 规范定义了事务管理器(TM)与资源管理器(RM)的交互接口。多数关系型数据库都支持 XA。
3.1 两阶段提交(2PC)
2PC 分为 prepare 与 commit/rollback:协调者先询问各参与者是否就绪(准备并锁资源),再统一提交或回滚。

优点:
- 协议清晰,理解成本低
主要问题(这也是 2PC 在高并发业务里难用的原因):
- 阻塞与锁持有时间长:
prepare后资源被锁住,吞吐下降明显 - 协调者单点/共识成本:协调者故障会放大影响面
- 不一致窗口:第二阶段如果消息只到了一部分参与者,会出现分支状态不一致
工程上常见的“补丁”是超时与互询:协调者超时触发回滚;参与者在 READY 状态超时后不能擅自回滚,需要向其他参与者查询最终决议。但注意:当所有参与者都卡在 READY,系统仍可能陷入长时间阻塞,这是协议本身的边界。
3.2 三阶段提交(3PC)
3PC 通过增加 can_commit(预询)与 pre_commit 来降低阻塞时间,并引入超时策略减少长时间锁资源的概率。

3PC 思路更“完备”,但实现与运维复杂度高、性能一般,实际落地远少于 2PC。
4. TCC:把“锁资源”变成“预留资源”
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)/幂等表”来解决,本质就是把时序问题收敛到一套通用状态机里。
5. Saga:长流程拆成多个本地事务 + 补偿
Saga 把长事务拆成多个本地短事务,由编排器协调执行。正常则完成;某一步失败则按相反顺序调用补偿操作。
两种恢复策略:
- 向后恢复:失败后补偿已完成步骤
- 向前恢复:重试失败步骤,假设最终会成功(不依赖补偿,但对可重试性要求更高)
Saga 的关键点是:它通常不提供数据库级隔离(隔离要靠业务约束/锁/状态机)。适用场景是业务步骤多、流程长、允许中间态(例如机票/酒店/租车组合订单)。补偿不等于“回滚数据”,更多是“业务向前修正/取消状态”。
6. 本地消息表(Outbox):最常见的最终一致落地
本地消息表的核心是把“业务落库”与“发消息”放进同一个本地事务里,保证原子性;然后异步投递到 MQ,消费者幂等处理。
典型流程:
- 生产者在一个本地事务里:写业务表 + 写 outbox 消息表
- 异步任务轮询 outbox,投递到 MQ,投递成功后标记已发送
- 消费者消费并执行业务(必须幂等),失败按策略重试/入死信
你至少需要一个像这样的消息表:
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
);
这类方案的“难点不在表”,而在工程纪律:
- 投递端要处理重复投递(至少一次语义)
- 消费端要做幂等(按业务唯一键去重)
- 要有对账与补偿:消息丢了/重复了/乱序了,都得能修

7. 事务消息:把 Outbox 挪到 MQ 里
以 RocketMQ 的事务消息为代表,它本质上把“消息半状态 + 回查机制”做进了 MQ:
- 发送 half 消息(对消费者不可见)
- 执行本地事务
- 根据本地事务结果提交/回滚 half 消息
- 若长时间未决,Broker 回查生产者,本质是问“本地事务到底成功了吗?”
这减少了生产者侧轮询 outbox 的负担,但并不改变消费者必须幂等、必须可重试的现实。
注意:Kafka/Pulsar 的“事务”与 RocketMQ 的事务消息语义不同,选型时不要混用概念。
8. 最大努力通知:通知型业务的务实选择
最大努力通知适用于“通知失败不影响主流程”的场景:主系统做完本地事务后发通知;通知系统按策略重试 N 次,最终失败则放弃;接收方需要提供查询接口做兜底。
它的核心设计点只有两个:
- 重试策略:次数上限 + 指数退避 + 最终死信/人工处理
- 查询兜底:回调不可靠时,靠查询把状态拉齐
9. AT 模式:框架托管补偿的折中(以 Seata 为例)
AT(Seata 的一种事务模式)试图在“业务不用写补偿”和“最终一致可接受”之间找平衡:框架记录 undo log,在回滚时自动补偿。好处是接入相对轻;代价是会引入锁与额外日志,并可能遇到脏回滚等问题,性能与复杂度介于 XA 与业务补偿之间。
10. 工程落地:真正决定成败的不是方案名字
无论你选 XA/TCC/Saga/消息一致性,最后都会回到同一组工程动作:
10.1 统一事务标识与幂等
要有贯穿全链路的唯一事务标识(订单号/支付单号/xid),并让每个参与方的接口满足:
- 可重试(失败可重试)
- 幂等(重复请求不重复扣款/不重复发货)
- 可去重(按业务唯一键或幂等表判断是否已处理)
10.2 重试要克制
分布式事务的底层兜底通常就是重试,但暴力重试会在故障时制造雪崩。一个可用的默认策略:
- RPC 重试次数尽量少(常见建议 ≤ 3)
- 消息重放要有上限(例如 ≤ 50)并进入死信队列
- 指数退避 + 抖动(1s、2s、4s、8s…),避免同一时刻打爆下游
10.3 对账与补偿:不要等事故才想起它
只要你允许最终一致,就必须有对账与补偿:
- 关键业务表要有可对账字段(状态机、时间戳、关联单号)
- 对账任务要能找出“卡住的中间态”
- 补偿要可审计、可回放、可人工介入
10.4 产品侧的中间态设计
最终一致不是纯技术问题。你需要明确:
- 中间态是否展示给用户(例如“处理中/待确认”)
- 超时后的用户提示与客服路径
- 资金/库存等高敏感状态的展示与冻结策略
一个经验原则:会给用户带来直接利益的动作尽量放到更靠后执行,但也要结合失败率与复杂度来排序,避免“先做简单的、后做复杂的,最后不得不补偿更多”。
小结:用约束选方案,用工程纪律保成功
- 要强一致,就要付出锁与阻塞的代价(XA/2PC/AT)
- 能接受最终一致,就把一致性收敛到“消息 + 幂等 + 对账”(Outbox/事务消息/最大努力通知)
- 业务流程长且可补偿,就用 Saga/TCC,但要正视补偿与状态机的工程成本