跳转到主要内容

15 内存结构上的锁

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

15.1. 自旋锁

为了保护共享内存中的结构,使用的是非传统的重锁,而是多种更轻量、成本更低的锁。

其中最简单的是 自旋锁(spinlock),也称为 自旋锁。它们用于在极短的时间内(几条处理器指令)获取,并保护内存的特定区域免受同时修改。

自旋锁基于处理器的原子指令实现,例如 compare-and-swap1。它们仅支持单一的独占模式。如果锁已被占用,等待的进程会执行主动等待——重复执行该指令(“在循环中旋转”,因此得名)。如果在特定时间内无法获取锁,进程将短暂暂停,然后再次开始主动等待循环。

这种策略在冲突概率极低的情况下是有意义的,因此一旦收到拒绝,可以预期锁将在几条指令后被释放。

自旋锁无法检测死锁。从应用角度看,只能相信 PostgreSQL 开发者正确使用了这些锁。

15.2. 轻量锁

接下来是所谓的 轻量锁 2(lightweight locks, lwlocks)。它们被持有以完成对数据结构(例如哈希表或指针列表)的操作所需的时间。通常轻量锁持有时间较短,但在某些情况下它们保护 I/O 操作,因此时间可能相当长。

存在两种模式:独占模式(用于修改数据)和共享模式(仅用于读取)。支持等待队列,但当锁以共享模式持有时,其他读取进程可绕过队列直接访问。在高并发和高负载系统中,这可能导致不良影响3。

未提供死锁检测机制;轻量锁和自旋锁的正确使用,同样依赖于 PostgreSQL 开发者的责任。

15.3. 例子

为了对自旋锁和轻量锁的使用方式和位置有一个大致了解,我们来看三个共享内存结构的示例:缓冲区缓存、预写日志缓冲区和 SLRU 缓存。我不会列出所有锁;完整图景过于复杂,仅对内核开发者有参考价值。

缓冲区缓存

要访问哈希表以查找缓存中的缓冲区,进程必须获取轻量锁 锁的符号表示 BufferMapping:以共享模式用于读取,以独占模式用于修改。(参见 183)

缓冲区缓存:buffer strategy;BufferMapping ×128;空闲缓冲区;“时针”;缓冲区固定;缓冲区;哈希表;buffer header;BufferIO;BufferContent
图 15.1 缓冲区缓存

对哈希表的访问非常频繁,这种锁常常成为瓶颈。为了提高粒度,它实际上被设计为一个 分段,由 128 个独立的轻量锁组成,每个锁保护哈希表的一部分 4。

说明

首次将保护哈希表的单一锁改为 16 个锁的分段是在 PostgreSQL 8.2 版本(2006 年)中实现的。十年后,在 9.5 版本中分段大小增加到 128。在现代多核系统中,有时这个值仍然不足。

进程通过获取自旋锁 锁的符号表示 buffer header 5 来访问缓冲区头(名称为示意性,因为自旋锁没有外部名称)。某些操作,如增加访问计数,可以使用处理器的原子指令在无显式锁的情况下执行。

要读取缓冲区内容,需要获取锁 锁的符号表示 BufferContent。它位于该缓冲区的头中6。通常,该锁仅在读取行版本指针所需的时间内持有,之后只需依赖 锁的符号表示 缓冲区固定提供的保护。要修改缓冲区内容,必须以独占模式获取 BufferContent 锁。(参见 189)

在从磁盘读取缓冲区(或写入磁盘)时,还会获取缓冲区头中的锁 锁的符号表示 BufferIO;实际上这不是真正的锁,而是一个扮演锁角色的标志7。该锁向其他对该页面感兴趣的进程发出信号,表示必须等待 I/O 完成。

指向空闲缓冲区的指针和淘汰机制的“时针”也由一个共同的自旋锁 锁的符号表示 buffer strategy8 保护。

WAL 缓冲区

预写日志缓冲区:WALBufMapping;WALWrite;哈希表;WALInsert ×8;插入位置;PrevBytePos;CurBytePos
图 15.2 预写日志缓冲区

日志缓存也使用哈希表,用于将页面映射到缓冲区。与缓冲区缓存的哈希表不同,该表仅由一个轻量锁 锁的符号表示 WALBufMapping 保护,因为日志缓存的大小小得多(通常等于一个段),且对缓冲区的访问更有序9。

将日志页面写入磁盘的操作由轻量锁 锁的符号表示 WALWrite 保护,以确保在任何时刻只有一个进程可以执行此操作。

要创建日志记录,进程首先在 WAL 页面内预留空间,然后填充所需数据。预留操作是严格有序的;进程必须获取自旋锁 锁的符号表示 insert position,以保护插入指针10。但已预留的空间可以由多个进程同时填充。为此,进程必须获取 任意一个 由八个轻量锁组成的分段 锁的符号表示 WALInsert11。

SLRU 缓存

SLRU(简单最近最少使用)缓存 12 用于多事务(multixact)、事务状态(clog)或子事务(subtrans)等数据结构的页面。与缓冲区缓存不同,SLRU 缓存结构简单,设计目标是主要的读写操作集中在最近的页面上:例如,事务的提交和状态检查通常在相近时间发生,之后便不再访问该事务的状态。被淘汰的页面是长时间未被访问的页面。(参见 86)

SLRU 缓存:…;Buffer;SLRU × N
图 15.3 SLRU 缓存

缓存被划分为若干个分组(bank),每组 16 页;页面所属分组由其编号的低四位比特决定。这种设计无需哈希表:在规模较小的分组内,线性搜索已足够。为特定数据结构服务的缓存总大小可单独配置。

每个分组由自己的轻量锁 SLRU 保护(分组名称取决于数据结构:例如,clog 的分组称为 XactSLRU)。每页也有自己的轻量锁 Buffer(clog 为 XactBuffer)。进程持有分组锁以获取或修改其中缓冲区的状态。获取分组锁也用于获取页面锁,以便将页面写入磁盘或从磁盘读取;但在 I/O 操作期间,分组锁会被释放,以便其他进程可以访问该分组中的其他页面。

15.4. 监控等待

当然,锁对于正确运行是必要的,但它们也可能导致不希望的等待。跟踪这些等待有助于查明其原因。

获取系统中长时间持有锁的最简单方法是启用参数 log_lock_waits。此时,每当事务等待时间超过 deadlock_timeout,服务器日志中都会记录详细的锁信息。这些数据在死锁检测检查完成后输出,因此得名。(默认值: off) (默认值: 1s) (参见 286)

但更完整且更有用的信息可以在视图 pg_stat_activity 中找到。当进程(系统或服务)无法执行其工作并等待某些内容时,该等待会在 wait_event_type(等待类型)和 wait_event(具体等待名称)列中显示。

所有等待可以分为几个类型13。

其中一大类是各种锁的等待:

Lock — 重锁;

LWLock — 轻量锁;

BufferPin — 缓冲区固定。

每种类型由等待名称进一步具体化。例如,对于轻量锁,这将是锁名称或相应分段的名称14。

但进程也可能等待其他事件:

IO — 输入/输出,当需要写入或读取数据时;

Client — 来自客户端的数据(在此状态下,psql 通常花费大部分时间);

IPC — 来自其他进程的数据;

Extension — 扩展注册的特定事件;

InjectionPoint — 在注入点执行代码以测试 PostgreSQL(此事件在生产中不会出现)。(v. 17)

有时进程只是不执行任何有用工作。这类等待包括:

Activity — 后台进程在其主循环中;

Timeout — 定时器。

通常,这类等待是“正常”的,不表示任何问题。然而,例如,类型为 Timeout 的 SpinDelay 事件表明正在等待自旋锁。(v. 17)

需要注意的是,该视图仅显示在源代码中适当处理的等待。如果未定义等待类型名称,进程不在任何已知等待中。这种时间应视为 未计入,因为这并不意味着进程一定没有等待;实际上,此时正在发生什么尚不清楚。 15

=> SELECT backend_type, wait_event_type AS event_type, wait_event
FROM pg_stat_activity
ORDER BY backend_type;
         backend_type         | event_type |     wait_event
------------------------------+------------+---------------------
 autovacuum launcher          | Activity   | AutovacuumMain
 background writer            | Activity   | BgwriterMain
 checkpointer                 | Activity   | CheckpointerMain
 client backend               |            |
 io worker                    | Activity   | IoWorkerMain
 io worker                    | Activity   | IoWorkerMain
 io worker                    | Activity   | IoWorkerMain
 logical replication launcher | Activity   | LogicalLauncherMain
 walwriter                    | Activity   | WalWriterMain
(9 rows)

在这种情况下,访问视图时所有后台服务进程都在空转,而服务进程(client backend)正忙于执行查询,没有任何等待。

15.5. 采样

不幸的是,视图 pg_stat_activity 仅显示当前时刻的等待信息;统计数据不会累积。唯一能够获得随时间变化的等待情况的方法是——以一定间隔对视图状态进行采样。

必须考虑采样的概率性质。相对于采样周期,等待时间越短,被记录到的概率就越低。因此,采样周期越长,需要的测量次数就越多,才能获得可靠的图景。而提高采样频率则会导致开销增加。正因为如此,对于短生命周期会话的分析,采样几乎毫无用处。

系统未提供内置的采样机制,但作为示例,我们将使用扩展 pg_wait_sampling16。需要将该库写入参数 shared_preload_libraries 并重启服务器。

=> ALTER SYSTEM SET shared_preload_libraries = 'pg_wait_sampling';
postgres$ pg_ctl restart -l /home/postgres/logfile

现在在数据库中安装该扩展:

=> CREATE EXTENSION pg_wait_sampling;

该扩展允许查看保存在环形缓冲区中并逐渐被新数据覆盖的最近等待情况。但更有趣的是获取等待的概要——即整个运行期间累积的统计信息。

例如,观察基准测试运行期间产生的等待情况。启动工具 pgbench,并在它运行时确定其进程号:

postgres$ /usr/local/pgsql/bin/pgbench -T 60 internals
=> SELECT pid FROM pg_stat_activity
WHERE application_name = 'pgbench';
  pid
-------
 71963
(1 row)

工作结束后等待的概要:

=> SELECT pid, event_type, event, count
FROM pg_wait_sampling_profile WHERE pid = 71963
ORDER BY count DESC LIMIT 5;
  pid  | event_type |   event    | count
-------+------------+------------+-------
 71963 | Client     | ClientRead |  2601
 71963 | IO         | WalSync    |  2092
 71963 |            |            |  1226
 71963 | IO         | WalWrite   |    22
 71963 | LWLock     | ProcArray  |     3
(5 rows)

默认设置下(参数 pg_wait_sampling.profile_period),值每秒保存 100 次。因此,要估算等待时间(以秒为单位),需将 count 除以 100;总持续时间大致对应于进程的运行时间。(默认值:10ms)(v. 1.1.6)

本例中,工具大部分时间都在执行有用工作。非生产性等待主要与日志记录同步到磁盘有关。此例很好地说明了 未计入 时间的概念。事件 WALSync 的处理功能自 PostgreSQL 12 版本引入;在更早版本中,该概要不会包含第一行,尽管等待本身当然仍然存在。

轻量级锁 ProcArray 保护服务器共享内存中的同名数据结构。(参见 99)

如果人为地减慢文件系统性能(例如使用 slowfs 17),使任何 I/O 操作耗时 0.1 秒,那么概要将呈现如下:

postgres$ /usr/local/pgsql/bin/pgbench -T 60 internals
=> SELECT pid FROM pg_stat_activity WHERE application_name = 'pgbench';
  pid
-------
 72414
(1 row)
=> SELECT pid, event_type, event, count
FROM pg_wait_sampling_profile WHERE pid = 72414
ORDER BY count DESC LIMIT 5;
  pid  | event_type |     event      | count
-------+------------+----------------+-------
 72414 | IO         | WalWrite       |  5329
 72414 | LWLock     | WALWrite       |   167
 72414 |            |                |    50
 72414 | IO         | WalSync        |    43
 72414 | IO         | DataFileExtend |    19
(5 rows)

现在 I/O 操作成为首要等待项,主要与日志的同步写入有关。由于日志写入受轻量级锁 WALWrite 保护,相应行也出现在概要中。

显然,该锁在前一个示例中也已被请求过,但由于其等待时间短于采样周期,因此要么极少被采样到,要么根本未被采样。这再次说明,分析短时等待需要长时间的采样。