Skip to content

PostgreSQL 中的锁 — 3. 其他类型的锁(Other locks)

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

本篇的定位

前两篇分别讲了关系级锁(表/索引级别)和行级锁(数据行级别)。但 PostgreSQL 内部还有一批"配角"型的锁机制,它们各自解决非常具体的问题:死锁怎么检测、非关系型的数据库对象(schema、角色、类型等)怎么保护、表文件增长时怎么防止并发冲突、GIN 索引的待处理列表怎么合并、应用层怎么自定义加锁逻辑,以及可串行化隔离级别下如何用"伪锁"来追踪读写依赖关系。这篇文章把这些内容依次串起来。

死锁(Deadlock)

产生原因

死锁的本质是循环等待:事务 A 想要的资源被事务 B 占着,而事务 B 想要的资源又被事务 A 占着,两边互不相让。可以把这种关系画成一张"等待图"(wait-for graph),节点是事务,边表示"谁在等谁";一旦这张图里出现环,就意味着死锁已经发生。

PostgreSQL 如何发现死锁

PostgreSQL 并不会时刻不停地去检查是否存在死锁——那样开销太大。它的策略是"惰性检测":一个进程在申请锁失败、进入排队等待后,会启动一个计时器;如果超过 deadlock_timeout(默认 1 秒)仍未等到锁,系统才会去构建等待图、检查是否存在环。一旦发现死锁环,PostgreSQL 会选择环上的某一个事务强制中止(回滚),释放其占有的资源,从而打破循环,让其余事务能继续往前走。

相关参数与统计

  • deadlock_timeout:进入死锁检测前的等待阈值,默认 1 秒;
  • lock_timeout:限制单条语句在锁等待上能耗费的最长时间,超时直接报错,不再等待;
  • statement_timeout:限制单条语句总的执行时间;
  • pg_stat_database.deadlocks:数据库级别的统计列,记录曾经检测到过多少次死锁。

如何从设计上规避死锁

最常见、最有效的手段是约定好"资源加锁顺序":让所有事务都按照相同的顺序去访问/修改多个资源(比如永远先锁 ID 较小的账户,再锁 ID 较大的账户),这样就不会出现互相等待对方已持有资源的情况,从根本上避免循环。

原文用一个经典例子说明:两个事务分别转账,事务1先改账户1再改账户2,事务2却先改账户2再改账户1——如果两者时间上稍微交错,就会各自持有一个账户的锁、同时等待对方持有的另一个账户,形成死锁。只要统一改成"总是先操作 ID 较小的账户",这种死锁就不会发生。

容易被忽视的场景:单条语句内部也会死锁

原文特别指出,即便只是一条 UPDATE 语句(表面上看是单一原子操作),也可能因为内部借助索引扫描而按不同顺序处理行,与另一条并发语句的处理顺序恰好相反,从而在语句内部就构成循环等待、触发死锁。这提醒我们:死锁不总是"业务逻辑写错了加锁顺序",有时候是索引选择和执行计划差异带来的意外结果,排查时不能想当然地认为"我这条语句已经是原子的了,不可能死锁"。

对象级锁(Object locks)

作用范围

除了表、索引这些"关系"对象,数据库里还有大量非关系型的对象:表空间(tablespace)、订阅(subscription)、模式(schema)、枚举类型,以及系统目录里几乎所有能被 CREATE/ALTER/DROP 的东西。这些对象同样需要并发保护,PostgreSQL 用统一的"对象锁"机制来处理。

建表时会隐式加哪些对象锁

原文举了一个很直观的例子:执行 CREATE TABLE 时,除了会在新表本身上加锁之外,还会顺带在两个相关对象上申请 AccessShareLock

  1. 表的所有者角色(对应 pg_authid 目录里的一条记录)——目的是防止这张表刚建好、所有者角色就被删掉的尴尬情况;
  2. 表所在的 schema(对应 pg_namespace 目录里的一条记录)——同理,防止 schema 在建表过程中被并发删除。

在 pg_locks 里怎么识别对象锁

对象锁在 pg_locks 视图里通过三个字段联合定位具体对象:

  • database:对象所属数据库的 OID(如果是集群级的全局对象,这里是 0);
  • classid:来自 pg_class 的 OID,表明这个对象具体属于哪个系统目录(比如 pg_namespacepg_authid);
  • objid:该对象在对应目录里的 OID。

关系扩展锁(Relation extension lock)

当一张表、一个索引或一个物化视图的数据不断增长、现有页面已经写满、需要向底层文件追加新的物理页面时,PostgreSQL 需要保证不会有两个进程同时抢着去扩展同一个文件(否则可能出现页面冲突、数据损坏)。为此引入了一种专门的 extend 类型锁:谁先申请到,谁就负责去追加新页面,其他并发的写入者则排队等待。

这种锁的特点是"用完即放":它不会像事务锁那样一直持有到事务结束,而是完成扩展动作后立刻释放。从 PostgreSQL 9.6 开始还做了优化——不再是每次只追加一个页面,而是根据当前排队等待扩展的进程数量,一次性批量追加多个页面(数量与等待进程数成正比,但最多不超过 512 个),这样可以显著减少高并发写入场景下反复申请扩展锁的开销。

页级锁(Page-level locks)

这类锁主要出现在 GIN 索引(Generalized Inverted Index,常用于全文检索、数组包含查询等场景)开启了 fastupdate 特性时。为了避免每次插入新词条都去更新结构复杂的主索引,GIN 会先把新条目快速追加到一个无序的"待处理列表"(pending list)里,攒够一定量之后,再统一把这些条目合并进主索引结构。

在做这次"合并搬迁"的过程中,GIN 索引的元数据页(metapage)会被加上一个排他的页级锁,以保证合并过程的一致性。好在这个锁只在合并期间短暂持有,并不会妨碍索引的正常查询和插入使用,影响范围很有限。

建议锁 / 咨询锁(Advisory locks)

特点:完全由应用程序自己掌控

和前面提到的所有锁不同,建议锁(advisory lock)从不会被数据库自动申请——它存在的唯一目的就是让应用开发者可以借用 PostgreSQL 现成的锁基础设施,实现一套完全自定义的互斥/协调逻辑,而不依赖对具体表或行加锁。

典型用法

因为建议锁是靠一个(或一对)整数来标识"锁的是什么资源",开发者通常需要自己把某个业务含义的字符串/资源名哈希成数字:

sql
SELECT pg_advisory_lock(hashtext('resource1'));

这类锁在 pg_locks 视图里体现为 locktype = 'advisory'

生命周期:不随事务自动释放

普通的行锁、关系锁都会在事务提交或回滚时自动释放,但建议锁默认是"会话级"的——即便调用它的事务已经提交,锁依然保持,直到显式调用 pg_advisory_unlock() 释放,或者会话结束。这个特性使得它很适合用来实现跨多个事务、甚至跨越单次数据库交互边界的协调(比如应用层的分布式互斥锁)。

常用函数族

  • pg_advisory_lock / pg_advisory_unlock:会话级排他锁及其释放;
  • pg_advisory_lock_shared:会话级共享锁;
  • pg_advisory_xact_lock:事务级排他锁,随事务结束自动释放(不需要手动 unlock);
  • pg_try_advisory_lock:非阻塞版本,锁不可用时立即返回 false,而不是排队等待;
  • 以上模式还各自有对应的 shared / xact 变体组合,可按需选用。

谓词锁与可串行化隔离(Predicate locks & Serializable)

历史背景:为什么叫"锁"却不锁东西

早期一些数据库系统为了在可串行化隔离级别下防止"幻读"(phantom read)之类的异常,采用真正意义上的谓词锁(predicate lock)——即直接锁住查询条件(谓词)本身,而不是具体某一行。但这种做法在工程上非常复杂、代价高昂。

PostgreSQL 选择了一条不同的路:它的可串行化隔离建立在"快照隔离"(snapshot isolation)基础之上,再叠加一层事务间读写依赖关系的追踪。这里所谓的"谓词锁"其实是个历史遗留的名字——原文特别强调:"这些'锁'什么都不锁,它们只是用来记录事务之间的数据依赖关系。"

追踪的两类依赖关系

  • RW 依赖:事务 A 读取了某一行,之后事务 B 更新了这一行;
  • WR 依赖:事务 A 更新了某一行,之后事务 B 读取了这个更新后的结果。

谓词锁具体负责捕捉的是 RW 依赖。系统会在事务提交时检查已经记录下来的依赖链条:如果发现一种"危险的依赖模式"(通常表现为在依赖图上形成特定结构的环或链,暗示可能出现了不可串行化的异常结果),就会主动让其中一个事务以序列化失败(serialization error)的方式中止,逼迫应用层重试。

唯一的锁模式:SIReadLock

所有谓词锁统一使用一种模式,叫 SIReadLock(Serializable Isolation Read),在 pg_locks 里能看到这个标记。

加锁粒度取决于访问路径

  • 顺序扫描(Seq Scan):不管查询条件把结果过滤得多细,都会直接在整张表上加一个谓词锁,粒度是"关系级"的;
  • 索引扫描(比如 B-tree):则会更精细,分别对实际读取到的元组,以及扫描路径上经过的索引叶子页面,各自加锁——这种基于范围的方式能提供更精确的隔离,减少不必要的误报。

升级(Escalation)机制:为控制内存开销而牺牲一定精度

为了避免谓词锁本身无限膨胀占满内存,PostgreSQL 设了两级自动升级阈值:

  • 如果同一页面里的元组级谓词锁数量超过 max_pred_locks_per_page,会自动合并成一个页级锁;
  • 如果同一关系里的页级谓词锁数量超过 max_pred_locks_per_relation,会进一步合并成一个关系级锁。

这种升级是一种典型的空间换精度的权衡:合并之后占用内存更少,但粒度变粗了,会导致更多原本并不冲突的事务被误判为存在依赖关系,从而"误伤"更多的事务以序列化错误中止(即假阳性增多)。

总容量限制

系统里全部谓词锁的总数上限是 max_pred_locks_per_transaction × max_connections(默认值分别是 64 和 100,即默认总容量 6400 左右)。一旦超出这个上限,会直接报错。

索引类型支持情况

谓词锁细粒度追踪对索引类型是有要求的:

  • B-tree 索引:从很早的版本起就支持(原文提到 PostgreSQL 11 之前就已支持);
  • Hash、GiST、GIN 索引:从 PostgreSQL 11 开始也获得了支持。

对于不支持细粒度谓词锁的索引类型,系统只能退化为锁住整个索引,这会显著增加因误报导致的事务中止概率。

锁的存续时间

和其他锁不同,谓词锁并不会在持有它的事务一结束就立刻清空——因为它的作用是记录"跨事务"的依赖关系,只要还有可能和后续事务产生依赖冲突,相关信息就需要保留一段时间,由系统自动管理其生命周期和清理时机。

只有 Serializable 级别才会用到

原文明确指出:"正是谓词锁的使用,把'完全隔离'这一保证限定在 Serializable 隔离级别上。" 换句话说,运行在 Read Committed 或 Repeatable Read 级别下的事务根本不会申请、也不会被谓词锁检查,只有显式声明为 SERIALIZABLE 的事务才会启用这整套依赖追踪机制。

小结

这篇文章覆盖了几类"配角"锁,它们各自解决专门的问题:

  • 死锁:本质是循环等待,PostgreSQL 用"超时后惰性检测等待图"的策略自动发现并中止其中一个事务;统一的加锁顺序是最有效的预防手段,且需要注意单条语句内部也可能构成死锁;
  • 对象锁:保护 schema、角色、表空间等非关系型系统目录对象,在 pg_locks 里通过 database/classid/objid 三元组定位;
  • 关系扩展锁:防止多个进程同时向同一文件追加页面,用完即放,PG 9.6 起支持批量扩展多页;
  • 页级锁:主要用于 GIN 索引 fastupdate 特性下待处理列表合并到主结构时的元数据页保护;
  • 建议锁:完全由应用主动申请、语义自定义,默认会话级、需手动释放(或用 xact 版本随事务自动释放);
  • 谓词锁:名字虽是"锁",实际是可串行化隔离下用于追踪事务间 RW 依赖关系的机制,粒度可从元组级升级到页级再到关系级,是 PostgreSQL 实现 Serializable 隔离级别的核心基础设施。