Predicate_locks_in_pg

谓词锁:

  1. 传统谓词锁:
  • 真锁:锁住一个数学条件(如 age > 10)。
  • 缺点:
    • 数据库难以判断任意两个条件是否重叠(数学上不可判定)
    • 性能极差
  1. postgresql谓词锁:
  • 假锁,实际上是标记(SIRead 标记)。
  • 目的:支持 SSI(可串行化快照隔离)协议。
  • 本质:在内存中不阻塞任何事务,只做“记事本”,记录谁读了什么,谁后来改了什么。

为什么记录 RW(读后写)依赖?

  • 写偏斜(Write Skew)最难防。
  • 常规锁只能解决 WR 依赖(先写后读)。
  • 对 RW 依赖(先读后写),普通锁无法察觉

PostgreSQL 的解决方法

  1. 事务 A 读数据 → 默默加 SIRead 标记。
  2. 事务 B 修改数据 → 检查 SIRead 标记 → 在后台依赖图上连线(A →rw B)

An Example

让我们创建一个具有跨多个页面的索引的表(可以通过使用较低的填充因子值来实现):

1
2
3
4
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;

如果查询执行顺序扫描,则会在整个表上获取谓词锁(即使某些行不满足提供的过滤条件)。

Sequential scan 丢失了谓词的精度,PG 只能保守地锁整表。这是一种宁可误判、不能漏判的策略——牺牲并发性,换取正确性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mytest=# SELECT pg_backend_pid();
pg_backend_pid
----------------
512701
(1 row)

mytest=# BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
mytest=*# EXPLAIN (analyze, costs off, timing off, summary 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
Buffers: shared hit=45
Planning:
Buffers: shared hit=15
(6 rows)

尽管谓词锁(predicate locks)拥有自己独立的底层基础设施,但 pg_locks 视图仍然会将它们与重量级锁(heavyweight locks)放在一起共同显示。所有的谓词锁在获取时,其模式一律为 SIRead 模式,该模式是“可串行化隔离读”(Serializable Isolation Read)的缩写。

1
2
3
4
5
mytest=*# SELECT relation::regclass, locktype, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 512701 ORDER BY 1, 2, 3, 4;
relation | locktype | page | tuple
----------+----------+------+-------
pred | relation | |
(1 row)

需要注意的是,谓词锁的持有时间可能会超过事务本身的持续时间,因为它们被用来追踪事务之间的依赖关系。但无论如何,它们都是自动管理的。

如果查询执行的是索引扫描(index scan),情况就会有所好转。对于 B-tree 索引,只需在读取的堆元组(heap tuples,即实际的数据行)以及索引中被扫描的叶子页面(leaf pages)上设置谓词锁即可。这将“锁定”已被读取的整个范围,而不仅仅是那些精确的数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mytest=# BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
mytest=*# 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.00 loops=1)
Index Cond: ((n >= '1000'::numeric) AND (n <= '1001'::numeric))
Index Searches: 1
Buffers: shared hit=1 read=2
Planning:
Buffers: shared hit=6
(6 rows)

mytest=*# SELECT relation::regclass, locktype, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 512701 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)

已经扫描过的元组(数据行)所对应的索引叶子页面(leaf pages)数量是会发生变化的:例如,当向表中插入新行时,索引页面可能会发生分裂(split)。然而,PostgreSQL 充分考虑到了这种情况,并且也会将新出现的页面锁定

1
2
3
4
5
6
7
8
9
10
11
mytest=*# SELECT relation::regclass, locktype, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 512701 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 参数值的限制(尽管从参数名称来看,谓词锁并不是按每个独立事务分别计算的)

在这里,我们遇到了与行级锁(row-level locks)相同的问题,但解决方式却截然不同:这里应用了“锁升级”(lock escalation)机制。

一旦与单个页面(page)相关的元组锁(tuple locks)数量超过了 max_pred_locks_per_page 参数的值(默认值设为 2),它们就会被一个单一的页面级锁(page-level lock)所取代

1
2
3
4
5
6
7
8
mytest=*# 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=1002.00 loops=1)
Index Cond: ((n >= '1000'::numeric) AND (n <= '1002'::numeric))
Index Searches: 1
Buffers: shared hit=15
(4 rows)

现在,我们不再拥有三个元组类型的锁,而是拥有了一个页面类型的锁:

1
2
3
4
5
6
7
8
9
10
mytest=*# SELECT relation::regclass, locktype, page, tuple FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 512701 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)

锁升级肯定会导致误报序列化错误,这会对系统吞吐量产生负面影响。因此,必须在性能和在锁上花费可用资源之间找到适当的平衡。

Predicate locks 支持以下索引类型:

  • B-Trees
  • hash indexes, GiST, and GIN

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

为了在可串行化(Serializable)级别下实现更高效的运行,通过 READ ONLY 子句将事务显式声明为“只读”是非常有意义的。如果锁管理器(lock manager)发现一个只读事务不会与其他事务发生冲突,它就可以释放已经设置的谓词锁,并且不再获取新的谓词锁。而如果这样的事务同时被声明为 DEFERRABLE(可延迟的),那么只读事务异常(read-only transaction anomaly)也将得以避免。