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