PostgreSQL 中的锁 — 2. 行级锁(Row-level locks)
原文:https://habr.com/en/companies/postgrespro/articles/503008/ (作者 Egor Rogov,PostgresPro)
行锁为什么和关系级锁不是一回事
第一篇讲的关系级锁存放在共享内存的锁表里,条目数量受 max_locks_per_transaction 限制。但一张表可能有几百万、几千万行,如果每一行的锁都要在内存里占一个槽位,锁表根本撑不住——很多数据库系统(比如某些行锁实现较"重"的引擎)在行锁数量过多时会做"锁升级",把大量行锁合并成一个表锁。
PostgreSQL 走了完全不同的路线:行锁的信息根本不放在内存里,而是直接写在数据页中每一行元组自己的头部(tuple header)里,具体来说是复用了 xmax 这个字段。这个设计决定带来了一组鲜明的优缺点:
- 优点:行锁数量理论上不受限——不管你锁多少行,都不消耗共享内存里的锁槽位,也就没有"锁表爆满"的风险;
- 缺点:因为锁信息不在内存里,系统无法像关系级锁那样维护一个全局的等待队列去做统一调度;想知道"当前有哪些行被锁着"也没有一个现成的内存视图可查,只能扫描数据页本身。
四种行锁模式
PostgreSQL 提供 4 种行级锁定模式,可以分成两组:
排他类(一次只能有一个事务持有):
FOR UPDATE—— 准备完整地修改或删除这一行(包括修改主键/唯一约束涉及的列);FOR NO KEY UPDATE—— 准备修改这一行,但不涉及会影响唯一性约束的"关键"列。
共享类(可以被多个事务同时持有):
FOR SHARE—— 只是想读取并"锁住"这一行,阻止其他事务对其做任何修改;FOR KEY SHARE—— 更弱的共享锁,只关心这一行的关键列不要被改变,允许其他事务修改非关键列。
四者的兼容关系里最值得记住的一点是:FOR KEY SHARE 与 FOR NO KEY UPDATE 是彼此兼容的。这个设计专门是为了外键检查场景服务的——数据库在校验外键约束时,通常只需要确认"被引用的键值没有变",用的正是 FOR KEY SHARE;而普通的 UPDATE(如果没有改到主键列)默认申请的是 FOR NO KEY UPDATE。这样一来,"更新某表的非主键字段"和"另一张表校验外键引用"就可以并发进行,互不阻塞,大大减少了外键相关的锁竞争。
xmax 字段是怎么身兼数职的
在 PostgreSQL 的 MVCC 实现里,xmax 本来的语义是"删除/替换这一行版本的事务号"——一行被 UPDATE 时,旧版本的 xmax 会被填上当前事务号,表示"这个版本从此对该事务及之后的事务不可见";DELETE 同理。
行锁复用了这同一个字段:当一个事务只是要"锁定"某行而不是真的删除它时,也会把自己的事务号写入该行的 xmax,同时通过元组头部里的信息位(t_infomask 中的标志位)来区分"这只是一个锁标记,行本身仍然存活"还是"这个版本真的被删除/替换了"。原文提到其中一个关键标志位是 lock_only(t_infomask 的第 128 位这一类的比特位),用来告诉后续读取者:xmax 里的事务号只是加了锁,不代表行已经死亡。
举例说明(示意):
-- 只更新非关键列
UPDATE accounts SET amount = amount + 100 WHERE acc_no = 1;
-- 该行旧版本的 xmax 被设为当前事务号,keys_upd 标志位 = 0
-- 更新了关键列(比如主键/唯一列)
UPDATE accounts SET acc_no = 20 WHERE acc_no = 2;
-- xmax 同样被设为当前事务号,但 keys_upd 标志位 = 1一个事务想知道"这行是不是被别人锁住了",只需要检查 xmax 里的事务号是否属于一个仍在运行(未提交/未回滚)的事务:如果是,说明这行被锁定或正在被修改;如果那个事务已经结束,xmax 的约束效力也就随之消失。
多事务机制(MultiXact):当多个事务共享同一把锁时
xmax 只有一个字段,只能记录一个事务号。但共享类锁(FOR SHARE/FOR KEY SHARE)恰恰允许多个事务同时持有同一行的锁——这就产生了矛盾:怎么在一个字段里记录"好几个事务都锁住了这一行"?
PostgreSQL 的答案是引入多事务(MultiXact):把一组同时持有锁的事务打包成一个"多事务对象",这个对象本身分配一个独立的编号(编号空间与普通事务号是分开计数的),然后把这个"多事务号"写进 xmax。元组头部另一个标志位 xmax_is_multi(t_infomask 的第 4096 位这一类比特)用来告诉读取者:"这里的 xmax 不是一个普通事务号,而是一个多事务号,要去别的地方查具体成员"。
具体每个多事务对象里包含哪些事务、各自持有的是什么锁模式,这些明细数据存放在 $PGDATA/pg_multixact/ 目录下的专门文件里,系统会为其维护热点缓冲区以加速频繁访问。
可以用扩展函数 pgrowlocks() 直观地看到这类信息,示例输出(示意):
locked_row | (0,1)
locker | 61
multi | t
xids | {530494,530495}
modes | {"Key Share","No Key Update"}也就是说这一行同时被两个事务(530494、530495)以不同模式锁着。
加锁的两阶段流程与它带来的排队行为
原文详细描述了一个事务要修改某一行时,实际经历的两层锁过程:
- 先对该元组本身申请一个(内部的、极短暂的)排他锁,确保没有别的进程同时在改同一行的头部;
- 检查这一行现有的
xmax是否已经指向一个还在运行的事务(即这行已经被别人锁定/修改中):如果是,就转而去申请对那个事务号的锁(这是关系级锁体系里的一种特殊对象锁,锁住的对象是"事务 ID"本身),并进入等待,直到那个事务提交或回滚; - 等到可以安全操作时,写入自己的
xmax和相应的信息位; - 释放第 1 步申请的元组内部锁。
这个两级结构造出了一种特殊的排队方式:假设事务 T1 正在修改某行并持有它,事务 T2 随后想修改同一行,T2 会先拿到"元组内部锁"(很快),然后转而排队等待"T1 这个事务号"的锁;此时如果又来了事务 T3、T4,它们会发现元组内部锁已经被 T2 占用(因为 T2 还在"排队等 T1"的过程中占着这把内部锁),于是 T3、T4 只能排队等这把元组内部锁本身,而不是各自单独去等 T1。这样设计的好处是:一旦 T1 结束,T2 被唤醒去完成自己的修改,然后 T3、T4 会看到一个"新版本"的行,再重新决定要不要基于新版本继续排队修改——避免了大量事务同时挤在等 T1 的名单里造成混乱,也降低了"抢不到锁"的概率。
原文用 PID 示例展示了这个过程:
事务1 (pid 5892):短暂持有元组锁,写入 xmax,然后提交
事务2 (pid 5928):持有元组锁,正在等待事务号 530497(ShareLock 模式)
事务3 (pid 5964):在排队等元组锁本身(granted = f)
事务4 (pid 6000):同样在排队等这把元组锁当事务1提交后,事务2被唤醒并完成自己的更新;事务3、4 则会看到新的行版本,转而排到事务2的事务号后面。
共享锁导致的"插队"问题
共享类锁因为彼此兼容,会造成一种比较隐蔽的排队问题。举例:
事务1:SELECT ... FOR SHARE,锁住某行(共享模式)
事务2:UPDATE 同一行,需要排他锁,只能等待事务1,同时持有元组内部锁
事务3:又执行 SELECT ... FOR SHARE
—— 因为 FOR SHARE 与事务1持有的共享锁是兼容的,
事务3不需要排队,直接"插队"拿到了共享锁!结果是:等事务1结束后,事务2本以为可以进行更新了,却发现事务3仍然持有着共享锁,只能继续等待。如果这种 FOR SHARE 请求源源不断地到来,事务2的更新可能被无限期推迟——这是一种典型的"写饥饿"现象,也是原文特别提醒要谨慎使用共享行锁的原因。
多事务号的冻结(Freezing)
和普通事务 ID 存在回卷(wraparound)问题类似,多事务号(MultiXact ID)的编号空间也是有限的,同样需要周期性"冻结"处理以避免用尽。不同的是,普通事务冻结只涉及行的 xmin,而多事务冻结要处理的是仍然存活、被锁定的元组的 xmax。
与此相关的几个参数:
vacuum_multixact_freeze_min_agevacuum_multixact_freeze_table_ageautovacuum_multixact_freeze_max_age
这些参数控制 autovacuum 何时以及多"激进"地去清理和冻结多事务信息,避免 pg_multixact 目录无限膨胀或触发紧急保护机制。
NOWAIT 与 SKIP LOCKED:不想等待时的选择
对于不希望阻塞等待的场景,PostgreSQL 提供两个子句:
NOWAIT:如果目标行已被锁定,立即报错返回,而不是排队等待:
SELECT * FROM accounts FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "accounts"SKIP LOCKED:跳过已经被锁住的行,只处理当前未被锁定的行:
SELECT * FROM accounts ORDER BY acc_no FOR UPDATE SKIP LOCKED;
-- 只返回排在前面、且当前未被锁定的那些行这两个子句在实现"多线程消费者从队列表中抢任务"这类场景时非常实用:每个 worker 都可以直接跳过被别的 worker 占用的行,无需排队等待,从而实现高效的并行任务分发。
小结
- PostgreSQL 的行锁不占用共享内存里的锁表槽位,而是直接编码在元组的
xmax字段及相关信息位中,因此数量不受限,但也无法通过一个全局视图直接列出"当前所有被锁的行"; - 一共 4 种行锁模式(
FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE),其中FOR KEY SHARE与FOR NO KEY UPDATE兼容,专门为降低外键校验带来的锁冲突而设计; - 当多个事务同时持有同一行的共享锁时,PostgreSQL 用"多事务(MultiXact)"机制把它们打包记录,相关明细存放在
pg_multixact目录; - 加锁采用两阶段流程(先锁元组本身,再排队等待持锁事务结束),这种设计减少了锁等待链的混乱程度,但共享锁的兼容性也可能导致排他锁请求被持续"插队"而饥饿;
- 多事务号同样需要定期冻结,避免编号空间耗尽;
NOWAIT和SKIP LOCKED是避免阻塞等待、实现非阻塞任务队列的实用工具。
实践建议(源自原文结论):应尽量避免多个并发事务频繁更新同一行;共享锁(FOR SHARE)的使用要格外谨慎,防止写操作被饿死;而基于 FOR KEY SHARE 的外键校验本身对性能影响较小,因为它与常见的行更新兼容。