跳转到内容

锁:为什么行锁不占内存,而 DDL 会阻塞读

准备中…

MVCC 解决了「读写互不阻塞」,但写和写之间仍然需要协调。这就是锁的领域。

BEGIN;
LOCK TABLE orders IN SHARE MODE;
SELECT locktype, relation::regclass::text AS 对象, mode AS 锁模式, granted AS 已获得
FROM pg_locks WHERE relation = 'orders'::regclass;
⌘/Ctrl + Enter
ROLLBACK;
⌘/Ctrl + Enter

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;
⌘/Ctrl + Enter
SELECT locktype AS 锁类型, mode AS 模式, granted
FROM pg_locks WHERE locktype IN ('transactionid', 'tuple');
⌘/Ctrl + Enter
ROLLBACK;
⌘/Ctrl + Enter

注意这里没有出现「行锁」的条目。表级只有一个 ROW EXCLUSIVE,另外多了一个 transactionid 类型的锁。

原因是 Postgres 的一个关键设计:行锁不存在锁管理器里,它存在元组头的 xmax 字段里。

一个事务锁住某行,就是把自己的事务号写进那行的 xmax(加上「这是锁不是删除」的标记位)。这意味着:

  • 锁一亿行和锁一行,占用的内存完全一样——都是零。锁信息随数据一起躺在页面上。
  • 代价是加锁会产生行版本变更,也就是会写 WAL、会产生死元组。SELECT ... FOR UPDATE 不是只读操作。

那么等待是怎么实现的?会话 B 想改一行,发现它的 xmax 是事务 A,于是 B 去等待「事务 A 这个对象」的锁——这就是上面那个 transactionid 锁。每个事务在开始时都对自己的事务号持有一把排他锁,事务结束时释放,所有等待者被唤醒。

模式 语句 用途
FOR UPDATE 最强 打算修改或删除这行
FOR NO KEY UPDATE 普通 UPDATE 隐式使用 不改键列的更新
FOR SHARE 共享 不让别人改,但允许别人也读锁
FOR KEY SHARE 最弱,外键检查隐式使用 只防止键被改
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;
⌘/Ctrl + Enter
ROLLBACK;
⌘/Ctrl + Enter

ACCESS EXCLUSIVE——在这个事务提交之前,任何人连 SELECT jobs 都做不到

加一列本身是瞬间完成的(PG 11 之后连带默认值也不用重写表)。危险不在于操作本身有多慢,而在于拿锁要排队,而排队会传染

一个 ALTER TABLE 如何让整站宕机编排回放 · 非实时执行
Session A
Session B
0 / 8

这是 DDL 事故的完整机制ALTER TABLE 本身只要几毫秒,但它在等一个长事务;而它一旦进入等待队列,后面所有的查询都被它挡住——因为锁队列是先进先出的,新来的 SELECT 不能越过排队中的 ACCESS EXCLUSIVE 请求。

结果就是:一个无害的 ALTER TABLE + 一个碰巧存在的长事务 = 整张表停摆。

SET lock_timeout = '100ms';
SHOW lock_timeout;
⌘/Ctrl + Enter
SET lock_timeout = 0;
SELECT 'lock_timeout 已恢复' AS 状态;
⌘/Ctrl + Enter

拿不到行锁时有三种选择:

BEGIN;
SELECT id FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
⌘/Ctrl + Enter
ROLLBACK;
⌘/Ctrl + Enter
写法 拿不到锁时
FOR UPDATE(默认) 一直等
FOR UPDATE NOWAIT 立即报错
FOR UPDATE SKIP LOCKED 跳过这行,继续找下一行

SKIP LOCKED 是实现任务队列的关键,本阶段最后一节会展开。

有时你要保护的东西不在数据库里——「同一时刻只能有一个实例跑这个定时任务」。咨询锁(advisory lock)提供了一个和数据无关的命名互斥量:

SELECT pg_try_advisory_lock(12345) AS 拿到锁了吗;
⌘/Ctrl + Enter
SELECT pg_advisory_unlock(12345) AS 释放成功;
⌘/Ctrl + Enter

pg_try_advisory_lock 拿不到就立即返回 false,不阻塞。这比用一张表加 SELECT FOR UPDATE 模拟锁干净得多——不产生死元组,不需要 VACUUM

先自己回答,再点开对照。

哪一把表锁会阻塞 SELECT?哪些操作会拿它?

只有 ACCESS EXCLUSIVE。它阻塞一切,包括最弱的 ACCESS SHARE——也就是 SELECT

拿它的操作:多数 ALTER TABLEDROPTRUNCATEVACUUM FULL

其余七把锁没有一把挡得住 SELECT。普通读写在表级永不冲突:SELECTACCESS SHAREINSERT / UPDATE / DELETEROW 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 被谁挡住, query
FROM pg_stat_activity WHERE wait_event_type = 'Lock';

pg_blocking_pids() 给出阻塞方的 pid,再顺着它的 query 反推是哪一行。

常见错误:把 pg_locks 当作完备的锁视图——翻一遍没发现冲突,就得出「没有行锁争用」的结论。它能告诉你谁在等哪个事务,但行级的冲突信息只存在于页面上的元组头里,视图里一片安静完全不能证明没有争用。

一个只读了一行的长事务,怎么会导致 ALTER TABLE 让整张表停摆?

三步传导:

  1. 长事务 A 持有 ACCESS SHARE——最弱的锁,本身不挡任何正常读写;
  2. ALTER TABLEACCESS EXCLUSIVE,被 A 挡住,进入等待队列
  3. 锁队列是先进先出的,不允许插队。此后所有新来的 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 是唯一能保证「失败也只是失败」的机制;查长事务是补充,不是替代。

详见 SKIP LOCKED 队列与在线 DDL

会话级和事务级咨询锁的区别?用连接池时该选哪个?

pg_advisory_lock 的生命周期是会话——事务回滚不释放,必须显式 pg_advisory_unlock 或断开连接。pg_advisory_xact_lock 的生命周期是事务,事务结束自动释放。

用连接池时几乎总是应该用事务级的那个。

常见错误:以为 ROLLBACK 会把事务里拿到的所有锁一并释放掉。行锁和表锁确实随事务结束释放,会话级咨询锁不会——它压根不属于事务。连接池下这个泄漏尤其隐蔽:连接被归还进池子、复用给另一个请求,锁还挂在上面,后续所有拿到这条连接的请求都会以为「已经有别人在跑了」而直接跳过——一个静默的、按连接分布的功能失效,而不是一次响亮的报错。