第 14 章:多样的锁

第 14 章:多样的锁

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 — 系统目录表中列出的 oid,被 classid 所引用

database 值指向 internals 数据库;它是当前会话所连接的数据库。classid 列指向 pg_namespace 表,该表列出了模式。

现在我们可以解读 objid 了:

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

所以,PostgreSQL 已经锁定了 public 模式,以确保事务仍在运行时没有人可以删除它。

类似的,对象删除需要获取对象本身及其依赖的所有资源的独占锁 2

=> ROLLBACK;

14.2 关系扩展锁

随着关系中元组数量的增长,PostgreSQL 会尽可能将新元组插入到现有页面的空闲空间中。但很明显,在某些时候,它将不得不添加新的页面,即扩展表。就物理布局而言,新的页面会被添加到相应文件的末尾 (这会导致创建一个新的文件)。

为了使新页面一次只能由一个进程添加,这个操作受到一种特殊的 extend 类型 3 的重锁保护。索引清理也使用这种锁来禁止在索引扫描期间添加新的页面。

关系扩展锁的行为与我们迄今为止看到的有些许不同:

  • 一旦扩展完成,它们便会被立即释放,无需等待事务完成。

  • 它们不会导致死锁,所以不包含在等待图中。

    然而,如果扩展关系的过程超过了 deadlock_timeout,仍会执行死锁检测。这不是一种典型的情况,但如果大量进程同时执行多个插入操作,就可能会发生。在这种情况下,会多次调用死锁检测,这实际上会导致正常的系统操作瘫痪。

    为了将此风险降至最低,堆文件会一次性扩展多个页面 (与等待锁的进程数成比例,但每次操作不超过 512 个页面) 4。例外的是 B 树索引文件,一次只扩展一个页面 5

14.3 页锁

page 类型的页级重锁 6 仅应用于 GIN 索引,并且仅在以下情况使用。

GIN 索引可以加速复合值中元素的搜索,例如文本文档中的单词。它们大致可以描述为存储单独的单词而不是整个文档的 B 树。当添加新的文档时,必须彻底更新索引,以包含此文档中出现的每个单词。

为了提高性能,GIN 索引允许延迟插入,由 fastupdate 存储参数控制。新单词首先会被快速添加到一个无序的 pending list 中,一段时间之后,所有累积的条目都会被移动到主索引结构中。由于不同的文档可能包含重复的单词,因此这种方式无疑是十分划算的。

为了避免多个进程同时转移单词,索引的元页面会以独占模式锁定,直到所有的单词从 pending list 中移动到主索引。这个锁不会干扰正常的索引使用。

就像关系扩展锁一样,页锁在任务完成后立即释放,无需等待事务结束,因此它们永远不会导致死锁。

14.4 咨询锁

与其他重锁 (如关系锁) 不同,咨询锁 7 永远不会自动获取:它们由应用开发人员控制。如果应用程序出于某些特定目的需要使用专门的锁逻辑,这些锁就很方便使用。

假设我们需要锁定一个不与任何数据库对象对应的资源 (我们可以使用 SELECT FOR 或 LOCK TABLE 命令锁定的资源)。在这种情况下,需要为此资源分配一个数字 ID 。如果该资源有一个唯一的名称,那么最简单的方式是为此名称生成一个哈希码:

=> SELECT hashtext('resource1');
 hashtext
−−−−−−−−−−−
 991601810
(1 row)

PostgreSQL 提供了一整套用于管理咨询锁的函数 8。它们的名称以 pg_advisory 前缀开头,并且包含以下暗示函数用途的单词:

lock — 获取锁

try — 如果可以无需等待,便获取锁

unlock — 释放锁

share — 使用共享锁模式 (默认情况下,使用独占模式)

xact — 获取并持有锁直至事务结束 (默认情况下,锁会持有至会话结束)

让我们获取一个独占锁,直至会话结束:

=> BEGIN;
=> SELECT pg_advisory_lock(hashtext('resource1'));
=> SELECT locktype, objid, mode, granted
FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid();
 locktype |   objid   |     mode      | granted
−−−−−−−−−−+−−−−−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−−−
 advisory | 991601810 | 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 | 991601810 | ExclusiveLock | t
(1 row)

一旦对资源的操作结束,锁必须被显式释放:

=> SELECT pg_advisory_unlock(hashtext('resource1'));

14.5 谓词锁

谓词锁这个术语最早出现在首次尝试实现基于锁的完全隔离的时候 9。当时面临的问题是,即使锁定了要读取和更新的所有行,仍然无法保证完全隔离。实际上,如果新插入的行满足过滤条件,它们将成为幻象。

因此,有人建议锁定条件 (谓词) 而不是行。如果使用 a > 10 的谓词运行查询,锁定这个谓词将不允许在满足此条件的情况下向表中添加新行,因此可以避免幻象的出现。麻烦的是,如果出现带有不同谓词的查询,例如 a < 20,你必须找出这些谓词是否重叠。理论上,这个问题在算法上是无解的;实际上,仅仅是非常简单的谓词类别才能解决 (如本例所示)。

在 PostgreSQL 中,可串行化隔离级别以不同的方式实现:它使用可串行化快照隔离 (SSI) 协议 10。谓词锁这个术语仍然存在,但其含义已经彻底变了。事实上,这种"锁"并不锁定任何东西:它们用于跟踪不同事务之间的数据依赖关系。

已经证明,可重复读级别的快照隔离除了写偏序和只读事务异常之外,不允许任何异常。这两种异常会导致数据依赖图中的特定模式,这些模式可以以相对较低的成本发现。

问题是我们必须区分两种类型的依赖关系:

  • 第一个事务读取了之后由第二个事务更新的行 (RW 依赖)。
  • 第一个事务修改了之后由第二个事务读取的行 (WR 依赖)。

WR 依赖可以使用常规锁来检测,但是 RW 依赖必须通过谓词锁来跟踪。这种跟踪在可串行化隔离级别下会自动开启,这也正是为什么将该级别用于所有事务 (或至少所有相互关联的事务) 的重要原因。如果任何事务在不同级别下运行,它将不会设置 (或检查) 谓词锁,因此可串行化级别将降级为可重复读。

我想再次强调,尽管它们的名字如此,但谓词锁不会锁定任何东西。相反,当一个事务即将提交时,会检查"危险"的依赖关系,如果 PostgreSQL 怀疑有异常,这个事务将被中止。

让我们创建一个带有索引的表,该索引包括多个页面 (可以通过使用一个较低的 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
−−−−−−−−−−−−−−−−
          34753
(1 row)
=> BEGIN ISOLATION LEVEL SERIALIZABLE;
=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT * FROM pred WHERE n > 100;
               QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
 Seq Scan on pred (actual rows=9900 loops=1)
   Filter: (n > '100'::numeric)
   Rows Removed by Filter: 100
(3 rows)

虽然谓词锁有它们自己的基础设施,但 pg_locks 视图将它们与重锁显示在一起。所有的谓词锁总是以 SIRead 模式获取,这表示可串行化隔离读:

=> SELECT relation::regclass, locktype, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 34753
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)
SELECT * FROM pred WHERE n BETWEEN 1000 AND 1001;
                          QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
 Index Scan using pred_n_idx on pred (actual rows=2 loops=1)
   Index Cond: ((n >= '1000'::numeric) AND (n <= '1001'::numeric))
(2 rows)
=> SELECT relation::regclass, locktype, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 34753
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)

与已扫描元组相对应的叶子页面数量可以发生改变:例如,当向表中插入新行时,索引页面可能会发生分裂。然而,PostgreSQL 会将其考虑在内,并且也会锁定新出现的页面:

=> 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 = 34753
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 的限制 (尽管参数名如此,谓词锁并不是按单独事务计数的)。

在这里,我们遇到了与行级锁相同的问题,但解决方式不同:应用了锁升级。11

一旦与单个页面相关的元组锁的数量超过了 max_pred_locks_per_page 参数的值,便会被一个单独的页级锁替代

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

现在,不再是三个 tuple 类型的锁,而是一个 page 类型的锁。

=> SELECT relation::regclass, locktype, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 34753
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 除以 max_pred_locks_per_relation 的绝对值;因此,默认的阈值为 32)。

锁升级肯定会导致多次 false-positive 序列化错误,这会对系统吞吐量产生负面影响。所以你必须在性能和在锁上花费的内存之间找到一个合适的平衡点。

谓词锁支持以下索引类型:

  • B 树
  • 哈希索引,GiST 和 GIN

如果执行了索引扫描,但索引不支持谓词锁,那么整个索引将被锁定。可以预料,在这种情况下,无故中止的事务数量也会增加。

为了在可串行化级别下更高效地操作,使用 read only 子句显式声明只读事务是有意义的。如果锁管理器看到只读事务不会与其他事务发生冲突 12,它可以释放已经设置的谓词锁并避免获取新的谓词锁。如果这样的事务也被声明为 DEFERABLE 的,那么也可以避免只读事务异常。


  1. backend/storage/lmgr/lmgr.c, LockDatabaseObject & LockSharedObject functions ↩︎

  2. backend/catalog/dependency.c, performDeletion function ↩︎

  3. backend/storage/lmgr/lmgr.c, LockRelationForExtension function ↩︎

  4. backend/access/heap/hio.c, RelationAddExtraBlocks function ↩︎

  5. backend/access/nbtree/nbtpage.c, _bt_getbuf function ↩︎

  6. backend/storage/lmgr/lmgr.c, LockPage function ↩︎

  7. postgresql.org/docs/14/explicit-locking#ADVISORY-LOCKS.html ↩︎

  8. postgresql.org/docs/14/functions-admin#FUNCTIONS-ADVISORY-LOCKS.html ↩︎

  9. K. P. Eswaran, J. N. Gray, R. A. Lorie, I. L. Traiger. The notions of consistency and predicate locks in a database system ↩︎

  10. backend/storage/lmgr/README-SSI
    backend/storage/lmgr/predicate.c ↩︎

  11. backend/storage/lmgr/predicate.c, PredicateLockAcquire function ↩︎

  12. backend/storage/lmgr/predicate.c, SxactIsROSafe macrou ↩︎