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 SHARE | AS | 最宽松,纯读取场景 |
| ROW SHARE | RS | 带行锁意图的读 |
| ROW EXCLUSIVE | RE | 普通写(增删改) |
| SHARE UPDATE EXCLUSIVE | SUE | 维护性操作,允许并发读写 |
| SHARE | S | 只读并发,禁止写 |
| SHARE ROW EXCLUSIVE | SRE | 更严格的结构性变更 |
| EXCLUSIVE | E | 几乎独占 |
| ACCESS EXCLUSIVE | AE | 完全独占,禁止一切并发访问 |
这 8 种模式两两之间是否兼容,由一张兼容性矩阵决定。原文给出的兼容关系大致可以这样记忆:前 4 种模式(AS、RS、RE、SUE)互相之间基本兼容,它们的共同特点是允许其他事务继续并发地修改表数据;而后 4 种模式(S、SRE、E、AE)一旦被持有,就会阻止别的事务修改数据,它们之间以及它们与前面模式的兼容范围也更窄。AE 是最强的模式,与所有模式(包括它自己)都互斥。
这套设计的用意是:绝大多数日常的增删改查操作(SELECT/INSERT/UPDATE/DELETE)只需要前 4 种较宽松的模式,因此彼此之间不会互相阻塞,能够高并发地跑;只有真正需要改变表结构、做全表级别维护的操作,才会申请后 4 种更严格的模式,这时才会排斥并发的读写。
各类 SQL 命令对应的锁模式
原文列出了常见命令与它们实际申请的锁模式的对应关系,大致如下:
SELECT(不带FOR UPDATE/SHARE)→ ACCESS SHARESELECT ... FOR UPDATE / FOR SHARE(以及类似的行锁定子句)→ ROW SHAREINSERT、UPDATE、DELETE(普通 DML)→ ROW EXCLUSIVEVACUUM(不带 FULL)、部分ALTER TABLE变体、CREATE INDEX CONCURRENTLY→ SHARE UPDATE EXCLUSIVECREATE INDEX(非并发)→ SHARECREATE TRIGGER、部分ALTER TABLE变体 → SHARE ROW EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLY→ EXCLUSIVEDROP TABLE、TRUNCATE、VACUUM FULL、LOCK TABLE(默认)、大多数ALTER TABLE→ ACCESS 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 会自动周期性地检测这种循环等待关系,一旦确认存在死锁,就会主动选择其中一个事务将其中止(报错回滚),从而打破循环,让其余事务得以继续。
监控与诊断手段
原文介绍了两个用于诊断锁等待问题的实用工具:
log_lock_waits参数:打开后,如果某个会话等待锁的时间超过了deadlock_timeout(默认 1 秒),服务器就会在日志中记录一条信息,说明等待发生的位置、等待了多久,以及是被哪个进程阻塞的,便于事后排查。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()函数是排查锁问题的三大常用手段。
这些概念是后续几篇文章(行级锁、其他类型锁、内存中的锁)的基础。