max_pred_locks_per_transaction 看起来像“单个事务最多能持有多少谓词锁”,但这个名字很容易让人得出错误结论。它更接近一个以后台连接槽位为基数计算的共享内存容量参数,而不是施加在每个事务上的硬限制。
这个区别在 PostgreSQL 的 SERIALIZABLE 隔离级别下尤其重要:谓词锁不仅不阻塞普通读写,其中相当一部分还可能在事务提交后继续存在,用于判断仍在运行的可串行化事务之间是否形成了危险依赖。
它控制的是共享表大小,不是事务配额
PostgreSQL 使用 Serializable Snapshot Isolation(SSI)检测可串行化事务之间的读写依赖。这里的“谓词锁”并不一定对应 SQL 中显式的谓词,也可能记录在关系、页面或元组等不同粒度上。
max_pred_locks_per_transaction 的核心含义可以近似理解为:
谓词锁共享表容量
∝ max_pred_locks_per_transaction
× (max_connections + max_prepared_transactions)
这是容量模型,不是精确的内存字节公式。内部还需要维护锁目标、事务状态和哈希表等结构。
因此,一个事务可以占用超过 max_pred_locks_per_transaction 个条目,只要共享池里还有空间;反过来,即使某个事务没有达到这个数值,整个实例也可能因为其他事务和历史状态占用了共享表而遇到容量压力。
它和 max_locks_per_transaction 有相似的命名问题:名称像是“每事务上限”,实际却用于按后台槽位估算整个共享结构的大小。
为什么已提交事务还会占用谓词锁
普通行锁通常会让人形成这样的直觉:事务结束,锁就释放。但 SSI 的谓词锁不是用来阻塞访问的,而是用来记录“谁读过什么”,从而发现可能破坏可串行化语义的依赖关系。
考虑三个相互重叠的事务:
- 事务 A 读取一组数据。
- 事务 B 修改其中的数据并提交。
- 事务 C 与 A、B 的生命周期重叠,又产生了另一条读写依赖。
即使 B 已经提交,它留下的依赖信息仍可能影响 A 或 C 能否安全提交。因此,PostgreSQL 不能简单地在每个事务提交时立即删除其所有 SSI 状态。通常要等到与之重叠、仍可能形成危险结构的事务结束后,这些信息才可以被清理。
这解释了一个容易被忽略的现象:pg_predicate_locks 中记录的数量,不能简单地用“当前活跃事务数 × 每事务锁数”推算。长时间运行的可串行化事务会延长旧状态的保留时间,也可能成为谓词锁共享内存压力的放大器。
谓词锁还会发生粒度提升。例如,大量元组级记录可能提升为页面级记录,大量页面级记录又可能提升为关系级记录。这样可以节省共享内存,但更粗的记录也可能扩大依赖检测范围,增加不必要的序列化失败概率。
动手观察谓词锁
下面可以用一个临时 PostgreSQL 容器观察谓词锁。示例假设本机已经安装 Docker 和 psql;端口使用 55432,避免与本机 PostgreSQL 冲突。
docker run --name pg-pred-demo \
-e POSTGRES_PASSWORD=postgres \
-p 55432:5432 \
-d postgres:16
export PGURL='postgresql://postgres:postgres@localhost:55432/postgres'
psql "$PGURL" <<'SQL'
CREATE TABLE account (
id integer PRIMARY KEY,
balance integer NOT NULL
);
INSERT INTO account
SELECT n, 1000
FROM generate_series(1, 100) AS n;
SQL
在终端 A 中启动一个可串行化事务,并保持事务不提交:
psql "$PGURL" <<'SQL'
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT sum(balance)
FROM account
WHERE id BETWEEN 10 AND 20;
SELECT pg_sleep(60);
COMMIT;
SQL
在这 60 秒内,从终端 B 检查当前谓词锁:
psql "$PGURL" -x -c '
SELECT
locktype,
relation::regclass AS relation,
page,
tuple,
pid,
mode
FROM pg_predicate_locks
ORDER BY pid, relation, page, tuple;
'
也可以先做聚合,快速判断记录集中在哪种粒度:
psql "$PGURL" -c '
SELECT
locktype,
relation::regclass AS relation,
count(*) AS lock_count
FROM pg_predicate_locks
GROUP BY locktype, relation
ORDER BY lock_count DESC;
'
具体看到元组、页面、关系还是索引相关记录,会受查询计划和 PostgreSQL 版本影响。这个实验的重点不是得到固定行数,而是确认谓词锁是实例级共享状态,并观察长事务如何让这些记录持续存在。
实验结束后可以删除容器:
docker rm -f pg-pred-demo
调整前先确认问题在哪里
可以先查看相关参数的当前值和生效方式:
SELECT
name,
setting,
unit,
context,
pending_restart
FROM pg_settings
WHERE name IN (
'max_pred_locks_per_transaction',
'max_pred_locks_per_relation',
'max_pred_locks_per_page',
'max_connections',
'max_prepared_transactions'
)
ORDER BY name;
如果确认需要提高容量,可以这样修改:
ALTER SYSTEM SET max_pred_locks_per_transaction = 128;
然后重启 PostgreSQL,而不是只执行 reload:
# systemd 部署示例
sudo systemctl restart postgresql
# 确认新值
psql -d postgres -c 'SHOW max_pred_locks_per_transaction;'
容器或 Kubernetes 环境应通过对应的实例配置和滚动重启机制完成变更。该参数涉及启动时分配的共享内存,不能期待通过普通配置重载立即生效。
调大参数之前,建议按以下顺序排查:
- 应用是否真的大量使用
SERIALIZABLE,还是只有少量事务需要它。 - 是否存在持续数分钟甚至数小时的可串行化事务。
max_connections是否被设置得过高,导致共享内存估算被整体放大。- 是否能通过连接池减少后台连接槽位。
max_pred_locks_per_relation和max_pred_locks_per_page是否导致过早或过晚的粒度提升。- 应用是否正确重试 SQLSTATE
40001的序列化失败。
提高 max_pred_locks_per_transaction 能扩大共享结构,并可能保留更细粒度的依赖信息,但代价是更多共享内存。它也不是消除序列化失败的开关:SSI 为保证正确性,本来就可能中止事务。生产系统最终仍需要短事务、受控连接数、合理容量以及可靠的重试逻辑共同配合。