两阶段提交原理(Two-Phase Commit)
单个 PostgreSQL 实例内部的事务是原子的:要么全提交,要么全回滚。但当事务要跨越两个数据库、两台服务器时,问题立刻变复杂——第一个库提交成功了,第二个库提交失败,数据就永久不一致了。
两阶段提交(Two-Phase Commit,2PC) 就是为解决这个问题而生的协议。它把「提交」拆成两步:先让所有参与方做好准备并承诺,全部就绪后再统一下达提交指令。PostgreSQL 通过 PREPARE TRANSACTION 系列命令实现它。本页讲清协议原理、协调者与参与者的角色、PostgreSQL 的 pg_prepared_xacts、XA 标准的关系,以及最危险的「悬挂事务」风险。
前置知识
阅读本节前,建议先了解:BEGIN / COMMIT / ROLLBACK、事务级顾问锁
基础概念
单库事务为什么不够
单个 PostgreSQL 内部的原子性由 WAL(预写式日志)保证:提交时写一条 commit 记录,崩溃恢复时据此决定前滚或回滚。
单库:
BEGIN; 扣库存; 加积分; COMMIT;
→ WAL 里一条 commit,原子生效
跨库(订单库 + 库存库):
订单库 BEGIN; INSERT order; COMMIT; ✓
库存库 BEGIN; UPDATE stock; COMMIT; ✗ 网络断了
→ 订单已创建,库存没扣。数据永久不一致。天真的「依次提交」永远无法解决这个窗口。要么引入补偿逻辑(Saga),要么让两个库在提交前先协调好——这就是 2PC。
两阶段提交的协议流程
2PC 引入一个协调者(Coordinator) 角色,参与者(Participant)是被操作的那些数据库。协议分两阶段:
阶段一:准备(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/rollback | PostgreSQL 本身 |
PostgreSQL 的定位很明确:它只做参与者,不做协调者。官方表述是这些命令「are intended for use by external transaction management systems」——即供外部事务管理系统调用。PostgreSQL 不提供「自动协调多个库」的功能,它提供的是「作为一个能被协调的参与者」的能力。
外部事务管理器(协调者)
├── PREPARE TRANSACTION 'xid-1' → PostgreSQL 库 A(参与者)
└── PREPARE TRANSACTION 'xid-2' → PostgreSQL 库 B(参与者)
↓ 两个都回就绪
├── COMMIT PREPARED 'xid-1' → 库 A
└── COMMIT PREPARED 'xid-2' → 库 B三条命令
PREPARE TRANSACTION 'gid'; -- 阶段一:让当前事务进入 prepared 状态
COMMIT PREPARED 'gid'; -- 阶段二:提交一个 prepared 事务
ROLLBACK PREPARED 'gid'; -- 阶段二:回滚一个 prepared 事务gid 是全局事务标识符(Global Transaction Identifier),由协调者生成,必须是全局唯一的字符串。
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'tx-2026-09-28-0001';PREPARE TRANSACTION这条语句执行后,当前会话已经没有活动事务了,但数据改动并未提交——它们被「冻结」在 prepared 状态,等待最终裁决。
prepared 事务的观测窗口:pg_prepared_xacts
SELECT * FROM pg_prepared_xacts; transaction | gid | prepared | owner | database
-------------+-------------------------+-------------------------------+-------+----------
759104 | tx-2026-09-28-0001 | 2026-09-28 14:03:11.882+08 | app | shop
(1 row)| 字段 | 类型 | 含义 |
|---|---|---|
transaction | xid | 内部事务 ID |
gid | text | 全局事务标识符 |
prepared | timestamptz | 进入 prepared 状态的时刻 |
owner | name | 执行 PREPARE TRANSACTION 的用户 |
database | name | 该事务所属的数据库 |
官方说明:条目的移除发生在提交或回滚时;访问该视图时,内部事务管理器结构会被短暂加锁。
max_prepared_transactions:默认关闭
这个特性默认是关闭的:
max_prepared_transactions(integer)
默认值:0
含义:0 表示禁用 prepared 事务特性
设置时机:只能在服务器启动时设置(需要重启)
建议值:至少与 max_connections 相当
复制要求:standby 上的值必须 >= primary,否则 standby 不允许执行查询当它为 0 时,执行任何 2PC 命令都会直接失败:
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'x1';ERROR: prepared transactions are disabled
HINT: Set max_prepared_transactions to a nonzero value.修改方式:
# postgresql.conf
max_prepared_transactions = 100 # 需重启生效SHOW max_prepared_transactions; 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):
$PGDATA/
├── pg_wal/ ← WAL 日志
├── pg_twophase/ ← 跨 checkpoint 的 prepared 事务状态文件(文件名即事务 ID)
│ └── 00000000000B9520
└── ...这也解释了为什么「悬挂事务」会一直阻碍 VACUUM:它的状态随时可能被恢复,涉及的资源不能被回收。
与 XA 标准的关系
XA 是 X/Open 组织定义的一套分布式事务标准(如今由 Open Group 维护),它规定了事务管理器(TM)与资源管理器(RM)之间的接口。2PC 是 XA 的核心协议。
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.
拆开看,四个后果:
1. 阻碍 VACUUM 回收存储 → 表膨胀
2. 极端情况可能因事务 ID 回绕(wraparound)导致数据库关闭
3. 继续持有它此前获取的所有锁 → 阻塞其他会话
4. 一直占用锁槽位与 max_prepared_transactions 名额验证「继续持锁」这一点:
-- 会话 A
BEGIN;
SELECT pg_advisory_xact_lock(42);
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'hold-lock-demo';-- 任意会话
SELECT locktype, pid, granted FROM pg_locks
WHERE locktype = 'advisory'
AND ((classid::bigint << 32) | objid::bigint) = 42; 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,必须由人(或脚本)处理。
语法与用法
三条命令的完整语法
PREPARE TRANSACTION transaction_id
COMMIT PREPARED transaction_id
ROLLBACK PREPARED transaction_idtransaction_id 是字符串字面量,官方限制:
- 必须是字符串字面量(不能是表达式或变量直接拼接,需在应用层生成好)
- 长度必须小于 200 字节
- 不能与任何当前打开的 prepared 事务的标识符相同最小可运行示例
-- 阶段一
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'demo-0001';BEGIN
UPDATE 1
PREPARE TRANSACTION-- 此刻锁已释放,数据未提交
SELECT * FROM pg_prepared_xacts; transaction | gid | prepared | owner | database
-------------+------------+-------------------------------+-------+----------
759112 | demo-0001 | 2026-09-28 14:10:22.115+08 | app | shop
(1 row)-- 阶段二:在「任意会话」提交
COMMIT PREPARED 'demo-0001';COMMIT PREPARED-- 确认已清理
SELECT count(*) FROM pg_prepared_xacts; count
-------
0
(1 row)从 pg_locks 观察 prepared 事务的锁
prepared 事务的锁不属于任何会话,pg_locks.pid 为空,需要借助 virtualtransaction 与 pg_prepared_xacts.transaction 关联:
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; 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 拼接:
SELECT * FROM pg_locks pl
LEFT JOIN pg_prepared_xacts ppx
ON pl.virtualtransaction = '-1/' || ppx.transaction;详细说明
阶段一到底做了什么
PREPARE TRANSACTION 不是「暂存」,而是把事务的所有效果持久化到磁盘并宣告不可撤销:
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.
验证「发起会话已无活动事务」:
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; no_active_xact
----------------
t
(1 row)失败时自动变成 ROLLBACK
官方明确:如果 PREPARE TRANSACTION 执行失败,事务会被回滚:
If the PREPARE TRANSACTION command fails, the transaction will be rolled back.
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
SELECT 1/0; -- 事务已 aborted
PREPARE TRANSACTION 'demo-0003';ERROR: current transaction is aborted, commands ignored until end of transaction block此时不会有 prepared 事务产生,直接:
ROLLBACK;哪些事务与对象不能 prepared
除「必须对数据库产生实际改动」外,官方还列出一串明确禁止的情形:
不能被 PREPARE 的事务:
1. 只读事务(READ ONLY)——没有写入,不需要参与 2PC
2. 涉及临时表(temporary tables)或临时命名空间(pg_temp_n)
3. 使用了 WITH HOLD 的游标(cursors declared WITH HOLD)
4. 执行过 LISTEN / UNLISTEN / NOTIFY
原因:后三类对象或状态都与「会话」强绑定,
而 prepared 事务被设计成脱离会话存在,语义冲突。BEGIN READ ONLY;
SELECT count(*) FROM accounts;
PREPARE TRANSACTION 'demo-ro';ERROR: cannot PREPARE a read-only transactionCREATE TEMP TABLE t_tmp(x int);
BEGIN;
INSERT INTO t_tmp VALUES (1);
PREPARE TRANSACTION 'demo-temp';ERROR: cannot PREPARE a transaction that has operated on temporary objectsROLLBACK;
DROP TABLE t_tmp;实践中的应对很直接:只读参与者不参与 2PC(无副作用、无需原子提交);临时表、WITH HOLD 游标、LISTEN 相关操作则必须移到 2PC 范围之外执行。
还有一个极易踩的坑:PREPARE TRANSACTION 必须在事务块内执行,但它终结了那个事务块,之后不能再往里加语句:
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'demo-0004';
UPDATE accounts SET balance = balance + 1 WHERE id = 2; -- 已是全新的隐式事务!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.
BEGIN;
SET search_path = special_schema; -- 注意:非 LOCAL
UPDATE orders SET status = 'x' WHERE id = 1;
PREPARE TRANSACTION 'demo-set';-- prepared 事务已产生,但 search_path 的修改仍留在会话上!
SHOW search_path; 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 时所在的那个数据库。
-- 在 db_a 里 prepare 了 'x1'
-- 连接到 db_b 后执行:
COMMIT PREPARED 'x1';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)。提示语已经把解法写清了:回到原库。这给运维带来实际约束——清理悬挂事务时必须逐个库去查、去清。
权限要求
COMMIT PREPARED / ROLLBACK PREPARED:
- 原执行 PREPARE 的用户,或
- 超级用户-- 以另一普通用户尝试收尾别人的 prepared 事务
COMMIT PREPARED 'demo-0005';ERROR: permission denied to finish prepared transaction
HINT: Must be superuser or the user that prepared the transaction.其他相关错误:
prepared transactions are disabled ← max_prepared_transactions = 0
transaction identifier 'xxx' is already in use ← gid 重复
prepared transaction with identifier 'xxx' is busy ← 该 gid 正在被处理悬挂事务的检测与清理
检测:
SELECT
gid,
prepared,
now() - prepared AS age,
owner,
database
FROM pg_prepared_xacts
ORDER BY prepared; gid | prepared | age | owner | database
--------------+-------------------------------+--------------+-------+----------
tx-8f31a2 | 2026-09-28 09:12:03.771+08 | 05:01:22.331 | app | shop
(1 row)清理(两种方式):
-- 方式一:提交(仅当你确定全局事务应当成功)
COMMIT PREPARED 'tx-8f31a2';-- 方式二:回滚(当你不确定时,回滚更安全)
ROLLBACK PREPARED 'tx-8f31a2';谁还有权决定?
一旦协调者失联,没有人知道这个事务到底该提交还是该回滚。这正是 2PC 的「阻塞问题(Blocking Problem)」:参与者无法单方面脱身。实践中的选择取决于业务:通常偏向 ROLLBACK PREPARED(保证不留半成品),但如果有其他参与者已经提交,回滚会导致不一致——所以必须结合其他参与者的状态来判断。
阻塞问题与崩溃恢复
2PC 最被诟病的一点是它是阻塞式(blocking)协议:
参与者 A:已 prepare,等待协调者
参与者 B:已 prepare,等待协调者
协调者:崩溃了
↓
A 与 B 都无法前进:
- 不能自己提交(可能 B 已经回滚)
- 不能自己回滚(可能 B 已经提交)
- 只能一直持有锁,直到有人介入这正是分布式系统更倾向 Saga / TCC / Outbox 这类「最终一致 + 补偿」方案,而非无脑上 2PC 的原因:2PC 提供强一致,代价是可用性与运维复杂度。
崩溃恢复后的状态也印证了这一点:
participant 崩溃重启:
→ pg_prepared_xacts 中的条目依然存在(已持久化)
→ 相关的锁重新被持有
→ 仍在等待协调者的 COMMIT/ROLLBACK PREPARED
coordinator 崩溃重启:
→ 有责任恢复自己的决策日志,重新向参与者下发指令
→ 如果决策日志也丢了 → 悬挂事务所以协调者的持久化至关重要:2PC 把「原子性的责任」从数据库转移到了协调者身上。协调者不可靠,2PC 就不成立。
逻辑复制与 2PC
PostgreSQL 的逻辑复制默认不理解 2PC 命令:
基础回调(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 表。
准备环境:
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-- 两个库各自准备
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。
-- 库 bank_a:扣款
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-2026-09-28-0001-A';BEGIN
UPDATE 1
PREPARE TRANSACTION-- 库 bank_b:入账
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-2026-09-28-0001-B';BEGIN
UPDATE 1
PREPARE TRANSACTION检查两边都就绪。
-- 在 bank_a
SELECT gid, prepared, database FROM pg_prepared_xacts; gid | prepared | database
--------------------------+-------------------------------+----------
xfer-2026-09-28-0001-A | 2026-09-28 14:31:07.201+08 | bank_a
(1 row)-- 在 bank_b
SELECT gid, prepared, database FROM pg_prepared_xacts; gid | prepared | database
--------------------------+-------------------------------+----------
xfer-2026-09-28-0001-B | 2026-09-28 14:31:07.882+08 | bank_b
(1 row)验证结果。
-- 在 bank_a 与 bank_b 分别执行
SELECT id, balance FROM accounts WHERE id = 1; id | balance
----+---------
1 | 900.00 ← bank_a:扣款后的余额
(1 row)失败路径。 如果 bank_b 的 prepare 阶段出错(例如违反 CHECK 约束):
-- bank_b
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
UPDATE accounts SET balance = -1 WHERE id = 1; -- 违反 CHECKERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (1, alice, -1.00).ROLLBACK; -- bank_b 尚未 prepare,普通回滚即可此时协调者必须决定整体回滚,于是通知已经 prepare 的 bank_a:
-- bank_a
ROLLBACK PREPARED 'xfer-fail-0001-A';ROLLBACK PREPARED-- 确认两边都干净
SELECT count(*) FROM pg_prepared_xacts; count
-------
0
(1 row)这正是 2PC 的价值:只要有一个参与者 prepare 失败,已经 prepare 的其他参与者还能被安全回滚,全局不会留下半成品。
演示 prepared 事务继续持锁并阻塞他人
-- 会话 A
BEGIN;
SELECT pg_advisory_xact_lock(777);
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'lock-demo';-- 会话 B:尝试拿同一把顾问锁 → 被阻塞
SET lock_timeout = '2s';
SELECT pg_try_advisory_lock(777); pg_try_advisory_lock
----------------------
f
(1 row)-- 会话 B:查看持有者(pid 为空,说明属于 prepared 事务)
SELECT pid, granted, virtualtransaction
FROM pg_locks
WHERE locktype = 'advisory'
AND ((classid::bigint << 32) | objid::bigint) = 777; pid | granted | virtualtransaction
-----+---------+--------------------
| t | -1/759140
(1 row)-- 用 virtualtransaction 关联回 prepared 事务
SELECT ppx.gid, ppx.prepared, ppx.owner
FROM pg_prepared_xacts ppx
WHERE '-1/' || ppx.transaction = '-1/759140'; gid | prepared | owner
-----------+-------------------------------+-------
lock-demo | 2026-09-28 14:40:02.554+08 | app
(1 row)-- 收尾
ROLLBACK PREPARED 'lock-demo';运维脚本:汇总全部库的悬挂事务
prepared 事务归属单个数据库,所以无法用一条连接看全,必须逐库遍历:
#!/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=== 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)评估影响面时,还要看每个悬挂事务卡住了多少锁对象:
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; 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_aprepare 的事务。 会报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——完整语法、限制清单、悬挂事务清理流程与两库转账的逐步实操。