bigywater.tech
分布式事务 · 3

2PC 的故障场景:协调者崩溃时参与者卡在哪

2PC 最让人担心的是协调者崩溃。它在不同位置崩溃时,参与者分别停在哪?能自己决定吗?靠什么恢复?这篇把协调者作为独立进程,在五个位置各 kill -9 一次,用真实的 PostgreSQL 把每种结局都跑出来。

4 / 5
五个崩溃位置里,协调者死后至少有一个参与者停在已 prepare 等决定的位置数(位置 1 到 4)
3001.3 ms
业务会话 UPDATE 被 prepare 的那一行,等到 lock_timeout 超时的时间
6000
挂着一个 prepared 事务时,VACUUM 清不掉的死元组数

五个崩溃位置

实测 · 本机执行输出
  协调者在哪个位置被杀                           | 退出码   | 订单库 prepared 数 | 库存库 prepared 数 | 订单数 | 库存  | 决定日志  
  -------------------------------------+-------+----------------+----------------+-----+-----+-------
  0. 开始之前                              | rc=-9 | 0              | 0              | 0   | 100 | (无)   
  1. 第一个参与者 prepare 之后                 | rc=-9 | 1              | 0              | 0   | 100 | (无)   
  2. 全部 prepare 之后、写决定日志之前             | rc=-9 | 1              | 1              | 0   | 100 | (无)   
  3. 决定日志落盘之后、发出第一个 COMMIT PREPARED 之前 | rc=-9 | 1              | 1              | 0   | 100 | COMMIT
  4. 第一个 COMMIT PREPARED 之后、第二个之前      | rc=-9 | 0              | 1              | 1   | 100 | COMMIT
  位置 3 时协调者进程打印的最后几行:
    [协调者] 订单库: PREPARE TRANSACTION 'x3' -> 投 YES
      [协调者] 库存库: PREPARE TRANSACTION 'x3' -> 投 YES
      [协调者] 决定日志已落盘:COMMIT
      [协调者] 在“决定日志落盘之后、发出第一个 COMMIT PREPARED 之前”被 kill -9

协调者在开始前、第一个 prepare 后、全部 prepare 后写日志前、写日志后、第一个 COMMIT PREPARED 后各被杀一次。位置 1 到 4 都有参与者停在已承诺、等决定的状态,位置 4 还出现一边已提交、一边仍 prepared。

阻塞的代价

实测 · 本机执行输出
  库存库 pg_prepared_xacts(gid, 已经 prepare 了多少秒): [('xw', 0.0)]
  业务会话 UPDATE sku-1(被卡住的那一行):等了 3001.3 ms 后 LockNotAvailable
  同一个会话 UPDATE sku-2(别的行):0.8 ms 完成,没被挡 —— 锁的粒度是被 prepare 的那一行
  又过了一会儿,库存库 pg_prepared_xacts: [('xw', 4.0)] —— 协调者没了,它只会越来越老
  有 prepared 事务挂着时,把 churn 表(2000 行)整体 UPDATE 3 次后 VACUUM:
    tuples: 0 removed, 8000 remain, 6000 are dead but not yet removable
    removable cutoff: 741, which was 9 XIDs old when operation ended
  把 prepared 事务 ROLLBACK PREPARED 'xw' 之后再 VACUUM:
    tuples: 6000 removed, 2000 remain, 0 are dead but not yet removable
    removable cutoff: 750, which was 0 XIDs old when operation ended

被 prepare 的行一直被锁,别的事务写同一行等到超时;它还让 VACUUM 的 removable cutoff 停住,死元组清不掉。直到有人收尾它。

参与者不能自己决定

实测 · 本机执行输出
  现场:订单库 prepared=0 条、订单 1 条(已提交);库存库 prepared=1 条、库存 100(扣减还不可见);协调者的决定日志:COMMIT
  库存库的 DBA 能看到的:本库 pg_prepared_xacts 有 'xv';订单库那边 pg_prepared_xacts 里已经没有 'xv' —— 没有了可能是已提交,也可能是已回滚,看不出来
  做法 A(等不到协调者就自己回滚,“超时即 abort”):库存库 ROLLBACK PREPARED 'xv'
    结果:订单 1 条,库存 100 —— 不一致(订单已经提交了,库存却没扣)
  做法 B(去问协调者留下的决定日志):日志里是 COMMIT -> 库存库 COMMIT PREPARED 'xv2'
    结果:订单 1 条,库存 99 —— 一致

对方不再 prepared 时,可能已提交,也可能已回滚,从自己这边分不出来。单方面回滚会造成订单已提交、库存没扣。

靠决定日志收尾

实测 · 本机执行输出
  协调者在哪个位置被杀                           | 恢复动作(订单/库存:动作)           | 订单数 | 库存  | 结论
  -------------------------------------+--------------------------+-----+-----+---
  0. 开始之前                              | (无事可做)                   | 0   | 100 | 一致
  1. 第一个参与者 prepare 之后                 | 订单:ROLLBACK              | 0   | 100 | 一致
  2. 全部 prepare 之后、写决定日志之前             | 订单:ROLLBACK; 库存:ROLLBACK | 0   | 100 | 一致
  3. 决定日志落盘之后、发出第一个 COMMIT PREPARED 之前 | 订单:COMMIT; 库存:COMMIT     | 1   | 99  | 一致
  4. 第一个 COMMIT PREPARED 之后、第二个之前      | 库存:COMMIT                | 1   | 99  | 一致

协调者重启后按日志:有 COMMIT 补 COMMIT PREPARED,没记录就回滚。五个位置都恢复一致。

日志丢了怎么办

实测 · 本机执行输出
  协调者在哪个位置被杀                           | 恢复动作                     | 订单数 | 库存  | 结论 
  -------------------------------------+--------------------------+-----+-----+----
  0. 开始之前                              | (无事可做)                   | 0   | 100 | 一致 
  1. 第一个参与者 prepare 之后                 | 订单:ROLLBACK              | 0   | 100 | 一致 
  2. 全部 prepare 之后、写决定日志之前             | 订单:ROLLBACK; 库存:ROLLBACK | 0   | 100 | 一致 
  3. 决定日志落盘之后、发出第一个 COMMIT PREPARED 之前 | 订单:ROLLBACK; 库存:ROLLBACK | 0   | 100 | 一致 
  4. 第一个 COMMIT PREPARED 之后、第二个之前      | 库存:ROLLBACK              | 1   | 100 | 不一致
  位置 3 虽然也“一致”,但那是把一个本来已经决定提交的事务回滚了;位置 4 订单库已经提交,只能靠人工补

一律回滚时,位置 4 必然不一致,位置 3 则把已决定提交的事务回滚了。日志是 2PC 里最关键的数据。

写日志的先后

实测 · 本机执行输出
  协调者的写法                         | 崩溃时决定日志 | 恢复动作        | 订单数 | 库存  | 结论 
  -------------------------------+---------+-------------+-----+-----+----
  先写日志再发 COMMIT PREPARED(正确)     | COMMIT  | 库存:COMMIT   | 1   | 99  | 一致 
  先发第一个 COMMIT PREPARED 再写日志(错误) | (无)     | 库存:ROLLBACK | 1   | 100 | 不一致

先写日志再发命令,才能恢复;反过来,崩溃时日志是空的,恢复程序只能回滚,结果不一致。

参与者先死

实测 · 本机执行输出
  协调者打印:
    [协调者] 订单库: PREPARE TRANSACTION 'xp' -> 投 YES
    [协调者] 阶段一失败:consuming input failed: server closed the connection unexpectedly
    [协调者] 库存库 回滚出错:the connection is closed
  协调者的结论:ABORT;决定日志:ABORT
  订单库 prepared 事务数: 0 ,订单数: 0 (订单库的 prepared 事务已被协调者回滚)
  库存库重启后 prepared 事务数: 0 ,库存: 100 —— 没有投票就死了,什么都没留下,整体 ABORT

参与者在投 YES 之前死,协调者 ABORT 并回滚其它已 prepare 的参与者,干净。危险都在投了 YES 之后。

分步演示

协调者崩溃
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用两个独立的 PostgreSQL 16.15 实例和我自己写的协调者(tools/articles/tpc.py,独立进程,在代码里的指定位置 kill -9 自己);决定日志在 SQLite 里(synchronous=FULL);恢复程序是同一个文件里的 recover()
    • 没有验证:多于两个参与者;协调者崩溃后用另一个协调者实例接管(这里是同一份日志、同一个程序重启);合作终止协议(参与者之间互相问);XA 协议在 MySQL 里的行为(下一篇);长时间(分钟 / 小时级)阻塞下参与者资源的耗尽;max_prepared_transactions 用满后的表现
    京ICP备2026067502号-1京公网安备11010502063487号