Skip to content

两阶段提交原理(Two-Phase Commit) ​

单个 PostgreSQL 实例内部的事务是原子的:要么全提交,要么全回滚。但当事务要跨越两个数据库、两台服务器时,问题立刻变复杂——第一个库提交成功了,第二个库提交失败,数据就永久不一致了。

两阶段提交(Two-Phase Commit,2PC) 就是为解决这个问题而生的协议。它把「提交」拆成两步:先让所有参与方做好准备并承诺,全部就绪后再统一下达提交指令。PostgreSQL 通过 PREPARE TRANSACTION 系列命令实现它。本页讲清协议原理、协调者与参与者的角色、PostgreSQL 的 pg_prepared_xacts、XA 标准的关系,以及最危险的「悬挂事务」风险。

前置知识

阅读本节前,建议先了解:BEGIN / COMMIT / ROLLBACK、事务级顾问锁

基础概念 ​

单库事务为什么不够 ​

单个 PostgreSQL 内部的原子性由 WAL(预写式日志)保证:提交时写一条 commit 记录,崩溃恢复时据此决定前滚或回滚。

text
单库:
  BEGIN; 扣库存; 加积分; COMMIT;
  → WAL 里一条 commit,原子生效

跨库(订单库 + 库存库):
  订单库 BEGIN; INSERT order; COMMIT;   ✓
  库存库 BEGIN; UPDATE stock; COMMIT;   ✗ 网络断了
  → 订单已创建,库存没扣。数据永久不一致。

天真的「依次提交」永远无法解决这个窗口。要么引入补偿逻辑(Saga),要么让两个库在提交前先协调好——这就是 2PC。

两阶段提交的协议流程 ​

2PC 引入一个协调者(Coordinator) 角色,参与者(Participant)是被操作的那些数据库。协议分两阶段:

text
阶段一:准备(Prepare / Voting)
  协调者 → 参与者 A:PREPARE,你能否提交?
  协调者 → 参与者 B:PREPARE,你能否提交?
  参与者 A:把事务所有改动刷入 WAL,锁定资源,回复「就绪(YES)」
  参与者 B:同上,回复「就绪」
  → 此刻参与者已把「提交还是回滚」的决定权交给了协调者

阶段二:提交(Commit)
  协调者 → 参与者 A:COMMIT PREPARED
  协调者 → 参与者 B:COMMIT PREPARED
  → 参与者按指令终结事务,释放锁

关键点:一旦某个参与者回复「就绪」,它就再也无法单方面回滚(除非协调者明确下 rollback)。它必须把状态持久化到磁盘,即使随后崩溃,重启后也要能按协调者的最终决定完成事务。

三个角色,各管一段 ​

角色职责在 PostgreSQL 中是谁
应用 / 客户端发起全局事务,收集各库的本地事务业务代码、XA 事务管理器
协调者(Coordinator)决定提交或回滚,负责在崩溃后恢复决策通常是外部事务管理器(如 Narayana、Atomikos)
参与者(Participant)执行本地事务,响应 prepare/commit/rollbackPostgreSQL 本身

PostgreSQL 的定位很明确:它只做参与者,不做协调者。官方表述是这些命令「are intended for use by external transaction management systems」——即供外部事务管理系统调用。PostgreSQL 不提供「自动协调多个库」的功能,它提供的是「作为一个能被协调的参与者」的能力。

text
外部事务管理器(协调者)
   ├── PREPARE TRANSACTION 'xid-1'  → PostgreSQL 库 A(参与者)
   └── PREPARE TRANSACTION 'xid-2'  → PostgreSQL 库 B(参与者)
   ↓ 两个都回就绪
   ├── COMMIT PREPARED 'xid-1'      → 库 A
   └── COMMIT PREPARED 'xid-2'      → 库 B

三条命令 ​

text
PREPARE TRANSACTION 'gid';     -- 阶段一:让当前事务进入 prepared 状态
COMMIT PREPARED 'gid';         -- 阶段二:提交一个 prepared 事务
ROLLBACK PREPARED 'gid';       -- 阶段二:回滚一个 prepared 事务

gid 是全局事务标识符(Global Transaction Identifier),由协调者生成,必须是全局唯一的字符串。

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'tx-2026-09-28-0001';
text
PREPARE TRANSACTION

这条语句执行后,当前会话已经没有活动事务了,但数据改动并未提交——它们被「冻结」在 prepared 状态,等待最终裁决。

prepared 事务的观测窗口:pg_prepared_xacts ​

sql
SELECT * FROM pg_prepared_xacts;
text
 transaction |           gid           |           prepared            | owner | database
-------------+-------------------------+-------------------------------+-------+----------
      759104 | tx-2026-09-28-0001      | 2026-09-28 14:03:11.882+08    | app   | shop
(1 row)
字段类型含义
transactionxid内部事务 ID
gidtext全局事务标识符
preparedtimestamptz进入 prepared 状态的时刻
ownername执行 PREPARE TRANSACTION 的用户
databasename该事务所属的数据库

官方说明:条目的移除发生在提交或回滚时;访问该视图时,内部事务管理器结构会被短暂加锁。

max_prepared_transactions:默认关闭 ​

这个特性默认是关闭的:

text
max_prepared_transactions(integer)
  默认值:0
  含义:0 表示禁用 prepared 事务特性
  设置时机:只能在服务器启动时设置(需要重启)
  建议值:至少与 max_connections 相当
  复制要求:standby 上的值必须 >= primary,否则 standby 不允许执行查询

当它为 0 时,执行任何 2PC 命令都会直接失败:

sql
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'x1';
text
ERROR:  prepared transactions are disabled
HINT:  Set max_prepared_transactions to a nonzero value.

修改方式:

ini
# postgresql.conf
max_prepared_transactions = 100    # 需重启生效
sql
SHOW max_prepared_transactions;
text
 max_prepared_transactions
---------------------------
 100
(1 row)

没有外部协调者就别开启它

官方明确建议:If no external transaction manager is in use, it is recommended that max_prepared_transactions be set to 0. 因为开启它只是打开了「悬挂事务」这扇门,而没有配套的清理机制。

跨 checkpoint 时记录到 pg_twophase ​

官方实现说明:短生命周期的 prepared 事务仅存在于共享内存和 WAL 中;当一个 prepared 事务跨越了 checkpoint,它会被记录到 pg_twophase 目录下的文件中(文件名即事务 ID):

text
$PGDATA/
├── pg_wal/            ← WAL 日志
├── pg_twophase/        ← 跨 checkpoint 的 prepared 事务状态文件(文件名即事务 ID)
│   └── 00000000000B9520
└── ...

这也解释了为什么「悬挂事务」会一直阻碍 VACUUM:它的状态随时可能被恢复,涉及的资源不能被回收。

与 XA 标准的关系 ​

XA 是 X/Open 组织定义的一套分布式事务标准(如今由 Open Group 维护),它规定了事务管理器(TM)与资源管理器(RM)之间的接口。2PC 是 XA 的核心协议。

text
XA 接口            对应 PostgreSQL 命令
  xa_start         → BEGIN(开启本地事务)
  xa_end           → (结束工作,PostgreSQL 无独立语句)
  xa_prepare       → PREPARE TRANSACTION 'gid'
  xa_commit        → COMMIT PREPARED 'gid'
  xa_rollback      → ROLLBACK PREPARED 'gid'

官方原文:PostgreSQL 的 2PC 实现「follows the X/Open XA standard's features and model」,但未实现一些不常用的方面(some less commonly used aspects)。因此在与 Java 的 JTA(Java Transaction API)、Spring 的 JtaTransactionManager、Narayana/Atomikos 等集成时,需要按具体实现的兼容性约定来配置。

悬挂事务:2PC 最危险的副作用 ​

悬挂事务(In-doubt Transaction / Orphaned Prepared Transaction) 指的是:一个事务已经 prepared,但协调者再也没有下达 commit 或 rollback 指令(协调者崩溃、网络中断、应用逻辑 bug 等)。

后果有多严重?官方的 Caution 段落措辞非常重:

It is unwise to leave transactions in the prepared state for a long time. This will interfere with the ability of VACUUM to reclaim storage, and in extreme cases could cause the database to shut down to prevent transaction ID wraparound... Also, a prepared transaction continues to hold whatever locks it held before it was prepared.

拆开看,四个后果:

text
1. 阻碍 VACUUM 回收存储 → 表膨胀
2. 极端情况可能因事务 ID 回绕(wraparound)导致数据库关闭
3. 继续持有它此前获取的所有锁 → 阻塞其他会话
4. 一直占用锁槽位与 max_prepared_transactions 名额

验证「继续持锁」这一点:

sql
-- 会话 A
BEGIN;
SELECT pg_advisory_xact_lock(42);
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'hold-lock-demo';
sql
-- 任意会话
SELECT locktype, pid, granted FROM pg_locks
WHERE locktype = 'advisory'
  AND ((classid::bigint << 32) | objid::bigint) = 42;
text
 locktype | pid  | granted
----------+------+---------
 advisory |      | t
(1 row)

注意 pid 是空的——锁由 prepared 事务持有,不属于任何活跃会话。官方说明:prepared 事务的锁 pid 为 null;prepared 事务从不等待锁,但会继续持有已获得的锁。

2PC 与普通事务的语义差异 ​

维度普通事务prepared 事务
由谁决定提交发起事务的会话任意会话(需权限),协调者决定
状态可见性pg_stat_activity 可见pg_prepared_xacts 可见
崩溃后WAL 恢复决定前滚/回滚保持 prepared,等协调者指令
是否持锁是是(继续持有)
是否阻碍 VACUUM仅未提交期间是(可能长期)
能否回滚可以只能靠 ROLLBACK PREPARED
超时自动清理idle_in_transaction_session_timeout没有任何自动超时

最后一行尤其要记住:PostgreSQL 不会自动清理悬挂的 prepared 事务。没有超时、没有 GC,必须由人(或脚本)处理。

语法与用法 ​

三条命令的完整语法 ​

text
PREPARE TRANSACTION transaction_id

COMMIT PREPARED transaction_id

ROLLBACK PREPARED transaction_id

transaction_id 是字符串字面量,官方限制:

text
- 必须是字符串字面量(不能是表达式或变量直接拼接,需在应用层生成好)
- 长度必须小于 200 字节
- 不能与任何当前打开的 prepared 事务的标识符相同

最小可运行示例 ​

sql
-- 阶段一
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'demo-0001';
text
BEGIN
UPDATE 1
PREPARE TRANSACTION
sql
-- 此刻锁已释放,数据未提交
SELECT * FROM pg_prepared_xacts;
text
 transaction |    gid     |           prepared            | owner | database
-------------+------------+-------------------------------+-------+----------
      759112 | demo-0001  | 2026-09-28 14:10:22.115+08    | app   | shop
(1 row)
sql
-- 阶段二:在「任意会话」提交
COMMIT PREPARED 'demo-0001';
text
COMMIT PREPARED
sql
-- 确认已清理
SELECT count(*) FROM pg_prepared_xacts;
text
 count
-------
     0
(1 row)

从 pg_locks 观察 prepared 事务的锁 ​

prepared 事务的锁不属于任何会话,pg_locks.pid 为空,需要借助 virtualtransaction 与 pg_prepared_xacts.transaction 关联:

sql
SELECT l.locktype, l.relation::regclass AS relation, l.mode, l.pid, l.virtualtransaction
FROM pg_locks l
WHERE l.pid IS NULL
ORDER BY l.locktype, relation;
text
 locktype |   relation   |     mode      | pid | virtualtransaction
----------+--------------+---------------+-----+--------------------
 advisory |              | ExclusiveLock |     | -1/759112
 relation | accounts     | RowExclusiveLock |   | -1/759112
 transactionid|             | ExclusiveLock |     | -1/759112
(3 rows)

官方给出的关联方式是把 pg_locks.virtualtransaction 与 pg_prepared_xacts.transaction 拼接:

sql
SELECT * FROM pg_locks pl
LEFT JOIN pg_prepared_xacts ppx
  ON pl.virtualtransaction = '-1/' || ppx.transaction;

详细说明 ​

阶段一到底做了什么 ​

PREPARE TRANSACTION 不是「暂存」,而是把事务的所有效果持久化到磁盘并宣告不可撤销:

text
1. 把事务涉及的所有改动(含 XID 分配)刷入 WAL 并 fsync
2. 记录一个两阶段状态记录,写入 pg_twophase(若跨 checkpoint)
3. 释放「会话级」身份——事务不再属于任何会话
4. 保持所有行锁、表锁、顾问锁(继续持有)
5. 从发起会话的视角看,效果类似于 ROLLBACK(事务不在它的会话里了)

官方对第 5 点的描述:From the point of view of the issuing session, PREPARE TRANSACTION is not so much different from a ROLLBACK in terms of its effects.

验证「发起会话已无活动事务」:

sql
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'demo-0002';
SELECT txid_current_if_assigned() IS NULL AS no_active_xact;
text
 no_active_xact
----------------
 t
(1 row)

失败时自动变成 ROLLBACK ​

官方明确:如果 PREPARE TRANSACTION 执行失败,事务会被回滚:

If the PREPARE TRANSACTION command fails, the transaction will be rolled back.

sql
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
SELECT 1/0;                    -- 事务已 aborted
PREPARE TRANSACTION 'demo-0003';
text
ERROR:  current transaction is aborted, commands ignored until end of transaction block

此时不会有 prepared 事务产生,直接:

sql
ROLLBACK;

哪些事务与对象不能 prepared ​

除「必须对数据库产生实际改动」外,官方还列出一串明确禁止的情形:

text
不能被 PREPARE 的事务:
  1. 只读事务(READ ONLY)——没有写入,不需要参与 2PC
  2. 涉及临时表(temporary tables)或临时命名空间(pg_temp_n)
  3. 使用了 WITH HOLD 的游标(cursors declared WITH HOLD)
  4. 执行过 LISTEN / UNLISTEN / NOTIFY

原因:后三类对象或状态都与「会话」强绑定,
     而 prepared 事务被设计成脱离会话存在,语义冲突。
sql
BEGIN READ ONLY;
SELECT count(*) FROM accounts;
PREPARE TRANSACTION 'demo-ro';
text
ERROR:  cannot PREPARE a read-only transaction
sql
CREATE TEMP TABLE t_tmp(x int);
BEGIN;
INSERT INTO t_tmp VALUES (1);
PREPARE TRANSACTION 'demo-temp';
text
ERROR:  cannot PREPARE a transaction that has operated on temporary objects
sql
ROLLBACK;
DROP TABLE t_tmp;

实践中的应对很直接:只读参与者不参与 2PC(无副作用、无需原子提交);临时表、WITH HOLD 游标、LISTEN 相关操作则必须移到 2PC 范围之外执行。

还有一个极易踩的坑:PREPARE TRANSACTION 必须在事务块内执行,但它终结了那个事务块,之后不能再往里加语句:

sql
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'demo-0004';
UPDATE accounts SET balance = balance + 1 WHERE id = 2;   -- 已是全新的隐式事务!
text
BEGIN
UPDATE 1
PREPARE TRANSACTION
UPDATE 1

最后那条 UPDATE 与 prepared 事务毫无关系,它自己独立提交了。正确姿势是让 PREPARE TRANSACTION 成为事务块的最后一条语句。

SET(非 LOCAL)的持久化陷阱 ​

官方指出:使用 SET(而非 SET LOCAL)修改的运行时会话参数,在 PREPARE TRANSACTION 之后仍然保持:

The effects of SET (without LOCAL) ... will persist across PREPARE TRANSACTION.

sql
BEGIN;
SET search_path = special_schema;      -- 注意:非 LOCAL
UPDATE orders SET status = 'x' WHERE id = 1;
PREPARE TRANSACTION 'demo-set';
sql
-- prepared 事务已产生,但 search_path 的修改仍留在会话上!
SHOW search_path;
text
    search_path
------------------
 special_schema
(1 row)

这是一种「会话状态污染」:事务走了,GUC 留下了。正确处理是优先用 SET LOCAL,或在 prepare 之前显式 RESET。

跨库提交的限制:必须回到原库 ​

COMMIT PREPARED / ROLLBACK PREPARED 可以在任意会话执行,不必是原会话。官方说明:它 must be issued by the original user or a superuser, but need not be in the same session。

但有一条硬性限制:必须连接到 prepare 时所在的那个数据库。

sql
-- 在 db_a 里 prepare 了 'x1'
-- 连接到 db_b 后执行:
COMMIT PREPARED 'x1';
text
ERROR:  prepared transaction belongs to another database
HINT:  Connect to the database where the transaction was prepared to finish it.

这个错误来自 PostgreSQL 源码的 LockGXact(src/backend/access/transam/twophase.c)。提示语已经把解法写清了:回到原库。这给运维带来实际约束——清理悬挂事务时必须逐个库去查、去清。

权限要求 ​

text
COMMIT PREPARED / ROLLBACK PREPARED:
  - 原执行 PREPARE 的用户,或
  - 超级用户
sql
-- 以另一普通用户尝试收尾别人的 prepared 事务
COMMIT PREPARED 'demo-0005';
text
ERROR:  permission denied to finish prepared transaction
HINT:  Must be superuser or the user that prepared the transaction.

其他相关错误:

text
prepared transactions are disabled         ← max_prepared_transactions = 0
transaction identifier 'xxx' is already in use   ← gid 重复
prepared transaction with identifier 'xxx' is busy ← 该 gid 正在被处理

悬挂事务的检测与清理 ​

检测:

sql
SELECT
    gid,
    prepared,
    now() - prepared AS age,
    owner,
    database
FROM pg_prepared_xacts
ORDER BY prepared;
text
     gid      |           prepared            |     age      | owner | database
--------------+-------------------------------+--------------+-------+----------
 tx-8f31a2     | 2026-09-28 09:12:03.771+08    | 05:01:22.331 | app   | shop
(1 row)

清理(两种方式):

sql
-- 方式一:提交(仅当你确定全局事务应当成功)
COMMIT PREPARED 'tx-8f31a2';
sql
-- 方式二:回滚(当你不确定时,回滚更安全)
ROLLBACK PREPARED 'tx-8f31a2';

谁还有权决定?

一旦协调者失联,没有人知道这个事务到底该提交还是该回滚。这正是 2PC 的「阻塞问题(Blocking Problem)」:参与者无法单方面脱身。实践中的选择取决于业务:通常偏向 ROLLBACK PREPARED(保证不留半成品),但如果有其他参与者已经提交,回滚会导致不一致——所以必须结合其他参与者的状态来判断。

阻塞问题与崩溃恢复 ​

2PC 最被诟病的一点是它是阻塞式(blocking)协议:

text
参与者 A:已 prepare,等待协调者
参与者 B:已 prepare,等待协调者
协调者:崩溃了
   ↓
A 与 B 都无法前进:
  - 不能自己提交(可能 B 已经回滚)
  - 不能自己回滚(可能 B 已经提交)
  - 只能一直持有锁,直到有人介入

这正是分布式系统更倾向 Saga / TCC / Outbox 这类「最终一致 + 补偿」方案,而非无脑上 2PC 的原因:2PC 提供强一致,代价是可用性与运维复杂度。

崩溃恢复后的状态也印证了这一点:

text
participant 崩溃重启:
  → pg_prepared_xacts 中的条目依然存在(已持久化)
  → 相关的锁重新被持有
  → 仍在等待协调者的 COMMIT/ROLLBACK PREPARED

coordinator 崩溃重启:
  → 有责任恢复自己的决策日志,重新向参与者下发指令
  → 如果决策日志也丢了 → 悬挂事务

所以协调者的持久化至关重要:2PC 把「原子性的责任」从数据库转移到了协调者身上。协调者不可靠,2PC 就不成立。

逻辑复制与 2PC ​

PostgreSQL 的逻辑复制默认不理解 2PC 命令:

text
基础回调(base callbacks)下:
  PREPARE TRANSACTION  → 被忽略
  COMMIT PREPARED      → 解码为普通 COMMIT
  ROLLBACK PREPARED    → 解码为普通 ROLLBACK

要正确解码 prepared 事务,输出插件需要实现额外的回调:begin_prepare_cb、prepare_cb、commit_prepared_cb、rollback_prepared_cb、stream_prepare_cb,以及可选的 filter_prepare_cb。

另外官方提示一个隐蔽风险:如果 prepared 事务排他锁定了用户目录表(user catalog tables),解码 prepare 可能阻塞到主事务提交,甚至可能死锁。

实战示例 ​

完整演示:两库转账(手工模拟协调者) ​

假设两个数据库 bank_a 和 bank_b,各有一张 accounts 表。

准备环境:

bash
psql -d bank_a -c "ALTER SYSTEM SET max_prepared_transactions = 100;"
psql -d bank_b -c "ALTER SYSTEM SET max_prepared_transactions = 100;"
pg_ctl restart -D $PGDATA
sql
-- 两个库各自准备
CREATE TABLE IF NOT EXISTS accounts (
    id      integer PRIMARY KEY,
    name    text NOT NULL,
    balance numeric(14,2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts(id, name, balance) VALUES (1, 'alice', 1000.00)
ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance;

阶段一:两个库分别 prepare。

sql
-- 库 bank_a:扣款
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-2026-09-28-0001-A';
text
BEGIN
UPDATE 1
PREPARE TRANSACTION
sql
-- 库 bank_b:入账
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-2026-09-28-0001-B';
text
BEGIN
UPDATE 1
PREPARE TRANSACTION

检查两边都就绪。

sql
-- 在 bank_a
SELECT gid, prepared, database FROM pg_prepared_xacts;
text
           gid            |           prepared            | database
--------------------------+-------------------------------+----------
 xfer-2026-09-28-0001-A   | 2026-09-28 14:31:07.201+08    | bank_a
(1 row)
sql
-- 在 bank_b
SELECT gid, prepared, database FROM pg_prepared_xacts;
text
           gid            |           prepared            | database
--------------------------+-------------------------------+----------
 xfer-2026-09-28-0001-B   | 2026-09-28 14:31:07.882+08    | bank_b
(1 row)

验证结果。

sql
-- 在 bank_a 与 bank_b 分别执行
SELECT id, balance FROM accounts WHERE id = 1;
text
 id | balance
----+---------
  1 |  900.00     ← bank_a:扣款后的余额
(1 row)

失败路径。 如果 bank_b 的 prepare 阶段出错(例如违反 CHECK 约束):

sql
-- bank_b
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
UPDATE accounts SET balance = -1 WHERE id = 1;    -- 违反 CHECK
text
ERROR:  new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL:  Failing row contains (1, alice, -1.00).
sql
ROLLBACK;   -- bank_b 尚未 prepare,普通回滚即可

此时协调者必须决定整体回滚,于是通知已经 prepare 的 bank_a:

sql
-- bank_a
ROLLBACK PREPARED 'xfer-fail-0001-A';
text
ROLLBACK PREPARED
sql
-- 确认两边都干净
SELECT count(*) FROM pg_prepared_xacts;
text
 count
-------
     0
(1 row)

这正是 2PC 的价值:只要有一个参与者 prepare 失败,已经 prepare 的其他参与者还能被安全回滚,全局不会留下半成品。

演示 prepared 事务继续持锁并阻塞他人 ​

sql
-- 会话 A
BEGIN;
SELECT pg_advisory_xact_lock(777);
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'lock-demo';
sql
-- 会话 B:尝试拿同一把顾问锁 → 被阻塞
SET lock_timeout = '2s';
SELECT pg_try_advisory_lock(777);
text
 pg_try_advisory_lock
----------------------
 f
(1 row)
sql
-- 会话 B:查看持有者(pid 为空,说明属于 prepared 事务)
SELECT pid, granted, virtualtransaction
FROM pg_locks
WHERE locktype = 'advisory'
  AND ((classid::bigint << 32) | objid::bigint) = 777;
text
 pid | granted | virtualtransaction
-----+---------+--------------------
     | t       | -1/759140
(1 row)
sql
-- 用 virtualtransaction 关联回 prepared 事务
SELECT ppx.gid, ppx.prepared, ppx.owner
FROM pg_prepared_xacts ppx
WHERE '-1/' || ppx.transaction = '-1/759140';
text
    gid    |           prepared            | owner
-----------+-------------------------------+-------
 lock-demo | 2026-09-28 14:40:02.554+08    | app
(1 row)
sql
-- 收尾
ROLLBACK PREPARED 'lock-demo';

运维脚本:汇总全部库的悬挂事务 ​

prepared 事务归属单个数据库,所以无法用一条连接看全,必须逐库遍历:

bash
#!/usr/bin/env bash
# 遍历所有数据库,列出 age 超过 5 分钟的 prepared 事务
for db in $(psql -At -c "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate"); do
  echo "=== $db ==="
  psql -d "$db" -c "
    SELECT gid, owner, prepared, now() - prepared AS age
    FROM pg_prepared_xacts
    WHERE now() - prepared > interval '5 minutes'
    ORDER BY prepared;"
done
text
=== shop ===
           gid            | owner |           prepared            |     age
--------------------------+-------+-------------------------------+--------------
 tx-8f31a2                | app   | 2026-09-28 09:12:03.771+08    | 05:31:22.331
(1 row)

=== bank_a ===
 gid | owner | prepared | age
-----+-------+----------+-----
(0 rows)

评估影响面时,还要看每个悬挂事务卡住了多少锁对象:

sql
SELECT
    ppx.gid,
    ppx.owner,
    ppx.database,
    now() - ppx.prepared AS age,
    count(l.*) AS held_locks
FROM pg_prepared_xacts ppx
LEFT JOIN pg_locks l
  ON l.virtualtransaction = '-1/' || ppx.transaction
GROUP BY ppx.gid, ppx.owner, ppx.database, ppx.prepared
ORDER BY age DESC;
text
     gid      | owner | database |           prepared            |     age      | held_locks
--------------+-------+----------+-------------------------------+--------------+------------
 tx-8f31a2     | app   | shop     | 2026-09-28 09:12:03.771+08    | 05:31:22.331 |         14
(1 row)

held_locks = 14 意味着这个悬挂事务正卡着 14 个锁对象——可能是整张表的行锁。

注意事项 ​

  • 错误!在 max_prepared_transactions = 0(默认)时使用 2PC。 任何 PREPARE TRANSACTION 都会报 prepared transactions are disabled。
  • 错误!长期保留 prepared 事务。 官方明确警告:会阻碍 VACUUM 回收存储,极端情况下可能因事务 ID 回绕导致数据库关闭,并持续持有锁。
  • 错误!在 bank_b 尝试 COMMIT PREPARED 一个在 bank_a prepare 的事务。 会报 prepared transaction belongs to another database,必须回到原库。
  • 错误!以为 prepared 事务会自动超时清理。 PostgreSQL 不提供任何自动清理机制,悬挂事务只能人工处理。
  • 错误!在包含临时表、WITH HOLD 游标、LISTEN/NOTIFY 的事务上 prepare。 这些都会被直接拒绝。
  • 不推荐在只有单个数据库、没有外部事务管理器的环境里开启 2PC。官方建议保持 max_prepared_transactions = 0。
  • 错误!在 PREPARE TRANSACTION 之后继续往同一逻辑单元里写语句。 那条语句属于新事务,会造成难以察觉的逻辑错乱。
  • 错误!用 SET(非 LOCAL)修改 GUC 后 prepare。 该设置会残留在会话上,污染后续语句;改用 SET LOCAL。
  • 不推荐让协调者没有持久化决策日志就使用 2PC。协调者一崩,参与者就永久悬挂。

最佳实践 ​

  • 明确协调者:只在有专门事务管理器(Narayana、Atomikos、Seata 等)时启用 2PC;否则优先 Saga/Outbox。
  • 参数从严:max_prepared_transactions 按需设置,且 standby 上必须 ≥ primary。
  • gid 全局唯一且有含义:形如 业务-日期-序号-参与者,便于排障与人工清理。
  • prepared 窗口尽量短:prepare 与最终 commit/rollback 之间不要插入任何耗时操作。
  • 监控悬挂事务:对 pg_prepared_xacts 中 age 超过阈值的条目告警,并统计其持有的锁数量。
  • 清理前先查全局状态:决定提交还是回滚前,必须确认其他参与者与协调者的状态,切忌盲目回滚。
  • 优先 ROLLBACK PREPARED:不确定时回滚比提交更安全(不会留下「只做了一半」的数据),但要评估一致性影响。
  • 善用 virtualtransaction 关联:排查锁问题时,用 '-1/' || transaction 把锁和 prepared 事务对应起来。

下一节

继续学习:PREPARE TRANSACTION / COMMIT PREPARED——完整语法、限制清单、悬挂事务清理流程与两库转账的逐步实操。

参考链接 ​