跳到正文

图解两阶段提交(2PC):prepare、commit 以及它为何会阻塞

两阶段提交如何让跨数据库的事务保持原子性,协调者中途故障时会发生什么,以及什么时候 saga 或 outbox 更合适。

· 创始人兼开发者

发布于

时序图中有一个应用、一个协调者、一个订单数据库和一个支付数据库。一个 par 片段显示 PREPARE 发往两个数据库并返回 YES 投票。一个 alt 片段显示全部投赞成时的 COMMIT 和确认,以及有任何一票反对时的 ROLLBACK。

订单写入一个数据库,付款写入另一个数据库。两者必须都发生,或者都不发生。每个数据库都能原子地提交自己的那一部分,但本地机制无法阻止一个已经提交、另一个却崩溃了。两阶段提交(2PC)是解决这个问题的经典协议:它通过 XA 标准内置在大多数关系型数据库中,如今也在分布式 SQL 数据库内部使用。

它也有一个著名的弱点:如果协调者在错误的时刻故障,参与者可能会一直持有锁、卡在原地,而且无法自行做出决定。本文用一张时序图走一遍这个协议,逐一讲解每种故障情形、真实系统如何实现它,以及什么时候 saga 或 outbox 是更好的工具。

角色

  • 协调者,即事务管理器:推动协议并决定结果。它可以是应用服务器中的一个库、一个独立服务,或分布式数据库中的一个组件。
  • 参与者,即资源管理器:各自持有事务一部分的数据库或队列。

每一方都维护一份持久日志。写日志不是实现细节;正是这些写入让协议能在崩溃后存活下来。

协议

阶段 1:prepare。

  1. 应用在一个全局事务中完成对各参与者的操作,然后请协调者提交。
  2. 协调者向每个参与者发送 PREPARE。
  3. 每个参与者确保无论接下来发生什么都能提交:它把事务的变更和一条 prepare 记录写入持久日志,继续持有锁,并投 YES。如果无法提交,例如某个约束检查失败,它就投 NO 并回滚。

阶段 2:commit 或 abort。

  1. 如果所有投票都是 YES,协调者就在自己的日志中写入 COMMIT 决定。这次写入就是提交点:从此刻起,事务已经提交,即使一毫秒后所有机器都崩溃也一样。
  2. 协调者向每个参与者发送 COMMIT。每个参与者提交、释放锁并发送确认。
  3. 如果有参与者投 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 的场景:写入数据库的同时发布一条消息。很多时候,最好的解决办法是结构上的:像数据库分片图中讨论的那样,把必须一起变化的数据放进同一个数据库或同一个分片。

两阶段提交图

时序图中有一个应用、一个协调者、一个订单数据库和一个支付数据库。一个 par 片段显示 PREPARE 发往两个数据库并返回 YES 投票。一个 alt 片段显示全部投赞成时的 COMMIT 和确认,以及有任何一票反对时的 ROLLBACK。

10 个组件 · 12 个连接

在 Archboard 中打开
图表内容
  • 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
跨两个数据库的两阶段提交。在阶段 1,协调者请两个参与者都执行 prepare 并收集它们的投票;在阶段 2,它把决定写入日志并通知所有参与者提交,如果有任何一票为否,则通知回滚。

这张图的读法如下:

  • 四条生命线。 应用、协调者、订单数据库和支付数据库。时间从上往下流动。
  • par 片段是阶段 1。 PREPARE 同时发给两个参与者,每个参与者在写入 prepare 记录后返回投票。
  • alt 片段是阶段 2。 上面的分支是正常情况:所有投票都是 YES,协调者把 COMMIT 写入日志,两个参与者提交并确认。下面的分支是中止路径。
  • 便签标出了存疑窗口,也就是从投出 YES 到收到决定之间的时段,参与者在此期间持有锁,且无法自行决定。
  • Markdown 块列出了上表中的故障情形。

如实地绘制分布式事务

  • 画出日志写入。 没有“写入 prepare 记录”和“写入决定”的 2PC 图,恰恰漏掉了让它正确的部分。
  • 标出提交点。 结果由一条消息或一次日志写入决定;把它指出来。
  • 画出发生阻塞的窗口。 这正是团队回避 2PC 的原因,把它藏起来的图会误导读者。
  • 把故障路径放进单独的图里。 prepare 之后协调者崩溃的情形,值得配上包含恢复步骤的独立时序图。

关于记法本身,时序图示例介绍了生命线、片段和命名。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加四个时序节点包中的 生命线 节点:Application、Coordinator、Orders DB 和 Payments DB。
  3. 从上到下把消息画成连线:提交请求、发往每个数据库的 PREPARE、YES 投票、发往每个数据库的 COMMIT,以及确认。
  4. 在 prepare 消息外围添加一个时序节点包中的 组合片段,运算符设为 par;再在提交和回滚分支外围添加第二个 组合片段,运算符设为 alt。
  5. 为存疑窗口添加一个 便签,并添加一个基础节点包中的 Markdown 块,放入故障表。

这个示例共用 7 个图表节点,在 Free 套餐的限制之内。

常见问题

为什么说两阶段提交是阻塞协议? 投了 YES 的参与者既不能自行提交,也不能自行中止。如果协调者在发出决定之前变得无法访问,参与者就会持有锁一直等待,所有需要这些行的事务也会一起等待。

两阶段提交和分布式事务是一回事吗? 它是提交分布式事务最常见的方式。分布式事务跨越多个资源;两阶段提交是让它的提交具有原子性的协议。

三阶段提交能解决阻塞问题吗? 只有在很少成立的假设下才行:消息延迟有上限,并且不存在网络分区。实际可行的办法是像 Spanner 那样,用共识复制协调者和参与者。

可以在微服务之间使用两阶段提交吗? 很少能用好。各个服务需要暴露 prepare 步骤并为彼此持有锁,这会把它们的可用性绑在一起。服务之间通常选择 saga 和 outbox 模式。

两阶段提交能保证隔离性吗? 它保证的是原子性。隔离性来自参与者的锁,2PC 会一直持有这些锁直到决定到达,因此并发事务看不到部分结果。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。