锁:为什么行锁不占内存,而 DDL 会阻塞读
MVCC 解决了「读写互不阻塞」,但写和写之间仍然需要协调。这就是锁的领域。
表锁:八个级别
Section titled “表锁:八个级别”BEGIN; LOCK TABLE orders IN SHARE MODE; SELECT locktype, relation::regclass::text AS 对象, mode AS 锁模式, granted AS 已获得 FROM pg_locks WHERE relation = 'orders'::regclass;
ROLLBACK;
Postgres 有八种表级锁模式。实际使用中只需要记住这张表——哪个操作拿哪把锁,以及它挡住谁:
| 锁模式 | 谁获取它 | 阻塞什么 |
|---|---|---|
ACCESS SHARE |
SELECT |
只被 ACCESS EXCLUSIVE 阻塞 |
ROW SHARE |
SELECT FOR UPDATE/SHARE |
EXCLUSIVE 及以上 |
ROW EXCLUSIVE |
INSERT UPDATE DELETE |
SHARE 及以上 |
SHARE UPDATE EXCLUSIVE |
VACUUM ANALYZE CREATE INDEX CONCURRENTLY |
自己和更高级别 |
SHARE |
CREATE INDEX(非并发) |
写操作 |
SHARE ROW EXCLUSIVE |
CREATE TRIGGER 等 |
写操作和 SHARE |
EXCLUSIVE |
很少见 | 除 ACCESS SHARE 外全部 |
ACCESS EXCLUSIVE |
多数 ALTER TABLE DROP TRUNCATE VACUUM FULL |
全部,包括 SELECT |
规律:同名的两把锁一定互相冲突(ROW EXCLUSIVE 是唯一例外,写与写在表级不冲突——它们靠行锁协调)。
看一眼 UPDATE 期间的锁:
BEGIN; UPDATE orders SET amount = amount WHERE id = 1; SELECT relation::regclass::text AS 对象, mode AS 锁模式 FROM pg_locks WHERE relation = 'orders'::regclass;
SELECT locktype AS 锁类型, mode AS 模式, granted
FROM pg_locks WHERE locktype IN ('transactionid', 'tuple');ROLLBACK;
注意这里没有出现「行锁」的条目。表级只有一个 ROW EXCLUSIVE,另外多了一个 transactionid 类型的锁。
原因是 Postgres 的一个关键设计:行锁不存在锁管理器里,它存在元组头的 xmax 字段里。
一个事务锁住某行,就是把自己的事务号写进那行的 xmax(加上「这是锁不是删除」的标记位)。这意味着:
- 锁一亿行和锁一行,占用的内存完全一样——都是零。锁信息随数据一起躺在页面上。
- 代价是加锁会产生行版本变更,也就是会写 WAL、会产生死元组。
SELECT ... FOR UPDATE不是只读操作。
那么等待是怎么实现的?会话 B 想改一行,发现它的 xmax 是事务 A,于是 B 去等待「事务 A 这个对象」的锁——这就是上面那个 transactionid 锁。每个事务在开始时都对自己的事务号持有一把排他锁,事务结束时释放,所有等待者被唤醒。
行锁的四种强度
Section titled “行锁的四种强度”| 模式 | 语句 | 用途 |
|---|---|---|
FOR UPDATE |
最强 | 打算修改或删除这行 |
FOR NO KEY UPDATE |
普通 UPDATE 隐式使用 |
不改键列的更新 |
FOR SHARE |
共享 | 不让别人改,但允许别人也读锁 |
FOR KEY SHARE |
最弱,外键检查隐式使用 | 只防止键被改 |
DDL 与锁:最常见的生产事故
Section titled “DDL 与锁:最常见的生产事故”DROP TABLE IF EXISTS jobs; CREATE TABLE jobs (id serial PRIMARY KEY, status text DEFAULT 'pending', payload text); BEGIN; ALTER TABLE jobs ADD COLUMN c1 int; SELECT relation::regclass::text AS 对象, mode AS 锁模式 FROM pg_locks WHERE relation = 'jobs'::regclass;
ROLLBACK;
ACCESS EXCLUSIVE——在这个事务提交之前,任何人连 SELECT jobs 都做不到。
加一列本身是瞬间完成的(PG 11 之后连带默认值也不用重写表)。危险不在于操作本身有多慢,而在于拿锁要排队,而排队会传染:
这是 DDL 事故的完整机制:ALTER TABLE 本身只要几毫秒,但它在等一个长事务;而它一旦进入等待队列,后面所有的查询都被它挡住——因为锁队列是先进先出的,新来的 SELECT 不能越过排队中的 ACCESS EXCLUSIVE 请求。
结果就是:一个无害的 ALTER TABLE + 一个碰巧存在的长事务 = 整张表停摆。
主动加锁与不等待
Section titled “主动加锁与不等待”SET lock_timeout = '100ms'; SHOW lock_timeout;
SET lock_timeout = 0; SELECT 'lock_timeout 已恢复' AS 状态;
拿不到行锁时有三种选择:
BEGIN; SELECT id FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
ROLLBACK;
| 写法 | 拿不到锁时 |
|---|---|
FOR UPDATE(默认) |
一直等 |
FOR UPDATE NOWAIT |
立即报错 |
FOR UPDATE SKIP LOCKED |
跳过这行,继续找下一行 |
SKIP LOCKED 是实现任务队列的关键,本阶段最后一节会展开。
有时你要保护的东西不在数据库里——「同一时刻只能有一个实例跑这个定时任务」。咨询锁(advisory lock)提供了一个和数据无关的命名互斥量:
SELECT pg_try_advisory_lock(12345) AS 拿到锁了吗;
SELECT pg_advisory_unlock(12345) AS 释放成功;
pg_try_advisory_lock 拿不到就立即返回 false,不阻塞。这比用一张表加 SELECT FOR UPDATE 模拟锁干净得多——不产生死元组,不需要 VACUUM。
先自己回答,再点开对照。
哪一把表锁会阻塞 SELECT?哪些操作会拿它?
只有 ACCESS EXCLUSIVE。它阻塞一切,包括最弱的 ACCESS SHARE——也就是 SELECT。
拿它的操作:多数 ALTER TABLE、DROP、TRUNCATE、VACUUM FULL。
其余七把锁没有一把挡得住 SELECT。普通读写在表级永不冲突:SELECT 拿 ACCESS SHARE,INSERT / UPDATE / DELETE 拿 ROW EXCLUSIVE,两者相容。
常见错误:以为 VACUUM 会锁表、所以要挑半夜跑。普通 VACUUM 拿的是 SHARE UPDATE EXCLUSIVE,只挡自己和更高级别,读写全都照常。真正会让整张表停摆的是 VACUUM FULL——名字只差两个词,锁级别隔了四档。同理 CREATE INDEX(非并发)拿 SHARE,挡写但不挡读。
Postgres 的行锁存在哪里?这个设计带来了什么好处和什么代价?
存在元组头的 xmax 字段里,不在锁管理器里。一个事务锁住某行,就是把自己的事务号写进那行的 xmax,再加上「这是锁不是删除」的标记位。
好处:锁一亿行和锁一行占用的内存完全一样——都是零。 锁信息随数据躺在页面上。
代价:加锁本身就是一次行版本变更,要写 WAL、要产生死元组。
常见错误一:以为 SELECT ... FOR UPDATE 是只读操作,可以当成普通查询随便用。它会改元组头、写 WAL、留下需要 VACUUM 打扫的痕迹——一个高频 FOR UPDATE 的表,膨胀速度和高频 UPDATE 的表是一个量级的。
常见错误二:担心「锁的行太多会触发锁升级变成表锁」,于是刻意把批量操作切小。Postgres 没有锁升级这回事——行锁不占锁管理器的内存,也就没有需要节省的资源。这条经验在别的数据库上成立,在这里是多余的顾虑。
为什么 pg_locks 里看不到具体哪一行被锁了?该怎么排查行锁争用?
因为行锁根本不经过锁管理器,而 pg_locks 只反映锁管理器里的东西。会话 B 想改一行,发现它的 xmax 是事务 A,于是去等待「事务 A 这个对象」的锁——pg_locks 里那个 transactionid 条目。等待的对象是事务,不是行,所以「哪一行」这个信息压根没进视图。
排查要靠 pg_stat_activity:
SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) AS 被谁挡住, queryFROM pg_stat_activity WHERE wait_event_type = 'Lock';pg_blocking_pids() 给出阻塞方的 pid,再顺着它的 query 反推是哪一行。
常见错误:把 pg_locks 当作完备的锁视图——翻一遍没发现冲突,就得出「没有行锁争用」的结论。它能告诉你谁在等哪个事务,但行级的冲突信息只存在于页面上的元组头里,视图里一片安静完全不能证明没有争用。
一个只读了一行的长事务,怎么会导致 ALTER TABLE 让整张表停摆?
三步传导:
- 长事务 A 持有
ACCESS SHARE——最弱的锁,本身不挡任何正常读写; ALTER TABLE要ACCESS EXCLUSIVE,被 A 挡住,进入等待队列;- 锁队列是先进先出的,不允许插队。此后所有新来的
SELECT都排在那个ACCESS EXCLUSIVE请求后面。
于是一个人畜无害的只读长事务,加一条几毫秒就能完成的 ALTER TABLE,等于整张表对所有人不可用——直到 A 结束。
常见错误:以为「ALTER TABLE 只要几毫秒,窗口极小,风险可以接受」。停摆时长和 DDL 本身的耗时完全无关,它等于「前面那个长事务还要活多久」。评估一次 DDL 的风险,要看的是集群里最长事务的时长,不是 ALTER 语句自己跑多快。这就是为什么「加一列是安全操作」这句话在有长事务的库上是错的。
执行 DDL 前必须做的一件事是什么?
设 lock_timeout。
SET lock_timeout = '3s';ALTER TABLE jobs ADD COLUMN c int;拿不到锁就在 3 秒后失败退出,而不是无限排队把后面的查询全堵住。失败了重试即可——重试一百次,总有一次能在长事务的间隙里挤进去。
配套:执行前查一眼有没有长事务,并把 idle_in_transaction_session_timeout 设上。
常见错误:查了 pg_stat_activity、确认当前没有长事务,就直接裸奔执行。检查和执行之间存在时间差,而拿不到锁的后果不是「自己慢一点」,是队列传染——整张表停摆。lock_timeout 是唯一能保证「失败也只是失败」的机制;查长事务是补充,不是替代。
会话级和事务级咨询锁的区别?用连接池时该选哪个?
pg_advisory_lock 的生命周期是会话——事务回滚不释放,必须显式 pg_advisory_unlock 或断开连接。pg_advisory_xact_lock 的生命周期是事务,事务结束自动释放。
用连接池时几乎总是应该用事务级的那个。
常见错误:以为 ROLLBACK 会把事务里拿到的所有锁一并释放掉。行锁和表锁确实随事务结束释放,会话级咨询锁不会——它压根不属于事务。连接池下这个泄漏尤其隐蔽:连接被归还进池子、复用给另一个请求,锁还挂在上面,后续所有拿到这条连接的请求都会以为「已经有别人在跑了」而直接跳过——一个静默的、按连接分布的功能失效,而不是一次响亮的报错。