生产环境最典型的「突然全站变慢」往往不是慢查询本身,而是一次锁等待引发的雪崩:一条长事务持有某张表的锁,后续查询排队等待,连接池被占满,健康检查超时,服务开始熔断。整个过程可能只用了十几秒,但根因只是一条没提交的 UPDATE 或一个排队的 ALTER TABLE。
PostgreSQL 的锁机制是多粒度、多模式的:表级锁有 8 种模式,行级锁有 4 种,它们之间的冲突关系决定了哪些操作会互相阻塞。理解这套体系,配合 pg_locks 与 pg_blocking_pids() 这类诊断工具,才能把「服务挂了」快速定位到「谁在等谁」。本文从锁模式讲起,覆盖阻塞链与死锁的形成、诊断 SQL、典型场景的解法,以及从源头预防的手段。
一、锁的体系
1.1 表级锁的 8 种模式
PostgreSQL 的表级锁按「强度」从弱到强排列:
| 模式 | 典型触发 | 冲突强度 |
|---|---|---|
ACCESS SHARE | SELECT | 最弱,只与 ACCESS EXCLUSIVE 冲突 |
ROW SHARE | SELECT FOR UPDATE | 与 EXCLUSIVE/ACCESS EXCLUSIVE 冲突 |
ROW EXCLUSIVE | INSERT / UPDATE / DELETE | 与 SHARE 及以上冲突 |
SHARE UPDATE EXCLUSIVE | VACUUM / ANALYZE / CREATE INDEX CONCURRENTLY | 与自身及以上冲突 |
SHARE | CREATE INDEX(非并发) | 阻塞写 |
SHARE ROW EXCLUSIVE | ALTER TABLE 部分操作 | 阻塞写与部分读 |
EXCLUSIVE | 阻塞所有非 ACCESS SHARE | 强 |
ACCESS EXCLUSIVE | ALTER TABLE / DROP TABLE / TRUNCATE | 最强,阻塞一切 |
关键认知:普通的 SELECT 只拿 ACCESS SHARE,它只与 ACCESS EXCLUSIVE 冲突。所以「查询之间不会互相阻塞」,但一个 ALTER TABLE 会阻塞所有查询——这就是为什么 DDL 必须谨慎。
1.2 行级锁的 4 种模式
行级锁只在写操作时出现,且不阻塞「读」:
| 模式 | 触发 | 阻塞 |
|---|---|---|
FOR UPDATE | SELECT ... FOR UPDATE / UPDATE | 阻塞其它 FOR UPDATE |
FOR NO KEY UPDATE | 不更新键列的 UPDATE | 弱于 FOR UPDATE |
FOR SHARE | SELECT ... FOR SHARE | 阻塞 FOR UPDATE |
FOR KEY SHARE | 外键引用检查 | 最弱,只阻塞改键的 UPDATE |
FOR NO KEY UPDATE 与 FOR KEY SHARE 的引入是为了减少外键带来的锁冲突:外键的引用检查只需 FOR KEY SHARE,而更新非键列的 UPDATE 只需 FOR NO KEY UPDATE,两者互不冲突。这让「父表更新非键列」不会阻塞「子表插入」。
1.3 咨询锁(Advisory Lock)
咨询锁是应用自定义的锁,与表/行无关,纯粹是「一个可以全局协调的锁标识」:
-- 会话级:直到显式释放或会话结束
SELECT pg_advisory_lock(12345);
SELECT pg_advisory_unlock(12345);
-- 事务级:事务结束时自动释放(推荐)
SELECT pg_advisory_xact_lock(12345);
-- 非阻塞版本,拿不到立即返回 false
SELECT pg_try_advisory_lock(12345);
它常用于「同一时刻只有一个 worker 执行某任务」的场景,如防止多个实例同时跑定时任务。优先用事务级 pg_advisory_xact_lock,避免会话级锁忘记释放导致永久死锁。
1.4 锁的排队与快速路径
PostgreSQL 的表锁采用「快速路径(fast-path)」优化:当一个会话只持有弱锁(ACCESS SHARE 等)且冲突的强锁很少时,锁信息记录在进程私有的快速路径槽位里,无需修改全局锁表,开销极低。只有当快速路径槽位耗尽(默认每个会话若干槽位)或出现强锁时,才升级为全局锁表条目。
这解释了两个现象:
- 纯读并发几乎无锁开销,因为都走快速路径。
- 一旦出现
ACCESS EXCLUSIVE,所有后续请求都要进全局队列,即使它们彼此不冲突——队列是 FIFO 的,后来的必须先排队。
因此「阻塞雪崩」的触发点往往是第一个强锁请求,而不是数据量或并发量本身。把 DDL、TRUNCATE、VACUUM FULL 这类强锁操作隔离到低峰期,比调任何参数都有效。
二、阻塞链与死锁
2.1 阻塞链
阻塞不是一对一的,而是可以形成链条:A 等 B、B 等 C、C 等 D。链条越长,恢复越慢。一条典型的链是:
ALTER TABLE orders ... -- 持有 ACCESS EXCLUSIVE 的等待者(排队中)
← SELECT * FROM orders LIMIT 1 -- 持有 ACCESS SHARE,阻塞了 ALTER
← 另一个 SELECT ... -- 排在 ALTER 后面(因为 PostgreSQL 锁队列是 FIFO)
这里最反直觉的是第三行:后到的 SELECT 本来与 ALTER 不冲突,但因为锁队列是先进先出,它必须排在 ALTER 后面等——而 ALTER 在等前面的 SELECT 释放。于是「一个短查询后面堵了一长串查询」。这就是「DDL 排队雪崩」的完整机制。
2.2 死锁
死锁是两个事务互相等待对方持有的锁:
事务 A: UPDATE t WHERE id = 1; -- 持有行 1
事务 B: UPDATE t WHERE id = 2; -- 持有行 2
事务 A: UPDATE t WHERE id = 2; -- 等 B
事务 B: UPDATE t WHERE id = 1; -- 等 A → 死锁
PostgreSQL 有死锁检测器(deadlock detector),默认每 deadlock_timeout(1 秒)检查一次,发现环后回滚其中一个事务并报错:
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890;
blocked by process 54321.
死锁本身不会让数据库挂掉,但频繁死锁说明应用存在「不一致的加锁顺序」。修复方法是统一加锁顺序:所有事务按相同的顺序访问资源(如按 ID 升序更新)。批量更新时尤其要注意:
-- 错误:按业务顺序逐行更新,容易死锁
UPDATE accounts SET balance = balance - 100 WHERE id = 5;
UPDATE accounts SET balance = balance + 100 WHERE id = 3;
-- 正确:按 id 排序后一次性更新
UPDATE accounts SET balance = balance + CASE WHEN id = 3 THEN 100
WHEN id = 5 THEN -100 END
WHERE id IN (3, 5);
三、诊断手法
3.1 找出阻塞关系
pg_blocking_pids() 是诊断阻塞最直接的工具,它返回「阻塞了指定 PID 的那些 PID」:
SELECT
pid,
pg_blocking_pids(pid) AS blocked_by,
state,
wait_event_type,
wait_event,
now() - query_start AS duration,
left(query, 60) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0
ORDER BY duration DESC;
blocked_by 非空的行就是「正在被阻塞的会话」,blocked_by 里的 PID 就是「持锁不放的元凶」。顺着这个字段可以一路找到链条的头部。
3.2 查看锁详情
pg_locks 是锁的原始视图,需要与 pg_stat_activity、pg_class 关联才能读懂:
SELECT
l.pid,
a.state,
l.locktype,
l.mode,
l.granted,
c.relname,
l.relation::regclass AS relation_name,
a.query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE NOT l.granted
ORDER BY l.pid;
granted = false 的行是正在等待的锁。locktype 常见取值:relation(表锁)、tuple(行锁)、transactionid(等待事务结束)、advisory(咨询锁)。
等待 transactionid 是行锁冲突的典型表现——行级锁不是显式记录的,而是通过「等待对方事务 ID 结束」来实现。
3.3 定位长事务
长事务是阻塞的头号来源。找出运行超过 5 分钟的事务:
SELECT pid, state, xact_start,
now() - xact_start AS xact_age,
now() - state_change AS idle_age,
left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;
特别注意 state = 'idle in transaction' 的会话:它们开着事务却不干活,持有锁不放,是「隐形杀手」。这类会话通常源于应用忘记提交/回滚,或连接池归还连接时没结束事务。
3.4 综合诊断视图
把上面几步合并成一个「阻塞总览」查询,是 DBA 的日常工具:
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = b.pid
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
这个视图能一眼看出「谁被谁挡住了、挡了多久、双方在跑什么 SQL」。把它做成监控告警的定时查询,能在雪崩前提前介入。配合 PostgreSQL 监控与诊断 里的指标采集,可以构建完整的可观测性闭环。
3.5 锁等待的分布统计
排查阶段性问题时,先看「等待集中在哪种锁上」,能快速缩小范围:
SELECT mode, locktype, count(*) AS waiters
FROM pg_locks
WHERE NOT granted
GROUP BY mode, locktype
ORDER BY waiters DESC;
| 等待的锁 | 含义 | 常见根因 |
|---|---|---|
transactionid | 等待某个事务结束 | 行锁冲突、长事务 |
relation + AccessExclusiveLock | 等表级排他锁 | DDL 排队 |
relation + ShareLock | 等共享锁 | CREATE INDEX 非并发 |
advisory | 等咨询锁 | 应用自定义锁未释放 |
若 waiters 集中在 transactionid,说明是行级写冲突(热点行或长事务);若集中在 AccessExclusiveLock,说明有 DDL 在排队。两类问题的解法完全不同:前者要缩短事务或打散热点,后者要用 lock_timeout 让 DDL 快速失败。
四、典型阻塞场景与解法
4.1 DDL 排队雪崩
场景:业务低峰期执行 ALTER TABLE orders ADD COLUMN note text;,结果该表上有长查询,ALTER 拿不到 ACCESS EXCLUSIVE 而排队,后续所有 SELECT orders 又排在 ALTER 后面,整张表「冻结」。
解法:先设锁超时,让 DDL 失败而不是无限等待。
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN note text;
-- 超时报错:canceling statement due to lock timeout
拿不到锁就放弃、重试,避免 DDL 长时间挂在队列头部。更彻底的做法是用 lock_timeout + 重试循环:
while ! psql -c "SET lock_timeout='3s'; ALTER TABLE orders ADD COLUMN note text;"; do
echo "locked, retrying in 10s"; sleep 10
done
对于新增列这类操作,PostgreSQL 11+ 的 ADD COLUMN 若带 DEFAULT 且默认值是常量,是元数据操作、不重写表,本身很快——慢的只是拿锁。所以关键是缩短「持有 ACCESS EXCLUSIVE 的等待窗口」。
4.2 外键锁冲突
场景:父表某行被 SELECT FOR UPDATE 锁住,导致子表插入该父行的外键时等待。
解法:理解外键检查只需 FOR KEY SHARE,它与 FOR NO KEY UPDATE 不冲突。因此更新父表非键列不会阻塞子表插入。若发现阻塞,多半是应用用了 SELECT ... FOR UPDATE 而非 FOR NO KEY UPDATE。改用更弱的锁模式:
-- 若不需要阻止并发改键,用更弱的模式
SELECT * FROM accounts WHERE id = 42 FOR NO KEY UPDATE;
4.3 热点行更新
场景:秒杀、计数器、库存扣减——大量事务更新同一行,全部串行化,形成「排队」。
行级锁意味着同一行同一时刻只能有一个写事务,这是无法绕开的。缓解思路是「把一行拆成多行」:
-- 把计数器拆成 16 个分片行
CREATE TABLE counters (key text, shard int, value bigint, PRIMARY KEY (key, shard));
-- 写入时随机选一个分片
UPDATE counters SET value = value + 1
WHERE key = 'page_views' AND shard = floor(random() * 16);
-- 读取时求和
SELECT sum(value) FROM counters WHERE key = 'page_views';
这把并发冲突从「16 个事务抢 1 行」变成「16 个事务各写 1 行」,吞吐提升接近 16 倍。代价是读要聚合。这是经典的「写分散、读聚合」权衡。
4.4 长事务阻塞 VACUUM
场景:一个 idle in transaction 的会话,其快照阻止了 VACUUM 回收死元组,导致表持续膨胀,进而让所有查询变慢。
这不算「锁阻塞」,但危害类似。解法是设置空闲事务超时:
idle_in_transaction_session_timeout = '5min'
超过 5 分钟的 idle in transaction 会话会被自动断开。这个参数应该成为生产环境的标配。
4.5 连接池与锁的叠加
连接池会放大锁问题:一个持锁的慢事务占着一个连接,连接池的其余连接也在排队等待,很快就耗尽。事务级池化(pgBouncer pool_mode = transaction)要求应用在事务结束后归还连接,但若应用在事务里做慢 IO,锁与连接会同时被长期占用。锁问题与连接池配置往往交织在一起,排查时要把两者放在一起看。
4.6 大事务的锁放大
一个更新 100 万行的事务会持有 100 万行的行锁,直到提交才全部释放。这在并发写场景下几乎必然造成大面积等待。把大事务拆成小批次是标准解法:
-- 分批更新,每批 5000 行,批间提交
DO $$
DECLARE
rows_updated int;
BEGIN
LOOP
UPDATE orders SET status = 'archived'
WHERE id IN (
SELECT id FROM orders WHERE status = 'old' LIMIT 5000
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT;
END LOOP;
END $$;
FOR UPDATE SKIP LOCKED 是关键:它跳过已被其它事务锁住的行,避免多个 worker 互相等待。这让「多进程并行消费同一张表」成为可能——每个 worker 各取各的批次,互不阻塞。这是队列式处理的经典模式,代价是跳过 0 行的批次也要退出,需要处理好边界。
注意 DO 块里 COMMIT 只在 PL/pgSQL 的 CALL/过程(CREATE PROCEDURE)中允许,匿名块需改为过程或由应用侧循环控制提交。用应用侧循环 + 每批一个独立事务,是更可控的做法。
五、从源头预防
5.1 参数配置
# 死锁检测间隔,过长会让死锁发现变慢,过短会增加检查开销
deadlock_timeout = 1s
# 空闲事务超时,断开忘记提交的会话
idle_in_transaction_session_timeout = 5min
# 语句超时,防止单条 SQL 无限跑
statement_timeout = 30s
lock_timeout 建议按会话/语句设置而非全局——DDL 需要短超时,普通业务查询则不需要。可以用角色的默认参数:
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE migration_user SET lock_timeout = '3s';
5.2 应用侧约定
- 事务要短:事务里不做网络调用、不等待用户输入、不做大批量计算。
- 加锁顺序一致:所有事务按相同顺序访问多行/多表,消除死锁。
- 优先用
FOR NO KEY UPDATE/FOR KEY SHARE:能减少与外键检查的冲突。 - DDL 走独立的低权限角色 +
lock_timeout,绝不与应用连接共用。 - 重试死锁:捕获
40P01(deadlock detected)后随机退避重试,是分布式事务的标准做法。
ORM 的事务封装容易让开发者无意中把慢操作(HTTP 调用、批量计算)放进事务,从而把锁持有时间拉长。用 Prisma 时显式控制事务边界、避免在 $transaction 回调里做外部 IO,是常见的优化点,具体写法见 Prisma 与 PostgreSQL 集成
。
5.3 监控告警
把「阻塞数」与「最长阻塞时长」作为核心告警指标:
SELECT count(*) AS blocked_sessions,
max(now() - query_start) AS max_blocked_duration
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
当 blocked_sessions 超过阈值(如 10)或 max_blocked_duration 超过 30 秒时告警。这个查询很轻,可以每 10 秒采集一次。把它接到监控系统后,就能在雪崩发生前几分钟收到信号。
5.4 复盘与工具
排查锁问题时,pg_stat_activity 的 query_start 与 state_change 是两条关键时间线:前者说明查询跑了多久,后者说明状态多久没变。idle in transaction 且 state_change 很久没变,基本就是「忘了提交」。
排查用的 SQL 应整理成 runbook 固化下来,而不是每次临时手写。把本文的诊断查询、PostgreSQL 查询优化实战 里的慢查询分析、以及 PostgreSQL 性能调优 里的参数调优方法组合起来,就构成了一套完整的「性能故障响应手册」。事务隔离级别如何影响锁行为,也是排查时需要考虑的一环——不同隔离级别下加锁的范围与时机不同。
六、常见错误速查
| 报错 / 症状 | 根因 | 处置 |
|---|---|---|
deadlock detected | 加锁顺序不一致 | 统一顺序,应用侧捕获重试 |
canceling statement due to lock timeout | lock_timeout 生效 | 正常,重试即可 |
canceling statement due to statement timeout | 语句超时 | 优化查询或调大超时 |
| 全表查询集体变慢 | DDL 排队阻塞 | 找到 DDL 会话,pg_cancel_backend |
idle in transaction 堆积 | 应用忘记提交 | 设 idle_in_transaction_session_timeout |
| 库存扣减排队 | 热点行冲突 | 分片计数,写分散读聚合 |
| 死锁只在压测时出现 | 并发顺序随机 | 用确定性排序消除 |
紧急情况下的处置手段:pg_cancel_backend(pid) 取消某个会话当前的查询,pg_terminate_backend(pid) 直接断开连接。前者更温和(可回滚),后者用于「会话卡死、连取消都不响应」的情况。执行前务必确认 PID 对应的会话确实是元凶,误杀业务连接会引发二次故障。
-- 先确认,再操作
SELECT pid, usename, application_name, state, left(query, 50)
FROM pg_stat_activity WHERE pid = 12345;
SELECT pg_cancel_backend(12345);
小结
PostgreSQL 锁问题的本质是「多粒度锁 + FIFO 队列」的相互作用:普通查询只拿 ACCESS SHARE,但一个 ACCESS EXCLUSIVE 的 DDL 会把它后面的所有查询一起堵住,形成雪崩。诊断的抓手是 pg_blocking_pids()——它直接给出「谁被谁阻塞」,顺着链条就能找到元凶。预防上,lock_timeout 让 DDL 快速失败、idle_in_transaction_session_timeout 清理忘记提交的会话、统一加锁顺序消除死锁、热点行用分片打散。最后,把阻塞数作为监控告警指标,把诊断 SQL 固化成 runbook——锁问题几乎不可能靠「事后反应」解决,必须在它演变成雪崩前就介入。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。