PostgreSQL 五个容易踩坑的锁行为:你以为不会阻塞的地方,偏偏就阻塞了

2026-05-27 15 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

PostgreSQL 用 MVCC 实现并发控制——读不阻塞写,写不阻塞读,这是很多人对 PG 的基本印象。但一旦真正上线运维,锁的冲突会在你完全没预料到的地方冒出来:一条本该秒级完成的 ALTER TABLE 把整个服务拖死,两条只做 INSERT 的事务居然死锁了,autovacuum 突然不可取消……本文梳理五个这样的反直觉锁行为,每个都附带复现路径和应对方案。

环境说明:以下行为在 PostgreSQL 18、默认隔离级别 READ COMMITTED 下验证。


一条 ALTER TABLE 怎么就把全站 SELECT 都堵住了?

这是最常见也最致命的场景:锁等待的 FIFO 队列引发连锁阻塞

假设 session 1 在表 t 上跑一个长查询:

-- Session 1:长查询,持有 ACCESS SHARE 锁
SELECT pg_sleep(600) FROM t LIMIT 1;

session 2 执行一条看起来瞬间就能完成的 DDL:

-- Session 2:需要 ACCESS EXCLUSIVE 锁,被 session 1 阻塞,进入等待队列
ALTER TABLE t ADD COLUMN name text;

到目前为止还算正常——ACCESS EXCLUSIVEACCESS SHARE 确实冲突,session 2 等 session 1 完成就行。问题在于 PostgreSQL 的锁等待是 FIFO 排队的:session 2 的 ACCESS EXCLUSIVE 请求一旦入队,后续所有针对表 tSELECT 都排在它后面,哪怕这些 SELECT 和 session 1 的长查询完全不冲突。

连锁反应如下:

  1. 长查询正在执行(持有 ACCESS SHARE)。
  2. ALTER TABLE 请求 ACCESS EXCLUSIVE,被迫等待。
  3. 之后所有 SELECT 排在步骤 2 后面,全部阻塞。

更隐蔽的变体:一个 SELECTBEGIN 事务内执行完毕,但应用忘记 COMMIT,连接变成 idle in transactionACCESS SHARE 锁要等到事务结束才释放,于是哪怕查询本身早已完成,这条"僵尸事务"照样堵住后续 DDL,再通过 FIFO 队列堵住所有新查询。

应对

  • 在需要 ACCESS EXCLUSIVE 的事务里设置 lock_timeout,不让等待无限拖长: sql SET lock_timeout = '5s'; ALTER TABLE t ADD COLUMN name text; -- 5秒内拿不到锁就自动失败,而不是一直排队
  • 执行 DDL 前通过 pg_stat_activity 检查是否有长查询或 idle in transactionsql SELECT pid, state, query, now() - query_start AS duration FROM pg_stat_activity WHERE query LIKE '%t%' AND state IN ('active', 'idle in transaction');

外键约束引发的"隐形死锁"

应用代码里只有 INSERT,怎么看都不可能死锁——但外键约束会在背后悄悄锁住父表行。

当向子表 tINSERT 时,PostgreSQL 自动对每个外键指向的父表 s 的对应行加 FOR KEY SHARE 锁。这个锁和 FOR UPDATE 冲突。于是下面这种操作顺序就会死锁:

-- Session 1
BEGIN;
SELECT * FROM s WHERE id = 1 FOR UPDATE;  -- 锁住 s.id=1

-- Session 2
BEGIN;
SELECT * FROM s WHERE id = 2 FOR UPDATE;  -- 锁住 s.id=2

-- Session 1
INSERT INTO t(s_id) VALUES (2);  -- 需要对 s.id=2 加 FOR KEY SHARE,等 session 2

-- Session 2
INSERT INTO t(s_id) VALUES (1);  -- 需要对 s.id=1 加 FOR KEY SHARE,等 session 1 → 死锁!

反直觉的地方在于:应用 SQL 里只有 INSERT,锁获取完全不可见。你不会在代码里看到任何显式锁语句,但外键机制已经在父表上加锁了。

应对

  • 避免多 session 先 FOR UPDATE 锁父表行、再 INSERT 子表引用不同 session 锁定行的设计。
  • 如果业务确实需要,统一锁获取顺序——比如始终按 id 升序锁行,消除循环等待: sql -- 所有 session 都按 id 升序锁行,不会形成反向循环等待 SELECT * FROM s WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
  • 死锁必然以错误形式上报,应用层要实现重试逻辑。

两条纯 INSERT 也能死锁:唯一约束的重复检查等待

两个事务各插入对方已插入的值,顺序相反,就会死锁:

-- Session 1
BEGIN;
INSERT INTO t(id) VALUES (1);

-- Session 2
BEGIN;
INSERT INTO t(id) VALUES (2);

-- Session 1
INSERT INTO t(id) VALUES (2);  -- session 2 正在插入 id=2,等待其结束

-- Session 2
INSERT INTO t(id) VALUES (1);  -- session 1 正在插入 id=1,等待其结束 → 死锁!

原因:PRIMARY KEY / UNIQUE 约束的重复检查机制——如果另一个事务正在插入相同值,当前事务会等对方结束再判定结果:"对方提交则违反唯一约束,对方回滚则插入成功"。于是双方互相等对方结束,形成死锁。

应对

  • SERIAL / IDENTITY 序列生成主键,从根本上避免多 session 插入相同值: sql CREATE TABLE t ( id BIGSERIAL PRIMARY KEY, -- 序列自动分配,不会重复 data text );
  • 同样,应用层需要重试死锁失败的事务。

防 XID 回绕的 autovacuum:不可取消的那一个

普通 autovacuum 持有 SHARE UPDATE EXCLUSIVE 锁,遇到冲突会自动取消,所以大家常说"不用管 autovacuum,它不会堵你"。但有一个例外:防止事务 ID 回绕的 autovacuum 不会自动取消

当表的 relfrozenxid 年龄超过 autovacuum_freeze_max_age(默认 2 亿事务),PG 会启动带 (to prevent wraparound) 标记的 autovacuum。此时如果有人执行 ALTER TABLE 创建分区,ALTER TABLE 等 autovacuum 释放锁——但 autovacuum 不会被取消。再叠加第一节的 FIFO 连锁阻塞,后续所有 SELECT 排队,事故迅速升级。

应对:预防比事后补救重要得多。

定期监控 relfrozenxid 年龄,在 DDL 操作前检查目标表是否接近阈值:

-- 查看各表 relfrozenxid 年龄,按风险从高到低排列
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY xid_age DESC;

-- 如果某表接近 2 亿(autovacuum_freeze_max_age 默认值),提前手动 freeze
VACUUM FREEZE my_large_table;

pg_stat_activity 中看到 VACUUM ... (to prevent wraparound) 时,要意识到这是一个不会被取消的 autovacuum,等它跑完再做 DDL。


VACUUM 截断阶段的隐藏 ACCESS EXCLUSIVE 锁

VACUUM 通常以 SHARE UPDATE EXCLUSIVE 运行,但最后一步——截断表尾部空页、归还磁盘空间给操作系统——会短暂获取 ACCESS EXCLUSIVE 锁。如果此时有长查询正在跑,VACUUM 等 ACCESS EXCLUSIVE 入队,再次触发 FIFO 连锁阻塞。

在流复制备库上情况更特殊:主库获取并释放 ACCESS EXCLUSIVE 的操作通过 WAL 传到备库,备库的 WAL 回放进程要应用这个操作,但备库上的长查询和它冲突,WAL 回放被卡住。当延迟超过 max_standby_streaming_delay(默认 30 秒),备库上的长查询会被强制取消——主库上查询可以自然等完,备库上查询却被外部因素杀掉,这是关键区别。

应对

  • PostgreSQL 12+ 可按表禁用截断阶段: sql ALTER TABLE my_table SET (vacuum_truncate = false);
  • PostgreSQL 18+ 还新增了全局参数 vacuum_truncate
  • 备库跑长查询时,调高 max_standby_streaming_delay(设 -1 为无限等待): sql ALTER SYSTEM SET max_standby_streaming_delay = '300s'; -- 或者 -1 表示无限等待,但会增加复制延迟风险

上线前的最小防御清单

这五个行为的共同点是:PG 的锁设计在规范层面完全正确,但在运维层面是坑。尤其是 FIFO 连锁阻塞,和其他场景叠加后能迅速演变成致命事故。以下是建议的最低防御线:

检查项 操作
DDL 事务设 lock_timeout SET lock_timeout = '5s'; 再执行 ALTER
监控长查询和 idle in transaction 定期查 pg_stat_activity,设 idle_in_transaction_session_timeout
监控 relfrozenxid 年龄 用上面 age(relfrozenxid) 查询,接近阈值就提前 VACUUM FREEZE
应用层重试死锁 捕获 SQLSTATE 40P01,自动重试整个事务
备库长查询场景 评估 max_standby_streaming_delay,考虑禁用 vacuum_truncate
外键 + FOR UPDATE 场景 统一锁获取顺序,消除循环等待可能

一个可以直接跑的监控脚本,放到 cron 或定时任务里:

#!/bin/bash
# pg_lock_watch.sh — 每分钟检查锁等待和 xid 年龄,超过阈值告警
PG_HOST="localhost"
PG_PORT="5432"
PG_USER="monitor"
PG_DB="mydb"

# 1. 检查锁等待链(有等待锁的 session 数 > 5 就告警)
WAIT_COUNT=$(psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -t -A -c \
  "SELECT count(*) FROM pg_locks WHERE NOT granted;")

if [ "$WAIT_COUNT" -gt 5 ]; then
  echo "ALERT: $WAIT_COUNT sessions waiting for locks!"
  psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -c \
    "SELECT pid, query, now()-query_start AS wait_duration
     FROM pg_stat_activity WHERE state = 'active' AND pid IN (
       SELECT pid FROM pg_locks WHERE NOT granted
     );"
fi

# 2. 检查 xid 年龄接近阈值(超过 1.5 亿就告警,默认阈值 2 亿)
HIGH_AGE=$(psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -t -A -c \
  "SELECT relname, age(relfrozenxid) FROM pg_class
   WHERE relkind = 'r' AND age(relfrozenxid) > 150000000;")

if [ -n "$HIGH_AGE" ]; then
  echo "ALERT: Tables approaching XID wraparound threshold:"
  echo "$HIGH_AGE"
fi

跑之前确保 monitor 用户有 pg_stat_activitypg_class 的读取权限。阈值数字根据你的 autovacuum_freeze_max_age 调整——默认 2 亿事务,建议在 1.5 亿时就开始告警并手动 freeze,给 DDL 操作留出安全窗口。


相关推荐