本文将从死锁开始介绍,并简要回顾对象级锁,最后讨论谓词锁。
死锁
使用锁时,我们可能会遇到死锁。当一个事务尝试获取另一个事务已在使用的资源,而第二个事务尝试获取第一个事务正在使用的资源时,就会发生这种情况。左下图说明了这一点:实线箭头表示获取的资源,而虚线箭头表示尝试获取已在使用的资源。
为了可视化死锁,构建等待图很方便。为此,我们删除特定资源,只留下事务并指示哪个事务等待其他事务。如果一个图包含一个循环(从一个顶点,我们可以沿着箭头走到它自己),这是一个死锁。

死锁肯定不仅会发生在两个事务上,也可能发生在任何更大数量的事务上。
如果发生死锁,涉及的事务只能无限等待。因此,包括 PostgreSQL 在内的所有 DBMS 都会自动跟踪锁。
但是,检查需要一定的努力,并且每次请求新锁时都进行检查是不可取的(毕竟死锁非常罕见)。因此,当进程尝试获取锁但无法获取时,它会排队并“进入睡眠”,但会将计时器设置为deadlock_timeout参数中指定的值(默认为 1 秒)。如果资源更早被释放,这很好,我们可以省去检查。但是如果在deadlock_timeout到期时,等待继续,等待进程将唤醒并启动检查。
如果检查(包括构建等待图并搜索循环)没有检测到死锁,它会继续休眠,这次是“直到最终胜利”。
lock_timeout参数会影响任何运算符并允许避免无限长的等待:如果在指定的时间内无法获取锁,则运算符会因lock_not_available错误而终止。不要将此参数与statement_timeout混淆,它限制了执行运算符的总时间,无论后者是等待锁定还是执行常规工作。
但是,如果检测到死锁,则其中一个事务(在大多数情况下,发起检查的事务)将被迫中止。这会释放它获得的锁并使其他事务能够继续。
死锁通常意味着应用程序设计不正确。有两种方法可以检测这种情况:一是服务器日志中会出现消息,二是值pg_stat_database.deadlocks会增加。
死锁的例子
通常死锁是由锁定表行的顺序不一致引起的。
让我们考虑一个简单的例子。第一笔交易将从第一个账户转移 100 卢布到第二个账户。为此,交易减少了第一个账户:
=> BEGIN;
=> UPDATE accounts SET amount = amount - 100.00 WHERE acc_no = 1;
UPDATE 1
同时,第二笔交易将把 10 卢布从第二个账户转移到第一个账户。它从减少第二个帐户开始:
| => BEGIN;
| => UPDATE accounts SET amount = amount - 10.00 WHERE acc_no = 2;
| UPDATE 1
现在第一个事务尝试增加第二个帐户,但检测到该行有锁定。
=> UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 2;
然后第二个交易尝试增加第一个帐户,但也被阻止。
| => UPDATE accounts SET amount = amount + 10.00 WHERE acc_no = 1;
因此会出现循环等待,它不会自行结束。在一秒钟内,第一个还不能访问资源的事务会启动死锁检查并被服务器强制中止。
ERROR: deadlock detected DETAIL: Process 16477 waits for ShareLock on transaction 530695; blocked by process 16513. Process 16513 waits for ShareLock on transaction 530694; blocked by process 16477. HINT: See server log for query details. CONTEXT: while updating tuple (0,2) in relation "accounts"
现在第二个事务可以继续。
| UPDATE 1
| => ROLLBACK;
=> ROLLBACK;
执行此类操作的正确方法是以相同的顺序锁定资源。例如:在这种情况下,可以按编号的升序锁定帐户。
两个 UPDATE 命令的死锁
有时,在看似永远不会发生的情况下,我们可能会陷入僵局。例如:将 SQL 命令视为原子命令很方便也很常见,但 UPDATE 命令会在行更新时锁定行。这不会立即发生。因此,如果一个命令更新行的顺序与另一个命令执行此操作的顺序不一致,就会发生死锁。
虽然这种情况不太可能发生,但它仍然可能发生。为了重现它,我们将按amount降序在列上创建一个索引amount:
=> CREATE INDEX ON accounts(amount DESC);
为了能够观察会发生什么,让我们创建一个函数来增加传递的值,但是非常缓慢,只要一整秒:
=> CREATE FUNCTION inc_slow(n numeric) RETURNS numeric AS $$
SELECT pg_sleep(1);
SELECT n + 100.00;
$$ LANGUAGE SQL;
我们还需要pgrowlocks扩展。
=> CREATE EXTENSION pgrowlocks;
第一个 UPDATE 命令将更新整个表。执行计划很明显——它是顺序扫描:
| => EXPLAIN (costs off)
| UPDATE accounts SET amount = inc_slow(amount);
| QUERY PLAN
| ----------------------------
| Update on accounts
| -> Seq Scan on accounts
| (2 rows)
由于表格页面上的元组按数量的升序排列(正是我们添加它们的方式),它们也将按相同的顺序更新。让更新开始。
| => UPDATE accounts SET amount = inc_slow(amount);
同时,在另一个会话中,我们将禁止顺序扫描:
|| => SET enable_seqscan = off;
在这种情况下,对于下一个 UPDATE 操作符,规划器决定使用索引扫描:
|| => EXPLAIN (costs off)
|| UPDATE accounts SET amount = inc_slow(amount) WHERE amount > 100.00;
|| QUERY PLAN
|| --------------------------------------------------------
|| Update on accounts
|| -> Index Scan using accounts_amount_idx on accounts
|| Index Cond: (amount > 100.00)
|| (3 rows)
第二行和第三行满足条件,由于索引是按照数量降序建立的,所以行更新的顺序是相反的。
让我们运行下一次更新。
|| => UPDATE accounts SET amount = inc_slow(amount) WHERE amount > 100.00;
快速查看表格页面显示,第一个操作符已经成功更新了第一行 (0,1),第二个操作符更新了最后一行 (0,3):
=> SELECT * FROM pgrowlocks('accounts') \gx
-[ RECORD 1 ]-----------------
locked_row | (0,1)
locker | 530699 <- the first
multi | f
xids | {530699}
modes | {"No Key Update"}
pids | {16513}
-[ RECORD 2 ]-----------------
locked_row | (0,3)
locker | 530700 <- the second
multi | f
xids | {530700}
modes | {"No Key Update"}
pids | {16549}
又一秒过去了。第一个操作员更新了第二行,第二个操作员想这样做,但不能。
=> SELECT * FROM pgrowlocks('accounts') \gx
-[ RECORD 1 ]-----------------
locked_row | (0,1)
locker | 530699 <- the first
multi | f
xids | {530699}
modes | {"No Key Update"}
pids | {16513}
-[ RECORD 2 ]-----------------
locked_row | (0,2)
locker | 530699 <- the first was quicker
multi | f
xids | {530699}
modes | {"No Key Update"}
pids | {16513}
-[ RECORD 3 ]-----------------
locked_row | (0,3)
locker | 530700 <- the second
multi | f
xids | {530700}
modes | {"No Key Update"}
pids | {16549}
现在第一个操作员想要更新表的最后一行,但它已经被第二个操作员锁定。因此陷入僵局。
其中一项交易中止:
|| ERROR: deadlock detected || DETAIL: Process 16549 waits for ShareLock on transaction 530699; blocked by process 16513. || Process 16513 waits for ShareLock on transaction 530700; blocked by process 16549. || HINT: See server log for query details. || CONTEXT: while updating tuple (0,2) in relation "accounts"
第二个继续:
| UPDATE 3
关于死锁的讨论到此结束,我们继续讨论剩余的对象级锁。

锁定非关系
当我们需要锁定一个不是PostgreSQL 意义上的关系的资源时,就会object使用该类型的锁。几乎我们能想到的任何东西都可以引用这样的资源:表空间、订阅、模式、枚举数据类型等等。粗略地说,这就是可以在系统目录中找到的所有内容。
通过一个简单的例子来说明这一点。让我们开始一个事务并在其中创建一个表:
=> BEGIN;
=> CREATE TABLE example(n integer);
现在让我们看看该object类型的锁出现在pg_locks:
=> SELECT
database,
(SELECT datname FROM pg_database WHERE oid = l.database) AS dbname,
classid,
(SELECT relname FROM pg_class WHERE oid = l.classid) AS classname,
objid,
mode,
granted
FROM pg_locks l
WHERE l.locktype = 'object' AND l.pid = pg_backend_pid();
database | dbname | classid | classname | objid | mode | granted
----------+--------+---------+--------------+-------+-----------------+---------
0 | | 1260 | pg_authid | 16384 | AccessShareLock | t
16386 | test | 2615 | pg_namespace | 2200 | AccessShareLock | t
(2 rows)
要弄清楚这里特别锁定的是什么,我们需要查看三个字段:database,classid和objid。我们从第一行开始。
database是与被锁定资源相关的数据库的 OID。在这种情况下,此列包含零。这意味着我们处理的是一个不特定于任何数据库的全局对象。
classid包含与pg_class实际确定资源类型的系统目录表名称相匹配的 OID 。在这种情况下,它是pg_authid,即角色(用户)是资源。
objid包含系统目录表中的 OID,由 指示classid。
=> SELECT rolname FROM pg_authid WHERE oid = 16384;
rolname
---------
student
(1 row)
我们作为student,而这正是锁定的角色。
现在让我们澄清第二行。数据库已指定,并且是test,我们连接到该数据库。
classid表示pg_namespace包含模式的表。
=> SELECT nspname FROM pg_namespace WHERE oid = 2200;
nspname
---------
public
(1 row)
这表明public架构已锁定。
因此,我们已经看到,当创建对象时,创建对象的所有者角色和模式被锁定(在共享模式下)。这是合理的:否则,有人可能会在事务尚未完成时删除角色或模式。
=> ROLLBACK;
锁定关系扩展
当关系(表、索引或物化视图)中的行数增加时,PostgreSQL 可以使用可用页面中的空闲空间进行插入,但显然,一旦新页面也必须添加。物理上,它们被添加到相应文件的末尾。这是关系扩展的意思。
为确保两个进程不会同时急于添加页面,扩展进程由该extend类型的专用锁保护。在为其他进程清空索引以在扫描期间无法添加页面时,使用相同的锁。
这个锁肯定会在不等待事务完成的情况下被释放。
早些时候,表格一次只能扩展一页。这会导致多个进程同时插入行时出现问题;因此,从 PostgreSQL 9.6 开始,一次将多个页面添加到表中(与等待进程的数量成比例,但不大于 512)。
页面锁定
该page类型的页级锁仅用于唯一的情况(谓词锁除外,稍后将讨论)。
GIN 索引使我们能够加速复合值的搜索,例如:文本文档(或数组元素)中的单词。粗略地说,这些索引可以表示为一个常规的 B 树,它存储来自文档的单独单词而不是文档本身。因此,当添加新文档时,必须重建索引,以便将文档中的每个新单词添加到那里。
为了获得更好的性能,GIN 索引具有延迟插入功能,该功能由fastupdate存储参数开启。新词首先快速添加到无序列表中,一段时间后,所有积累的内容都移动到主索引结构中。收益是由于相同的词在不同的文档中出现的概率很高。
为了防止多个进程同时从挂起列表移动到主索引,在移动期间,索引元页面被锁定在独占模式下。这并不妨碍索引的正常使用。
咨询锁
与其他锁(例如关系级锁)不同,咨询锁永远不会自动获取——应用程序开发人员控制它们。例如,当应用程序出于某种原因需要与常规锁的标准逻辑不符的锁定逻辑时,它们很有用。
假设我们有一个不匹配任何数据库对象的假设资源(我们可以使用诸如 SELECT FOR 或 LOCK TABLE 之类的命令来锁定它)。我们需要为它设计一个数字标识符。如果资源具有唯一名称,一个简单的选择是使用其哈希码:
=> SELECT hashtext('resource1');
hashtext
-----------
991601810
(1 row)
这是我们获取锁的方式:
=> BEGIN;
=> SELECT pg_advisory_lock(hashtext('resource1'));
像往常一样,有关锁的信息可在pg_locks以下位置获得:
=> 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'));
用于咨询锁的丰富函数集合可用于所有意图和目的:
- pg_advisory_lock_shared 获得了共享锁。
- pg_advisory_xact_lock(和pg_advisory_xact_lock_shared) 有一个共享锁,直到事务结束。
- pg_try_advisory_lock(以及pg_try_advisory_xact_lockand pg_try_advisory_xact_lock_shared)不等待锁,但false如果不能立即获取锁则返回。
try_除了上一篇文章中列出的那些之外,函数集合是另一种避免等待锁的技术。
谓词锁
该谓词锁定期限发生不久前,当早期的DBMS为实施基于锁完全隔离的第一次尝试(可序列化的水平,虽然当时没有SQL标准)。他们当时面临的问题是,即使锁定所有读取和更新的行也不能确保完全隔离:表中可能会出现满足相同选择条件的新行,从而导致幻像出现(请参阅有关隔离的文章)。
谓词锁的想法是锁定谓词而不是行。如果在执行条件a > 10的查询期间我们锁定a > 10 谓词,这将不允许我们将满足条件的新行添加到表中,并使我们能够避免幻象。问题是这个问题在计算上很复杂。在实践中,它只能解决非常简单的谓词。
在 PostgreSQL 中,在基于数据快照的可用隔离之上,Serializable 级别的实现方式不同。尽管谓词锁定术语仍在使用,但其含义发生了巨大变化。实际上,这些“锁”什么也阻挡不了;它们用于跟踪事务之间的数据依赖性。
事实证明,快照隔离允许不一致的写(写倾斜)异常和只读事务异常,但任何其他异常都是不可能的。为了弄清楚我们处理的是上述两个异常中的一个,我们可以分析事务之间的依赖关系并发现其中的某些模式。
我们感兴趣的是两种依赖关系:
- 一个事务读取一行,然后由第二个事务更新(RW 依赖项)。
- 一个事务更新一行,然后由第二个事务读取(WR 依赖)。
我们可以使用已经可用的常规锁来跟踪 WR 依赖关系,但必须专门跟踪 RW 依赖关系。
重申一下,尽管有这个名字,谓词锁什么都没有。相反,在事务提交时执行检查,如果发现可能表明异常的可疑依赖关系序列,则事务中止。
让我们看看如何处理谓词锁。为此,我们将创建一个带有大量锁和索引的表。
=> CREATE TABLE pred(n integer);
=> INSERT INTO pred(n) SELECT g.n FROM generate_series(1,10000) g(n);
=> CREATE INDEX ON pred(n) WITH (fillfactor = 10);
=> ANALYZE pred;
如果使用整个表的顺序扫描执行查询,则会获取整个表的谓词锁(即使不是所有行都满足过滤条件)。
| => SELECT pg_backend_pid();
| pg_backend_pid
| ----------------
| 12763
| (1 row)
| => BEGIN ISOLATION LEVEL SERIALIZABLE;
| => EXPLAIN (analyze, costs off)
| SELECT * FROM pred WHERE n > 100;
| QUERY PLAN
| ----------------------------------------------------------------
| Seq Scan on pred (actual time=0.047..12.709 rows=9900 loops=1)
| Filter: (n > 100)
| Rows Removed by Filter: 100
| Planning Time: 0.190 ms
| Execution Time: 15.244 ms
| (5 rows)
所有谓词锁都以一种特殊模式获取 - SIReadLock(可序列化隔离读取):
=> SELECT locktype, relation::regclass, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763;
locktype | relation | page | tuple
----------+----------+------+-------
relation | pred | |
(1 row)
| => ROLLBACK;
但是如果使用索引扫描执行查询,情况就会好转。如果我们处理 B 树,在读取的行和遍历的叶索引页上获取锁就足够了——这使我们不仅可以跟踪特定的值,还可以跟踪读取的所有范围。
| => BEGIN ISOLATION LEVEL SERIALIZABLE;
| => EXPLAIN (analyze, costs off)
| SELECT * FROM pred WHERE n BETWEEN 1000 AND 1001;
| QUERY PLAN
| ------------------------------------------------------------------------------------
| Index Only Scan using pred_n_idx on pred (actual time=0.122..0.131 rows=2 loops=1)
| Index Cond: ((n >= 1000) AND (n <= 1001))
| Heap Fetches: 2
| Planning Time: 0.096 ms
| Execution Time: 0.153 ms
| (5 rows)
=> SELECT locktype, relation::regclass, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763;
locktype | relation | page | tuple
----------+------------+------+-------
tuple | pred | 3 | 236
tuple | pred | 3 | 235
page | pred_n_idx | 22 |
(3 rows)
注意一些复杂性。
首先,为每个读取元组创建一个单独的锁,并且此类元组的数量可能非常大。系统中的谓词锁总数受参数值乘积的限制:max_pred_locks_per_transaction × max_connections(默认值分别为64和100)。这些锁的内存在服务器启动时分配;试图超过此限制将导致错误。
因此,升级用于谓词锁(并且仅用于它们!)。在 PostgreSQL 10 之前,限制是硬编码的,但从这个版本开始,我们可以通过参数控制升级。如果与一页相关的元组锁的数量超过max_pred_locks_per_page,这些锁将替换为一个页级锁。考虑一个例子:
=> SHOW max_pred_locks_per_page;
max_pred_locks_per_page
-------------------------
2
(1 row)
| => EXPLAIN (analyze, costs off)
| SELECT * FROM pred WHERE n BETWEEN 1000 AND 1002;
| QUERY PLAN
| ------------------------------------------------------------------------------------
| Index Only Scan using pred_n_idx on pred (actual time=0.019..0.039 rows=3 loops=1)
| Index Cond: ((n >= 1000) AND (n <= 1002))
| Heap Fetches: 3
| Planning Time: 0.069 ms
| Execution Time: 0.057 ms
| (5 rows)
我们看到一个page类型的锁而不是三个类型的锁tuple:
=> SELECT locktype, relation::regclass, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763;
locktype | relation | page | tuple
----------+------------+------+-------
page | pred | 3 |
page | pred_n_idx | 22 |
(2 rows)
同样,如果与一个关系相关的页面上的锁数量超过max_pred_locks_per_relation,这些锁将替换为一个关系级锁。
没有其他级别:谓词锁仅针对关系、页面和元组获取,并且始终处于 SIReadLock 模式。
当然,锁的升级不可避免地会导致因序列化错误而错误终止的事务数量增加,最终系统吞吐量会下降。在这里,您需要平衡 RAM 消耗和性能。
第二个复杂性是对索引的不同操作(例如,由于插入新行时索引页的拆分)会改变覆盖读取范围的叶页数。但实现考虑到了这一点:
=> INSERT INTO pred SELECT 1001 FROM generate_series(1,1000);
=> SELECT locktype, relation::regclass, page, tuple
FROM pg_locks WHERE mode = 'SIReadLock' AND pid = 12763;
locktype | relation | page | tuple
----------+------------+------+-------
page | pred | 3 |
page | pred_n_idx | 211 |
page | pred_n_idx | 212 |
page | pred_n_idx | 22 |
(4 rows)
| => ROLLBACK;
顺便说一下,谓词锁并不总是在事务完成后立即释放,因为需要它们来跟踪多个事务之间的依赖关系。但无论如何,它们都是自动控制的。
绝不是 PostgreSQL 中所有类型的索引都支持谓词锁。在 PostgreSQL 11 之前,只有 B-trees 可以夸耀这一点,但那个版本改善了这种情况:hash、GiST 和 GIN 索引被添加到列表中。如果使用索引访问,但索引不支持谓词锁,则获取对整个索引的锁。当然,这也会增加虚假交易中止的数量。
最后,请注意谓词锁的使用将所有事务限制为在 Serializable 级别工作,以确保完全隔离。如果某个事务使用不同的级别,它就不会获取(和检查)谓词锁。




