跳转到主要内容

12 关系级锁

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

12.1. 关于锁

锁(locks),也称为 锁,用于协调对共享资源的并发访问。

并发访问指的是多个进程同时访问某个资源。这些进程可以并行执行(如果硬件允许),也可以在时间分片模式下顺序执行——这并不重要。如果没有竞争,就不需要锁(例如,共享缓冲区缓存需要锁,而本地缓存则不需要)。

在访问资源之前,进程必须 获取(acquire)与该资源关联的锁,而在完成工作后必须 释放(release)它,以便其他进程可以使用该资源。当锁由数据库管理系统(DBMS)管理时,这种顺序会自动维护;如果锁由应用程序设置,则必须由应用程序自身遵守规则。

在底层,锁表现为共享内存中的一段区域,其中以某种方式标记锁的状态(空闲或已被获取),并可能包含额外信息(如进程号和获取时间)。

说明

可以注意到,这段共享内存本身也是一个资源。对它的并发访问通过操作系统提供的特殊同步原语(如信号量或互斥锁)来协调。这些原语确保访问共享资源的代码严格按顺序执行。在最低层,这些原语

说明

是基于处理器的原子指令(如 test-and-set 或 compare-and-swap)实现的。

原则上,任何可以唯一标识并映射到锁地址的资源都可以作为受保护的资源。

例如,资源可以是数据库正在操作的对象,如表(通过系统目录中的 oid 标识)、数据页面(通过文件名和文件内位置标识)、行版本(通过页面和页面内偏移标识)。资源也可以是内存中的结构,如哈希表或缓冲区(通过预先分配的编号标识)。有时甚至方便使用没有实际物理意义的抽象资源。

锁的获取并非总是可行:资源可能已被其他进程占用。此时,进程要么进入队列(如果锁机制支持),要么在一段时间后重试获取。无论如何,这都会导致进程必须等待资源释放。

我将强调两个显著影响锁效率的因素。

粒度——锁的细化程度。当资源具有层次结构时,粒度尤为重要。

例如,表由页组成,页包含行版本。这些对象都可以作为资源。如果在表级别设置锁(粗粒度),即使多个进程访问不同的页或行,也无法同时工作。

行级锁(细粒度)没有这一缺点,但锁的数量会显著增加。为了防止这些信息占用过多内存,可以采用各种方法,例如 升级(escalation):当低级别、细粒度锁的数量超过某个阈值时,它们会被一个更高级别的锁所替代。

锁模式集合,即锁可以被获取的模式。

通常只使用两种模式。排他(exclusive)或 独占 模式与任何其他模式(包括自身)都不兼容。共享(shared)模式允许多个进程同时获取锁。共享模式通常用于读取资源,而排他模式用于修改资源。

一般情况下,模式可以更多。模式名称本身并不重要,重要的是它们之间的兼容性矩阵。

粒度越细、可兼容的模式越多,就越能实现并行化。

锁可以根据使用时间进行分类。

长期锁可能持有很长时间(通常直到事务结束),通常用于关系和行等资源。数据库系统通常自动管理这些锁,但用户仍可在某种程度上控制该过程。

长期锁具有较多模式,允许对数据执行多种并发操作。通常这类锁配有完善的基础设施(等待队列、死锁检测、监控),因为维护这些机制的成本远低于保护数据的操作成本。

短期锁持有时间仅为几毫秒(通常仅为执行几条处理器指令的时间),通常用于共享内存中的数据结构。数据库系统完全自动管理这些锁。

短期锁的特点是模式最少,基础设施简单,甚至可能没有监控机制。

PostgreSQL 使用多种类型的锁1。长期锁包括 重锁(在关系及其他对象级别);单独列出 行级锁。短期锁包括各种 内存结构上的锁。特别的是 谓词锁,尽管名称如此,但它们并非真正的锁。(参见 268)(参见 305)(参见 298)

12.2. 重锁

重锁(heavyweight)属于长期锁。它们在 对象 级别设置:主要是关系,但也包括其他一些对象。这些锁通常用于防止对象被同时修改或在重组期间被使用,但也可能用于其他用途。这种模糊的定义源于这些锁被用于各种不同目的,而它们的共同点仅在于其结构方式。

通常,当未明确说明时,“锁”指的就是这种重锁。

重锁位于服务器的共享内存中 2,可通过 pg_locks 视图查看。其数量受限于两个服务器参数值的乘积:max_locks_per_transaction 和 max_connections。(默认值:64)(默认值:100)

锁池对所有事务共享,即一个事务可以获取超过 max_locks_per_transaction 数量的锁。重要的是系统中锁的总数不超过设定上限。锁池在服务器启动时创建,因此修改任一参数都需要重启服务器。

如果资源已被不兼容模式的锁占用,试图获取锁的进程将被放入队列。等待的进程不消耗 CPU 时间:它们休眠,并在资源释放时由操作系统唤醒。

可能发生 死锁(deadlock),即两个事务陷入 僵局,其中第一个事务需要第二个事务占用的资源,而第二个事务又需要第一个事务占用的资源。这是最简单的情况;多个事务也可能陷入僵局。由于死锁会导致无限等待,PostgreSQL 会自动检测僵局并强制中止其中一个事务,以使其他事务得以继续执行。(参见 286)

由于不同类型的重锁服务于不同目的,保护不同的资源,并具有不同的模式,因此每种类型都需单独讨论。

以下列表中的名称对应 pg_locks 视图中 locktype 列的值:

transactionid 和 virtualxid —— 事务编号锁;(参见 259)

relation —— 关系锁;(参见 261)

tuple —— 行版本锁;(参见 275)

object —— 非关系对象的锁;(参见 293)

extend —— 在向关系文件添加新页面时的文件锁;(参见 295)

page —— 页面锁(某些索引类型使用);(参见 296)

advisory —— 建议性锁。(参见 296)

几乎所有重锁都是在需要时自动设置,并在事务结束时自动释放。例外情况包括:关系锁可以显式请求,而建议性锁的管理完全由用户控制。

12.3. 事务 ID 上的锁

每个事务始终持有其自身编号(虚拟和真实,若存在)的排他锁。(参见 91)

使用两种锁模式:排他和共享。冲突矩阵非常简单:共享模式与自身兼容;排他模式与任何模式都不兼容。

模式SharedExclusive
Shared×
Exclusive××
表 12.1 锁模式冲突矩阵:Shared 与 Exclusive

要等待某个事务结束,只需请求其编号的锁(任意模式)。由于该事务本身已持有其编号的排他锁,因此无法获取。请求进程将进入队列并休眠。当事务结束时,锁被释放,等待的进程将被唤醒。当然,它无法获取所请求的锁——资源已不存在。但这并非必需。

在独立会话中启动一个事务并获取其服务进程编号:

=> BEGIN;
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          64412
(1 row)

刚刚启动的事务持有其自身虚拟编号的排他锁:

=> SELECT locktype, virtualxid, mode, granted
FROM pg_locks WHERE pid = 64412;
  locktype  | virtualxid |     mode      | granted
------------+------------+---------------+---------
 virtualxid | 4/2        | ExclusiveLock | t
(1 row)

此处 locktype 为锁类型,virtualxid 为事务的虚拟编号(标识资源),mode 为模式(此处为排他)。granted 标志显示是否成功获取了请求的锁。

当事务获得真实编号时,其锁也会被添加到列表中:

=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
             191442
(1 row)
=> SELECT locktype, virtualxid, transactionid AS xid, mode, granted
FROM pg_locks WHERE pid = 64412;
   locktype    | virtualxid |  xid   |     mode      | granted
---------------+------------+--------+---------------+---------
 virtualxid    | 4/2        |        | ExclusiveLock | t
 transactionid |            | 191442 | ExclusiveLock | t
(2 rows)

现在事务持有其两个编号的排他锁。

12.4. 关系级锁

关系锁定义了八种不同的模式3。如此多的模式是为了尽可能让针对同一关系(表、索引或其他对象)的多种命令能够并发执行。

下图展示了冲突矩阵,并附有需要相应锁模式的命令示例。没有必要试图记忆这些模式或在它们的名称中寻找逻辑。但仔细研究这张表,得出一般性结论,并在需要时查阅——毫无疑问,这是值得的。

模式ASRSRESUESSREEAE命令
Access Share×SELECT
Row Share××SELECT FOR UPDATE/SHARE
Row Exclusive××××INSERT, UPDATE, DELETE
Share Update Exclusive×××××VACUUM, CREATE INDEX CONCURRENTLY
Share×××××CREATE INDEX
Share Row Exclusive××××××CREATE TRIGGER
Exclusive×××××××REFRESH MAT. VIEW CONCURRENTLY
Access Exclusive××××××××DROP, TRUNCATE, VACUUM FULL, LOCK TABLE, REFRESH MAT. VIEW
表 12.2 关系锁冲突矩阵

第一个模式 Access Share 是最弱的。它与除最后一个模式 Access Exclusive 外的任何其他模式都兼容。因此,SELECT 命令几乎不会妨碍其他命令的执行,但例如,它不会允许删除正在被查询访问的表。相反,Access Exclusive 模式是最严格的:它与任何其他模式都不兼容,即具有排他性。

前四种模式允许同时对表中的数据进行修改,而后四种模式则不允许。例如,CREATE INDEX 命令使用 Share 模式。该模式与自身兼容(即可以同时为同一张表创建多个索引),也与读取类命令的模式兼容。因此,SELECT 命令可以执行,但 INSERT、UPDATE 和 DELETE 命令将被阻塞。

相反,正在修改表中数据的未完成事务将阻塞 CREATE INDEX 命令的执行。因此,存在 CREATE INDEX CONCURRENTLY 选项,它使用较弱的 Share Update Exclusive 模式:该命令创建索引的时间比普通命令更长(甚至可能失败),但允许同时进行数据修改。

ALTER TABLE 命令有多种变体,需要不同的锁模式(Share Update Exclusive、Share Row Exclusive、Access Exclusive)。当然,所有模式都在文档中都有描述4。

本章中的示例将使用我们已熟悉的账户表:

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

查询锁表的操作还会多次用到,因此我们为它创建一个视图,将所有标识符合并为一个简短输出的通用列:

=> CREATE VIEW locks AS
SELECT pid,
  locktype,
  CASE locktype
    WHEN 'relation' THEN relation::regclass::text
    WHEN 'transactionid' THEN transactionid::text
    WHEN 'virtualxid' THEN virtualxid
  END AS lockid,
  mode,
  granted
FROM pg_locks
ORDER BY 1, 2, 3;

第一个会话中的事务(尚未结束)正在更新一行。与表一起,该表的所有索引也被锁定,因此出现了两个类型为 relation、模式为 Row Exclusive 的新锁:

=> UPDATE accounts SET amount = amount + 100.00 WHERE id = 1;
=> SELECT locktype, lockid, mode, granted
FROM locks WHERE pid = 64412;
   locktype    |    lockid     |       mode       | granted
---------------+---------------+------------------+---------
 relation      | accounts      | RowExclusiveLock | t
 relation      | accounts_pkey | RowExclusiveLock | t
 transactionid | 191442        | ExclusiveLock    | t
 virtualxid    | 4/2           | ExclusiveLock    | t
(4 rows)

12.5. 等待队列

重锁提供“公平”的等待队列5。如果一个进程试图获取与已持有锁的模式不兼容,或与队列中已有等待进程的模式不兼容的锁,它就会进入等待队列。

当第一个会话中的事务正在执行更新时,尝试在另一个会话中为该表创建索引:

=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          64897
(1 row)
=> CREATE INDEX ON accounts(client);

该命令“挂起”以等待资源释放。事务试图以 Share 模式获取表锁,但无法成功:

=> SELECT locktype, lockid, mode, granted
FROM locks WHERE pid = 64897;
  locktype  |  lockid  |     mode      | granted
------------+----------+---------------+---------
 relation   | accounts | ShareLock     | f
 virtualxid | 6/3      | ExclusiveLock | t
(2 rows)

现在,假设在第三个会话中启动了 VACUUM FULL 命令。它也会进入队列,因为其所需的 Access Exclusive 模式与任何其他模式都不兼容:

      => SELECT pg_backend_pid();
       pg_backend_pid
      ----------------
                65106
      (1 row)
      => VACUUM FULL accounts;
=> SELECT locktype, lockid, mode, granted
FROM locks WHERE pid = 65106;
   locktype    |  lockid  |        mode         | granted
---------------+----------+---------------------+---------
 relation      | accounts | AccessExclusiveLock | f
 transactionid | 191446   | ExclusiveLock       | t
 virtualxid    | 8/4      | ExclusiveLock       | t
(3 rows)

现在,所有后续请求者都将进入队列,无论其锁模式如何。即使普通的 SELECT 查询也会诚实地排在 VACUUM FULL 之后,尽管它们与第一个会话中执行 UPDATE 命令所持有的 Row Exclusive 锁是兼容的。

=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          65322
(1 row)
=> SELECT * FROM accounts;
=> SELECT locktype, lockid, mode, granted
FROM locks WHERE pid = 65322;
  locktype  |  lockid  |      mode       | granted
------------+----------+-----------------+---------
 relation   | accounts | AccessShareLock | f
 virtualxid | 10/3     | ExclusiveLock   | t
(2 rows)
等待队列:T1;UPDATE;关系;T2;CREATE INDEX;T3;VACUUM FULL;T4;SELECT
图 12.1 等待队列

函数 pg_blocking_pids 可以提供等待情况的总体视图。它显示了在指定进程之前排队的进程编号,这些进程要么持有不兼容的锁,要么正在请求不兼容的锁:

=> SELECT pid,
  pg_blocking_pids(pid),
  wait_event_type,
  state,
  left(query,50) AS query
FROM pg_stat_activity
WHERE pid IN (64412,64897,65106,65322) \gx
-[ RECORD 1 ]----+---------------------------------------------------
pid              | 64412
pg_blocking_pids | {}
wait_event_type  | Client
state            | idle in transaction
query            | UPDATE accounts SET amount = amount + 100.00 WHERE
-[ RECORD 2 ]----+---------------------------------------------------
pid              | 64897
pg_blocking_pids | {64412}
wait_event_type  | Lock
state            | active
query            | CREATE INDEX ON accounts(client);
-[ RECORD 3 ]----+---------------------------------------------------
pid              | 65106
pg_blocking_pids | {64412,64897}
wait_event_type  | Lock
state            | active
query            | VACUUM FULL accounts;
-[ RECORD 4 ]----+---------------------------------------------------
pid              | 65322
pg_blocking_pids | {65106}
wait_event_type  | Lock
state            | active
query            | SELECT * FROM accounts;

更详细的视图可以通过仔细分析 pg_locks 表的内容获得6。

当事务结束(无论通过提交还是回滚)时,其所有锁都会被释放7。队列中的第一个进程将获得所需的锁并被唤醒。

说明

一个不明显的情况是,当回滚到保存点(通过 ROLLBACK TO SAVEPOINT 命令或进入 PL/pgSQL 块的 EXCEPTION 部分)时,事务自该保存点以来获取的所有锁都会被释放。这是由于嵌套事务的回滚及其锁的释放所致。

在我们的示例中,第一个会话中事务的结束将导致队列中所有查询的依次执行:(参见 92)

=> ROLLBACK;
ROLLBACK
CREATE INDEX
VACUUM
 id | client  | amount
----+---------+--------
  1 | alice   | 100.00
  2 | bob     | 200.00
  3 | charlie | 300.00
(3 rows)