PostgreSQL Serializable 隔离调优:看懂谓词锁从行到页再到表的升级

2026-09-22 20 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

PostgreSQL 的 Serializable 隔离并不是简单地给读过的每一行加一把会阻塞写入的锁。它通过 SSI(Serializable Snapshot Isolation)记录事务“读过什么”,再据此识别可能形成序列化异常的读写依赖。负责记录这些读取范围的是谓词锁,通常在 pg_locks 中显示为 SIReadLock

问题在于,共享内存不可能无限保存细粒度的读取记录。于是 PostgreSQL 会把多个元组级谓词锁合并成页级锁,再把多个页级锁合并成关系级锁。max_pred_locks_per_pagemax_pred_locks_per_relation 控制的正是这两次粒度升级。

谓词锁不是普通的阻塞锁

SIReadLock 的主要任务是记录读取范围,而不是阻止其他事务修改数据。另一个事务仍然可以更新相关行,但 PostgreSQL 会追踪事务之间的读写依赖。如果这些依赖可能构成无法串行化的环,某个事务就会以 SQLSTATE 40001 失败:

ERROR:  could not serialize access due to read/write dependencies among transactions

因此,使用 Serializable 隔离时,应用必须具备“重试整个事务”的能力。只重试最后一条 SQL 通常不够,因为新的事务快照和依赖图都需要从事务边界重新建立。

谓词锁可以有不同粒度:

  • tuple:只记录某个元组,精度最高,但占用更多锁目标条目。
  • page:覆盖一个数据页,条目更少,但可能包含事务没有真正读取的其他元组。
  • relation:覆盖整张表,最节省条目,也最容易扩大冲突判断范围。

这种升级不会改变查询结果,却可能增加“假阳性”序列化失败:两个事务实际访问的是不同记录,但粗粒度锁让它们看起来访问了同一页,甚至同一张表。

两个参数分别控制什么

max_pred_locks_per_page

该参数控制一个页面内允许保留多少个细粒度谓词锁目标。达到阈值后,多个元组级记录会被合并为一个页级记录。

值较低时,系统更积极地节省谓词锁条目;代价是锁覆盖范围更快扩大。值较高时,读取范围记录得更精确,但会消耗更多共享谓词锁资源。

max_pred_locks_per_relation

该参数控制一张关系中可以保留多少个较细粒度的页或元组谓词锁目标。达到阈值后,记录会被提升为关系级 SIReadLock

该参数可以是负数。负值表示根据 max_pred_locks_per_transaction 按比例计算,而不是使用固定数量。例如,具体含义和当前值应直接从运行中的服务器确认:

SELECT name,
       setting,
       unit,
       context,
       pending_restart,
       short_desc
FROM pg_settings
WHERE name IN (
    'max_pred_locks_per_transaction',
    'max_pred_locks_per_page',
    'max_pred_locks_per_relation'
)
ORDER BY name;

不要只凭参数名推断内存行为。max_pred_locks_per_pagemax_pred_locks_per_relation 主要控制何时合并锁目标;谓词锁共享内存容量还与 max_pred_locks_per_transaction 以及服务器可同时存在的事务数量有关。

动手观察锁粒度

下面的实验可以在测试数据库中直接运行。它创建一张表,强制走索引读取,并在 Serializable 事务内查看当前后端持有的 SIReadLock

先创建测试数据:

DROP TABLE IF EXISTS predicate_lock_demo;

CREATE TABLE predicate_lock_demo (
    id      bigint PRIMARY KEY,
    payload text NOT NULL
);

INSERT INTO predicate_lock_demo (id, payload)
SELECT i, repeat('x', 100)
FROM generate_series(1, 10000) AS g(i);

ANALYZE predicate_lock_demo;

然后在同一个 psql 会话中执行:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 仅用于实验,使小范围查询更稳定地使用索引。
SET LOCAL enable_seqscan = off;

SELECT count(*)
FROM predicate_lock_demo
WHERE id BETWEEN 100 AND 120;

SELECT locktype,
       relation::regclass AS relation_name,
       page,
       tuple,
       mode,
       granted
FROM pg_locks
WHERE pid = pg_backend_pid()
  AND mode = 'SIReadLock'
ORDER BY locktype, relation_name, page, tuple;

ROLLBACK;

结果可能包含 tuple、page 或 relation 类型的锁目标,实际形态取决于 PostgreSQL 版本、访问路径、表布局、读取范围和当前参数。不要把一次实验中的锁数量当成稳定接口;真正值得观察的是锁是否频繁升级到 relation 粒度。

还可以比较小范围索引扫描与大范围扫描:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

SELECT count(*)
FROM predicate_lock_demo
WHERE id BETWEEN 1 AND 9000;

SELECT locktype,
       relation::regclass AS relation_name,
       count(*) AS lock_count
FROM pg_locks
WHERE pid = pg_backend_pid()
  AND mode = 'SIReadLock'
GROUP BY locktype, relation
ORDER BY locktype, relation_name;

ROLLBACK;

大范围扫描本来就可能采用关系级记录。此时仅仅调高升级阈值未必有帮助,因为执行计划本身决定了读取范围。优化索引、缩小扫描区间,往往比单纯修改 GUC 更有效。

如何调整而不把问题转移到共享内存

可以在测试环境使用类似配置作为实验起点,但下面的数字只是示例,不应直接复制到生产环境:

# postgresql.conf:示例值,必须根据真实并发与事务读集验证
max_pred_locks_per_page = 4
max_pred_locks_per_relation = 64

修改后,先让 PostgreSQL 重新读取配置,再检查是否需要重启:

psql -X -v ON_ERROR_STOP=1 -d postgres <<'SQL'
SELECT pg_reload_conf();

SELECT name, setting, context, pending_restart
FROM pg_settings
WHERE name IN (
    'max_pred_locks_per_page',
    'max_pred_locks_per_relation',
    'max_pred_locks_per_transaction'
)
ORDER BY name;
SQL

如果 pending_restart 为真,应通过部署系统安排受控重启,而不是假设 reload 已经让新值生效。

调大阈值并非没有成本:

  • 更晚升级可以减少粗粒度锁导致的误判和序列化失败。
  • 保留更多细粒度目标会提高谓词锁表的压力。
  • 只提高 page 或 relation 阈值,却不检查整体谓词锁容量,可能把“事务频繁回滚”变成“共享内存不足”。
  • 顺序扫描、低选择性条件和缺失索引造成的宽读集,不能靠参数彻底修复。

上线前的判断清单

调优时应围绕实际冲突,而不是孤立地追求更多细粒度锁:

  1. 确认应用只对确实需要的事务使用 Serializable。
  2. 统计 SQLSTATE 40001,并确认客户端会重试整个事务。
  3. 通过 pg_locks 观察 SIReadLock 是否过早集中为 relation 级别。
  4. 同时检查查询计划;宽范围顺序扫描通常比阈值更值得优先处理。
  5. 调整前记录三个 max_pred_locks_* 参数、连接上限和峰值并发。
  6. 在接近生产并发的压测中,同时监控序列化失败率、共享内存错误、延迟和吞吐量。

这两个参数的本质是一项精度与容量之间的交换:过早升级会扩大冲突范围,过晚升级会消耗更多共享谓词锁条目。正确做法不是把数值一味调大,而是让锁粒度、查询访问路径和实际并发读集彼此匹配。


相关推荐