订单写入一个数据库,付款写入另一个数据库。两者必须都发生,或者都不发生。每个数据库都能原子地提交自己的那一部分,但本地机制无法阻止一个已经提交、另一个却崩溃了。两阶段提交(2PC)是解决这个问题的经典协议:它通过 XA 标准内置在大多数关系型数据库中,如今也在分布式 SQL 数据库内部使用。
它也有一个著名的弱点:如果协调者在错误的时刻故障,参与者可能会一直持有锁、卡在原地,而且无法自行做出决定。本文用一张时序图走一遍这个协议,逐一讲解每种故障情形、真实系统如何实现它,以及什么时候 saga 或 outbox 是更好的工具。
角色
- 协调者,即事务管理器:推动协议并决定结果。它可以是应用服务器中的一个库、一个独立服务,或分布式数据库中的一个组件。
- 参与者,即资源管理器:各自持有事务一部分的数据库或队列。
每一方都维护一份持久日志。写日志不是实现细节;正是这些写入让协议能在崩溃后存活下来。
协议
阶段 1:prepare。
- 应用在一个全局事务中完成对各参与者的操作,然后请协调者提交。
- 协调者向每个参与者发送 PREPARE。
- 每个参与者确保无论接下来发生什么都能提交:它把事务的变更和一条 prepare 记录写入持久日志,继续持有锁,并投 YES。如果无法提交,例如某个约束检查失败,它就投 NO 并回滚。
阶段 2:commit 或 abort。
- 如果所有投票都是 YES,协调者就在自己的日志中写入 COMMIT 决定。这次写入就是提交点:从此刻起,事务已经提交,即使一毫秒后所有机器都崩溃也一样。
- 协调者向每个参与者发送 COMMIT。每个参与者提交、释放锁并发送确认。
- 如果有参与者投 NO 或没有及时响应,协调者就决定 ABORT,改为发送 ROLLBACK。
YES 票是一个承诺。投出 YES 之后,参与者就放弃了单独做决定的权利:如果被告知提交,即使重启之后也必须提交;它也不能自行回滚,因为协调者可能已经决定提交。
代价
对于 N 个参与者,一次成功的提交需要协调者发起两轮往返,每个参与者四条消息:PREPARE、投票、COMMIT 和确认。每个参与者要强制把日志刷盘两次,分别在 prepare 和提交时;协调者则强制刷盘一次以记录决定。锁在整个过程中一直被持有,因此其他每个参与者要把冲突的工作阻塞多久,由最慢的那个参与者决定。
出现故障时会怎样
| 故障 | 时机 | 结果 |
|---|---|---|
| 参与者崩溃或超时 | 投票之前 | 协调者中止事务 |
| 参与者投 NO | 阶段 1 | 协调者中止事务 |
| 参与者崩溃 | 投 YES 之后 | 重启时找到 prepare 记录,向协调者询问结果,在此期间一直持有锁 |
| 协调者崩溃 | 写入决定之前 | 已 prepare 的参与者等待。恢复时日志中没有决定,协调者中止事务 |
| 协调者崩溃 | 写入 COMMIT 之后 | 恢复时读取日志并重新发送 COMMIT |
| 网络分区 | 某个参与者投 YES 之后 | 该参与者一直阻塞,直到能联系上协调者 |
关键的是那些参与者已经投了 YES、却收不到决定的行。它不能提交,因为另一个参与者可能投了 NO。它也不能中止,因为协调者可能已经提交。只要协调者无法访问,它就持有锁一直等待。所有触及这些行的其他事务也都在等待。人们说 2PC 是阻塞协议,指的就是这一点。
实践中卡住的已准备事务
PostgreSQL 用 PREPARE TRANSACTION、COMMIT PREPARED 和 ROLLBACK PREPARED 实现参与者一侧。该功能默认关闭(max_prepared_transactions 为 0),文档警告说,它是为外部事务管理器设计的,而不是给应用使用的。被遗忘的已准备事务会一直持有锁,并阻止 VACUUM 回收存储空间。
查找并处理这样的事务如下:
-- Prepared transactions waiting for a decision
SELECT gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;
-- Only after checking the coordinator's log for the real outcome:
COMMIT PREPARED 'order-7f3a';
-- or
ROLLBACK PREPARED 'order-7f3a';
永远不要靠猜测来处理已准备事务。如果协调者已经提交,而你回滚了其中一个参与者,你就恰好制造了 2PC 本来要防止的不一致。MySQL 用 XA PREPARE、XA COMMIT 和 XA RECOVER 提供了同样的机制。
减轻痛苦的变体
- Presumed abort(假定中止)。 如果协调者没有某个事务的记录,答案就是中止。这为被中止的事务省下了日志写入,也让恢复更简单;大多数实现都采用它。
- 只读参与者。 没有修改任何内容的参与者可以投“read-only”,跳过阶段 2。
- 三阶段提交。 增加一轮 pre-commit,让参与者在没有协调者的情况下也能完成,但前提是消息延迟有上限、网络从不分区。这些假设很少成立,所以 3PC 主要是教科书里的话题。
- 复制协调者和参与者。 解决阻塞的真正办法,是把每个角色都做成能承受一台机器丢失的复制组。Google 的 Spanner 正是出于这个原因在 Paxos 组之上运行两阶段提交,其他分布式 SQL 数据库则用 Raft 实现同样的思路。结果仍由两阶段提交决定;共识负责让每一方保持可用。
何时使用 2PC,何时不用
当所有参与者都是支持 prepare 步骤的数据库或消息代理、它们都由你掌控、事务很短,并且你需要在它们之间保证原子性和隔离性时,2PC 很合适。它不适合用在其他团队拥有的服务之间、没有 prepare 步骤的 HTTP API 之间(支付服务商不会把你的扣款保持在已准备状态),也不适合持续数分钟甚至数天的业务流程。
| 两阶段提交 | Saga | Transactional outbox | |
|---|---|---|---|
| 一致性 | 原子;锁提供隔离 | 最终一致,带补偿步骤 | 数据库与消息代理之间最终一致 |
| 阻塞 | 是,在决定悬而未决期间 | 否 | 否 |
| 前提 | 支持 prepare 的参与者 | 每一步的补偿逻辑 | 一个数据库加一个中继 |
| 典型用途 | 你自己运行的数据库或分片 | 跨服务的业务工作流 | 在写入后可靠地发布事件 |
Saga 模式图展示了基于补偿的替代方案,transactional outbox 模式则覆盖了团队最常想到用 2PC 的场景:写入数据库的同时发布一条消息。很多时候,最好的解决办法是结构上的:像数据库分片图中讨论的那样,把必须一起变化的数据放进同一个数据库或同一个分片。
两阶段提交图
图表内容
- Application
- Coordinator
- Orders DB
- Payments DB
- phase 1: prepare
- phase 2: all votes YES
- commit()
- PREPARE
- YES (prepare record on disk)
- COMMIT
- ACK, locks released
- ACK
- ROLLBACK
- committed or aborted
这张图的读法如下:
- 四条生命线。 应用、协调者、订单数据库和支付数据库。时间从上往下流动。
- par 片段是阶段 1。 PREPARE 同时发给两个参与者,每个参与者在写入 prepare 记录后返回投票。
- alt 片段是阶段 2。 上面的分支是正常情况:所有投票都是 YES,协调者把 COMMIT 写入日志,两个参与者提交并确认。下面的分支是中止路径。
- 便签标出了存疑窗口,也就是从投出 YES 到收到决定之间的时段,参与者在此期间持有锁,且无法自行决定。
- Markdown 块列出了上表中的故障情形。
如实地绘制分布式事务
- 画出日志写入。 没有“写入 prepare 记录”和“写入决定”的 2PC 图,恰恰漏掉了让它正确的部分。
- 标出提交点。 结果由一条消息或一次日志写入决定;把它指出来。
- 画出发生阻塞的窗口。 这正是团队回避 2PC 的原因,把它藏起来的图会误导读者。
- 把故障路径放进单独的图里。 prepare 之后协调者崩溃的情形,值得配上包含恢复步骤的独立时序图。
关于记法本身,时序图示例介绍了生命线、片段和命名。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加四个时序节点包中的 生命线 节点:Application、Coordinator、Orders DB 和 Payments DB。
- 从上到下把消息画成连线:提交请求、发往每个数据库的 PREPARE、YES 投票、发往每个数据库的 COMMIT,以及确认。
- 在 prepare 消息外围添加一个时序节点包中的 组合片段,运算符设为
par;再在提交和回滚分支外围添加第二个 组合片段,运算符设为alt。 - 为存疑窗口添加一个 便签,并添加一个基础节点包中的 Markdown 块,放入故障表。
这个示例共用 7 个图表节点,在 Free 套餐的限制之内。
常见问题
为什么说两阶段提交是阻塞协议? 投了 YES 的参与者既不能自行提交,也不能自行中止。如果协调者在发出决定之前变得无法访问,参与者就会持有锁一直等待,所有需要这些行的事务也会一起等待。
两阶段提交和分布式事务是一回事吗? 它是提交分布式事务最常见的方式。分布式事务跨越多个资源;两阶段提交是让它的提交具有原子性的协议。
三阶段提交能解决阻塞问题吗? 只有在很少成立的假设下才行:消息延迟有上限,并且不存在网络分区。实际可行的办法是像 Spanner 那样,用共识复制协调者和参与者。
可以在微服务之间使用两阶段提交吗? 很少能用好。各个服务需要暴露 prepare 步骤并为彼此持有锁,这会把它们的可用性绑在一起。服务之间通常选择 saga 和 outbox 模式。
两阶段提交能保证隔离性吗? 它保证的是原子性。隔离性来自参与者的锁,2PC 会一直持有这些锁直到决定到达,因此并发事务看不到部分结果。