跳转到主要内容

13 行级锁

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

13.1. 锁设计

由于基于快照的隔离,读取表中的行时无需锁定。但不能允许两个事务同时修改同一行。需要对行进行锁定,然而重锁并不适合此用途:每把重锁都会占用服务器共享内存中的空间(数百字节,还不包括辅助基础设施),且内部机制并未设计用于处理大量同时存在的锁。

在某些数据库系统中,该问题通过提升锁级别来解决:当行级锁数量过多时,它们会被一个更通用的锁(例如页级或表级锁)所替代。这简化了实现,但可能导致效率显著下降。

在 PostgreSQL 中,关于某行被锁定的信息仅存储在行版本的头部。实际上,这仅仅是数据页面中的标志位,而非真正的锁——这些标志在内存中不会体现。

通常在修改或删除行时会锁定该行。在这两种情况下,当前行版本都会被标记为已删除。其标志是 xmax 字段中的事务号,而该事务号(结合额外的信息位)也表明该行已被锁定。当某个事务试图修改一行,但发现当前版本的 xmax 字段中包含一个未完成的事务号时,它必须等待该事务完成。之后所有锁都将被释放,等待的事务便可继续对行执行操作。(参见 87)

这种方案允许锁定任意数量的行,且不消耗任何资源。

其缺点在于,由于内存中没有锁信息,其他进程无法排队等待。因此,仍不得不使用常规的重锁。等待行被释放意味着等待阻塞事务的结束,而为此必须请求该事务号的锁。因此,所使用的重锁数量与同时运行的进程数成正比,而非与被修改的行数成正比。

13.2. 行级锁模式

存在四种可以锁定行的模式1。其中两种为互斥锁,同一时间只能由一个事务持有;另外两种为共享锁,可被多个事务同时持有。

冲突矩阵如下所示:

模式Key ShareShareNo Key UpdateUpdate
Key Share×
Share××
No Key Update×××
Update××××
表 13.1 行锁冲突矩阵

独占模式

Update 模式表示修改行的任意字段或删除该行;No Key Update 模式表示仅修改不涉及唯一索引的字段(即不会影响外键的修改)。

UPDATE 命令会自行选择最合适的锁模式;由于键通常不会改变,因此大多数情况下行是以 No Key Update 模式被锁定的。 UPDATE

创建一个函数,利用 pageinspect 扩展来显示我们关心的行版本信息,特别是 xmax 字段和一些信息位:

=> CREATE FUNCTION row_locks(relname text, pageno integer)
RETURNS TABLE(
  ctid tid, xmax text,
  lock_only text, is_multi text,
  keys_upd text, keyshr text, shr text
) AS $$
SELECT (pageno,lp)::text::tid,
  t_xmax,
  CASE WHEN t_infomask  & 128   = 128   THEN 't' END,
  CASE WHEN t_infomask  & 4096  = 4096  THEN 't' END,
  CASE WHEN t_infomask2 & 8192  = 8192  THEN 't' END,
  CASE WHEN t_infomask  & 16    = 16    THEN 't' END,
  CASE WHEN t_infomask  & 16+64 = 16+64 THEN 't' END
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE sql;

启动一个事务,并更新 accounts 表中第一个账户的金额(键未改变)和第二个账户的编号(键被改变):

=> BEGIN;
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
=> UPDATE accounts SET id = 20 WHERE id = 2;

查看页面内容:

=> SELECT * FROM row_locks('accounts',0) LIMIT 2;
 ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr | shr
-------+--------+-----------+----------+----------+--------+-----
 (0,1) | 191451 |           |          |          |        |
 (0,2) | 191451 |           |          | t        |        |
(2 rows)

锁模式由信息位 keys_updated 决定。

=> ROLLBACK;

同一 xmax 字段也用于 SELECT FOR 命令的锁标志,但此时会设置额外的信息位 xmax_lock_only。该位表示行版本仅被锁定,尚未被删除,且仍为当前有效版本:

=> BEGIN;
=> SELECT * FROM accounts WHERE id = 1 FOR NO KEY UPDATE;
=> SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
=> SELECT * FROM row_locks('accounts',0) LIMIT 2;
 ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr | shr
-------+--------+-----------+----------+----------+--------+-----
 (0,1) | 191452 | t         |          |          |        |
 (0,2) | 191452 | t         |          | t        |        |
(2 rows)
=> ROLLBACK;

共享模式

Share 模式确保读取的行不会被其他事务修改。Key Share 模式允许修改行的任意字段,但不能修改键字段。

在共享模式中,PostgreSQL 核心仅在检查外键时使用 Key Share 模式。它与 No Key Update 互斥模式兼容,即外键检查不会妨碍多个事务同时更新非键字段。当然,应用程序也可使用任何合适的模式。

再次强调,读取操作不会使用任何行级锁。

=> BEGIN;
=> SELECT * FROM accounts WHERE id = 1 FOR KEY SHARE;
=> SELECT * FROM accounts WHERE id = 2 FOR SHARE;

在行版本中可见:

=> SELECT * FROM row_locks('accounts',0) LIMIT 2;
 ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr | shr
-------+--------+-----------+----------+----------+--------+-----
 (0,1) | 191453 | t         |          |          | t      |
 (0,2) | 191453 | t         |          |          | t      | t
(2 rows)

两种情况下均设置了 xmax_keyshr_lock 位,而 Share 模式可通过查看其他信息位来识别2。

13.3. 组事务

锁标志由 xmax 字段中的锁定事务号表示,但共享锁可被多个事务同时持有。如何在一个字段中记录多个事务号?

对于共享锁,使用所谓的 多事务 3(MultiXact)。多事务是一组事务,被赋予一个独立的编号。该组成员及各成员锁模式的详细信息存储在 PGDATA/pg_multixact 目录下的文件中。由于多事务的成员数量可任意,内存中页面的处理采用两个 SLRU 缓存:一个用于多事务成员信息,另一个用于在第一个缓存中查找信息的偏移量。缓存大小由参数 multixact_member_buffers 和 multixact_offset_buffers 控制。(参见 309)(默认值:32)(默认值:16)

多事务编号与普通事务编号具有相同的位宽(32 位),但编号空间独立分配,因此事务编号与多事务编号可能重叠。为区分两者,使用额外的信息位 xmax_is_multi。

向现有锁再添加一个由另一事务执行的互斥锁(由于 Key Share 与 No Key Update 模式兼容,此操作可行):

=> BEGIN;
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
=> SELECT * FROM row_locks('accounts',0) LIMIT 2;
 ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr | shr
-------+--------+-----------+----------+----------+--------+-----
 (0,1) | 1      |           | t        |          |        |
 (0,2) | 191453 | t         |          |          | t      | t
(2 rows)

第一行中,普通编号被多事务编号替代——这由 xmax_is_multi 位表明。

为避免深入多事务实现细节,可使用 pgrowlocks 扩展,它能以方便的格式显示所有类型行锁的全部信息:

=> CREATE EXTENSION pgrowlocks;
=> SELECT * FROM pgrowlocks('accounts') \gx
-[ RECORD 1 ]---------------------------------
locked_row | (0,1)
locker     | 1
multi      | t
xids       | {191453,191454}
modes      | {"For Key Share","No Key Update"}
pids       | {65884,66190}
-[ RECORD 2 ]---------------------------------
locked_row | (0,2)
locker     | 191453
multi      | f
xids       | {191453}
modes      | {"For Share"}
pids       | {65884}

该查询外观上类似于访问 pg_locks 视图,但 pgrowlocks 函数会读取表页,因为行级锁信息在内存中并不存在。

=> COMMIT;
=> ROLLBACK;

由于多事务编号为 32 位,因此与普通事务编号一样,也会面临计数器溢出(xid wraparound)问题。因此,对多事务编号也需执行类似冻结的操作:将旧编号替换为新编号(或在冻结时若锁仅由一个事务持有,则替换为普通事务编号)4。(参见 134)

但与仅对 xmin 字段执行普通事务编号冻结(因为 xmax 非空的版本已无效,将被清理)不同,多事务编号的冻结是针对 xmax 字段的:当前行版本可能持续被多个事务以共享模式锁定。

控制多事务冻结的服务器参数与普通冻结类似:vacuum_multixact_freeze_min_age、vacuum_multixact_freeze_table_age、autovacuum_multixact_freeze_max_age,以及 vacuum_multixact_failsafe_age。(v. 14)

13.4. 等待队列

独占模式

由于行锁仅是一个标志,因此排队机制实现得相当复杂。当事务试图修改一行时,它会执行以下步骤 5:

  1. 如果行版本的 xmax 字段及信息位表明该行以不兼容模式被锁定,则获取该行版本的互斥重锁;

  2. 如有必要,等待不兼容锁的释放,通过请求 xmax 字段中事务号(或多个事务号,若 xmax 为多事务号)的锁;

  3. 将自身事务号写入行版本的 xmax 字段,并设置必要的信息位;

  4. 若在第 1 步中获取了行版本锁,则释放该锁。

行版本锁(tuple lock)是另一种重锁类型,其类型为 tuple。请勿将其与行锁(row lock)混淆。

可能有人认为第 1 步和第 4 步是多余的,仅等待阻塞事务即可。但如果多个事务同时尝试更新同一行,它们都会等待当前正在操作该行的事务结束。当该事务完成后,等待的事务之间将出现竞争,这可能导致某些“倒霉”的事务无限期等待。这种情况称为 资源饥饿(starvation)。

行版本锁确保队列中的第一个事务获得下一次锁的权限。

但最好亲眼看看。由于运行过程中会产生大量不同类型的锁,且每把锁在 pg_locks 表中对应一行,我将创建另一个基于 pg_locks 的视图。该视图以紧凑方式显示信息,并仅保留我们当前关注的锁(与 accounts 表和当前事务相关的锁,但不包括虚拟事务号锁):

=> CREATE VIEW locks_accounts AS
SELECT pid,
  locktype,
  CASE locktype
    WHEN 'relation' THEN relation::regclass::text
    WHEN 'transactionid' THEN transactionid::text
    WHEN 'tuple' THEN relation::regclass||'('||page||','||tuple||')'
  END AS lockid,
  mode,
  granted
FROM pg_locks
WHERE locktype in ('relation','transactionid','tuple')
  AND (locktype != 'relation' OR relation = 'accounts'::regclass)
ORDER BY 1, 2, 3;

启动第一个事务并更新一行:

=> BEGIN;
=> SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       191456 |          66190
(1 row)
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;

该事务成功执行了序列中的四个步骤,现在持有表锁:

=> SELECT * FROM locks_accounts WHERE pid = 66190;
  pid  |   locktype    |  lockid  |       mode       | granted
-------+---------------+----------+------------------+---------
 66190 | relation      | accounts | RowExclusiveLock | t
 66190 | transactionid | 191456   | ExclusiveLock    | t
(2 rows)

启动第二个事务并尝试更新同一行。该事务在等待锁时挂起:

=> BEGIN;
=> SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       191457 |          66267
(1 row)
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
互斥模式:T1;No Key Update;ctid xmin xmax;T2;数据;(0,1);T1;tuple (0,1)
图 13.1 互斥模式

第二个事务仅执行到第二步。因此,除了表锁和自身事务号外,它还在 pg_locks 中添加了两条记录:第一步获取的 tuple 类型锁,以及第二步请求的首个事务号锁。

=> SELECT * FROM locks_accounts WHERE pid = 66267;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 66267 | relation      | accounts      | RowExclusiveLock | t
 66267 | transactionid | 191456        | ShareLock        | f
 66267 | transactionid | 191457        | ExclusiveLock    | t
 66267 | tuple         | accounts(0,1) | ExclusiveLock    | t
(4 rows)

第三个事务仅执行到第一步。它将尝试获取行版本锁,但在此处停止。

=> BEGIN;
=> SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       191458 |          66344
(1 row)
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
=> SELECT * FROM locks_accounts WHERE pid = 66344;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 66344 | relation      | accounts      | RowExclusiveLock | t
 66344 | transactionid | 191458        | ExclusiveLock    | t
 66344 | tuple         | accounts(0,1) | ExclusiveLock    | f
(3 rows)

第四个及后续事务若希望更新同一行,将与第三个事务无异——它们都将等待同一把行版本锁。

       => BEGIN;
       => SELECT txid_current(), pg_backend_pid();
        txid_current | pg_backend_pid
       --------------+----------------
              191459 |          66421
       (1 row)
       => UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
=> SELECT * FROM locks_accounts WHERE pid = 66344;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 66344 | relation      | accounts      | RowExclusiveLock | t
 66344 | transactionid | 191458        | ExclusiveLock    | t
 66344 | tuple         | accounts(0,1) | ExclusiveLock    | f
(3 rows)
独占模式:T1;无键更新;ctid xmin xmax;T2;数据;(0,1);T1;元组 (0,1);T3;T4
图 13.2 独占模式

当前等待情况的总体视图可以通过 pg_stat_activity 视图查看,并添加关于阻塞进程的信息:

=> SELECT pid,
  wait_event_type,
  wait_event,
  pg_blocking_pids(pid)
FROM pg_stat_activity
WHERE pid IN (66190,66267,66344,66421);
  pid  | wait_event_type |  wait_event   | pg_blocking_pids
-------+-----------------+---------------+------------------
 66190 | Client          | ClientRead    | {}
 66267 | Lock            | transactionid | {66190}
 66344 | Lock            | tuple         | {66267}
 66421 | Lock            | tuple         | {66267,66344}
(4 rows)

如果第一个事务回滚,整个结构将按预期工作:剩余事务将按顺序向前推进一步。

但更可能的是,第一个事务会提交。在可重复读或可串行化隔离级别下,这将导致第二个事务因序列化错误而中止6(随后队列中的其他事务也会中止)。但在读已提交隔离级别下,修改后的行会被重新读取,并尝试重新更新。

因此,第一个事务结束:

=> COMMIT;

第二个事务被唤醒,并成功执行了序列的第三和第四步:

UPDATE 1
=> SELECT * FROM locks_accounts WHERE pid = 66267;
  pid  |   locktype    |  lockid  |       mode       | granted
-------+---------------+----------+------------------+---------
 66267 | relation      | accounts | RowExclusiveLock | t
 66267 | transactionid | 191457   | ExclusiveLock    | t
(2 rows)

一旦第二个事务释放了行版本的锁,第三个事务也被唤醒,但它发现新行版本的 xmax 字段中已有另一个事务号。至此,上述锁定序列以失败告终。在读已提交隔离级别下,会尝试重新锁定行7,但这次尝试不再遵循前述序列。第三个事务将等待第二个事务完成,而不再尝试获取行版本锁:

=> SELECT * FROM locks_accounts WHERE pid = 66344;
  pid  |   locktype    |  lockid  |       mode       | granted
-------+---------------+----------+------------------+---------
 66344 | relation      | accounts | RowExclusiveLock | t
 66344 | transactionid | 191457   | ShareLock        | f
 66344 | transactionid | 191458   | ExclusiveLock    | t
(3 rows)

第四个事务也发生同样的情况:

=> SELECT * FROM locks_accounts WHERE pid = 66421;
  pid  |   locktype    |  lockid  |       mode       | granted
-------+---------------+----------+------------------+---------
 66421 | relation      | accounts | RowExclusiveLock | t
 66421 | transactionid | 191457   | ShareLock        | f
 66421 | transactionid | 191459   | ExclusiveLock    | t
(3 rows)

现在,第三个和第四个事务都在等待第二个事务完成,同时期待竞争获取锁。队列本身已经瓦解。

独占模式:T2;无键更新;ctid xmin xmax;数据;(0,1);T1;T3(0,2);T1;T4T2
图 13.3 独占模式

如果在队列仍然存在时,又有多个其他事务加入,它们都将无意中成为这场竞争的参与者。

结论:在多个并行进程中同时更新同一张表的同一行,不是一个好主意。这会形成热点,当负载足够高时,会成为性能瓶颈。

完成所有已启动的事务。

=> COMMIT;
UPDATE 1
=> COMMIT;
UPDATE 1
=> COMMIT;

共享模式

PostgreSQL 仅在检查引用完整性时使用行共享锁。在高负载应用中使用这些锁可能导致资源饥饿,而两级锁机制对此并无防范作用。

再次提醒一下事务在锁定行时执行的操作序列:

  1. 如果行版本的 xmax 字段和信息位表明该行已被以不兼容模式锁定,则获取该可变行版本的独占重锁;

  2. 如有必要,等待不兼容锁的释放,通过请求 xmax(或多个事务,若 xmax 为多事务)的事务号锁;

  3. 在行版本中写入自己的事务号(在 xmax 字段中)并设置必要的信息位;

  4. 如果在第1步中获取了行版本锁,则释放该锁。

前两个步骤表明,如果锁模式是兼容的,事务将无需排队执行。

让我们从头开始重复这个实验。

=> TRUNCATE accounts;
=> INSERT INTO accounts(id, client, amount)
VALUES (1,'alice',100.00),
       (2,'bob',200.00),
       (3,'charlie',300.00);

启动第一个事务:

=> BEGIN;
=> SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       191462 |          66190
(1 row)

第一个事务以共享模式锁定该行:

=> SELECT * FROM accounts WHERE id = 1 FOR SHARE;

第二个事务尝试更新同一行,但无法成功——共享模式与无键更新模式不兼容:

=> BEGIN;
=> SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       191463 |          66267
(1 row)
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;

第二个事务等待第一个事务完成,并持有行版本锁——与之前示例相同:

=> SELECT * FROM locks_accounts WHERE pid = 66267;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 66267 | relation      | accounts      | RowExclusiveLock | t
 66267 | transactionid | 191462        | ShareLock        | f
 66267 | transactionid | 191463        | ExclusiveLock    | t
 66267 | tuple         | accounts(0,1) | ExclusiveLock    | t
(4 rows)
共享模式:T1;Share;ctid xmin xmax;T2;数据;(0,1);T1;元组 (0,1)
图 13.4 共享模式

现在让第三个事务以共享模式锁定该行。其锁与现有锁兼容,因此第三个事务无需排队即可通过:

=> BEGIN;
=> SELECT txid_current(), pg_backend_pid();
 txid_current | pg_backend_pid
--------------+----------------
       191464 |          66344
(1 row)
=> SELECT * FROM accounts WHERE id = 1 FOR SHARE;

现在已有两个事务锁定了该行:

=> SELECT * FROM pgrowlocks('accounts') \gx
-[ RECORD 1 ]-------------------------
locked_row | (0,1)
locker     | 2
multi      | t
xids       | {191462,191464}
modes      | {"For Share","For Share"}
pids       | {66190,66344}
共享模式:T1;T3;Share;ctid xmin xmax;T2;数据;(0,1);multi;元组 (0,1)
图 13.5 共享模式

现在,如果第一个事务结束,第二个事务将被唤醒,但它会发现行锁并未消失,于是再次进入队列——这次排在第三个事务之后:

=> COMMIT;
=> SELECT * FROM locks_accounts WHERE pid = 66267;
  pid  |   locktype    |    lockid     |       mode       | granted
-------+---------------+---------------+------------------+---------
 66267 | relation      | accounts      | RowExclusiveLock | t
 66267 | transactionid | 191463        | ExclusiveLock    | t
 66267 | transactionid | 191464        | ShareLock        | f
 66267 | tuple         | accounts(0,1) | ExclusiveLock    | t
(4 rows)

只有当第三个事务结束(且在此期间未出现其他共享锁)时,第二个事务才能执行更新。

=> COMMIT;
UPDATE 1
=> COMMIT;

外键检查不太可能引发问题,因为键字段通常不会改变,且 Key Share 与 No Key Update 模式是兼容的。但大多数情况下,建议避免在应用中使用行共享锁模式。

13.5. 无等待锁定

通常,SQL 命令会等待所需资源的释放。但有时希望在无法立即获取锁时放弃执行。为此,SELECT、LOCK 和 ALTER 等命令支持 NOWAIT 子句。

锁定一行:

=> BEGIN;
=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;

带有 NOWAIT 子句的命令若资源已被占用,将立即以错误终止:

=> SELECT * FROM accounts
FOR UPDATE NOWAIT;
ERROR:  could not obtain lock on row in relation "accounts"

在应用代码中,可以捕获此类错误并进行处理。

UPDATE 和 DELETE 命令不支持 NOWAIT 子句。标准做法是先尝试使用 SELECT FOR UPDATE NOWAIT 锁定行,若成功再执行更新或删除。

在极少数情况下,跳过已锁定的行并立即获取空闲行进行处理会更方便。SELECT FOR 命令配合 SKIP LOCKED 子句可实现此功能:

=> SELECT * FROM accounts
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
 id | client | amount
----+--------+--------
  2 | bob    | 200.00
(1 row)

在此示例中,第一行(已锁定)被跳过,查询锁定了并返回了第二行。

这种方法可用于实现多线程队列处理或分组行的批量处理。但不应为该命令寻找其他用途——大多数任务可通过更简单的方法解决。(参见 179)

最后,可通过设置超时来避免长时间等待:

=> SET lock_timeout = '1s';
=> ALTER TABLE accounts DROP COLUMN amount;
ERROR:  canceling statement due to lock timeout

命令因在 1 秒内未能获取锁而失败。当然,超时可不仅设置在会话级别,也可设置在单个事务级别。

该方法可避免在高负载下执行需要独占锁的命令时出现长时间阻塞。在收到错误后,可稍后重试该命令。

说明

参数 lock_timeout 与参数 statement_timeout 的区别在于,前者限制的是等待锁的时间,而非操作的总执行时间。

=> ROLLBACK;

13.6. 死锁

一个事务继续运行可能需要另一个事务持有的资源;而那个事务可能需要第三个事务持有的资源,依此类推。事务会通过重锁机制排队等待。

但有可能出现这样的情况:一个正在排队的事务还需要另一个资源,而它自己也必须加入到该资源的同一队列中。这就产生了死锁 8:队列形成闭环,无法自行解除。

为了直观理解锁的状况,我们可以构建一个等待图。该图的顶点是正在执行的进程。其边是箭头,从等待获取锁的进程指向持有这些锁的进程。如果图中存在环,即从某个顶点沿箭头可以回到自身,那么就发生了死锁。(参见 301)

说明

我在图中展示的不是进程,而是事务。通常这种替换是允许的,因为一个进程通常只执行一个事务,且锁不会在事务之外被获取。但在一般情况下,必须讨论进程,因为并非所有锁都会在事务结束后立即释放。

死锁:T3;T2;资源3;T1;资源2;资源1
图 13.6 死锁

如果发生了死锁且没有任何参与者设置了等待超时,事务将无限期地相互等待。因此,锁管理器 9 会自动监控此类情况。

然而,检查需要一定的开销,我们不希望每次请求新锁时都进行检查(毕竟死锁相当罕见)。因此,当进程尝试获取锁但无法获取时,它只是进入队列并休眠,同时自动设置一个超时时间,该时间由参数 deadlock_timeout 10 指定。如果资源在超时前被释放,那就很好,节省了检查开销。但如果在 deadlock_timeout 时间单位后等待仍未结束,等待的进程将被唤醒并启动检查11。(默认值:1s)

本质上,检查过程就是构建等待图并在其中查找环12。在检查期间,所有重锁操作都会被暂停,以“冻结”当前图的状态。

如果检查未发现死锁,进程将再次休眠;迟早会轮到它继续执行。

如果检测到死锁,其中一个事务将被强制中止,以释放其持有的锁,使其他事务能够继续运行。大多数情况下,被中止的是发起检查的事务,但如果环中包含未因事务计数器溢出而冻结的自动清理工作进程,则自动清理将被中止,因其优先级较低。

死锁通常意味着应用程序设计不当。可以通过两种方式发现此类情况:首先,服务器日志中会出现相关消息;其次,pg_stat_database 表中的 deadlocks 值会增加。

行更新导致的死锁

尽管最终导致死锁的是重锁,但最常见的原因是表行的加锁顺序不一致。

假设第一个事务准备将 100 ₽ 从第一个账户转账到第二个账户。为此,它首先减少第一个账户的余额:

=> BEGIN;
=> UPDATE accounts SET amount = amount - 100.00 WHERE id = 1;
UPDATE 1

与此同时,第二个事务准备将 10 ₽ 从第二个账户转账到第一个账户。它从减少第二个账户余额开始:

=> BEGIN;
=> UPDATE accounts SET amount = amount - 10.00 WHERE id = 2;
UPDATE 1

现在第一个事务尝试增加第二个账户余额,但发现该行已被锁定:

=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 2;

随后第二个事务尝试增加第一个账户余额,但同样被阻塞:

=> UPDATE accounts SET amount = amount + 10.00 WHERE id = 1;

出现了循环等待,这种情况无法自行结束。一秒钟后,第一个事务因无法访问资源而发起死锁检查,被服务器中止:

ERROR:  deadlock detected
DETAIL:  Process 65884 waits for ShareLock on transaction 191470;
blocked by process 66190.
Process 66190 waits for ShareLock on transaction 191469; blocked by
process 65884.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,2) in relation "accounts"

现在第二个事务可以继续执行。它被唤醒并执行更新:

UPDATE 1

完成事务。

=> ROLLBACK;
=> ROLLBACK;

正确执行此类操作的方法是始终以相同的顺序锁定资源。例如,在本例中,可以按账户编号升序锁定账户。

两条 UPDATE 语句之间的死锁

有时,死锁看似不可能发生,但仍然会出现。

我们通常习惯将 SQL 命令视为原子操作。但以 UPDATE 为例,该命令是逐行更新并逐行加锁(而非一次性锁定所有行),且这一过程并非瞬时完成。因此,如果一个 UPDATE 命令以某种顺序更新多行,而另一个以不同顺序更新,它们就可能发生相互阻塞。

为了复现该情况,我们创建一个按 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;

第一个 UPDATE 命令将更新整个表。执行计划为全表顺序扫描:

=> EXPLAIN (costs off)
  UPDATE accounts SET amount = inc_slow(amount);
        QUERY PLAN
---------------------------
 Update on accounts
   -> Seq Scan on accounts
(2 rows)

我们将表中行版本按金额升序排列。为此,我们将它们重新插入已清空的表中:

=> TRUNCATE accounts;
=> INSERT INTO accounts(id, client, amount)
VALUES (1,'alice',100.00),
       (2,'bob',200.00),
       (3,'charlie',300.00);
=> ANALYZE accounts;
=> SELECT ctid, * FROM accounts;
 ctid  | id | client  | amount
-------+----+---------+--------
 (0,1) |  1 | alice   | 100.00
 (0,2) |  2 | bob     | 200.00
 (0,3) |  3 | charlie | 300.00
(3 rows)

在顺序扫描过程中,行将按相同顺序被更新(对于大表,这并非总是成立)。(参见 198)

启动更新操作:

=> 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;

通过 pgrowlocks 扩展查看表页面,我们会发现第一个操作已更新第一行(0,1),而第二个操作已更新最后一行(0,3):

=> SELECT locked_row, locker, modes FROM pgrowlocks('accounts');
 locked_row | locker |       modes
------------+--------+-------------------
 (0,1)      | 191476 | {"No Key Update"}    ← первый
 (0,3)      | 191477 | {"No Key Update"}    ← второй
(2 rows)

又过了一秒。第一个操作更新了第二行,而第二个操作想更新它,但无法实现。

=> SELECT locked_row, locker, modes FROM pgrowlocks('accounts');
 locked_row | locker |       modes
------------+--------+-------------------
 (0,1)      | 191476 | {"No Key Update"}
 (0,2)      | 191476 | {"No Key Update"}    ← первый успел раньше
 (0,3)      | 191477 | {"No Key Update"}
(3 rows)

现在第一个操作想更新表的最后一行,但它已被第二个操作锁定。死锁发生了。

其中一个事务被中止:

ERROR:  deadlock detected
DETAIL:  Process 66267 waits for ShareLock on transaction 191476;
blocked by process 66190.
Process 66190 waits for ShareLock on transaction 191477; blocked by
process 66267.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,2) in relation "accounts"

另一个事务完成执行:

UPDATE 3

尽管看似难以置信,但在高负载系统中批量更新行时,此类情况确实会发生。