土法炼钢兴趣小组的算法知识备份

【分布式系统百科】2PC 的真实失败模式:阻塞、脑裂与恢复

文章导航

分类入口
distributed
标签入口
#2pc#distributed-transactions#xa#coordinator-failure#presumed-abort

目录

Part VI · 分布式事务:最难的部分

上一篇:数据再平衡

凌晨三点,监控告警:订单服务和库存服务之间的分布式事务卡住了。数据库连接池全部耗尽,因为有四十多个事务一直挂在 XA PREPARE 之后等 XA COMMIT,每一个都持有行锁。排查发现是事务协调者所在的节点突然被 OOM Killer 干掉了。你能做的只有两件事:要么等协调者重启,要么手工逐个确认每个事务的状态然后强制提交或回滚——但你无法确定协调者到底做了什么决定。

这不是假想场景。这就是 Two-Phase Commit 最经典的失败模式。

教科书把 2PC 画得很简洁:Phase 1 投票,Phase 2 提交,两个阶段,四条消息。但教科书很少告诉你:协调者(Coordinator)在哪个时间点崩溃,决定了参与者(Participant)会不会陷入无限阻塞;预写日志(Write-Ahead Log)在每一步的刷盘时机,直接决定了协议的正确性;以及为什么 Presumed Abort 优化能省掉一半的日志强制刷盘。

这篇文章从状态机的精确定义出发,逐一拆解 Coordinator 故障的三个时间窗口,分析 Presumed Abort 和 Presumed Commit 的工程取舍,厘清 2PC 与隔离性的关系,展示 MySQL XA 的实际用法和已知陷阱,最后看 Spanner、CockroachDB 和 TiDB 如何在现代系统中规避经典 2PC 的致命缺陷。

一、2PC 状态机的精确定义

要理解 2PC 的故障模式,必须先把状态机画清楚。不是那种”箭头从左到右”的简化版,而是包含每个状态的进入条件、WAL 写入时机、以及故障恢复规则的完整状态机。

Coordinator 状态机

Coordinator 有四个状态:

状态 含义 WAL 记录
INIT 事务开始,尚未发起投票
WAITING 已发送 PREPARE,等待所有 Participant 的投票 写入事务开始记录
COMMITTED 已做出提交决定 强制刷盘 COMMIT 记录
ABORTED 已做出中止决定 写入 ABORT 记录(无需强制刷盘)

状态转移规则:

INIT ──[发送 PREPARE 给所有 Participant]──→ WAITING
WAITING ──[收到所有 YES]──→ COMMITTED   (必须先 force-write WAL)
WAITING ──[收到任一 NO 或超时]──→ ABORTED
COMMITTED ──[发送 COMMIT 给所有 Participant,收到全部 ACK]──→ 清理
ABORTED ──[发送 ABORT 给所有 Participant,收到全部 ACK]──→ 清理

这里最关键的一步是 WAITING → COMMITTED 的转移:Coordinator 必须在发送任何 COMMIT 消息之前,将 COMMIT 决定强制刷盘到 WAL。这不是优化建议,是正确性要求。如果 Coordinator 在发送部分 COMMIT 之后崩溃,它必须能从 WAL 中恢复出”我已经做了 COMMIT 决定”这个事实,然后继续向剩余的 Participant 发送 COMMIT

Participant 状态机

Participant 同样有四个状态:

状态 含义 WAL 记录
INIT 事务正在本地执行,尚未收到 PREPARE 事务操作的 redo/undo 日志
READY 已回复 YES,承诺可以提交 强制刷盘 READY 记录
COMMITTED 收到 COMMIT,事务已提交 写入 COMMIT 记录
ABORTED 收到 ABORT 或单方面中止 写入 ABORT 记录

状态转移规则:

INIT ──[收到 PREPARE,本地可以提交]──→ READY   (必须先 force-write WAL)
INIT ──[收到 PREPARE,本地无法提交]──→ ABORTED  (回复 NO)
INIT ──[超时未收到 PREPARE]──→ ABORTED          (单方面中止)
READY ──[收到 COMMIT]──→ COMMITTED
READY ──[收到 ABORT]──→ ABORTED

INIT → READY 的转移同样有一个强制刷盘要求:Participant 在回复 YES 之前,必须将 READY 记录和所有的 redo 日志强制刷盘。原因很简单——一旦投了 YES,Participant 就做出了”我随时可以提交”的承诺。如果这时候崩溃重启,它必须能从 WAL 中恢复出这个承诺,继续等待 Coordinator 的最终决定。

2PC Coordinator 与 Participant 状态机

为什么无故障时协议是正确的

在没有故障的情况下,2PC 的正确性可以简洁地证明:

  1. 一致性:所有 Participant 最终到达相同的终态(全部 COMMITTED 或全部 ABORTED),因为只有 Coordinator 做决定,且决定唯一。
  2. 原子性:如果任何一个 Participant 投了 NO,Coordinator 必须选择 ABORT;只有全部投 YES,Coordinator 才可以选择 COMMIT。决定一旦做出,不可更改。
  3. 持久性COMMIT 决定在发送之前已经刷盘,READY 承诺在回复之前已经刷盘。任何节点崩溃重启后,都可以从 WAL 恢复到崩溃前的状态。

说白了,2PC 在无故障时就是一个简单的投票协议:全票通过就提交,一票否决就中止。它真正的复杂性全部来自故障处理。

WAL 的四次关键写入

整个 2PC 流程中,有四次 WAL 写入直接关系到协议的正确性:

序号 写入方 写入内容 是否 force 目的
1 Participant redo/undo 日志 确保崩溃后能重做或回滚本地修改
2 Participant READY 记录 记住”我已投 YES”这个承诺
3 Coordinator COMMIT/ABORT 决定 COMMIT 时是 记住最终决定,恢复后重发
4 双方 清理记录 允许回收日志空间

这四次写入中,前三次的顺序不能乱,每一次都是正确性的前提。第四次是性能优化:告诉系统”这个事务的日志可以回收了”。

这里补充一个工程细节:force-write 意味着调用 fsync() 或等价操作,确保数据落盘。这是整个 2PC 最大的性能瓶颈——在 SSD 上,一次 fsync() 大约需要几十微秒到几百微秒;在 HDD 上,可能需要 5-10 毫秒。一个 2PC 事务至少需要三次 force-write(Participant 的 redo 日志、READY 记录、Coordinator 的决定),这直接限制了分布式事务的吞吐量。

WAL/fsync 时序详解

为了更精确地理解 fsync 在 2PC 中的作用,我们用时序图标注每个 fsync 点的位置和必要性:

sequenceDiagram
    participant C as Coordinator
    participant P1 as Participant 1
    participant P2 as Participant 2

    C->>P1: PREPARE
    C->>P2: PREPARE
    Note over P1: fsync #1:写入 redo/undo 日志
    Note over P1: fsync #2:写入 READY 记录
    P1-->>C: YES
    Note over P2: fsync #1:写入 redo/undo 日志
    Note over P2: fsync #2:写入 READY 记录
    P2-->>C: YES
    Note over C: fsync #3:写入 COMMIT 决定(关键点)
    C->>P1: COMMIT
    C->>P2: COMMIT
    Note over P1: fsync #4:写入 COMMIT 完成记录
    P1-->>C: ACK
    Note over P2: fsync #4:写入 COMMIT 完成记录
    P2-->>C: ACK
    Note over C: 写入 END 记录(无需 fsync)

上述时序图标注了一次完整 2PC 事务中所有的 fsync 调用点。其中 fsync #3(Coordinator 写入 COMMIT 决定)是整个协议的”不归点”(Point of No Return):一旦此记录落盘,即使后续所有节点都崩溃,恢复后也必须推进到 COMMIT 状态。fsync #1 和 #2 保证了 Participant 在崩溃后能恢复其投票承诺。fsync #4 允许 Participant 在恢复后跳过重新询问 Coordinator。

为什么每个 fsync 都不可省略

fsync 点 如果省略会怎样
Participant redo/undo 日志 崩溃后无法重做或回滚本地修改,数据损坏
Participant READY 记录 崩溃后忘记投过 YES,可能单方面 ABORT,破坏原子性
Coordinator COMMIT 决定 崩溃后不知道做过 COMMIT 决定,可能重新决定 ABORT,但部分 Participant 已提交
Participant COMMIT 完成记录 崩溃后需要重新询问 Coordinator,增加恢复延迟但不影响正确性

注意第四个 fsync 是唯一可以在 Presumed Abort 优化中省略的——如果采用 PA 优化,Participant 提交后可以不刷盘,因为恢复时如果找不到 COMMIT 记录,会去询问 Coordinator,而 Coordinator 的 COMMIT 记录是持久化的。

二、Coordinator 故障的三个时间窗口

现在我们把 Coordinator 弄崩溃。根据崩溃发生的时间点,有三种截然不同的情况。

Coordinator 故障的三个时间窗口

Case 1:崩溃在发送 PREPARE 之前

场景:Coordinator 刚刚收到客户端的提交请求,还没来得及向任何 Participant 发送 PREPARE,就崩溃了。

各节点状态: - Coordinator:INIT(WAL 中没有任何 2PC 相关记录) - Participant A:INIT(从未收到 PREPARE) - Participant B:INIT(从未收到 PREPARE

会发生什么:所有 Participant 在超时后会单方面中止事务。Coordinator 重启后,在 WAL 中找不到任何进行中的 2PC 记录,也不会做任何补偿动作。客户端会收到一个错误(连接断开或超时),然后可以安全地重试。

结论:完全安全。所有节点独立地到达 ABORTED 状态,无需任何协调。这种情况在 INIT 状态下,Participant 还没有对事务做出任何承诺,可以自由中止。

Case 2:崩溃在部分 PREPARE 发送后,决定之前

场景:Coordinator 已经向 Participant A 发送了 PREPARE,Participant A 回复了 YES,但 Coordinator 在收集完所有投票之前崩溃了。也许 Participant B 还没收到 PREPARE,或者 Coordinator 收到了所有 YES 但还没来得及写入决定。

各节点状态: - Coordinator:WAITING(WAL 中有事务开始记录,但没有 COMMITABORT 决定) - Participant A:READY(已投 YES,WAL 中有 READY 记录,持有行锁) - Participant B:INITREADY(取决于是否收到了 PREPARE

这就是经典的阻塞场景

让我们仔细分析 Participant A 的处境。它已经投了 YES,承诺可以提交。它持有事务相关的所有锁——行锁、间隙锁等等。它不能单方面提交,因为也许 Participant B 投了 NO,Coordinator 的决定本应是 ABORT。它也不能单方面中止,因为也许 Coordinator 已经收到了所有 YES 并做出了 COMMIT 决定(只是还没来得及发送),如果 A 单方面中止,就破坏了原子性。

Participant A 能做的只有一件事:等待。等 Coordinator 重启,重新建立连接,然后从 WAL 中恢复状态,继续协议。

在等待期间,A 持有的所有锁都不会释放。如果 A 是一个数据库,这意味着被锁定的行对其他事务不可见或不可修改。如果这个 2PC 事务锁定了热点数据(比如库存表中的畅销商品),那么整个数据库的这部分数据都会被阻塞,直到 Coordinator 恢复。

Participant B 的情况取决于它是否收到了 PREPARE: - 如果 B 没收到 PREPARE(还在 INIT),它可以在超时后单方面 ABORT。 - 如果 B 已收到 PREPARE 并投了 YES(在 READY),它和 A 一样陷入阻塞。

为什么 Participant 不能互相询问?即使 A 和 B 能直接通信,也无法解决问题。考虑这个场景:A 和 B 都投了 YES,Coordinator 崩溃。A 问 B:“你投了什么?”,B 说”我也投了 YES”。能提交吗?不能。因为可能还有一个 Participant C 投了 NO,A 和 B 不知道 C 的存在(Participant 通常不知道事务涉及的完整参与者列表)。即使知道所有参与者并且确认全部投了 YES,也不能提交——因为只有 Coordinator 有权做决定,而 Coordinator 在做决定之前崩溃了,也许它永远不会恢复。

这就是 2PC 被称为”阻塞协议”的根本原因。

恢复过程

1. Coordinator 重启
2. 扫描 WAL,找到所有状态为 WAITING 的事务
3. 对每个事务:
   a. 重新向所有 Participant 发送 PREPARE(或查询状态)
   b. 如果所有 Participant 回复 READY/YES → 做出 COMMIT 决定
   c. 如果任一 Participant 已经 ABORT 或不可达 → 做出 ABORT 决定
4. 将决定 force-write 到 WAL
5. 发送 COMMIT/ABORT 给所有 Participant

注意步骤 3c:如果 Coordinator 恢复时发现某个 Participant B 已经超时自行 ABORT 了(因为 B 在 INIT 状态超时),那么整个事务只能 ABORT。即使 Participant A 已经投了 YES,也必须回滚。这是安全的,因为 A 投 YES 只是承诺”我可以提交”,并不意味着”我已经提交”。

阻塞持续时间:取决于 Coordinator 多久能恢复。如果 Coordinator 的磁盘坏了,需要从备份中恢复,可能需要几个小时。在这段时间里,所有处于 READY 状态的 Participant 都在阻塞。这就是为什么很多团队在生产环境中对 2PC 心存忌惮。

Case 3:崩溃在决定写入 WAL 后,部分消息送达前

场景:Coordinator 收到所有 YES,将 COMMIT 决定 force-write 到 WAL,向 Participant A 发送了 COMMIT,但在向 Participant B 发送 COMMIT 之前崩溃了。

各节点状态: - Coordinator:COMMITTED(WAL 中有 COMMIT 记录) - Participant A:COMMITTED(已收到 COMMIT,已执行提交) - Participant B:READY(已投 YES,但还没收到最终决定,阻塞中)

Participant B 的处境和 Case 2 中类似——它在 READY 状态等待,持有锁,无法独立决定。区别在于 Coordinator 这次有恢复依据。

恢复过程

1. Coordinator 重启
2. 扫描 WAL,找到状态为 COMMITTED 的事务
3. 查看参与者列表(也在 WAL 中)
4. 向所有尚未确认收到 COMMIT 的 Participant 重发 COMMIT
5. 收到所有 ACK 后,写入清理记录

这个恢复过程是确定性的,不需要重新投票。Coordinator 只需要从 WAL 中读出决定,然后重发。这就是为什么 COMMIT 决定必须在发送之前 force-write——它是恢复过程的唯一依据。

与 Case 2 的关键区别:Case 2 中,Coordinator 的 WAL 里没有决定记录,恢复后需要重新走投票流程(或者直接 ABORT)。Case 3 中,决定已经持久化,恢复后只需要重发消息。Case 2 是”不知道该怎么办”,Case 3 是”知道该怎么办但还没做完”。

三个时间窗口的对比

Case 1 Case 2 Case 3
崩溃时机 PREPARE 发送前 PREPARE 后,决定前 决定后,消息送达前
Coordinator WAL 无记录 有 begin,无决定 有 COMMIT/ABORT
Participant 状态 全部 INIT 部分 READY 部分 READY/COMMITTED
是否阻塞 是(暂时)
恢复方式 无需恢复 重新投票或 ABORT 重发决定
恢复难度

Case 2 是 2PC 的阿喀琉斯之踵。Coordinator 在 WAITING 状态崩溃,Participant 在 READY 状态阻塞——这是一个单点故障导致全局阻塞的经典案例。后面我们会看到,所有现代分布式数据库的核心改进,都是围绕消除这个单点展开的。

恢复决策树

无论 Coordinator 在哪个时间窗口崩溃,恢复逻辑都遵循一套确定性的决策流程。下图将这个流程完整呈现:

flowchart TD
    A["Coordinator 崩溃重启"] --> B["扫描 WAL 日志"]
    B --> C{"找到 COMMIT 记录?"}
    C -->|是| D["向所有 Participant 重发 COMMIT"]
    D --> E["收集 ACK,写入 END 记录"]
    C -->|否| F{"找到 BEGIN/PREPARE 记录?"}
    F -->|是| G{"Presumed Abort 模式?"}
    G -->|是| H["直接决定 ABORT"]
    G -->|否| I["重新向 Participant 询问状态"]
    I --> J{"所有 Participant 回复 READY?"}
    J -->|是| K["重新投票决定"]
    J -->|否| H
    F -->|否| L["无进行中事务,无需恢复"]
    H --> M["向所有 Participant 发送 ABORT"]

这张决策树完整描述了 Coordinator 崩溃恢复后的判断逻辑。首先检查 WAL 中是否有 COMMIT 记录(对应 Case 3),如果有则直接重发 COMMIT 消息。如果只找到 BEGIN/PREPARE 记录(对应 Case 2),在 Presumed Abort 模式下可以直接决定 ABORT,否则需要重新询问 Participant 的状态。如果 WAL 中没有任何相关记录(对应 Case 1),说明事务从未真正开始,无需恢复。

三、Presumed Abort 与 Presumed Commit 优化

标准 2PC 的每个事务需要大量的 WAL 强制刷盘操作。在高吞吐场景下,fsync() 的成本是不可接受的。Presumed Abort 和 Presumed Commit 就是为了减少这些磁盘写入而设计的优化。

基本洞察

观察实际工作负载:

但不同系统的分布不同。有些系统(如 OLTP 数据库)提交率可能高达 99%,有些系统(如乐观并发控制下的高冲突场景)中止率可能很高。优化的方向取决于你想加速哪一种情况。

Presumed Abort(PA)

Presumed Abort 的核心规则:如果 Coordinator 不知道一个事务的状态,就假设它已经被中止了。

具体做法:

  1. Coordinator 在做出 ABORT 决定时,不需要ABORT 记录 force-write 到 WAL。甚至可以完全不写 ABORT 记录。
  2. Coordinator 在做出 COMMIT 决定时,仍然必须 force-write COMMIT 记录。
  3. 如果 Coordinator 崩溃重启后,在 WAL 中找不到某个事务的决定记录,就认为该事务已被中止。
  4. 如果 Participant 询问一个 Coordinator 不认识的事务(因为 ABORT 记录已被清理或从未写入),Coordinator 回复 ABORT

为什么这是正确的?因为只有两种情况会导致 Coordinator 不认识一个事务:

不可能出现”事务已提交但 Coordinator 不认识”的情况,因为 COMMIT 记录是 force-write 的,崩溃恢复后一定能读到。

PA 节省了什么

标准 2PC(ABORT 路径):
  Coordinator: force-write ABORT → 发送 ABORT → 等 ACK → 写清理记录
  
Presumed Abort(ABORT 路径):
  Coordinator: 发送 ABORT → 结束(无需等 ACK,无需写日志)

省掉了 ABORT 路径上的一次 force-write 和一轮 ACK。由于 Participant 超时后会自己中止(这是 PA 的一部分),Coordinator 甚至不需要发送 ABORT 消息给那些已经超时的 Participant。

PA 还有一个额外好处:只读事务的 Participant 可以在 Phase 1 就直接回复”read-only YES”并释放资源,不需要参与 Phase 2。Coordinator 不需要给这些 Participant 发送最终决定。

Presumed Commit(PC)

Presumed Commit 走相反的方向:如果 Coordinator 不知道一个事务的状态,就假设它已经提交了。

具体做法:

  1. Coordinator 在做出 COMMIT 决定时,不需要COMMIT 记录 force-write 到 WAL。
  2. Coordinator 在做出 ABORT 决定时,必须 force-write ABORT 记录。
  3. 但这引入了一个问题:在发送任何 PREPARE 之前,Coordinator 必须 force-write 一条”collecting”记录(记录参与者列表)。
  4. 如果 Coordinator 崩溃重启后,在 WAL 中发现了”collecting”记录但没有 ABORT 记录,就认为事务已提交。

为什么 PC 需要额外的”collecting”记录?因为如果 Coordinator 对一个完全不认识的事务默认回复 COMMIT,那就危险了——万一这个事务从来没开始过呢?“collecting”记录的存在证明”这个事务确实发起过投票”,配合”没有 ABORT 记录”的事实,可以安全地推断出”决定是 COMMIT”。

取舍分析

维度 Presumed Abort Presumed Commit
优化路径 ABORT 路径 COMMIT 路径
COMMIT 时 force-write 需要(COMMIT 记录) 不需要(但需要提前写 collecting)
ABORT 时 force-write 不需要 需要(ABORT 记录)
额外的日志写入 collecting 记录(Phase 1 之前)
实现复杂度
适用场景 通用 提交率极高的工作负载
实际采用 ARIES、大多数数据库 学术研究为主

先算 force-write 的次数:

标准 2PC:
  COMMIT 路径:1 次 force-write(COMMIT 决定)
  ABORT 路径:1 次 force-write(ABORT 决定)

Presumed Abort:
  COMMIT 路径:1 次 force-write(COMMIT 决定)
  ABORT 路径:0 次 force-write

Presumed Commit:
  COMMIT 路径:1 次 force-write(collecting 记录,提前写入)
  ABORT 路径:1 次 force-write(ABORT 决定)

乍一看,PA 和 PC 都只省了一个路径的 force-write。但 PA 的优势在于它不需要额外的”collecting”记录。在 PA 下,如果事务最终提交,force-write 次数和标准 2PC 一样;如果事务中止,force-write 次数减少。而 PC 下,无论最终提交还是中止,都至少有一次 force-write(collecting 或 ABORT),并没有真正减少总的 force-write 次数。

PA 的另一个优势是安全性默认值:当出现任何不确定状态时,假设 ABORT 比假设 COMMIT 要安全得多。ABORT 最多导致事务重试,COMMIT 则可能导致数据不一致。

这就是为什么 ARIES 恢复算法选择了 Presumed Abort,为什么几乎所有主流关系数据库(PostgreSQL、MySQL InnoDB、Oracle)都采用 PA。PC 虽然在理论上对高提交率场景有优势,但额外的复杂性和更低的安全裕度使其在工程实践中很少被采用。

日志组提交对 2PC 的影响

在讨论 force-write 优化时,不能忽略日志组提交(Group Commit)的影响。现代数据库会将多个事务的 WAL 记录合并成一次 fsync() 操作,摊薄每个事务的刷盘成本。

在 2PC 场景下,Group Commit 的效果取决于并发事务数量:

场景:100 个并发 2PC 事务同时到达 COMMIT 阶段
标准模式:100 次 fsync()(每个事务一次)
Group Commit:1 次 fsync()(合并所有 COMMIT 记录)

Group Commit 大幅降低了 force-write 的实际成本,但它不改变 PA/PC 优化的相对收益。PA 在 ABORT 路径上省掉的 force-write,即使在 Group Commit 下也是有意义的。

四、2PC 保证原子性,但不保证隔离性

这是很多工程师混淆的概念。面试中被问到”2PC 保证什么”,不少人会回答”保证分布式事务的 ACID”。这是错的。2PC 只保证原子性(Atomicity)——要么全部提交,要么全部中止。它不涉及隔离性(Isolation),也不涉及一致性(Consistency,这里指应用层的数据完整性约束)。

2PC 是提交协议,不是并发控制协议

2PC 解决的问题是:多个节点上的操作如何原子地提交或中止。它不管这些操作在并发执行时是否会产生异常。

举个例子:假设有两个分布式事务 T1 和 T2,都涉及节点 A 和节点 B。

T1: 在 A 上读 x,在 B 上写 y = x + 1
T2: 在 B 上读 y,在 A 上写 x = y + 1

2PC 保证 T1 和 T2 各自要么全部提交,要么全部中止。但它不保证 T1 和 T2 不会产生写偏斜(Write Skew)。如果 T1 和 T2 并发执行,可能出现 T1 读到 x 的旧值,T2 读到 y 的旧值,两者都提交,结果 x 和 y 的值都基于对方的旧值——这就是并发异常,2PC 完全不管这件事。

“分布式事务”≠ “2PC”

这是另一个常见的混淆。“分布式事务”是一个宽泛的概念,指的是跨多个节点的事务。“2PC”是实现分布式事务原子提交的一种具体协议。一个完整的分布式事务系统需要两个组件:

  1. 原子提交协议(Atomic Commit Protocol):确保所有节点要么全部提交,要么全部中止。2PC 就是最经典的原子提交协议。
  2. 并发控制机制(Concurrency Control):确保并发事务之间的隔离性。常见的机制有两阶段锁(2PL)、多版本并发控制(MVCC)、乐观并发控制(OCC)等。

这两个组件是正交的,可以自由组合。

2PC + 2PL = 可串行化

两阶段提交(2PC,Two-Phase Commit)加上两阶段锁(2PL,Two-Phase Locking),名字很像但解决的问题完全不同:

2PC + 2PL 的组合可以提供可串行化(Serializable)隔离级别。在分布式环境下,每个节点用 2PL 管理本地的锁,用 2PC 来协调全局的提交。锁在 2PC 的 Phase 2 完成后才释放。

代价是什么?锁的持有时间从”本地事务执行时间”延长到了”2PC 完成时间”。在 2PC 的 Phase 1 中,Participant 要持有锁等待投票结果;在 Phase 2 中,还要继续持有锁等待最终决定。如果 Coordinator 慢了,或者网络延迟高了,锁的持有时间会大幅增加,直接降低系统的并发吞吐量。

时间线(2PC + 2PL):

T1 开始 ──→ 本地执行(加锁)──→ PREPARE(持锁等待)──→ COMMIT(持锁等待)──→ 释放锁
                                   ↑                      ↑
                                   锁已持有                锁还在持有
                                   等待其他节点投票          等待所有节点确认

这段等待时间就是分布式事务比本地事务慢的核心原因之一。

2PC + MVCC = 快照隔离

Google 的 Percolator 系统展示了另一种组合:2PC + MVCC,提供快照隔离(Snapshot Isolation)。

Percolator 的关键设计:

这样做的好处是读操作不需要加锁——读者读取的是自己 start_ts 时刻的快照,写者的修改在标记为”未提交”时对读者不可见。只有写-写冲突需要处理(Percolator 用一个锁列来检测)。

Percolator 时间线:

T1: start_ts=100
  Prewrite: 在 A 上写 x(lock 列标记为未提交)
  Prewrite: 在 B 上写 y(lock 列标记为未提交)
  Commit:   commit_ts=105,清除 lock 列,写入 write 列

T2: start_ts=103
  读 x:看到 start_ts=103 之前最新的已提交版本(T1 的修改还没提交,不可见)
  读 y:同上

快照隔离的代价是它不能防止所有并发异常——特别是写偏斜。但在大多数 OLTP 场景下,快照隔离已经足够,性能远好于可串行化。

为什么区分提交协议和隔离级别很重要

混淆这两个概念会导致严重的设计错误:

  1. “我们用了 2PC,所以是强一致的”——错。2PC 只保证原子性,不保证隔离性。如果并发控制不够强,仍然可能出现数据异常。
  2. “快照隔离太弱了,我们需要 2PC”——牛头不对马嘴。你需要的可能是更强的并发控制(如 SSI),而不是更复杂的提交协议。
  3. “2PC 太慢了,我们去掉它”——如果你需要跨节点的原子性,就没法绕开原子提交协议。你可以优化 2PC(如 Parallel Commit),可以用其他协议(如 3PC、Paxos Commit),但不能不用。

五、MySQL XA 的实现与踩坑

XA 是 X/Open 组织定义的分布式事务接口标准。MySQL 从 5.0 开始支持 XA 事务。在很长一段时间里,MySQL XA 是 Java 企业应用中实现分布式事务的主要方式(通过 JTA/JTS)。但它的实际表现远没有标准描述的那么美好。

XA 事务的基本语法

-- 开始一个 XA 事务(xid 是全局唯一的事务标识符)
XA START 'xid-order-12345';

-- 执行 SQL 操作
INSERT INTO orders (id, user_id, amount) VALUES (12345, 1001, 99.99);
UPDATE inventory SET stock = stock - 1 WHERE product_id = 2001;

-- 结束事务的 SQL 执行阶段
XA END 'xid-order-12345';

-- Phase 1:准备提交
XA PREPARE 'xid-order-12345';

-- Phase 2:提交
XA COMMIT 'xid-order-12345';

-- 或者回滚
-- XA ROLLBACK 'xid-order-12345';

XA 事务的生命周期对应 Participant 的状态机:

XA START    → INIT(开始执行 SQL)
XA END      → IDLE(SQL 执行结束,准备进入投票)
XA PREPARE  → READY(投票 YES,承诺可以提交)
XA COMMIT   → COMMITTED(收到 COMMIT 指令)
XA ROLLBACK → ABORTED(收到 ABORT 指令)

查看悬挂的 XA 事务

当 Coordinator 崩溃后,可以用以下命令查看遗留的 prepared 事务:

XA RECOVER;

输出类似:

+----------+--------------+--------------+--------------------+
| formatID | gtrid_length | bqual_length | data               |
+----------+--------------+--------------+--------------------+
|        1 |           17 |            0 | xid-order-12345    |
|        1 |           17 |            0 | xid-order-12346    |
+----------+--------------+--------------+--------------------+

这些就是处于 READY 状态的事务——它们已经 prepared 但还没收到最终指令。管理员需要手工决定 XA COMMIT 还是 XA ROLLBACK

MySQL XA 的已知问题

问题一:XA 与二进制日志的不一致(MySQL 5.7 及之前)

在 MySQL 8.0 之前,XA PREPARE 不会写入二进制日志(Binary Log)。这意味着:

场景:主从复制环境下使用 XA 事务
1. 主库执行 XA PREPARE(成功,InnoDB 日志有记录)
2. 主库执行 XA COMMIT(成功,写入 binlog)
3. 从库回放 binlog,看到 XA COMMIT 但没有对应的 XA PREPARE
4. 从库报错或数据不一致

MySQL 8.0 修复了这个问题:XA PREPARE 操作也会写入二进制日志。但如果你还在使用 5.7 或更早版本,XA 和复制混用几乎一定会出问题。

问题二:每个 XA 操作都需要 fsync

MySQL 的 XA 实现在每个阶段转换时都会调用 fsync()

XA PREPARE → fsync() InnoDB redo log
XA COMMIT  → fsync() InnoDB redo log + fsync() binlog

innodb_flush_log_at_trx_commit = 1(默认值)的配置下,每个 XA 事务至少需要两次 fsync()。如果涉及 binlog 同步,还有额外的 fsync()。这直接限制了 XA 事务的吞吐量——在一块普通 SSD 上,fsync() 的极限大约是每秒几千到一万次,这意味着 XA 事务的理论上限大约是每秒几千个。

问题三:PREPARE 和 COMMIT 之间的崩溃

这是 XA 最危险的窗口:

XA PREPARE 'xid-12345' → 成功(InnoDB 中事务处于 PREPARED 状态)
                         → 此时 MySQL 崩溃
MySQL 重启
XA RECOVER → 看到 xid-12345

在 MySQL 重启之后,这个 prepared 事务会继续持有锁。如果你不手工处理,它会一直阻塞。更糟糕的是,在 MySQL 5.7 中,如果 MySQL 在 XA PREPARE 之后异常崩溃,重启后可能丢失这个 prepared 事务的信息(取决于 binlog 和 InnoDB 的同步状态)。

MySQL 8.0 改进了这一点:XA PREPARE 的状态在崩溃恢复后可以正确地重建。

问题四:XA 事务与 MySQL 内部事务 ID 的冲突

MySQL 内部使用自增的事务 ID(Transaction ID)。当一个 XA 事务处于 PREPARED 状态并跨越了 MySQL 重启时,可能出现事务 ID 空间的冲突。这在旧版 MySQL 中曾导致过数据损坏。

一个完整的跨库 XA 事务示例

以下是一个用 Python 手工管理 XA 事务的示例,展示了 Coordinator 端的逻辑:

import mysql.connector
import uuid
import logging

logger = logging.getLogger(__name__)

def xa_transfer(from_db_config, to_db_config, user_id, amount):
    """跨库转账:从 from_db 扣款,在 to_db 加款"""
    xid = f"transfer-{uuid.uuid4().hex[:16]}"
    
    conn_from = mysql.connector.connect(**from_db_config)
    conn_to = mysql.connector.connect(**to_db_config)
    
    try:
        cur_from = conn_from.cursor()
        cur_to = conn_to.cursor()
        
        # Phase 0: 执行业务 SQL
        cur_from.execute(f"XA START '{xid}-from'")
        cur_from.execute(
            "UPDATE accounts SET balance = balance - %s "
            "WHERE user_id = %s AND balance >= %s",
            (amount, user_id, amount)
        )
        if cur_from.rowcount == 0:
            cur_from.execute(f"XA END '{xid}-from'")
            cur_from.execute(f"XA ROLLBACK '{xid}-from'")
            raise ValueError("余额不足")
        cur_from.execute(f"XA END '{xid}-from'")

        cur_to.execute(f"XA START '{xid}-to'")
        cur_to.execute(
            "UPDATE accounts SET balance = balance + %s WHERE user_id = %s",
            (amount, user_id)
        )
        cur_to.execute(f"XA END '{xid}-to'")

        # Phase 1: PREPARE(两个 Participant 都投票)
        cur_from.execute(f"XA PREPARE '{xid}-from'")
        cur_to.execute(f"XA PREPARE '{xid}-to'")
        
        # 关键点:两个 PREPARE 都成功了
        # 在这里写入 Coordinator 日志:决定 COMMIT
        logger.info(f"XA transaction {xid}: all prepared, committing")
        
        # Phase 2: COMMIT
        cur_from.execute(f"XA COMMIT '{xid}-from'")
        cur_to.execute(f"XA COMMIT '{xid}-to'")
        
        logger.info(f"XA transaction {xid}: committed")
        
    except Exception as e:
        # 任何异常:尝试回滚
        logger.error(f"XA transaction {xid} failed: {e}")
        for conn, suffix in [(conn_from, 'from'), (conn_to, 'to')]:
            try:
                cur = conn.cursor()
                cur.execute(f"XA ROLLBACK '{xid}-{suffix}'")
            except Exception:
                # ROLLBACK 也可能失败(比如事务不在正确状态)
                # 这时候就需要人工介入了
                logger.error(f"Failed to rollback {xid}-{suffix}")
    finally:
        conn_from.close()
        conn_to.close()

注意这段代码的问题:如果 Python 进程在两个 XA PREPARE 之后、XA COMMIT 之前崩溃,两个数据库都有一个悬挂的 prepared 事务。没有外部的 Coordinator 日志来记录”已决定提交”这个事实,恢复就变成了人工操作。

为什么大多数团队避免 MySQL XA

总结 MySQL XA 的主要问题:

  1. 性能差:每次 XA 操作至少一次 fsync(),吞吐量受限
  2. 阻塞风险:PREPARED 状态的事务持有锁,Coordinator 故障导致阻塞
  3. 复制兼容性:5.7 之前 XA 和 binlog 不兼容
  4. 运维复杂:需要 XA RECOVER 和手工清理悬挂事务
  5. 生态支持弱:MySQL 的 XA 实现相比 Oracle/PostgreSQL 更不成熟

在实践中,大多数团队选择以下替代方案:

六、现代分布式数据库如何规避经典 2PC 缺陷

经典 2PC 的致命问题是 Coordinator 单点故障导致阻塞。所有现代分布式数据库的核心改进思路都一样:让 Coordinator 的状态被复制到多个节点,消除单点故障。

Google Spanner:Paxos + TrueTime

Spanner 的做法是把 2PC 的每一个参与者(包括 Coordinator)都变成一个 Paxos 组。不再是单个节点当 Coordinator,而是一个 Paxos 组当 Coordinator。

经典 2PC:
  Coordinator (单节点) ─── Participant A (单节点)
                       └── Participant B (单节点)

Spanner:
  Coordinator Group (Paxos, 5 副本) ─── Participant Group A (Paxos, 5 副本)
                                    └── Participant Group B (Paxos, 5 副本)

当 Coordinator 组的 Leader 崩溃时,Paxos 协议会在几秒内选出新的 Leader。新 Leader 从 Paxos 日志中恢复 2PC 的状态(包括收到的投票和做出的决定),继续完成协议。Participant 的阻塞时间从”Coordinator 恢复时间”缩短到”Paxos 选主时间”,通常是秒级。

Spanner 的另一个创新是 TrueTime——基于 GPS 和原子钟的全局时钟,误差范围在几毫秒内。TrueTime 让 Spanner 可以为事务分配全局一致的时间戳,而不需要集中式的时间戳分配器。具体来说:

Spanner 事务提交流程:
1. Coordinator 收到所有 PREPARED
2. 选择一个 commit timestamp s ≥ 所有 Participant 的 prepare timestamp
3. 等待 TrueTime 的不确定性窗口过去(commit-wait)
4. 发送 COMMIT(s) 给所有 Participant

commit-wait 是 Spanner 用来保证外部一致性(External Consistency)的关键机制:事务 T1 在 T2 之前提交,那么 T1 的 commit timestamp 一定小于 T2 的 commit timestamp。这依赖于 TrueTime 的误差范围——等待不确定性窗口过去后,可以确保全局顺序与物理时间顺序一致。

代价是 commit-wait 引入了额外延迟。TrueTime 的不确定性窗口通常在 1-7 毫秒,所以每个写事务至少要多等几毫秒。这在 Google 内部的跨数据中心场景下是可以接受的(跨数据中心的网络延迟本身就是几十毫秒),但在低延迟的单数据中心场景下可能不太理想。

CockroachDB:Parallel Commit

CockroachDB 没有 TrueTime(运行在普通硬件上),但它在 2PC 的效率上做了一个巧妙的优化:Parallel Commit。

标准 2PC 的提交是串行的:先收集所有投票,再发送 COMMIT。Parallel Commit 的核心思想是:在收集投票的同时就标记事务为”隐式提交”

标准 2PC:
  Phase 1: 发送 PREPARE,等待所有 YES    (1 RTT)
  Phase 2: 发送 COMMIT,等待所有 ACK     (1 RTT)
  总延迟: 2 RTT

CockroachDB Parallel Commit:
  Phase 1: 发送 PREPARE + 写入 STAGING 状态    (1 RTT)
  事务立即对外可见(如果所有写意图都 PREPARED)
  后台异步: 将 STAGING → COMMITTED             (异步)
  总延迟: 1 RTT

关键在于 STAGING 状态。CockroachDB 在事务记录(Transaction Record)中引入了一个中间状态:

当事务进入 STAGING 状态时,Coordinator 将事务记录的状态改为 STAGING,并记录所有写意图(Intent)的位置。另一个事务如果遇到了 STAGING 状态的事务,可以通过检查所有写意图是否都已成功写入来判断这个事务是否”隐式提交”——如果所有写意图都在,事务就是已提交的,不需要等待事务记录从 STAGING 变为 COMMITTED

这样做的效果是将 2PC 从 2 RTT 优化到 1 RTT(对客户端可见的延迟)。后台的 STAGING → COMMITTED 转换是异步的,不影响其他事务的推进。

TiDB / Percolator:数据即状态

TiDB 基于 Google 的 Percolator 模型,走了一条更激进的路线:没有独立的 Coordinator 节点,事务状态直接编码在数据中

Percolator 的设计中,每一行数据有三个列族(Column Family):

列族 用途
data 存储实际数据,key 是 (row, column, start_ts)
lock 存储锁信息,指向 primary key
write 存储提交信息,记录 commit_ts → start_ts 的映射

事务的提交过程(Percolator 模型):

Prewrite 阶段(对应 2PC Phase 1):
  1. 选择一个 key 作为 primary key
  2. 对每个写操作:
     - 检查 write 列是否有 [start_ts, +∞) 范围的提交记录(写-写冲突检测)
     - 检查 lock 列是否有其他事务的锁(冲突检测)
     - 写入 data 列:(row, col, start_ts) → value
     - 写入 lock 列:指向 primary key 的锁

Commit 阶段(对应 2PC Phase 2):
  1. 先提交 primary key:
     - 删除 lock 列的锁
     - 写入 write 列:(row, col, commit_ts) → start_ts
  2. 异步提交所有 secondary keys(同样的操作)

这里的精妙之处在于:primary key 的 lock 列就是”Coordinator 的决定”。如果 primary key 的锁存在,事务还没提交;如果锁被清除、write 列写入了提交记录,事务已提交。不需要一个独立的 Coordinator 节点来维护事务状态。

当遇到一个锁时,其他事务可以通过检查 primary key 的状态来判断这个事务是否已经提交:

事务 T2 遇到了 T1 的锁:
  1. 找到锁中记录的 primary key 位置
  2. 检查 primary key 的 lock 列:
     - 如果 lock 存在 → T1 还没提交,等待或超时后清理
     - 如果 lock 不存在,write 列有记录 → T1 已提交,帮忙清理 secondary 的锁
     - 如果 lock 不存在,write 列也没记录 → T1 已回滚,帮忙清理

这种设计的好处:

  1. 没有单独的 Coordinator 节点,不存在 Coordinator 单点故障问题
  2. 事务状态可以被任何能访问 primary key 的节点读取和推进
  3. Coordinator 崩溃(在 TiDB 中是 TiDB Server 节点崩溃)后,其他节点可以通过检查 primary key 来推进或清理事务

代价是什么?每次遇到冲突都需要额外的一次读取(去检查 primary key 的状态),如果 primary key 和 secondary key 不在同一个节点上,这是一次跨节点的 RPC。在高冲突场景下,这个额外开销可能很可观。

比较表

特性 经典 2PC Spanner CockroachDB TiDB/Percolator
Coordinator 单节点 Paxos 组 范围 Leaseholder 无(primary key)
Coordinator 故障恢复 等待重启 Paxos 选主(秒级) 范围租约转移 任何节点可推进
阻塞窗口 无限(直到恢复) 秒级 秒级 无(其他事务可清理)
提交延迟 2 RTT 2 RTT + commit-wait 1 RTT 2 RTT
时钟依赖 TrueTime(GPS+原子钟) HLC(混合逻辑时钟) TSO(中心授时)
隔离级别 取决于并发控制 可串行化 + 外部一致性 可串行化(SSI) 快照隔离(SI)

现代系统的分布式提交实现对比

除了上述架构层面的对比,各系统在分布式提交的具体工程实现上也存在显著差异。以下从 MySQL XA、PostgreSQL 2PC、Spanner 和 CockroachDB 四个系统出发,对比它们的实现细节:

维度 MySQL XA PostgreSQL 2PC Spanner CockroachDB
协议接口 XA START/END/PREPARE/COMMIT PREPARE TRANSACTION/COMMIT PREPARED 内部实现,对用户透明 内部实现,对用户透明
Coordinator 持久化 应用层负责 应用层负责 Paxos 组自动复制 Raft 自动复制
Prepare 日志 binlog + redo log 双写 WAL 写入 PREPARE 记录 Paxos 复制 PREPARE Raft 复制 Intent
Commit 日志 binlog 标记 + redo log WAL 写入 COMMIT PREPARED Paxos 复制 COMMIT 写入 Transaction Record
崩溃恢复 XA RECOVER 列出悬挂事务,手动处理 pg_prepared_xacts 视图查询,手动处理 自动:Paxos 选主后继续 自动:其他节点推进事务
悬挂事务风险 高:需要 DBA 手动介入 中:有超时机制但默认无限 无:自动恢复 无:自动恢复
跨分片原子性 依赖外部协调器 依赖外部协调器 内建于存储层 内建于存储层
性能开销 每个事务 2-3 次额外 fsync 每个事务 2-3 次额外 fsync fsync 被 Paxos 批量化 fsync 被 Raft 批量化

关键观察:MySQL XA 和 PostgreSQL 2PC 将 Coordinator 角色推给应用层,这意味着应用开发者必须自己处理 Coordinator 故障恢复——这在实践中极其容易出错。Spanner 和 CockroachDB 将 Coordinator 内嵌到数据库本身并通过共识协议复制其状态,从根本上消除了手动恢复的需要。

核心洞察

上面三个系统虽然实现细节差异很大,但共享一个核心思想:

复制 Coordinator 的状态,消除单点故障。

Spanner 用 Paxos 复制整个 Coordinator;CockroachDB 用 Raft 复制事务记录;TiDB/Percolator 将事务状态嵌入到被复制的数据本身中。方法不同,目标一致:确保 Coordinator 崩溃后,有其他节点能接管并完成事务。

这就是为什么在所有这些系统中,2PC 的阻塞问题在理论上已经被消除——虽然在 Paxos/Raft 选主期间仍然有短暂的不可用窗口,但这个窗口是有界的(通常几秒),而不是经典 2PC 中无界的阻塞。

七、结论

2PC 没有死。每一个现代分布式数据库内部都在用 2PC 的变体来保证原子提交。但”裸”的 2PC——单节点 Coordinator、无复制、无优化——确实不适合生产环境。

理解 2PC 的真实失败模式是选择分布式事务方案的前提:Coordinator 在 WAITING 状态崩溃会导致所有 Participant 无限阻塞,这是 2PC 的根本缺陷;Presumed Abort 通过默认假设 ABORT 来减少日志写入;2PC 只管原子性,不管隔离性,两者需要分开选择;现代系统通过复制 Coordinator 状态把无界阻塞变成了有界阻塞。

如果你的系统需要跨节点的强原子性,选择 TiDB、CockroachDB 或 Spanner 这样内建了优化 2PC 的数据库,比自己用 MySQL XA 手工搭建分布式事务要可靠得多。如果强原子性不是必须的,Saga 和事件驱动方案在可用性和运维方面的优势值得认真考虑——这是下一篇文章的主题。


导航


参考资料

论文

书籍

源码与文档

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-25 · distributed

【分布式系统实战】分布式事务不是你以为的那个 2PC

「用 2PC 就行了」——说这话的人大概没在生产环境里被 Coordinator 挂掉后全员阻塞的锁堵过三小时。2PC 的真实失败模式、Percolator 的精妙设计、Saga 与 TCC 的工程取舍,分布式事务远比教科书复杂。

2026-04-13 · distributed

【分布式系统百科】3PC 的理论改进与 Saga 的工程妥协

2PC 阻塞时协调者宕机怎么办?3PC 试图用额外一轮消息解决,却撞上网络分区。Saga 放弃全局锁,用补偿事务换取可用性。本文从 Skeen 论文推导 3PC 的非阻塞证明,分析其分区缺陷,再到 Saga 编排/协同、补偿陷阱、Temporal 工程实践和 TCC 资源预留——把分布式事务的理论与工程讲清楚。

2026-04-13 · distributed

【分布式系统百科】Percolator 模型:Google 的乐观事务方案

Percolator 在 Bigtable 之上用三列设计实现了跨行分布式事务,其核心思路是把事务协调状态编码进数据本身,从而消除了对专用协调者节点的依赖。本文拆解其两阶段提交流程、冲突检测与锁清理机制,并分析 TiDB 对该模型的工程改进。


By .