第 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 的,那么也可以避免只读事务异常。
-
backend/storage/lmgr/lmgr.c, LockDatabaseObject & LockSharedObject functions ↩︎
-
backend/catalog/dependency.c, performDeletion function ↩︎
-
backend/storage/lmgr/lmgr.c, LockRelationForExtension function ↩︎
-
backend/access/heap/hio.c, RelationAddExtraBlocks function ↩︎
-
backend/access/nbtree/nbtpage.c, _bt_getbuf function ↩︎
-
backend/storage/lmgr/lmgr.c, LockPage function ↩︎
-
postgresql.org/docs/14/explicit-locking#ADVISORY-LOCKS.html ↩︎
-
postgresql.org/docs/14/functions-admin#FUNCTIONS-ADVISORY-LOCKS.html ↩︎
-
K. P. Eswaran, J. N. Gray, R. A. Lorie, I. L. Traiger. The notions of consistency and predicate locks in a database system ↩︎
-
backend/storage/lmgr/README-SSI
backend/storage/lmgr/predicate.c ↩︎ -
backend/storage/lmgr/predicate.c, PredicateLockAcquire function ↩︎
-
backend/storage/lmgr/predicate.c, SxactIsROSafe macrou ↩︎