PostgreSQL的锁机制并不是简单的一把大锁,而是分为表级锁、行级锁、页级锁、咨询锁等多个层次,同时与MVCC并发控制互相配合。很多看似普通的INSERT、UPDATE、DELETE,背后都会在表、索引、行甚至事务ID上获取不同类型的锁。本文先梳理锁模式及其兼容关系,再说明死锁如何被检测,最后落到实际排查命令与预防策略。

一、PostgreSQL锁的类型与兼容关系
PostgreSQL的锁从作用范围上可以分为表级锁、行级锁、页级锁和咨询锁。表级锁有八种模式,常见关系如下:普通SELECT只获取 ACCESS SHARE,DDL 获取 ACCESS EXCLUSIVE,UPDATE/DELETE 获取 ROW EXCLUSIVE。行级锁则通过元组上的标记实现,包含 FOR KEY SHARE、FOR SHARE、FOR NO KEY UPDATE、FOR UPDATE 四种。表级锁之间有兼容矩阵,比如两个 SELECT 可以并发,ROW EXCLUSIVE 与 ACCESS EXCLUSIVE 冲突,所以一条 UPDATE 语句没有提交时,ALTER TABLE 会被阻塞。
很多锁等待并不是因为行锁冲突,而是表级意向锁之间的冲突。例如一个事务执行 UPDATE 只改了表的几行,它在表上持有 ROW EXCLUSIVE;另一个会话要执行 DROP TABLE 或者添加字段,需要 ACCESS EXCLUSIVE。这两种表锁互不兼容,即使实际修改的行完全不相干,DDL 也必须等待。这也是为什么在业务高峰直接执行 DDL 非常危险。
可以用下面语句查看当前实例中已授予和等待中的锁。relation 列通过 ::regclass 转换成表名,能够快速看到哪些表正在被锁。
SELECT locktype,
mode,
granted,
pid,
relation::regclass AS table_name
FROM pg_locks
WHERE relation IS NOT NULL
ORDER BY pid;
如果查询结果里出现同一个 relation 上多个 granted 为 false 的记录,通常意味着这里形成了等待队列。此时再结合 pg_stat_activity 就能知道哪个 pid 持有锁、哪个 pid 在等锁。
二、死锁的产生条件与检测机制
死锁需要四个条件同时满足:互斥、持有并等待、不可抢占、循环等待。PostgreSQL 的常规锁基本都满足前三个条件,因此只要多个事务以相反顺序访问同一组资源,就可能出现循环等待。数据库会在 deadlock_timeout 之后启动死锁检测器,扫描等待图并判断是否存在环。默认值是 1 秒,也就是说死锁发生后通常会在一秒左右被检测出来,而不像某些数据库可能等到锁超时。
最典型的死锁场景是两个事务互相更新对方已经锁住的行。例如会话 A 先改 id=1 再改 id=2,会话 B 先改 id=2 再改 id=1。当两边都持有第一把行锁并等待第二把行锁时,等待图形成环,PostgreSQL 会选择回滚其中一个事务,另一个事务继续执行。被回滚的会话会看到 deadlock detected 错误。
-- 会话A BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT; -- 会话B BEGIN; UPDATE accounts SET balance = balance - 50 WHERE id = 2; UPDATE accounts SET balance = balance + 50 WHERE id = 1; COMMIT;
除了显式更新同一张表,外键约束、唯一约束、咨询锁也可能制造死锁。比如两个事务分别插入互相引用的子记录,或者先更新父行再插入子行,而另一个事务反向操作,都可能触发死锁。由于死锁检测会强制回滚一个事务,应用层必须对所有事务实现重试逻辑,否则业务会直接报错。
三、锁等待与死锁的排查命令
排查锁等待的第一步不是只看 pg_locks,而是先看 pg_stat_activity。wait_event_type 等于 Lock 时,说明这个后端进程正在等待锁;wait_event 会给出更细的锁类型。pg_blocking_pids 函数可以直接返回直接阻塞当前进程的 pid 列表,适合快速定位源头。
SELECT pid,
usename,
state,
query,
wait_event_type,
wait_event,
pg_blocking_pids(pid) AS blocked_by_pids
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND pid <> pg_backend_pid();
拿到阻塞 pid 后,先看该会话在做什么、持锁时间多长。如果确认是长时间无响应的旧事务,可以根据影响范围选择 pg_cancel_backend 取消查询或 pg_terminate_backend 终止连接。前者只取消当前语句,后者直接断开会话,回滚整个事务。
SELECT pg_cancel_backend(12345); SELECT pg_terminate_backend(12345);
如果锁等待频繁出现,建议打开 log_lock_waits。该参数会在锁等待超过 deadlock_timeout 时记录日志,包含等待的锁类型和阻塞 pid。同时也可以把 deadlock_timeout 调低,让死锁检测更及时,代价是后台检测更频繁。
ALTER SYSTEM SET log_lock_waits = on; ALTER SYSTEM SET deadlock_timeout = '1s'; SELECT pg_reload_conf();
已经发生的死锁可以通过日志检索,关键字是 deadlock detected。日志里通常包含被阻塞和阻塞方的进程号、事务号、锁模式以及具体 SQL 片段。
grep 'deadlock detected' /var/lib/postgresql/data/log/postgresql.log
如果不需要改动全局参数,也可以在单个会话中设置 deadlock_timeout。常规原则是:先定位阻塞链,再判断要不要终止会话,最后从日志确认死锁发生的完整过程。
四、预防死锁的工程实践
减少死锁最有效的方法是让事务尽量短、锁范围尽量小。事务中不要包含远程 API 调用、用户交互或长时间计算。SQL 语句如果缺少索引,UPDATE/DELETE 可能会扫描并锁定大量无关行,原本不相干的业务也会互相阻塞。给过滤条件、外键列和唯一约束列建立合理索引,能够显著降低锁竞争。
对于批量队列处理,可以使用 FOR UPDATE SKIP LOCKED。多个消费者同时读取任务表时,每个事务只锁定自己拿到的行,跳过已经被其他事务锁住的行,从而避免多个消费者抢同一批任务造成死锁。
SELECT id, payload FROM tasks WHERE status = 'pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 10;
业务代码中还要统一加锁顺序。比如涉及多个账户转账,可以先按账户 ID 排序,再依次更新,这样不同事务就不会以相反顺序访问同一组行。这个简单的编码约定能消除大部分双行更新死锁。即使做到了这一点,数据库在并发环境下仍可能出现偶发死锁,所以事务重试机制不是可选项,而是基本要求。

