PostgreSQL 的锁:为什么你的 ALTER TABLE 会卡住,以及怎么查
上周同事问我,为什么给一张表加个字段,整个表就像被冻住了一样,连普通的 SELECT 都进不来。其实这就是 PostgreSQL 的锁在起作用。
很多人用 PG,觉得它 MVCC 很厉害,读写互不阻塞。这没错,但 MVCC 主要解决的是“读”和“写”之间的冲突。真到了写和写、DDL 和 DML 之间,还是得靠锁来协调。今天就把 PG 里常见的锁捋一遍,顺便说说怎么排查锁等待和死锁。
先搞清楚:普通 SELECT 到底加不加锁?
这是最容易误解的地方。在 PostgreSQL 里,普通的 SELECT 只会在表上加一个 ACCESS SHARE 锁,这个锁非常弱,唯一会跟它冲突的就是 ACCESS EXCLUSIVE。也就是说,你平时查询,基本不会阻塞别人,别人也很难阻塞你。
真正会打架的是写操作和 DDL。比如 ALTER TABLE、DROP TABLE、TRUNCATE 这些通常会加 ACCESS EXCLUSIVE,这个锁一出来,所有其他访问都得排队。
所以,那个同事遇到的“加字段卡住”,大概率是 ALTER TABLE 在等 ACCESS EXCLUSIVE,而前面有长事务或者慢查询占着表,导致后面所有请求都堵住了。
表级锁
PostgreSQL 表级锁有 8 种模式,强度从弱到强排下来是:
- ACCESS SHARE
- ROW SHARE
- ROW EXCLUSIVE
- SHARE UPDATE EXCLUSIVE
- SHARE
- SHARE ROW EXCLUSIVE
- EXCLUSIVE
- ACCESS EXCLUSIVE
兼容矩阵我贴在下面,X 表示冲突,空白表示兼容:
| 请求\持有 | ACCESS SHARE | ROW SHARE | ROW EXCLUSIVE | SHARE UPDATE EXCLUSIVE | SHARE | SHARE ROW EXCLUSIVE | EXCLUSIVE | ACCESS EXCLUSIVE |
|---|---|---|---|---|---|---|---|---|
| ACCESS SHARE | X | |||||||
| ROW SHARE | X | X | ||||||
| ROW EXCLUSIVE | X | X | X | X | ||||
| SHARE UPDATE EXCLUSIVE | X | X | X | X | X | |||
| SHARE | X | X | X | X | X | |||
| SHARE ROW EXCLUSIVE | X | X | X | X | X | X | ||
| EXCLUSIVE | X | X | X | X | X | X | X | |
| ACCESS EXCLUSIVE | X | X | X | X | X | X | X | X |
这张表不用背,记住几个关键就行:
ACCESS SHARE是最弱的,普通SELECT拿的就是它。它只跟ACCESS EXCLUSIVE冲突。ROW EXCLUSIVE是INSERT/UPDATE/DELETE拿的,它跟SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE冲突。ACCESS EXCLUSIVE是老大,跟所有锁都冲突。ALTER TABLE、DROP TABLE、TRUNCATE、VACUUM FULL、CLUSTER、REINDEX这些基本都会拿它。CREATE INDEX CONCURRENTLY比较特殊,它拿的是SHARE UPDATE EXCLUSIVE,所以不会阻塞 DML,这也是大表建索引推荐它的原因。
我一般会提醒团队:DDL 之前一定要设 lock_timeout。不然一个 ALTER TABLE 可能因为等锁排到所有查询后面,把整个库拖慢。
SET lock_timeout = '3s';
ALTER TABLE users ADD COLUMN age int;
如果 3 秒拿不到锁,它自己就放弃了,不会一直堵着。
行级锁
行级锁是写操作在行上加的锁。普通 SELECT 不加行锁。四种行锁:
FOR KEY SHAREFOR SHAREFOR NO KEY UPDATEFOR UPDATE
强度从左到右递增。兼容矩阵:
| 请求\持有 | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | X | |||
| FOR SHARE | X | X | ||
| FOR NO KEY UPDATE | X | X | X | |
| FOR UPDATE | X | X | X | X |
几个实际场景:
UPDATE通常拿FOR NO KEY UPDATE。如果更新的是唯一键列,可能会升级成FOR UPDATE。DELETE通常拿FOR UPDATE。SELECT ... FOR UPDATE显式拿最强的行锁,用来做悲观锁。SELECT ... FOR SHARE拿共享锁,别人可以读,但不能改。SELECT ... FOR KEY SHARE最弱,外键检查时经常用。比如子表插入一条记录,会去父表对应行上加FOR KEY SHARE,防止父表键被删掉或改掉。
行锁信息存在元组头的 xmax 里。多个事务锁同一行时,PG 会用 MultiXact 来记录。行锁在事务结束时释放,所以长事务会一直占着行锁,这是很多锁等待的根源。
一个常见坑:外键会让子表写入阻塞父表键更新。比如你更新父表的主键,子表可能正在插入,两边就会互相等。设计外键时心里要有数。
锁等待和死锁,怎么查?
PG 提供了几个视图,最常用的是 pg_locks 和 pg_stat_activity。
查当前没拿到的锁:
SELECT pid, locktype, relation::regclass, mode, granted, query
FROM pg_locks l
LEFT JOIN pg_stat_activity a USING (pid)
WHERE NOT granted;
查谁被谁阻塞了:
SELECT pid, pg_blocking_pids(pid) AS blocking_pids, query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
pg_blocking_pids 这个函数特别好用,直接告诉你哪个进程堵住了当前查询。
如果要杀查询,有两个选择:
SELECT pg_cancel_backend(pid); -- 取消当前查询,连接还在
SELECT pg_terminate_backend(pid); -- 直接断开连接
一般先 cancel,不行再 terminate。
死锁是另一个话题。PG 有 deadlock_timeout,默认 1 秒。如果一个事务等锁超过这个时间,就会触发死锁检测。检测到循环等待,它会杀掉其中一个事务,报 deadlock detected,应用应该捕获这个错误并重试。
死锁没法完全避免,但可以降低概率:
- 事务尽量短,别在事务里等用户输入。
- 多个表更新时,固定访问顺序。
- 用
SELECT ... FOR UPDATE NOWAIT或SKIP LOCKED避免无限等待。
比如队列消费场景,SKIP LOCKED 就很好用:
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
这样多个 worker 可以同时取任务,不会互相等。
咨询锁:应用层的互斥
除了表锁和行锁,PG 还有咨询锁(Advisory Lock)。它不绑定任何数据库对象,纯粹是应用自己约定的一把锁。
会话级:
SELECT pg_advisory_lock(12345);
SELECT pg_advisory_unlock(12345);
事务级:
SELECT pg_advisory_xact_lock(12345);
事务级会在事务结束时自动释放,用起来更省心。咨询锁适合做“同一时间只有一个任务跑”这种场景,比如定时任务、分布式锁的简易实现。但注意,它只在同一个数据库集群内有效,跨实例不行。
几个实践里踩过的坑
- 长事务是万恶之源。一个事务开着不提交,行锁不释放,VACUUM 也清不掉死元组。监控
pg_stat_activity里state = 'idle in transaction'的会话。 - DDL 一定要加
lock_timeout。尤其是ALTER TABLE,不加锁超时,它可能排在一个长查询后面,把后面所有请求都堵死。 - 大表建索引用
CREATE INDEX CONCURRENTLY。普通CREATE INDEX会拿SHARE锁,阻塞写。并发建索引虽然慢一点,但不阻塞 DML。 VACUUM FULL和CLUSTER会拿ACCESS EXCLUSIVE,生产环境慎用,最好在低峰期做。- 隔离级别要心里有数。
READ COMMITTED是默认,每个语句一个快照。REPEATABLE READ和SERIALIZABLE可能报序列化失败,应用要有重试逻辑。 - 监控锁等待。可以定期查
pg_locks里granted = false的记录,或者用pg_blocking_pids看阻塞链。
最后说一句
PostgreSQL 的锁并不复杂,复杂的是不知道谁在等谁。MVCC 让读不阻塞写,但写写冲突、DDL 冲突还是得靠锁。遇到卡顿,先看 pg_locks 和 pg_stat_activity,找到阻塞源头,再决定是等、是取消,还是优化事务。
锁不是敌人,它只是并发控制的工具。理解它,你就能少踩很多坑。
本文来自博客园,作者:Y00,转载请注明原文链接:https://www.cnblogs.com/ayic/p/23121980
浙公网安备 33010602011771号