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)是全局唯一的决定权威——它落盘那一刻决定了整个事务的命运。
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 已提交但尚未补偿的状态)。若业务场景需要隔离,须在应用层加悲观锁(预留令牌)或设计补偿逻辑对"已观察中间态"的情况容错。
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 往往在压测或故障演练时才暴露,修复成本极高。幂等键应在请求发出前生成,随请求体一起传输,接收方持久化,而不是在接收方端生成。
四种方案对比
| 方案 | ACID 保证 | 依赖 | 阻塞 / 脑裂风险 | 适用场景 |
|---|---|---|---|---|
| 2PC | ACID(强一致) | 数据库 XA / 支持 2PC 的存储 | 协调者崩溃 → 参与者阻塞 | 同构存储,短事务,可容忍短暂不可用 |
| 3PC | ACID(同步网络假设) | 同步网络 | 网络分区 → 脑裂 | 实务中不用,仅理论参考 |
| TCC | ACD(应用层原子) | 业务接口实现 Try/Confirm/Cancel | 空回滚、悬挂(须应用层防) | 异构存储,高并发金融场景 |
| Saga | ACD(无隔离) | 事件总线或编排器 | 中间状态可见,补偿不可逆 | 长流程微服务,可容忍中间状态 |
章末自测
- 2PC 的"阻塞问题"具体指什么?协调者在哪个时刻崩溃会让参与者卡死?
- 参与者在投出 YES 之后,为什么不能单方面提交、也不能单方面中止?
- 3PC 号称非阻塞,为什么说它在网络分区下并不算真正修复了 2PC?
- Saga 牺牲了 ACID 里的哪个性质?"补偿"和数据库"回滚"的本质区别是什么?
- 为什么 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 协调),延迟高于纯本地事务。