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 的正确性可以简洁地证明:
- 一致性:所有 Participant
最终到达相同的终态(全部
COMMITTED或全部ABORTED),因为只有 Coordinator 做决定,且决定唯一。 - 原子性:如果任何一个 Participant 投了
NO,Coordinator 必须选择ABORT;只有全部投YES,Coordinator 才可以选择COMMIT。决定一旦做出,不可更改。 - 持久性:
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 弄崩溃。根据崩溃发生的时间点,有三种截然不同的情况。
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
中有事务开始记录,但没有 COMMIT 或
ABORT 决定) - Participant
A:READY(已投 YES,WAL 中有
READY 记录,持有行锁) - Participant
B:INIT 或 READY(取决于是否收到了
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 不知道一个事务的状态,就假设它已经被中止了。
具体做法:
- Coordinator 在做出
ABORT决定时,不需要将ABORT记录 force-write 到 WAL。甚至可以完全不写ABORT记录。 - Coordinator 在做出
COMMIT决定时,仍然必须 force-writeCOMMIT记录。 - 如果 Coordinator 崩溃重启后,在 WAL 中找不到某个事务的决定记录,就认为该事务已被中止。
- 如果 Participant 询问一个 Coordinator 不认识的事务(因为
ABORT 记录已被清理或从未写入),Coordinator 回复
ABORT。
为什么这是正确的?因为只有两种情况会导致 Coordinator 不认识一个事务:
- 事务确实被中止了(ABORT 记录没写或已清理)——回复 ABORT 是正确的。
- 事务还没开始投票就崩溃了(Case 1)——事务自然会被中止,回复 ABORT 也是正确的。
不可能出现”事务已提交但 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 不知道一个事务的状态,就假设它已经提交了。
具体做法:
- Coordinator 在做出
COMMIT决定时,不需要将COMMIT记录 force-write 到 WAL。 - Coordinator 在做出
ABORT决定时,必须 force-writeABORT记录。 - 但这引入了一个问题:在发送任何
PREPARE之前,Coordinator 必须 force-write 一条”collecting”记录(记录参与者列表)。 - 如果 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”是实现分布式事务原子提交的一种具体协议。一个完整的分布式事务系统需要两个组件:
- 原子提交协议(Atomic Commit Protocol):确保所有节点要么全部提交,要么全部中止。2PC 就是最经典的原子提交协议。
- 并发控制机制(Concurrency Control):确保并发事务之间的隔离性。常见的机制有两阶段锁(2PL)、多版本并发控制(MVCC)、乐观并发控制(OCC)等。
这两个组件是正交的,可以自由组合。
2PC + 2PL = 可串行化
两阶段提交(2PC,Two-Phase Commit)加上两阶段锁(2PL,Two-Phase Locking),名字很像但解决的问题完全不同:
- 2PC 中的”两阶段”:Phase 1 投票,Phase 2 提交/中止
- 2PL 中的”两阶段”:Phase 1 加锁(只加不释放),Phase 2 解锁(只释放不加)
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(开始时间戳) - 读操作读取
start_ts之前最新的已提交版本 - 写操作在 Phase 1(Prewrite)时,将修改写入但标记为”未提交”
- Phase 2(Commit)时,给所有修改打上
commit_ts(提交时间戳)
这样做的好处是读操作不需要加锁——读者读取的是自己
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 场景下,快照隔离已经足够,性能远好于可串行化。
为什么区分提交协议和隔离级别很重要
混淆这两个概念会导致严重的设计错误:
- “我们用了 2PC,所以是强一致的”——错。2PC 只保证原子性,不保证隔离性。如果并发控制不够强,仍然可能出现数据异常。
- “快照隔离太弱了,我们需要 2PC”——牛头不对马嘴。你需要的可能是更强的并发控制(如 SSI),而不是更复杂的提交协议。
- “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 的主要问题:
- 性能差:每次 XA 操作至少一次
fsync(),吞吐量受限 - 阻塞风险:PREPARED 状态的事务持有锁,Coordinator 故障导致阻塞
- 复制兼容性:5.7 之前 XA 和 binlog 不兼容
- 运维复杂:需要
XA RECOVER和手工清理悬挂事务 - 生态支持弱:MySQL 的 XA 实现相比 Oracle/PostgreSQL 更不成熟
在实践中,大多数团队选择以下替代方案:
- Saga 模式:将分布式事务拆分为一系列本地事务加补偿操作,放弃全局原子性,换取更高的可用性
- TCC(Try-Confirm-Cancel):业务层实现的两阶段提交,资源预留在 Try 阶段,Confirm/Cancel 是幂等操作
- 事件驱动 + 最终一致性:通过消息队列异步同步状态,接受短暂的不一致
- 使用支持分布式事务的 NewSQL 数据库:TiDB、CockroachDB 等,在存储层内建了优化过的 2PC
六、现代分布式数据库如何规避经典 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)中引入了一个中间状态:
PENDING:事务正在执行STAGING:Coordinator 已经发出所有写意图,正在等待确认COMMITTED:事务已确认提交ABORTED:事务已中止
当事务进入 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 已回滚,帮忙清理
这种设计的好处:
- 没有单独的 Coordinator 节点,不存在 Coordinator 单点故障问题
- 事务状态可以被任何能访问 primary key 的节点读取和推进
- 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 和事件驱动方案在可用性和运维方面的优势值得认真考虑——这是下一篇文章的主题。
导航
- 上一篇:数据再平衡
- 下一篇:3PC 与 Saga
参考资料
论文
- Mohan, C., Lindsay, B., Obermarck, R. “Transaction Management in the R* Distributed Database Management System.” ACM Transactions on Database Systems, 11(4), 1986. (Presumed Abort 和 Presumed Commit 的原始论文)
- Mohan, C., Haderle, D., Lindsay, B., Pirahesh, H., Schwarz, P. “ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging.” ACM Transactions on Database Systems, 17(1), 1992. (ARIES 恢复算法,包含 PA 优化)
- Corbett, J. C. et al. “Spanner: Google’s Globally-Distributed Database.” OSDI, 2012. (Paxos 组作为 2PC 参与者,TrueTime)
- Peng, D., Dabek, F. “Large-scale Incremental Processing Using Distributed Transactions and Notifications.” OSDI, 2010. (Percolator 模型)
- Taft, R. et al. “CockroachDB: The Resilient Geo-Distributed SQL Database.” SIGMOD, 2020. (Parallel Commit 设计)
书籍
- Bernstein, P. A., Newcomer, E. Principles of Transaction Processing. 2nd Edition, Morgan Kaufmann, 2009. (2PC 状态机的形式化定义,XA 标准解读)
- Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. 第 9 章:一致性与共识.(2PC 与共识的关系)
- Weikum, G., Vossen, G. Transactional Information Systems. Morgan Kaufmann, 2001. 第 19 章:分布式事务. (2PC、3PC 的理论分析)
源码与文档
- MySQL 8.0 XA Transaction Reference: https://dev.mysql.com/doc/refman/8.0/en/xa.html
- TiDB 事务模型文档: https://docs.pingcap.com/tidb/stable/transaction-overview
- CockroachDB Parallel Commit RFC: https://github.com/cockroachdb/cockroach/blob/master/docs/RFCS/20180324_parallel_commit.md
- X/Open XA Specification: X/Open CAE Specification, Distributed Transaction Processing: The XA Specification, 1991.
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统实战】分布式事务不是你以为的那个 2PC
「用 2PC 就行了」——说这话的人大概没在生产环境里被 Coordinator 挂掉后全员阻塞的锁堵过三小时。2PC 的真实失败模式、Percolator 的精妙设计、Saga 与 TCC 的工程取舍,分布式事务远比教科书复杂。
【分布式系统百科】3PC 的理论改进与 Saga 的工程妥协
2PC 阻塞时协调者宕机怎么办?3PC 试图用额外一轮消息解决,却撞上网络分区。Saga 放弃全局锁,用补偿事务换取可用性。本文从 Skeen 论文推导 3PC 的非阻塞证明,分析其分区缺陷,再到 Saga 编排/协同、补偿陷阱、Temporal 工程实践和 TCC 资源预留——把分布式事务的理论与工程讲清楚。
【分布式系统百科】Percolator 模型:Google 的乐观事务方案
Percolator 在 Bigtable 之上用三列设计实现了跨行分布式事务,其核心思路是把事务协调状态编码进数据本身,从而消除了对专用协调者节点的依赖。本文拆解其两阶段提交流程、冲突检测与锁清理机制,并分析 TiDB 对该模型的工程改进。
【分布式系统百科】ZooKeeper 内核:从 ZAB 协议到分布式协调实践
深入拆解 ZooKeeper 的核心机制:ZAB 协议的三阶段流程、ZNode 数据模型、Watch 一次性通知、会话管理,以及分布式锁、Leader 选举、配置管理等典型用法。分析惊群效应等已知问题,并梳理 ZooKeeper 在 Kafka、HBase、Hadoop 生态中的角色。