Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

分布式事务: 2PC / Saga / TCC / Outbox / Spanner

TL;DR

单机事务靠 WAL + 锁把"多行修改"原子化;一旦数据分到多台机器(微服务拆分、分库分表),"跨库转账"这类操作需要跨机器原子性。这一章讲四条技术路线的数学本质与工程取舍:2PC(强一致,但协调者故障会阻塞)、Saga(无原子性,靠补偿最终一致)、TCC(业务把"预留/确认/取消"三阶段暴露出来)、Outbox/事务消息(把 DB 事务和消息投递绑成一个原子操作)。最后看 Google Spanner 怎么用 2PC + TrueTime 同时拿到强一致和可用性。工程上 90% 的"分布式事务"问题,正确答案是换一个不用跨库事务的数据模型,而不是引入 2PC。

读完应能:

  1. 复述 2PC 的两阶段流程,指出协调者/参与者各自故障时的阻塞点与可恢复性。
  2. 说出 3PC 比 2PC 好在哪、为什么没解决根本问题。
  3. 设计一个 Saga(含补偿顺序)和一个 TCC 接口(Try/Confirm/Cancel + 幂等 + 空回滚)。
  4. 说清 Outbox 为什么能保证"DB 写入与消息投递至少一次一致",以及消费侧怎么幂等。
  5. 判断一个业务场景到底该用 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)

两个阶段的关键性质:

  1. 准备阶段:参与者把事务做成"可提交但未提交"(写日志 + 持锁)。此时承诺:只要收到 commit 就一定提交;
  2. 提交阶段:协调者已决定,全体执行。决策一旦做出,协议不允许反悔

故障分析(这是面试/设计题核心):

故障后果恢复方式
参与者 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 编排器依次调用流程集中可见、易做重试/超时;编排器本身要高可用

正确性三要素:

  1. 补偿必须能成功:补偿里不能依赖"对方也失败"等假设,要设计成幂等、可重试;
  2. 补偿顺序 = 正序逆序t1, t2, t3 失败于 t3 → 补偿 c3, c2, c1(c3 通常是被动补偿,t3 自己的回滚);
  3. 无隔离保证:Saga 中间状态对外可见——需要额外手段(如订单状态机 + 对账)控制。

tip

面试/设计高频答案:Saga 适用于"最终一致可接受、链路长、跨多个独立服务"。先画状态机,再定补偿,最后用幂等键 + 对账脚本兜底。

五、TCC: 把三阶段暴露给业务

TCC(Try-Confirm-Cancel)把 2PC 的 prepare/commit 翻译成业务操作:

阶段做什么例子(库存扣减)
Try预留资源,不真正生效冻结库存 10 件
Confirm确认生效(幂等)把冻结转成实际扣减
Cancel释放预留(幂等)解冻 10 件

必须处理的三个边界:

  1. 幂等:Confirm/Cancel 可能因网络重试被调多次,业务侧要用事务 ID 去重;
  2. 空回滚:Try 因超时没真正执行,但 Cancel 到了——Cancel 必须能安全执行(通常查无此预留就返回成功);
  3. 悬挂: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 组

  1. 每个分片是一个 Paxos 复制组(leader 在多数派中);
  2. 跨分片事务仍走 2PC,但协调者/参与者故障由 Paxos 复制接管;
  3. 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

下一篇: 分布式存储与容错