Chapter 07

分布式事务:跨节点的原子提交

第 06 章的共识解决了"对一个值达成一致"。跨多个节点或服务的一个业务操作要么全做、要么全不做——原子提交既是共识的一个具体应用,也带来新的失败模式:协调者崩溃、网络分区、以及"语义回滚"与"真回滚"的根本区别。

本章你将建立的 schema

  • 2PC 的阻塞问题:协调者在哪个时刻崩溃会让参与者永久持锁
  • 3PC 为何不算真正修复:把阻塞换成了分区下的脑裂
  • Saga 牺牲隔离性:补偿是语义回滚,不是物理回滚
  • exactly-once 投递不可能、处理可达:靠幂等键把 at-least-once 变 exactly-once

7.12PC 两阶段提交

协调者通过两轮通信保证所有参与者要么都提交、要么都中止。

为什么需要它

跨多个数据库节点(或服务)的操作必须原子完成,否则"已从 A 扣款、B 库存扣失败"会留下不一致的中间态。在没有协调者的情况下,每个参与者只知道自己的状态,无法在本地判断"全局是否可以提交"。

机制:两轮通信

Phase 1 — Prepare:协调者向所有参与者发送 Prepare 消息。每个参与者将事务操作写入 prepare 日志,持有相关行锁,然后向协调者回复 YES(愿意提交)或 NO(遇到冲突 / 约束违反)。YES 意味着参与者做出承诺:即使协调者此后不再出现,参与者也已准备好提交,且已将 prepare 日志落盘。

Phase 2 — Commit / Abort:若协调者收齐全 YES,则将 commit 决定写入自身日志,然后广播 Commit 给所有参与者;任意一个 NO 则广播 Abort。参与者收到 Commit 后提交并释放锁。

阻塞问题:那个危险窗口

参与者投出 YES 之后,处于一个特殊的中间状态:它已承诺可以提交,但尚未知道全局决定。此时参与者既不能单方面提交(如果其他参与者投了 NO 而协调者决定 Abort,单方面提交违反原子性),也不能单方面中止(如果其他参与者全部投 YES 而协调者决定 Commit,单方面中止同样违反原子性)。唯一合法动作是等待协调者的决定。

若协调者在收齐 YES 之后、广播决定之前崩溃,参与者进入无限等待:锁不能释放,事务不能完成,直到协调者恢复并重播日志。这就是 2PC 的阻塞性(blocking)。

参与者无法通过相互联系打破僵局——它们各自只知道"自己投了 YES",不知道其他参与者是否也全投 YES;即便相互可见,多数参与者也无法推断出全局决定,因为协调者的 commit/abort 日志不在它们这里。

定位

2PC 是 CP 协议:可用性换一致性。XA 标准(JDBC 两阶段)实现了 2PC。Spanner 的跨分区提交也用 2PC,但将协调者状态本身用 Paxos 组复制,使协调者高可用,从而绕开阻塞问题——这是 06 章共识与本章 2PC 的直接结合。

想一想

协调者在写 commit 日志之前崩溃,和写日志之后、广播之前崩溃,参与者的处境有何不同?

展开答案(先停 10 秒)

写日志之前崩溃:协调者恢复后日志里没有 commit 记录,重新发送 Abort;参与者不会卡死,最终都会中止。写日志之后、广播之前崩溃:协调者恢复后读到 commit 记录,会重发 Commit;参与者在等待期间卡死持锁,但最终会提交。这正说明协调者日志(commit point)是全局唯一的决定权威——它落盘那一刻决定了整个事务的命运。

协调者 参与者 A 参与者 B Phase 1 Prepare Prepare YES(已写 prepare 日志,持锁) YES(已写 prepare 日志,持锁) 写 commit 日志 ← 决定点 危险窗口 若协调者此时崩溃 → 参与者卡死,持锁等待 Phase 2 Commit Commit Ack(释放锁) Ack(释放锁)
图 7.12PC 两阶段时序。注意:协调者写完 commit 日志、广播前崩溃,参与者已投 YES 且持锁,无法自行决定——这是阻塞窗口,也是 2PC 的根本弱点。

7.23PC 与"它不算真正的修复"

在 Prepare 与 Commit 之间加入 PreCommit 阶段,消除单点阻塞——但只在同步网络假设下成立。

为什么需要它

2PC 阻塞的根因是参与者在"全部投 YES"这个事实上没有共识——若参与者能确知"所有人都投了 YES",超时后就可以安全自行提交,无需等待协调者。3PC 的 PreCommit 阶段正是为了传播这个信息。

机制:三轮通信

Phase 1 — Prepare(同 2PC)。Phase 2 — PreCommit:协调者收齐全 YES 后,广播 PreCommit;参与者收到后回复 Ack,并记录"已收到 PreCommit"。Phase 3 — Commit:协调者收齐 Ack 后广播 Commit。

关键逻辑:收到过 PreCommit 的参与者知道"所有参与者都投了 YES"。若协调者此后崩溃,参与者超时后可以安全地自行提交——不存在某个参与者投 NO 的情况,因为 PreCommit 只在全 YES 之后才发出。这消除了 2PC 的阻塞场景。

为什么分区下仍然失败

3PC 依赖同步网络假设:消息必须在有限时间内到达(即存在消息延迟上界)。现实网络不满足这个假设。

网络分区时:参与者组 A 与协调者失联,超时后根据 PreCommit 的"全 YES 已确认"逻辑自行提交;与此同时,协调者决定中止(比如又收到其他信号)并通知了未分区的参与者组 B。结果 A 组提交、B 组中止——原子性被打破,出现脑裂。

3PC 把 2PC 的阻塞问题换成了分区下的脑裂问题,并未真正修复。实务系统中不使用 3PC;需要非阻塞原子提交的场景转向共识协议(Paxos / Raft)或者把协调者本身做成高可用复制状态机(Spanner 的做法)。

陷阱

3PC 的超时自行提交逻辑在有界延迟网络中安全,在真实互联网(无界延迟)中必然存在某个分区场景触发脑裂。不要在生产系统中用 3PC 替代 2PC,除非你已经用共识协议保证了消息可靠有序传递——但那样就不需要 3PC 了。

7.3TCC:应用层的三步补偿

把每个参与者拆成 Try / Confirm / Cancel 三个显式业务操作,在应用层替代数据库级锁。

为什么需要它

2PC 依赖数据库的 XA 接口,跨异构存储(Redis + MySQL + 外部服务)时无法统一协调。TCC 把协调逻辑上移到应用层,每个服务只需实现三个业务方法,不依赖底层数据库的事务协议。

机制:三个显式操作

Try:预留资源,不立即生效。例如将库存从"可用"状态改为"冻结"状态,确保资源不被他人抢占,但不减少实际库存。Confirm:将预留转正,幂等执行。例如将冻结库存实际扣减。Cancel:释放预留,幂等补偿。例如将冻结库存返回可用状态。

协调者按序调用所有参与者的 Try;全部成功后调用全部 Confirm;任一 Try 失败则调用已成功的参与者的 Cancel。Confirm 和 Cancel 必须幂等,协调者在部分失败时会重试。

失败模式

空回滚(empty cancel):Cancel 先于 Try 到达(因为网络乱序),此时服务没有对应的预留记录,Cancel 无从操作。解决方案:服务在 Cancel 时检查预留记录是否存在;若不存在,则记录"已取消"标记,让后续到达的 Try 直接返回失败(防悬挂)。

悬挂(suspension):Try 超时后协调者调用 Cancel,然后 Try 的消息延迟到达并被执行,此时预留成功但后续不会有 Confirm / Cancel,资源永久冻结。状态检查(看到"已取消"标记就拒绝 Try)可防止悬挂。

想一想

TCC 的 Confirm 必须幂等,但业务操作(如发送短信通知)天然非幂等。工程上如何处理?

展开答案(先停 10 秒)

为每次 Confirm 调用携带唯一幂等键(如 transaction_id + step_id)。服务在幂等表中记录已完成的键;收到重复请求时直接返回成功,不再重复执行副作用。这把"业务操作是否可重复"和"接口是否幂等"解耦——接口层保证幂等,业务副作用在首次执行后由幂等表保护。

7.4Saga:长事务的分解与补偿

长事务拆成一串本地事务 T1..Tn,每个配一个补偿 C1..Cn,失败时反向依次补偿。

为什么需要它

跨多个微服务的业务操作(如下单→库存→扣款→通知)不能共享一个数据库事务。如果用 2PC 协调跨服务,持锁时间随业务流程拉长而放大,在高并发系统中成为瓶颈。Saga 的每个本地事务立即提交,持锁时间缩短到单个服务本地操作的粒度。

机制:正向链与补偿链

每个本地事务 Ti 提交后即对外可见,业务流程推进到下一步。若 Tk 失败,系统反向执行 Ck-1、Ck-2、...、C1,每个 Ci 是 Ti 的语义补偿(semantic undo)。

编排方式有两种:choreography(编舞)——每个服务完成 Ti 后发布事件,下游服务监听事件触发 Ti+1,无中央协调器,耦合度低但全局流程难以观测;orchestration(编排)——中央编排器按序调用各服务,全局状态集中管理,便于监控和重试,但编排器成为单点。

Saga 丢失了什么:隔离性

每个 Ti 独立提交意味着中间状态对并发请求可见。ACID 中的 I(Isolation)被放弃,Saga 只剩 ACD——原子性(通过补偿链模拟)、一致性(业务语义保证,非数据库约束)、持久性。

补偿(Ci)不是数据库的 ROLLBACK。数据库回滚是物理撤销,对外完全不可见;Saga 补偿是一个新的正向业务操作,执行的是语义取消——它会产生新的业务记录,并且无法撤销已经被外部观察到的中间状态。

具体例子:下单 Saga 中,T1 建订单、T2 预留库存、T3 扣款。若 T3 扣款失败,补偿序列 C2(释放库存)+ C1(取消订单)执行。但若 T2 之后仓库已经开始备货,C2 的"释放库存"只是数据库记录的恢复,备货动作本身无法物理撤销——这是丢弃隔离性的真实后果。

陷阱

Saga 不提供隔离性。并发的两个 Saga 实例会读到彼此的中间状态,导致"更新丢失"(T2 读到了另一个 Saga T1 已提交但尚未补偿的状态)。若业务场景需要隔离,须在应用层加悲观锁(预留令牌)或设计补偿逻辑对"已观察中间态"的情况容错。

正向链 T1 建订单 提交 T2 预留库存 提交 T3 扣款 — 失败 失败 补偿链 C2 释放库存 补偿提交 C1 取消订单 T1、T2 已提交 → 中间状态对外可见 并发请求可读到"已建单 + 已预留库存"但最终被补偿的状态 — 隔离性丧失
图 7.4Saga 正向链与补偿链。注意:T1、T2 已提交并对外可见;C2、C1 是新的正向业务操作,不是数据库回滚——外部已观察到的中间状态无法被"撤销"。

7.5幂等与 exactly-once 处理

幂等:同一操作执行多次与执行一次结果相同;exactly-once 投递不可能,exactly-once 处理靠幂等键实现。

为什么需要它

网络会丢包、会重传;TCC 重试 Confirm、Saga 重发补偿——这些机制的正确性全部建立在"重试安全"的基础上。若操作不幂等,重试等于重复扣款或重复发货。

exactly-once 投递为什么不可能

发送方发出消息后,有三种情况:消息在途、消息已到达但 ACK 丢失、消息确实丢失。发送方无法区分这三种情况(01 章的核心约束:无法区分"慢"和"死")。若选择不重发,就丢消息(at-most-once);若选择重发,就会重复(at-least-once);二者之间没有第三条路——这是网络层面的不可能,不是工程疏漏。

exactly-once 处理如何可达

发送方为每个请求分配唯一幂等键(idempotency key,通常是 UUID 或雪花 ID)。接收方在处理前检查幂等表:若键已存在则返回上次结果,不重复执行副作用;若不存在则执行并持久化键与结果。这将 at-least-once 投递语义转换为 exactly-once 处理效果。

Kafka 的事务生产者在 broker 端使用 PID(Producer ID)+ 序列号实现同样的机制:broker 对同一 PID 的重复序列号直接去重,生产者端 at-least-once 投递 + broker 端去重 = 端到端 exactly-once。

TCC 的重试 Confirm 和 Saga 的补偿重发都依赖幂等键兜底:协调者携带 transaction_id + step_id 作为幂等键,参与者幂等表保证重复调用无副作用。

陷阱

没有幂等键的重试等于重复扣款。常见错误:协调者超时后重发请求,但请求体里没有幂等键,接收方当成新请求处理——金融系统中这类 bug 往往在压测或故障演练时才暴露,修复成本极高。幂等键应在请求发出前生成,随请求体一起传输,接收方持久化,而不是在接收方端生成。

发送方 携带幂等键 首次 接收方 查幂等表 → 不存在 → 执行 幂等表 key → result 持久化写入 写入 重试 查幂等表 → 已存在 → 返回缓存结果 命中 at-least-once 投递 + 幂等键去重 = exactly-once 处理效果
图 7.5幂等键去重将 at-least-once 投递转换为 exactly-once 处理。注意:幂等键由发送方在发出前生成,接收方持久化到幂等表——重试命中已有键时直接返回缓存结果,不重复执行副作用。

四种方案对比

分布式事务方案对比
方案 ACID 保证 依赖 阻塞 / 脑裂风险 适用场景
2PC ACID(强一致) 数据库 XA / 支持 2PC 的存储 协调者崩溃 → 参与者阻塞 同构存储,短事务,可容忍短暂不可用
3PC ACID(同步网络假设) 同步网络 网络分区 → 脑裂 实务中不用,仅理论参考
TCC ACD(应用层原子) 业务接口实现 Try/Confirm/Cancel 空回滚、悬挂(须应用层防) 异构存储,高并发金融场景
Saga ACD(无隔离) 事件总线或编排器 中间状态可见,补偿不可逆 长流程微服务,可容忍中间状态

章末自测

  1. 2PC 的"阻塞问题"具体指什么?协调者在哪个时刻崩溃会让参与者卡死?
  2. 参与者在投出 YES 之后,为什么不能单方面提交、也不能单方面中止?
  3. 3PC 号称非阻塞,为什么说它在网络分区下并不算真正修复了 2PC?
  4. Saga 牺牲了 ACID 里的哪个性质?"补偿"和数据库"回滚"的本质区别是什么?
  5. 为什么 exactly-once 投递不可能、但 exactly-once 处理可达?靠什么实现?
展开答案(先作答再看)

Q1:参与者全部投 YES 后,协调者在写 commit/abort 日志之前(或写完日志广播之前)崩溃,参与者已承诺可以提交但不知道全局决定,既不能单方提交也不能单方中止,只能持锁等待协调者恢复。

Q2:参与者只知道"自己投了 YES",不知道其他参与者是否也全投 YES。若单方面提交,可能有其他参与者投了 NO、协调者决定 Abort;若单方面中止,可能全员都投 YES、协调者决定 Commit——两种自行决定都可能违反原子性。

Q3:3PC 依赖同步网络假设(消息有界延迟)。网络分区时,一侧参与者超时后根据"已收 PreCommit = 全 YES 已确认"自行提交,另一侧协调者因其他原因决定中止——两侧执行相反的决定,原子性被打破(脑裂)。3PC 把阻塞换成了分区下脑裂,并非真正修复。

Q4:Saga 牺牲了 I(Isolation,隔离性)。数据库回滚是物理撤销,对外完全不可见;Saga 补偿是新的正向业务操作,产生新的记录,且已被外部观察到的中间状态无法被语义撤销。

Q5:网络层无法区分"消息在途"和"消息丢失",at-most-once(不重发)可能丢消息,at-least-once(重发)可能重复,二者之间没有第三条路,所以 exactly-once 投递不可能。exactly-once 处理通过幂等键实现:发送方为每个请求生成唯一键,接收方持久化已处理的键;重复请求命中幂等表直接返回上次结果,不重复执行副作用。

动手画一下

不看教程,在纸上画出一个三服务下单 Saga 的正向链和补偿链(T1 建单 → T2 预留库存 → T3 扣款,T3 失败时补偿)。再标注哪些步骤的中间状态对外可见、哪个补偿无法物理撤销。画完后对照图 7.4 检查。

进阶挑战 · 刚好够不着

用共识修复 2PC 的单点协调者

2PC 的协调者是单点,崩了参与者就卡死。06 章的共识(Raft/Paxos)如何修复这个问题?如果协调者状态本身被复制到一个 Raft 组里,协调者崩溃时会发生什么?这正是 Google Spanner 的结构:每个分片用 Paxos 复制,跨分片用 2PC,2PC 的协调者状态也落在 Paxos 组里——你能描述这个组合为什么能消除 2PC 的阻塞吗?

提示(卡住再展开)

2PC 阻塞的根因是协调者的 commit/abort 决定丢失。把协调者状态用 Raft 复制后,协调者崩溃时新 leader 接管并从日志里恢复决定,继续向参与者广播。参与者等待的不再是单点协调者,而是一个高可用的 Raft 组——只要 majority 存活,等待就会终止。这把"协调者单点崩溃"转化为"Raft 组多数派失败",后者的概率远低于单机崩溃概率。代价是跨分片事务需要两层协议(Paxos 复制 + 2PC 协调),延迟高于纯本地事务。