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 EXCLUSIVE 和 ACCESS SHARE 确实冲突,session 2 等 session 1 完成就行。问题在于 PostgreSQL 的锁等待是 FIFO 排队的:session 2 的 ACCESS EXCLUSIVE 请求一旦入队,后续所有针对表 t 的 SELECT 都排在它后面,哪怕这些 SELECT 和 session 1 的长查询完全不冲突。
连锁反应如下:
- 长查询正在执行(持有
ACCESS SHARE)。 ALTER TABLE请求ACCESS EXCLUSIVE,被迫等待。- 之后所有
SELECT排在步骤 2 后面,全部阻塞。
更隐蔽的变体:一个 SELECT 在 BEGIN 事务内执行完毕,但应用忘记 COMMIT,连接变成 idle in transaction。ACCESS 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 transaction:sql 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,怎么看都不可能死锁——但外键约束会在背后悄悄锁住父表行。
当向子表 t 做 INSERT 时,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_activity 和 pg_class 的读取权限。阈值数字根据你的 autovacuum_freeze_max_age 调整——默认 2 亿事务,建议在 1.5 亿时就开始告警并手动 freeze,给 DDL 操作留出安全窗口。