bigywater.tech
分布式事务 · 2

两阶段提交:准备与提交阶段的阻塞

两阶段提交(2PC)把“多个库一起提交”拆成投票和执行两步。它到底保证了什么、prepare 之后事务处在什么状态、代价是多少?这篇用真实的 PostgreSQL PREPARE TRANSACTION 把每一步都跑出来,并量出它在延迟、fsync 次数和热点行吞吐上的账。

4 次 vs 1 次
每个事务的 WAL fsync 次数:跨两个实例做 2PC 对比单实例
6.9 倍
2PC 对比单实例的提交延迟中位数(1.1 ms 对 0.2 ms)
5 事务/秒
prepare 之后多等 200 ms 时,两个线程抢同一行的 2PC 吞吐(不等时是 307)

阶段一与阶段二

实测 · 本机执行输出
  初始:订单库 0 条订单,库存库 sku-1=100
  订单库  BEGIN; INSERT INTO orders ...
  库存库  BEGIN; UPDATE stock SET qty = qty - 1 ...
  订单库  PREPARE TRANSACTION 'tx-1' -> PREPARE TRANSACTION
  库存库  PREPARE TRANSACTION 'tx-1' -> PREPARE TRANSACTION
  订单库 pg_prepared_xacts: [('tx-1', 'postgres', 'postgres')]
  库存库 pg_prepared_xacts: [('tx-1', 'postgres', 'postgres')]
  prepare 之后别的会话看到:订单库 0 条订单,库存库 sku-1=100  (两个修改都还不可见)
  订单库  COMMIT PREPARED 'tx-1' -> COMMIT PREPARED
  库存库  COMMIT PREPARED 'tx-1' -> COMMIT PREPARED
  提交后:订单库 1 条订单,库存库 sku-1=99;pg_prepared_xacts 里剩 0 / 0

阶段一每个参与者执行修改后 PREPARE,把“我能提交”的承诺写进日志;全部 YES 后,协调者在阶段二让大家 COMMIT PREPARED。prepare 之后修改不可见,提交后才可见。

prepared 事务脱离连接

实测 · 本机执行输出
  协调者的连接已关闭。pg_prepared_xacts: [('tx-2', 'postgres')]
  pg_locks 里属于它的锁(pid 为空 = 没有会话持有):
    ('relation', 'RowExclusiveLock', True, True, 'stock')
    ('relation', 'RowExclusiveLock', True, True, 'stock_pkey')
    ('transactionid', 'ExclusiveLock', True, True, '-')
  别的会话 SELECT:读到 qty=100(旧值),用时 4.3 ms,没有被挡
  别的会话 UPDATE 同一行(lock_timeout=1500ms):LockNotAvailable canceling statement due to lock timeout,等了 1502.0 ms
  另一个会话的 UPDATE 阻塞着,1.2 秒后协调者 COMMIT PREPARED 'tx-2':它等了 1206.4 ms 后返回;最终 qty=94(100 - 1 - 5)

协调者的连接断了,prepared 事务和它的行锁仍在。读(MVCC)不被挡,写同一行被挡到有人提交或回滚。

崩溃后还在

实测 · 本机执行输出
  kill -9 postmaster 和全部子进程
  重启用时 129.4 ms
  重启后 pg_prepared_xacts: [('tx-3',)]
  重启后别的会话 UPDATE 同一行:LockNotAvailable canceling statement due to lock timeout(行锁被恢复了)
  COMMIT PREPARED 'tx-3' -> COMMIT PREPARED
  提交后 qty=99
  PostgreSQL 日志里和恢复有关的行:
    LOG:  database system was not properly shut down; automatic recovery in progress
    LOG:  recovering prepared transaction 739 from shared memory
    LOG:  checkpoint starting: end-of-recovery immediate wait
    LOG:  1 two-phase state file was written for a long-running prepared transaction

参与者在 PREPARE 时把恢复所需信息写进 WAL。整个实例 kill -9 重启后,prepared 事务和它的锁都被恢复。它承诺过的事,不会因为崩溃而反悔。

代价:fsync 与延迟

实测 · 本机执行输出
  一个实例里一个事务:中位数 0.2 ms,p99 0.5 ms;两个实例合计 WAL fsync 300 次 = 每个事务 1.00 次
  两个实例做 2PC:  中位数 1.1 ms,p99 3.0 ms;两个实例合计 WAL fsync 1200 次 = 每个事务 4.00 次
  2PC / 单实例:中位数 6.9 倍;fsync 4.0 倍(协调者自己写决定日志的 fsync 不在这里面,它写在 SQLite 里)

两个参与者各一次 prepare、一次 commit prepared,每个事务 4 次 WAL fsync,是单实例的 4 倍;提交延迟中位数 6.9 倍。协调者的决定日志还要再算一次落盘。

持锁时间决定热点行吞吐

实测 · 本机执行输出
  prepare 之后多等 | 两线程抢同一行 (事务/秒) | 估算 1000/(hold+3.3 ms) | 两线程各用各的行 (事务/秒) | 各自的行 / 同一行
  -------------+----------------+-----------------------+-----------------+-----------
  0 ms         | 307            | 307                   | 1094            | 3.6       
  50 ms        | 18             | 19                    | 35              | 2.0       
  200 ms       | 5              | 5                     | 10              | 2.0       
  同一行上的事务是一个接一个排队的:每个事务占着行锁的时间 = 2PC 自己的耗时 + hold;不同行的事务互不影响,仍然能并行

行锁从 prepare 一直握到 commit prepared。同一行上的事务只能排队,吞吐约等于 1000/(持锁时间)。协调者慢一点,热点行的吞吐就按比例塌下去。

分步演示

两阶段提交
本步的对象

    本文用到的工具与边界

    • 本机 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 实例(max_prepared_transactions=20,wal_sync_method=fsync 以便数 pg_stat_wal.wal_sync),协调者是我自己写的 Python 代码(tools/articles/tpc.py),决定日志在 SQLite 里;PostgreSQL 在 macOS 上的 fsync 不等于写透磁盘缓存,绝对时间偏小,倍数更有参考价值
    • 没有验证:协调者崩溃时参与者怎么办(下一篇做);多于两个参与者时的扩展;跨机器网络下的往返延迟;MySQL 的 XA 实现(第 5 篇);PostgreSQL 以外数据库的 prepare 行为;本机 PostgreSQL 的 wal_sync_method 默认是 open_datasync(查过),按文档 pg_stat_wal.wal_sync 只在 fsync / fdatasync 方式下计数,所以我改成了 fsync;默认方式下每个事务的刷盘次数没有测
    京ICP备2026067502号-1京公网安备11010502063487号