分布式事务: 2PC / Saga / TCC / Outbox / Spanner
TL;DR
单机事务靠 WAL + 锁把"多行修改"原子化;一旦数据分到多台机器(微服务拆分、分库分表),"跨库转账"这类操作需要跨机器原子性。这一章讲四条技术路线的数学本质与工程取舍:2PC(强一致,但协调者故障会阻塞)、Saga(无原子性,靠补偿最终一致)、TCC(业务把"预留/确认/取消"三阶段暴露出来)、Outbox/事务消息(把 DB 事务和消息投递绑成一个原子操作)。最后看 Google Spanner 怎么用 2PC + TrueTime 同时拿到强一致和可用性。工程上 90% 的"分布式事务"问题,正确答案是换一个不用跨库事务的数据模型,而不是引入 2PC。
读完应能:
- 复述 2PC 的两阶段流程,指出协调者/参与者各自故障时的阻塞点与可恢复性。
- 说出 3PC 比 2PC 好在哪、为什么没解决根本问题。
- 设计一个 Saga(含补偿顺序)和一个 TCC 接口(Try/Confirm/Cancel + 幂等 + 空回滚)。
- 说清 Outbox 为什么能保证"DB 写入与消息投递至少一次一致",以及消费侧怎么幂等。
- 判断一个业务场景到底该用 2PC / Saga / TCC / 还是重构避免。
一、问题定义: 分布式原子提交
把转账拆成"扣 A 账户"(库 1)+"加 B 账户"(库 2)。两边各自有本地事务,但没有一个共同的锁/WAL 能同时覆盖两库。需要协议保证:要么两边都提交,要么两边都回滚,且任何进程崩溃后仍能收敛。
这就是 分布式原子提交(Distributed Atomic Commit) 问题。注意它和共识(consensus)的区别:
| 共识 (Paxos/Raft) | 原子提交 (2PC) | |
|---|---|---|
| 问什么 | 多节点对一个值达成一致 | 多节点对是否提交各自的事务达成一致 |
| 失败模型 | 容忍故障,总能推进 | 协调者故障时可能阻塞 |
| 典型产物 | 复制状态机、选主 | XA、数据库两阶段 |
note
2PC 每轮都要多数派之外的全部参与者点头,且协调者是单点——它解决的是"原子性"而不是"可用性"。把 2PC 当共识用是常见误解。
二、2PC: 准备 → 提交
协调者(Coordinator) 参与者(Participants)
│ prepare(tx) ───────────────────►│ ① 写 undo/redo 日志, 加锁
│◄─────────── yes / no ───────────│ ② 进入 prepared 状态, 可恢复
│ 如果全部 yes: │
│ commit ────────────────────────►│ ③ 释放锁, 提交
│ (任一 no: 发 abort)
两个阶段的关键性质:
- 准备阶段:参与者把事务做成"可提交但未提交"(写日志 + 持锁)。此时承诺:只要收到 commit 就一定提交;
- 提交阶段:协调者已决定,全体执行。决策一旦做出,协议不允许反悔。
故障分析(这是面试/设计题核心):
| 故障 | 后果 | 恢复方式 |
|---|---|---|
| 参与者 prepare 后崩溃 | 持有锁,事务悬挂 | 重启后查日志,等协调者补发 commit/abort |
| 协调者在 prepare 后、commit 前崩溃 | 所有参与者阻塞,资源被锁死 | 需要新协调者读日志恢复决策;否则只能人工介入 |
| 参与者收 commit 后崩溃 | 事务已提交,但副本可能未同步 | 重放日志 + 对下游补偿 |
warning
2PC 的"不可用"不是概率事件:协调者故障 + 网络分区时,参与者既不能提交也不敢回滚(怕违背承诺),只能干等。这就是"阻塞问题",也是 2PC 不适合长事务/跨公网的原因。
3PC 的有限改进
3PC 在 2PC 前加一轮 can-commit(投票),并把提交阶段拆成 pre-commit → do-commit,让参与者之间可以"互相恢复"(任何一个知道超时都可以去问别人)。它解决了协调者单点崩溃导致的无限阻塞,但:
- 网络分区 + 新主视图不一致时仍可能脑裂(有人提交有人回滚);
- 工程上几乎没人用纯 3PC,因为引入的复杂度大于收益。
三、XA: 2PC 的工业实现
Java 世界用 XA 接口把数据库/消息中间件接进 2PC:xa_start / xa_end / xa_prepare / xa_commit / xa_rollback。MySQL/PostgreSQL/Oracle 都支持。
现实中的 XA 代价:
- 每个参与者在 prepare 后锁资源直到全局决策——两库场景下并发和吞吐暴跌;
- 协调者(如应用进程)是单点且必须持久化事务表,否则重启即失忆;
- 跨库性能问题、运维事故频发,业界(尤其互联网公司)用 XA 的比例很低。
结论:XA/2PC 适合"少量短事务 + 强一致 + 内部低延迟网络"的场景(典型:银行核心、数据中心内两库联动),不适合跨公网、长事务、高并发互联网业务。
四、Saga: 没有原子性,用补偿
Saga 把一个大事务拆成 N 个本地事务 + 每个本地事务配一个补偿事务:
下单(库存扣减) → 生成订单 → 支付扣款
失败则反向补偿: 支付失败 → 取消订单 → 库存回补
两种编排:
| 编排方式 | 控制流 | 优缺点 |
|---|---|---|
| Choreography(事件驱动) | 每个服务完成后发事件,下一个服务订阅 | 无中心点、演进灵活;但流程不可见、难调试 |
| Orchestration(编排器) | 中央 Saga 编排器依次调用 | 流程集中可见、易做重试/超时;编排器本身要高可用 |
正确性三要素:
- 补偿必须能成功:补偿里不能依赖"对方也失败"等假设,要设计成幂等、可重试;
- 补偿顺序 = 正序逆序:
t1, t2, t3失败于 t3 → 补偿c3, c2, c1(c3 通常是被动补偿,t3 自己的回滚); - 无隔离保证:Saga 中间状态对外可见——需要额外手段(如订单状态机 + 对账)控制。
tip
面试/设计高频答案:Saga 适用于"最终一致可接受、链路长、跨多个独立服务"。先画状态机,再定补偿,最后用幂等键 + 对账脚本兜底。
五、TCC: 把三阶段暴露给业务
TCC(Try-Confirm-Cancel)把 2PC 的 prepare/commit 翻译成业务操作:
| 阶段 | 做什么 | 例子(库存扣减) |
|---|---|---|
| Try | 预留资源,不真正生效 | 冻结库存 10 件 |
| Confirm | 确认生效(幂等) | 把冻结转成实际扣减 |
| Cancel | 释放预留(幂等) | 解冻 10 件 |
必须处理的三个边界:
- 幂等:Confirm/Cancel 可能因网络重试被调多次,业务侧要用事务 ID 去重;
- 空回滚:Try 因超时没真正执行,但 Cancel 到了——Cancel 必须能安全执行(通常查无此预留就返回成功);
- 悬挂:Cancel 先到、Try 后到——要拒绝迟到的 Try(记录已 Cancel)。
主流实现:Seata AT/TCC 模式、DTM。TCC 的代价是把业务逻辑改造成"预留/确认/取消"三段——侵入性强,适合余额、库存、优惠券这类天然可分阶段操作的资源。
六、Outbox / 事务消息: 最容易落地的"半分布式事务"
最常用的需求其实是:"DB 写成功 + 发消息/事件成功"(比如下单后发 Kafka 事件给下游)。两个操作跨系统,朴素做法是"先写库再发消息",但二者之间崩溃就丢事件。
Transactional Outbox:
业务事务: INSERT 订单; INSERT outbox(事件) ← 同一个本地事务
后台任务: 扫 outbox → 发 Kafka → 标记已发送
关键性质:事件和业务数据在同一个本地事务里落库,要么都成要么都败;投递用 at-least-once,消费侧幂等去重。
工程要点:
- outbox 表要批次扫描 + 索引(未发送状态 + 创建时间),避免拖慢业务写;
- 发送后删除或标记(标记更稳,可审计);
- 对 Kafka 的"事务消息"(producer transaction)做跨系统原子,本质上仍是 outbox 的变体,且要求 Kafka 与 DB 之间有协调者——多数团队直接选 outbox;
- 高吞吐替代:**CDC(Debezium 监听 binlog)**把 outbox 变成事件流,避免轮询。
note
Outbox 解决了"原子性"(写库和发事件二选一),但没有解决"两个系统都强一致提交"——如果下游必须和上游同时可见(如分布式账本),它不够;如果只是"通知/异步解耦",它就是最佳工程答案。
七、Spanner: 2PC + TrueTime 的强一致组合拳
Google Spanner 并没有发明新共识,而是把 2PC 放进 Paxos 组:
- 每个分片是一个 Paxos 复制组(leader 在多数派中);
- 跨分片事务仍走 2PC,但协调者/参与者故障由 Paxos 复制接管;
- commit wait 用 TrueTime 时间戳(GPS + 原子钟)保证 external consistency:提交时间戳晚于所有并发事务,从而让读在任意节点都能看到"截至某时间点的全部已提交事务"。
工程启示:2PC 的可用性短板可以用共识层补(协调者高可用 + 状态复制),但代价是每个分片都要跑 Paxos——这不是"免费强一致",而是把成本摊到基础设施。
八、选型决策树
真的需要跨系统原子提交吗?
├─ 不需要 → 拆分/重设计 (把两写变成一写 + 异步) ← 默认答案
├─ 需要强一致, 且网络可控、事务短
│ └─ XA/2PC (数据中心内, 少参与者)
├─ 需要强一致, 且要容错高可用
│ └─ Spanner 式: 2PC + Paxos + TrueTime
├─ 最终一致可接受, 长链路业务
│ └─ Saga (编排器 + 补偿 + 幂等)
├─ 资源型操作 (库存/余额/优惠券)
│ └─ TCC (Try/Confirm/Cancel + 空回滚 + 悬挂处理)
└─ 只要"写库+发消息"原子
└─ Transactional Outbox / CDC
warning
最常见的架构错误:为了"两个表跨库强一致"引入 XA,结果 3 个月后因为锁阻塞把系统拖死。先量化真实需求——允许 1 秒延迟的一致和"绝对不能不一致"是两个量级完全不同的工程。
九、一页速查
2PC: prepare 全票 → commit; 协调者故障 = 阻塞; 适合数据中心内短事务
3PC: 多一轮投票 + pre-commit, 减少阻塞但可能脑裂, 工程少见
Saga: 本地事务 + 补偿, 无原子性, 编排器/事件驱动, 幂等 + 对账
TCC: Try 预留 / Confirm 生效 / Cancel 回滚, 幂等 + 空回滚 + 防悬挂
Outbox: 业务写 + 事件写同一个本地事务, at-least-once + 消费幂等
Spanner: 2PC 每分片跑 Paxos + TrueTime commit wait = external consistency
选型: 默认重构避免; 强一致短事务=2PC; 最终一致长链路=Saga; 资源型=TCC
下一篇: 分布式存储与容错。