Skip to content

PostgreSQL 中的锁 — 1. 关系级锁(Relation-level locks)

原文:https://habr.com/en/companies/postgrespro/articles/500714/ (作者 Egor Rogov,PostgresPro)

引言:为什么需要锁

任何支持并发的数据库系统都要解决同一个问题:多个进程/事务同时访问同一份数据时,如何保证结果是可预期、不冲突的。PostgreSQL 的做法是用"锁"来给共享资源的访问排序——当一个操作需要独占或部分独占某个资源时,它会先申请相应类型的锁;如果锁被别的事务以不兼容的方式持有,申请者就要排队等待。

PostgreSQL 内部其实存在好几层锁,覆盖的对象粒度和持续时间都不同:既有作用于整张表/索引这种"重量级"的关系级锁,也有作用到单行记录的行级锁,还有保护内存数据结构的轻量级锁(spinlock、LWLock)等。这个系列的第一篇文章聚焦在最容易被开发者直接感知到的一类:关系级锁,也就是加在表、索引、视图、序列等"关系"对象上的锁。

关系级锁存放在哪里

关系级锁属于"长时间持有、开销较大"的锁,它们并不像行锁那样藏在数据页里,而是集中存放在服务器的共享内存中,通过一张固定大小的锁表来管理。这张表的容量不是无限的,其上限由两个参数的乘积决定:

max_locks_per_transaction × max_connections

也就是说,系统平均要为每个连接预留一定数量的锁槽位。如果某个事务同时打开的关系数量(例如一次操作涉及非常多的分区表)超过了预留额度,锁表可能会用尽,报错提示需要调大 max_locks_per_transaction。需要注意的是,这两个参数都是需要重启数据库才能生效的启动参数,不能动态修改。

要查看当前系统里所有活跃的锁,可以查询系统视图 pg_locks。这张视图几乎是理解 PostgreSQL 锁机制的核心工具,后面几篇文章也会反复用到它。它的每一行代表"某个进程持有或正在等待某一把锁",关键列包括:锁的类型(locktype)、被锁对象的标识(database/relation/page/tuple 等,视锁类型而定)、锁模式(mode)、是否已经获得(granted)、持有/申请该锁的进程号(pid)等。

八种锁模式

关系级锁一共定义了 8 种模式,按照"限制性"从弱到强大致排列如下(并附常见缩写):

模式缩写大致含义
ACCESS SHAREAS最宽松,纯读取场景
ROW SHARERS带行锁意图的读
ROW EXCLUSIVERE普通写(增删改)
SHARE UPDATE EXCLUSIVESUE维护性操作,允许并发读写
SHARES只读并发,禁止写
SHARE ROW EXCLUSIVESRE更严格的结构性变更
EXCLUSIVEE几乎独占
ACCESS EXCLUSIVEAE完全独占,禁止一切并发访问

这 8 种模式两两之间是否兼容,由一张兼容性矩阵决定。原文给出的兼容关系大致可以这样记忆:前 4 种模式(AS、RS、RE、SUE)互相之间基本兼容,它们的共同特点是允许其他事务继续并发地修改表数据;而后 4 种模式(S、SRE、E、AE)一旦被持有,就会阻止别的事务修改数据,它们之间以及它们与前面模式的兼容范围也更窄。AE 是最强的模式,与所有模式(包括它自己)都互斥。

这套设计的用意是:绝大多数日常的增删改查操作(SELECT/INSERT/UPDATE/DELETE)只需要前 4 种较宽松的模式,因此彼此之间不会互相阻塞,能够高并发地跑;只有真正需要改变表结构、做全表级别维护的操作,才会申请后 4 种更严格的模式,这时才会排斥并发的读写。

各类 SQL 命令对应的锁模式

原文列出了常见命令与它们实际申请的锁模式的对应关系,大致如下:

  • SELECT(不带 FOR UPDATE/SHARE)→ ACCESS SHARE
  • SELECT ... FOR UPDATE / FOR SHARE(以及类似的行锁定子句)→ ROW SHARE
  • INSERTUPDATEDELETE(普通 DML)→ ROW EXCLUSIVE
  • VACUUM(不带 FULL)、部分 ALTER TABLE 变体、CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE
  • CREATE INDEX(非并发)→ SHARE
  • CREATE TRIGGER、部分 ALTER TABLE 变体 → SHARE ROW EXCLUSIVE
  • REFRESH MATERIALIZED VIEW CONCURRENTLYEXCLUSIVE
  • DROP TABLETRUNCATEVACUUM FULLLOCK TABLE(默认)、大多数 ALTER TABLEACCESS EXCLUSIVE

从这张对照表能看出一个很实用的经验规律:越是"破坏性"或"结构性"的操作(删表、清空表、改列类型等),锁级别越高,越容易和其他所有会话冲突;日常业务的读写反而互不干扰。这也是为什么在生产环境做 DDL(尤其是 ALTER TABLE)时要格外小心——哪怕语句本身执行得很快,只要它申请的是 ACCESS EXCLUSIVE,就会排斥所有并发的 SELECT。

排队机制:锁等待是"睡眠"而非"忙等"

当一个事务申请的锁与已有的锁不兼容时,它不会占用 CPU 空转轮询,而是进入等待队列并"休眠",直到持有冲突锁的事务释放资源、被唤醒为止。这种排队是公平的先进先出结构:后来的申请者会排在已经在等待的申请者之后,即使后来者要求的锁模式本可以和当前持有的锁共存。

一个经典的连锁场景是:会话 A 长时间持有一个表的某种锁(哪怕只是较弱的模式),此时会话 B 尝试申请一个与之冲突的高级别锁(比如某个 ALTER TABLE)并进入排队;接着会话 C 只是想执行一条普通的 SELECT(申请 ACCESS SHARE,本来和 A 持有的锁完全兼容),但因为它排在 B 后面,也必须等 B 排到并获得锁之后才能继续——于是即便 C 本身的请求与 A 并不冲突,它依然会被阻塞。这正是原文强调的重点:一条需要 ACCESS EXCLUSIVE 锁、执行本身很快的命令(比如一次 ALTER TABLE ADD COLUMN),如果排在了一堆长事务后面,可能会让整个系统对该表的访问"瘫痪"很长时间,因为后续所有请求(无论强弱)都被迫排在它后面等待。这也是很多线上"雪崩式"锁等待事故的根本原因。

死锁

当事务 A 需要事务 B 持有的资源、同时事务 B 又需要事务 A 持有的资源时,就形成了死锁(循环等待)。PostgreSQL 会自动周期性地检测这种循环等待关系,一旦确认存在死锁,就会主动选择其中一个事务将其中止(报错回滚),从而打破循环,让其余事务得以继续。

监控与诊断手段

原文介绍了两个用于诊断锁等待问题的实用工具:

  1. log_lock_waits 参数:打开后,如果某个会话等待锁的时间超过了 deadlock_timeout(默认 1 秒),服务器就会在日志中记录一条信息,说明等待发生的位置、等待了多久,以及是被哪个进程阻塞的,便于事后排查。

  2. pg_blocking_pids(pid) 函数(PostgreSQL 9.6 引入):传入一个正在等待锁的进程号,它会返回一个数组,列出所有直接或间接阻塞该进程的其他进程号。结合 pg_stat_activity 可以快速定位"到底是谁在拖慢/卡住了谁",比手工去解析 pg_locks 里的等待关系方便得多。

小结

这篇文章建立了理解 PostgreSQL 锁体系的基础框架:

  • 关系级锁保存在共享内存的锁表中,容量由 max_locks_per_transaction × max_connections 决定,改动需要重启;
  • 一共有 8 种锁模式,前 4 种(AS/RS/RE/SUE)彼此兼容、允许并发读写,后 4 种(S/SRE/E/AE)会不同程度地阻止并发修改,AE 是完全独占;
  • 不同 SQL 命令按照其影响范围申请不同强度的锁,DDL 类命令普遍需要高级别锁;
  • 锁的等待是排队式的休眠机制,且遵循先到先得,这意味着一个高强度锁的申请可能"插队"并连带阻塞后面所有原本互不冲突的请求;
  • PostgreSQL 会自动检测并解除死锁;
  • pg_locks 视图、log_lock_waits 参数和 pg_blocking_pids() 函数是排查锁问题的三大常用手段。

这些概念是后续几篇文章(行级锁、其他类型锁、内存中的锁)的基础。