跳转到主要内容

14 不同对象上的锁

PostgreSQL 18 · 基于俄文 PG18 原版的简体中文译本

14.1. 非关系锁

当需要锁定 PostgreSQL 概念中非 关系 的资源时,会使用类型为 object 的重锁1。此类资源可以是系统目录中几乎任何内容,例如表空间、订阅、模式、角色、策略、枚举类型等。

开始一个事务并在其中创建一张表:

=> BEGIN;
=> CREATE TABLE example(n integer);

现在查看 pg_locks 中的非关系锁:

=> SELECT database,
  (
    SELECT datname FROM pg_database WHERE oid = database
  ) AS dbname,
  classid,
  (
    SELECT relname FROM pg_class WHERE oid = classid
  ) AS classname,
  objid,
  mode,
  granted
FROM pg_locks
WHERE locktype = 'object'
  AND pid = pg_backend_pid() \gx
-[ RECORD 1 ]--------------
database  | 16391
dbname    | internals
classid   | 2615
classname | pg_namespace
objid     | 2200
mode      | AccessShareLock
granted   | t

此处锁定的资源由三个字段定义:

database — 锁定对象所属数据库的 oid(或为零,表示集群级的通用对象);

classid — pg_class 中的 oid,对应定义资源类型的系统目录表名;

objid — classid 所指向的系统目录表中的 oid。

列 database 显示当前会话连接的数据库 internals。列 classid 指向包含模式的表 pg_namespace。

现在我们可以解析 objid:

=> SELECT nspname FROM pg_namespace WHERE oid = 2200;
 nspname
---------
 public
(1 row)

因此,public 模式被锁定,以防止在事务完成前被删除。

相应地,删除对象时,会同时对对象本身及其所有依赖对象获取排他锁2。

=> ROLLBACK;

14.2. 关系扩展锁

当关系中的行数增加时,PostgreSQL 会尽可能在现有页面中利用空闲空间进行插入。但显然,在某个时刻必须添加新页面,即 扩展关系。物理上,页面会添加到相应段文件的末尾(这可能导致创建新的段文件)。

为防止两个进程同时开始添加页面,该操作受到一种特殊的重锁保护,其类型为 extend3。同样,索引清理时也使用这种锁,以防止其他进程在扫描期间添加新页面。

关系扩展锁的行为与之前讨论的锁略有不同:

  • 它在扩展操作完成后立即释放,无需等待事务结束;

  • 它不会导致死锁,因此在检测死锁的程序中对此做了特殊处理:它不会进入等待图。

说明

尽管如此,如果扩展等待时间超过 deadlock_timeout 参数设置的值,检测过程仍会被调用。这是一种异常情况,但当大量并行进程产生高插入负载时可能发生。在这种情况下,检测可能反复触发,实际上使系统陷入瘫痪。

在高插入负载下,扩展锁可能成为瓶颈,因此表文件的扩展不再仅增加一页,而是同时扩展多页(按等待锁的进程数成比例,但每次最多扩展 64 页)4。然而,基于 B-树的索引文件每次仍只增加一页5。(v. 16)

14.3. 页锁

仅在 GIN 索引的特定机制下使用类型为 page 的页面级重锁6。(参见 612)

GIN 索引允许加速在复合值中搜索元素,例如文本文档中的单词。这类索引大致可视为普通 B-树,其中存储的不是文档本身,而是文档中的各个单词。因此,当插入新文档时,索引需要大幅重构,将文档中的每个单词插入其中。

为提升性能,GIN 索引提供了延迟插入功能,通过存储参数 fastupdate 启用。新单词首先快速插入到一个无序的 待处理列表(pending list)中,经过一段时间后,所有累积的单词再批量移入主索引结构。这种优化基于不同文档中单词高度重复的假设,从而节省开销。(默认值:on)

为防止多个进程同时将待处理列表中的单词移入主索引,转移期间会以排他模式锁定索引的元页面。这不会影响索引的常规使用。

与关系扩展锁类似,页面锁在操作完成后立即释放(而非事务结束时),且不会导致死锁。

14.4. 咨询锁

与其他重锁(如关系锁)不同,建议锁 7(advisory locks)从不自动设置,由应用程序开发者手动管理。它们非常适合用于实现非标准的锁定逻辑。

假设存在一个不对应任何数据库对象的资源(我们无法通过 SELECT FOR 或 LOCK TABLE 等命令锁定它)。要锁定该资源,需将其映射为一个数值标识符。若资源具有唯一名称,一种简单方法是使用名称的哈希值:

=> SELECT hashtext('ресурс1');
 hashtext
-----------
 243773337
(1 row)

存在一组完整的函数 8 用于管理建议锁。其名称以 pg_advisory 开头,并包含额外词汇以明确功能:

lock — 获取锁;

try — 尝试获取锁,若无法立即获取则不等待;

unlock — 释放锁;

share — 使用共享模式(默认为排他模式);

xact — 锁定至事务结束(默认为会话结束)。

例如,获取一个会话级的排他锁:

=> BEGIN;
=> SELECT pg_advisory_lock(hashtext('ресурс1'));
=> SELECT locktype, objid, mode, granted
FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid();
 locktype |   objid   |     mode      | granted
----------+-----------+---------------+---------
 advisory | 243773337 | ExclusiveLock | t
(1 row)

为使锁定机制真正生效,其他进程也必须遵循相同的资源访问顺序。这一规则需由应用程序负责维护。

已设置的锁在事务结束后仍不会被释放:

=> COMMIT;
=> SELECT locktype, objid, mode, granted
FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid();
 locktype |   objid   |     mode      | granted
----------+-----------+---------------+---------
 advisory | 243773337 | ExclusiveLock | t
(1 row)

在完成对资源的操作后,显式释放锁:

=> SELECT pg_advisory_unlock(hashtext('ресурс1'));

14.5. 谓词锁

术语 谓词锁 最初出现在尝试基于锁实现完全隔离的早期尝试中9。当时面临的问题是,即使锁定了所有读取和修改的行,也无法实现完全隔离:表中可能新增满足原有筛选条件的 新行,从而导致 幻影 现象。(参见 51)

因此提出锁定条件(谓词)而非行。当执行带有谓词 a > 10 的查询时,锁定该谓词可防止向表中插入满足该条件的新行,从而避免幻影。但难点在于,当出现另一个条件(如 a < 20)时,需判断两个条件是否重叠。在一般情况下,该问题在算法上不可解;实践中仅能对具有极简单形式的谓词(如示例中)进行处理。

在 PostgreSQL 中,可串行化隔离级别通过另一种机制实现:基于快照的可串行化隔离协议(Serializable Snapshot Isolation, SSI)10。术语 谓词锁 保留了下来,但其含义已根本改变。实际上,此类“锁”并不真正阻塞任何操作,而是用于跟踪事务间的数据依赖关系。

已证明,基于快照的隔离(在 Repeatable Read 级别实现)允许出现 不一致写入异常 和 仅读事务异常,但不会出现其他异常。这两种异常在事务依赖图中对应特定模式,可通过低开销方式检测。(参见 67)

难点在于必须区分两种依赖类型:

  • 一个事务读取了一行,随后被另一个事务修改(RW 依赖);

  • 一个事务修改了一行,随后被另一个事务读取(WR 依赖)。

WR 依赖可通过现有常规锁检测,而 RW 依赖则需借助谓词锁额外跟踪。该跟踪机制在选择可串行化隔离级别时启用,因此至关重要:所有(或至少所有相互关联的)事务必须使用该级别。若任一事务使用其他隔离级别,它将不会设置(或检查)谓词锁,导致可串行化级别退化为 Repeatable Read。

再次强调:尽管名为“锁”,谓词锁实际上并不阻塞任何操作。相反,在事务提交时会进行检查,若发现“不良”依赖结构可能暗示异常,则事务将被中止。

创建一张包含数据的表,并建立一个占用若干页面的索引(为此设置了较低的 fillfactor 值):

=> CREATE TABLE pred(n numeric, s text);
=> INSERT INTO pred(n) SELECT n FROM generate_series(1,10000) n;
=> CREATE INDEX ON pred(n) WITH (fillfactor = 10);
=> ANALYZE pred;

若查询通过表的顺序扫描执行,谓词锁将作用于整个表(即使筛选条件仅匹配部分行)。

=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          70273
(1 row)
=> BEGIN ISOLATION LEVEL SERIALIZABLE;
=> EXPLAIN (analyze, costs off, timing off, summary off, buffers off)
  SELECT * FROM pred WHERE n > 100;
                   QUERY PLAN
------------------------------------------------
 Seq Scan on pred (actual rows=9900.00 loops=1)
   Filter: (n > '100'::numeric)
   Rows Removed by Filter: 100
(3 rows)

尽管谓词锁使用独立的基础设施,它们仍显示在 pg_locks 视图中,与重锁一同列出。任何谓词锁始终以一种特殊模式 SIRead(Serializable Isolation Read)获取:

=> SELECT relation::regclass, locktype, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 70273
ORDER BY 1, 2, 3, 4;
 relation | locktype | page | tuple
----------+----------+------+-------
 pred     | relation |      |
(1 row)
    => ROLLBACK;

需要注意的是,谓词锁并不总是在事务结束后立即释放,因为它们用于跟踪 事务之间 的依赖关系。但无论如何,其管理是自动完成的。

如果查询是通过索引扫描执行的,情况会向好的方向转变。对于 B-树,只需在读取的表行和索引的叶页面上设置预测性锁即可。这样不仅锁定了特定值,还锁定了整个读取的范围。

=> BEGIN ISOLATION LEVEL SERIALIZABLE;
=> EXPLAIN (analyze, costs off, timing off, summary off, buffers off)
  SELECT * FROM pred WHERE n BETWEEN 1000 AND 1001;
                            QUERY PLAN
-------------------------------------------------------------------
 Index Scan using pred_n_idx on pred (actual rows=2.00 loops=1)
   Index Cond: ((n >= '1000'::numeric) AND (n <= '1001'::numeric))
   Index Searches: 1
(3 rows)
=> SELECT relation::regclass, locktype, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 70273
ORDER BY 1, 2, 3, 4;
  relation  | locktype | page | tuple
------------+----------+------+-------
 pred       | tuple    |    4 |    96
 pred       | tuple    |    4 |    97
 pred_n_idx | page     |   28 |
(3 rows)

覆盖读取范围的叶页面数量可能会发生变化,例如由于插入新行导致索引页面分裂。但实现会考虑这种情况,并锁定新页面:

=> INSERT INTO pred
  SELECT 1000+(n/1000.0) FROM generate_series(1,999) n;
=> SELECT relation::regclass, locktype, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 70273
ORDER BY 1, 2, 3, 4;
  relation  | locktype | page | tuple
------------+----------+------+-------
 pred       | tuple    |    4 |    96
 pred       | tuple    |    4 |    97
 pred_n_idx | page     |   28 |
 pred_n_idx | page     |  266 |
 pred_n_idx | page     |  267 |
 pred_n_idx | page     |  268 |
 pred_n_idx | page     |  269 |
(7 rows)

每读取一个行版本都会创建一个独立的锁,但潜在的行版本数量可能非常多。预测性锁使用单独的锁池,其内存空间在服务器启动时分配。预测性锁的总数受参数 max_pred_locks_per_transaction 和 max_connections 的乘积限制(尽管参数名称暗示按事务分别计数,但实际上并不按事务单独计数)。(默认值:64)(默认值:100)

本质上,这与行锁的问题相同,但解决方式不同:通过提高锁的级别 11。

可以使用配置参数设置提高锁级别时的阈值。如果属于同一页面的行版本锁数量超过 max_pred_locks_per_page,这些锁将被替换为一个页面级别的锁。(默认值:2)

=> EXPLAIN (analyze, costs off, timing off, summary off, buffers off)
  SELECT * FROM pred WHERE n BETWEEN 1000 AND 1002;
                            QUERY PLAN
-------------------------------------------------------------------
 Index Scan using pred_n_idx on pred (actual rows=3.00 loops=1)
   Index Cond: ((n >= '1000'::numeric) AND (n <= '1002'::numeric))
   Index Searches: 1
(3 rows)

原本有三个 tuple 类型的锁,现在变为一个 page 类型的锁:

=> SELECT relation::regclass, locktype, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 70273
ORDER BY 1, 2, 3, 4;
  relation  | locktype | page | tuple
------------+----------+------+-------
 pred       | page     |    4 |
 pred_n_idx | page     |   28 |
 pred_n_idx | page     |  266 |
 pred_n_idx | page     |  267 |
 pred_n_idx | page     |  268 |
 pred_n_idx | page     |  269 |
(6 rows)
=> ROLLBACK;

同样地,页面锁的级别也会提升。如果属于同一关系的页面锁数量超过 max_pred_locks_per_relation,这些锁将被替换为一个关系级别的锁。(当参数值为负数时,以 max_pred_locks_per_transaction 除以该参数绝对值作为上限;因此默认情况下上限为 32)。(默认值:-2)(默认值:64)

当然,锁级别提升不可避免地会导致更多事务因序列化错误而意外终止。系统吞吐量最终会下降。需要在锁的内存开销与性能之间寻找平衡。

预测性锁支持以下类型的索引:

  • B-树;

  • 哈希索引、GiST 和 GIN。

如果使用索引访问,但索引不支持预测性锁,则会将整个索引加锁。当然,这也增加了事务因冲突而失败的次数。

为了在可串行化级别更高效地运行,应显式将仅读取的事务标记为 READ ONLY。如果锁管理器确认读取事务与其他事务不可能发生冲突12,它将能够释放已为该事务设置的预测性锁,并不再分配新的锁。如果进一步将此类事务声明为可延迟的(DEFERRABLE),则可避免仅读取事务的异常。(参见 71)