max_prepared_transactions 看起来只是 PostgreSQL 的一个容量参数,实际上它决定了数据库是否允许事务停在“两阶段提交”的中间状态。这样的事务已经脱离原来的会话,却仍然保留锁和事务状态;如果协调器没有回来完成提交或回滚,它们就可能长期阻塞其他请求,并持续限制 Vacuum 清理旧版本。
这不是一个应该通过“调大一点”来消除告警的参数。更重要的问题是:应用是否真的需要 prepared transaction,以及谁负责收尾。
它控制的不是 prepared statement
PostgreSQL 中有两个容易混淆的概念:
- prepared statement:通过
PREPARE name AS ...预编译 SQL,主要用于减少解析和规划开销。 - prepared transaction:通过
PREPARE TRANSACTION 'gid'把事务置于两阶段提交的待决状态。
max_prepared_transactions 只控制第二种。它定义服务器可以同时保存多少个待决事务:
SHOW max_prepared_transactions;
当值为 0 时,prepared transaction 被禁用。普通的事务、prepared statement,以及大多数仅连接单个 PostgreSQL 数据库的应用都不受影响。
一次标准的两阶段提交大致如下:
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
PREPARE TRANSACTION 'payment-2025-0001';
-- 之后由事务协调器选择其中一个操作:
COMMIT PREPARED 'payment-2025-0001';
-- 或 ROLLBACK PREPARED 'payment-2025-0001';
执行 PREPARE TRANSACTION 后,原会话不再拥有这个事务,但事务占用的锁不会消失。它还会跨数据库重启保存,等待未来的 COMMIT PREPARED 或 ROLLBACK PREPARED。
为什么待决事务比 idle transaction 更难处理
普通的 idle in transaction 会话至少还有 PID,可以通过会话超时、连接池回收或 pg_terminate_backend() 处理。prepared transaction 已经与会话分离,因此 idle_in_transaction_session_timeout 对它不起作用。
一个被遗忘的 prepared transaction 会带来几类问题:
- 行锁、表锁以及其他事务锁继续存在,后续请求可能一直等待。
- Vacuum 的清理边界可能被旧事务拖住,死亡元组不能及时回收。
- 事务会跨重启存在,重启数据库不能代替提交或回滚。
- 达到
max_prepared_transactions后,新的PREPARE TRANSACTION会失败,分布式事务协调器必须正确处理这种失败。
因此,“允许 100 个待决事务”并不表示系统更安全,只表示最多可以积累 100 个需要外部决策的事务。
用 Docker 复现锁被保留的行为
下面的示例假设本机已经安装 Docker。它会启动一个临时 PostgreSQL 16 实例,并显式启用 prepared transaction:
docker rm -f pg-guc-demo 2>/dev/null || true
docker run --name pg-guc-demo \
-e POSTGRES_PASSWORD=postgres \
-p 55432:5432 \
-d postgres:16 \
-c max_prepared_transactions=10
until docker exec pg-guc-demo pg_isready -U postgres >/dev/null 2>&1; do
sleep 1
done
docker exec -i pg-guc-demo psql -U postgres <<'SQL'
CREATE TABLE accounts (
id integer PRIMARY KEY,
balance integer NOT NULL
);
INSERT INTO accounts VALUES (1, 1000);
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'demo-payment-001';
SELECT gid, owner, database, prepared
FROM pg_prepared_xacts;
SQL
此时更新已经被准备,但尚未提交。另一个会话尝试修改同一行时会等待锁;这里设置两秒超时,避免命令无限挂住:
docker exec -i pg-guc-demo psql -U postgres <<'SQL'
SET statement_timeout = '2s';
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
SQL
命令应因超时失败。现在回滚待决事务并再次更新:
docker exec -i pg-guc-demo psql -U postgres <<'SQL'
ROLLBACK PREPARED 'demo-payment-001';
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
SELECT * FROM accounts;
SQL
实验完成后可以删除容器:
docker rm -f pg-guc-demo
在真实故障中,不要仅仅因为事务“看起来很旧”就直接回滚。事务协调器可能已经在另一个参与者上记录了提交决定;随意回滚可能破坏跨系统一致性。
生产环境要监控年龄,而不只是数量
最基本的巡检查询是 pg_prepared_xacts:
SELECT
gid,
owner,
database,
prepared,
now() - prepared AS age
FROM pg_prepared_xacts
ORDER BY prepared;
数量能反映容量使用情况,年龄更能暴露失联的协调器。可以把以下检查改造成监控脚本;示例把五分钟以上的事务视为异常,实际阈值应匹配业务协议和最长正常事务时长:
#!/usr/bin/env bash
set -euo pipefail
DATABASE_URL=${DATABASE_URL:-postgresql://postgres@localhost/postgres}
MAX_AGE=${MAX_AGE:-5 minutes}
count=$(psql "$DATABASE_URL" -Atc \
"SELECT count(*) FROM pg_prepared_xacts WHERE prepared < now() - '$MAX_AGE'::interval")
if [ "$count" -gt 0 ]; then
echo "CRITICAL: $count prepared transaction(s) older than $MAX_AGE"
psql "$DATABASE_URL" -c \
"SELECT gid, owner, database, prepared, now() - prepared AS age FROM pg_prepared_xacts ORDER BY prepared"
exit 2
fi
echo 'OK: no stale prepared transactions'
处理告警时,应先从 gid 映射到协调器中的事务记录,再根据已经持久化的全局决定执行:
COMMIT PREPARED 'confirmed-commit-gid';
-- 或者,仅在协调器确认需要回滚时:
ROLLBACK PREPARED 'confirmed-rollback-gid';
参数应该设为多少
如果应用、驱动、中间件和事务协调器都不使用 PostgreSQL 两阶段提交,最稳妥的选择通常是禁用它:
# postgresql.conf
max_prepared_transactions = 0
这是启动级参数,修改后需要重启 PostgreSQL,单独执行配置重载不够。降低为 0 之前,先确认 pg_prepared_xacts 中没有遗留事务,并核对高可用、XA 或分布式事务组件的要求。
如果系统确实依赖两阶段提交,可以这样制定容量:
- 根据协调器可能同时准备的最大事务数预留空间,而不是凭感觉调大。
- 为流量峰值和故障恢复留出余量;保守配置也可能按
max_connections级别规划。 - 确保备用节点的配置不低于主节点需要恢复的 prepared transaction 数量。
- 同时评估共享内存和锁表容量,较大的上限不是免费的。
- 为每个
gid建立可追踪的命名规则,并让协调器能够重复、安全地执行恢复决策。
真正的安全措施不是某个参数值,而是一条完整闭环:协调器持久化提交决定,数据库暴露待决事务,监控检查数量与年龄,值班人员能够从 gid 找到业务记录,并且有经过演练的提交或回滚流程。缺少这条闭环时,把 max_prepared_transactions 保持为 0 往往比留下一个无人负责的分布式事务入口更可靠。