跳转到主要内容

9 缓冲区缓存

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

9.1. 输入输出与缓存

一般考虑

输入输出操作可以分为直接输入输出和缓冲输入输出。

直接输入输出(Direct I/O)在用户进程内存与磁盘之间直接传输数据。如果支持直接内存访问(DMA),这种操作甚至不会占用处理器。

缓冲输入输出会使用操作系统及其缓存。用户进程通过系统调用请求数据。如果所需数据在缓存中,操作系统会立即返回结果;否则会先从磁盘读取数据。无论哪种情况,数据都会先(可能提前)放入操作系统内核内存中的缓存,然后再复制到用户进程内存中;在这里,后者就是 PostgreSQL 的缓冲区缓存。

说明

虽然这里讨论的是内存和磁盘,但同样的缓存思想也用于弥补任意两种内存之间的性能差异:一种更快、更昂贵、容量更小,另一种更慢、更便宜、容量更大。快速内存不可能容纳慢速内存中的全部数据。但在大多数情况下,同时活跃处理的只是一个很小的子集,因此拿出一部分快速内存保存“热”数据,就能显著减少访问慢速设备的次数。在现代计算系统中,缓存无处不在,既存在于软件层面,也存在于硬件层面。仅处理器本身就可以有三四级缓存;磁盘阵列控制器和磁盘自身也可能有缓存。

输入输出操作也可以用另一种方式分类。

执行同步输入输出时,进程发送命令后会阻塞。操作执行期间,进程只能空闲等待它完成。

执行异步输入输出(Async I/O)时,进程创建并发送任务,然后继续工作。它会在方便的时候稍后回来获取结果。如果操作已经完成,进程会立即得到结果;否则会像同步输入输出一样阻塞,直到操作完成。

很明显,直接输入输出可以提供更高的吞吐量和更低的数据获取延迟,而异步输入输出可以减少空闲时间并完成更多有用工作。但从编程角度看,普通的同步缓冲输入输出要简单得多,性能损失也远没有想象中那么大。PostgreSQL 使用同步缓冲输入输出已经三十年了。这是因为,虽然数据库系统代码按顺序运行,输入输出实际上是异步执行的:操作系统经常会预测读取顺序,并在数据库系统后台提前把所需数据读入自己的缓存。数据预取和脏页延迟写入的工作都交给了操作系统。

自行实现异步输入输出的动机,是减少对操作系统的依赖:不同系统执行预取的效果并不相同,而数据库系统无论如何都更清楚自己何时需要哪些页面。此外,还可以异步地把数据同步到磁盘(fsync)。

切换到直接输入输出也有好处:可以消除双重缓存(操作系统缓存和 PostgreSQL 缓冲区缓存),并有可能显著提高性能。但这样也会失去操作系统提供的全部支持。我们必须根据访问方法的特点自行实现数据预取,并自行把脏页刷到磁盘上,均匀分摊写入负载;还需要把小型输入输出请求合并为大型请求的算法。缺少这些机制时,直接输入输出只会让系统变慢。

当前状态

让 PostgreSQL 摆脱文件系统的工作已经进行了几年。(v. 18)

直接输入输出由 debug_io_direct 参数启用,但在所需功能尚未全部实现之前,这个设置只对内核开发者有意义。(默认值: off)(v. 16)

异步输入输出1目前只支持读取;写入和同步仍然是同步的。现在有几种输入输出实现,其中一种由 io_method 参数指定:可以选择当前平台支持、并且在具体系统中表现最好的方法。(默认值: worker)

  • 当值为 worker 时,输入输出由独立的工作进程执行2。这种方法适用于所有平台,也是默认选择。

为了读取一个页面或连续的几个页面,服务进程或系统进程会把请求放入所有工作进程共享的队列。随后,其中一个工作进程会执行请求的操作,例如把所需页面读入缓冲区缓存。

工作进程数量固定,由 io_workers 参数决定。在核心数较多的服务器上,可以适当增大这个值(最大值为 32)。(默认值: 3)

=> SELECT pid, backend_type
FROM pg_stat_activity
WHERE backend_type = 'io worker';
  pid  | backend_type
-------+--------------
 55418 | io worker
 55419 | io worker
 55420 | io worker
(3 rows)
  • 当值为 io_uring 时,进程本身通过 Linux 内核的同名接口执行输入输出,因此只能在 Linux 上工作3。

io_uring 接口在用户进程内存中使用两个环形缓冲区(因此得名 user-space ring):一个用于输入输出请求,另一个用于获取这些请求的结果。由于缓冲区位于进程内存中,与接口交互时无需系统调用,也不会发生上下文切换。

  • 当值为 sync 时,使用普通的同步输入输出。

io_uring 接口的潜在效率高于工作进程,但也有一些细节需要注意。除了实际输入输出外,还需要检查已读页面的校验和;在输入输出仍然采用缓冲方式时,还需要把读取的数据从操作系统缓存复制到 PostgreSQL 缓冲区缓存。选择 io_uring 时,这些工作由当前进程执行;选择 worker 时,则由工作进程分担。因此,只有一个进程进行密集读取时,worker 很可能更有优势,尤其是在工作进程数量足够时;相反,如果有许多读取进程产生大量细小的单页请求,就可能受限于工作进程池的规模。

从本质上说,异步输入输出由两个数值调节:可以同时执行的异步输入输出操作数,以及一次操作读取的连续页面数。不过,相关参数不止两个。

io_max_concurrency 参数设置每个进程最多可以执行的异步操作数。实例启动时在共享内存中为异步输入输出创建的数据结构,其大小取决于这个值与系统中最大进程数(包括服务进程)的乘积。使用默认值时,数量会根据缓冲区缓存大小自动选择,但不会超过 644。(默认值: -1)

io_max_concurrency 本身并不限制单个进程的操作数。这个限制由另外两个参数设置:effective_io_concurrency 用于表数据的常规读取,maintenance_io_concurrency 用于清理等维护任务。将这两个参数设为零,实际上会禁用异步输入输出。由于并行度取决于输入输出设备的能力,这些参数也可以在表空间级别设置。(默认值: 16)(默认值: 16)

一次输入输出操作最多可以读取多少数据,由 io_combine_limit 参数设置;默认值相当于 16 个标准的 8 KB 页面。如果操作系统支持更大的操作,增大该值可能有利于经常完整扫描大表的 OLAP 系统。(默认值: 128 kB)

io_combine_limit 参数可以在线修改,但不能超过另一个参数 io_max_combine_limit 设定的范围;修改后者需要重启服务器。(默认值: 128 kB)

目前只有以下操作会异步执行:

  • 顺序读取(Seq Scan);(第 374 页)

  • 位图扫描(Bitmap Heap Scan);(第 427 页)

  • 清理和分析表,以及清理 B-树、GiST 和 SP-GiST 索引;(第 125 页)

  • 预热缓冲区缓存(扩展 pg_prewarm);

  • 检查关系完整性(amcheck 扩展的 verify_heapam 函数)。

如果禁用了异步输入输出,PostgreSQL 会在执行这些操作时,根据 effective_io_concurrency 或 maintenance_io_concurrency 的值,尝试向操作系统提示所需的预取深度。还有两种目前尚未使用异步输入输出、但同样会发起预取的操作:

  • 删除表行时读取索引页面5;

  • 恢复时读取 WAL 页面6。

视图 pg_aios 显示异步输入输出描述符;这些描述符确定操作,并在操作执行期间保存其状态。不过,该视图主要面向内核开发者,而不是用于日常监控。

9.2. 缓冲区缓存的结构

PostgreSQL 缓冲区缓存7保存关系页面,用来弥合内存访问时间(纳秒)与磁盘访问时间(毫秒)之间的差异。

缓冲区缓存位于服务器共享内存中,所有进程都可以访问。它占据大部分共享内存,是最重要、最复杂的数据结构之一。理解缓存的工作原理本身就很重要;此外,许多其他结构(例如嵌套事务、clog 事务状态和日志记录)也使用类似但更简单的缓存机制。

之所以称为缓冲区缓存,是因为它由一组缓冲区组成。每个缓冲区都会预留一段足以容纳一个数据页面及其对应缓冲区头部的内存8。

缓冲区缓存的结构:缓冲区缓存;缓冲区头部;页面
图 9.1 缓冲区缓存的结构

缓冲区头部包含缓冲区和其中已加载页面的信息,例如:

  • 页面的物理位置(关系文件标识符、层和层内的块号);

  • 表示页面数据已经改变、迟早需要写回磁盘的标志(这样的页面称为脏页);

  • 缓冲区访问次数(usage count);

  • 缓冲区钉住计数(pin count 或 reference count)。

要访问关系的数据页面,进程会向缓冲区管理器9请求页面,并得到包含该页面的缓冲区编号。进程从缓存中的页面读取数据,也可以直接在那里修改数据。在处理页面期间,缓冲区会被钉住,使管理器不能用其他页面替换它。每次钉住都会增加页面的访问计数。除了钉住之外,还会使用其他锁。(第 306 页)

页面位于缓冲区缓存中时,对它的操作不会产生文件操作。

可以使用 pg_buffercache 扩展查看缓冲区缓存:

=> CREATE EXTENSION pg_buffercache;

创建一张表,并向其中插入一行:

=> CREATE TABLE cacheme(
  id integer
) WITH (autovacuum_enabled = off);
=> INSERT INTO cacheme VALUES (1);

现在,缓冲区缓存中有一个包含刚插入行的表页面。可以筛选出属于特定表的缓冲区来确认这一点。后面还会用到这个查询,因此把它封装成一个函数:

=> CREATE FUNCTION buffercache(rel regclass)
RETURNS TABLE(
  bufferid integer, relfork text, relblk bigint,
  isdirty boolean, usagecount smallint, pins integer
) AS $$
SELECT bufferid,
  CASE relforknumber
    WHEN 0 THEN 'main'
    WHEN 1 THEN 'fsm'
    WHEN 2 THEN 'vm'
  END,
  relblocknumber,
  isdirty,
  usagecount,
  pinning_backends
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode(rel)
AND relforknumber = 0 -- только main
ORDER BY relforknumber, relblocknumber;
$$ LANGUAGE sql;
=> SELECT * FROM buffercache('cacheme');
 bufferid | relfork | relblk | isdirty | usagecount | pins
----------+---------+--------+---------+------------+------
      355 | main    |      0 | t       |          1 |    0
(1 row)

页面是脏的(isdirty),因为它已经被修改但还没有写入磁盘。访问计数(usagecount)为 1。

9.3. 缓存命中

当缓冲区管理器需要读取页面10时,会先尝试在缓冲区缓存中查找页面。为了快速找到所需缓冲区,系统使用保存缓冲区编号的哈希表11。

说明

许多现代编程语言都把哈希表作为基本数据类型之一。它们通常称为关联数组。从用户角度看,关联数组确实像数组,但作为索引(称为哈希键)可以使用任意数据类型,例如文本字符串,而不只是整数。

哈希键的可能取值范围可以非常大,但哈希表同时保存的值并不多。哈希的思想是通过哈希函数把键值映射为某个整数,再把这个数或其中的一部分位用作普通数组的索引。这样的数组元素称为哈希表的桶。

好的哈希函数会把键均匀分布到各个桶中,但仍可能为不同的键产生相同的数,从而把它们放入同一个桶;这称为冲突。因此,桶中除了值还会保存哈希键;按键读取值时,还要重新检查落入同一个桶的所有键。

哈希表有许多实现方式。缓冲区缓存使用可动态扩展、通过链解决哈希冲突的哈希表12。

哈希键由关系文件标识符、层类型以及该层文件中的页面编号组成。因此,只要知道页面,就可以快速找到包含它的缓冲区,或者确认该页面不在缓存中。

说明

长期以来,哈希表一直受到批评。这种结构无法帮助查找某个关系的所有页面占用的缓冲区。而在执行 DROP、清空(TRUNCATE)或清理期间截断表尾时,正需要这样查找,以便从缓存中移除相应页面13。但目前还没有人提出好的替代方案。

如果在哈希表中找到了缓冲区编号,管理器就会钉住该缓冲区,并把编号返回给进程。此后,进程可以在缓冲区缓存中处理页面,而不会发生输入输出。(第 133 页)

要钉住缓冲区,需要增加其缓冲区头部中的 pin count;同一个缓冲区可以同时被多个进程钉住。

缓存命中:3501, 0, 3;2610, 0, 7;哈希表
图 9.2 缓存命中

只要缓冲区处于钉住状态(即计数器大于零),就认为它正在使用中,其内容不应发生“根本性”变化。例如,页面中可以出现新的行版本(根据可见性规则,它不会可见),但页面不能被另一个页面替换。

带有 analyze 和 buffers 参数的 EXPLAIN 命令不只是显示查询计划,还会执行查询,并输出使用的缓冲区数量:

=> EXPLAIN (analyze, costs off, timing off, summary off)
  SELECT * FROM cacheme;
                   QUERY PLAN
------------------------------------------------
 Seq Scan on cacheme (actual rows=1.00 loops=1)
   Buffers: shared hit=1
 Planning:
   Buffers: shared hit=2 read=6
(4 rows)

hit=1 表示需要读取的一个页面已经在缓存中找到。

钉住缓冲区使访问计数增加了 1:

=> SELECT * FROM buffercache('cacheme');
 bufferid | relfork | relblk | isdirty | usagecount | pins
----------+---------+--------+---------+------------+------
      355 | main    |      0 | t       |          2 |    0
(1 row)

现在更新这一行,使页面中出现两个版本:一个是当前版本,另一个是死版本。稍后会用到这个状态。

=> UPDATE cacheme SET id = 2;
UPDATE 1

打开游标的好处在于钉住状态会保持,因此可以快速取得结果集的下一行。这样就能在查询执行期间观察钉住状态:

=> BEGIN;
=> DECLARE c CURSOR FOR SELECT * FROM cacheme;
=> FETCH c;
 id
----
  2
(1 row)
=> SELECT * FROM buffercache('cacheme');
 bufferid | relfork | relblk | isdirty | usagecount | pins
----------+---------+--------+---------+------------+------
      355 | main    |      0 | t       |          4 |    1
(1 row)

有时进程确实需要一个已经钉住的缓冲区,此时它会排队并休眠,直到能够独占访问该缓冲区。例如,冻结操作就是如此14。(第 134 页)

但通常情况下,如果钉住状态妨碍进程继续工作,进程会直接跳过这个缓冲区并选择另一个。表清理就是一个例子。这里会跳过页面,因为另一个进程设置的钉住状态阻止了死版本的物理删除:

=> VACUUM VERBOSE cacheme;
INFO:  vacuuming "internals.public.cacheme"
INFO:  finished vacuuming "internals.public.cacheme": index
scans: 0
pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0
eagerly scanned
tuples: 0 removed, 2 remain, 0 are dead but not yet removable
tuples missed: 1 dead from 1 pages not removed due to cleanup
lock contention
removable cutoff: 908, which was 0 XIDs old when operation ended
new relfrozenxid: 905, which is 1 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 0 pages set all-visible, 0 pages set all-frozen
(0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0
dead item identifiers removed
avg read rate: 0.000 MB/s, avg write rate: 137.061 MB/s
buffer usage: 33 hits, 0 reads, 3 dirtied
WAL usage: 4 records, 3 full page images, 25030 bytes, 0 buffers
full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

游标移动到另一个页面或关闭后,钉住状态就会解除。在本例中,这与事务结束同时发生:

=> COMMIT;
=> SELECT * FROM buffercache('cacheme') WHERE relfork = 'main';
 bufferid | relfork | relblk | isdirty | usagecount | pins
----------+---------+--------+---------+------------+------
      355 | main    |      0 | t       |          4 |    0
(1 row)

修改页面时,钉住机制也以同样方式工作。例如,再向表中插入一行(它会进入同一个页面):

=> INSERT INTO cacheme VALUES (2);
=> SELECT * FROM buffercache('cacheme') WHERE relfork = 'main';
 bufferid | relfork | relblk | isdirty | usagecount | pins
----------+---------+--------+---------+------------+------
      355 | main    |      0 | t       |          5 |    0
(1 row)

页面不会立即写入磁盘,而是继续在缓冲区缓存中保持脏状态:这样读取和写入都可以节省开销。

9.4. 缓存未命中

如果哈希表中没有与页面对应的记录,就说明页面不在缓冲区缓存中。此时会为页面选择一个新的缓冲区并立即钉住它,把页面读入该缓冲区,同时相应修改哈希表中的引用。

移除缓冲区中的页面(也可以重启实例,以此清空整个缓冲区缓存):(v. 17)

=> SELECT pg_buffercache_evict(355);
 pg_buffercache_evict
----------------------
 (t,t)
(1 row)

尝试读取页面会造成缓存未命中,页面会被加载到新为它分配的缓冲区中:

=> EXPLAIN (analyze, costs off, timing off, summary off)
  SELECT * FROM cacheme;
                   QUERY PLAN
------------------------------------------------
 Seq Scan on cacheme (actual rows=2.00 loops=1)
   Buffers: shared read=1 dirtied=1
 Planning:
   Buffers: shared hit=2
(4 rows)

这次命令显示的是 read,而不是 hit,表示发生了缓存未命中。查询还让页面变脏(dirtied),因为查询修改了页面上的提示位。(第 86 页)

查询缓冲区缓存可以看到,刚加载页面的使用计数为 1:

=> SELECT * FROM buffercache('cacheme');
 bufferid | relfork | relblk | isdirty | usagecount | pins
----------+---------+--------+---------+------------+------
      378 | main    |      0 | t       |          1 |    0
(1 row)

可以从 pg_statio_all_tables 视图获得表的缓冲区缓存使用汇总统计:

=> SELECT heap_blks_read, heap_blks_hit
FROM pg_statio_all_tables
WHERE relname = 'cacheme';
 heap_blks_read | heap_blks_hit
----------------+---------------
              1 |             9
(1 row)

索引和序列也有相应的视图。

缓冲区查找与驱逐

寻找合适的缓冲区15并不容易。可能出现两种情况。

  1. 服务器刚启动时,缓存中只有空的空闲缓冲区,它们都连接在同一个列表中。

只要还有空闲缓冲区,列表中的第一个就适合使用,并会从列表中移除。

只有在页面消失而不是被另一个页面替换时,缓冲区才会回到空闲缓冲区列表16。这发生在 DROP、TRUNCATE 或清理期间截断表的尾部时。(第 133 页)

  1. 迟早会用完空闲缓冲区(因为数据库大小通常超过为缓存分配的内存),此时管理器必须选择一个已占用的缓冲区并驱逐其中的页面。为此使用“时钟”算法(clock sweep):有一个“时钟指针”指向某个缓冲区,指针会循环遍历缓冲区缓存,把页面访问计数减 1。当指针到达一个未钉住且计数为零的缓冲区时,搜索结束。

一方面,每次使用(钉住)缓冲区时,访问计数都会增加;另一方面,为驱逐而查找缓冲区时,访问计数会减少。因此,最近较少访问的页面会优先被驱逐,而频繁使用的页面会留在缓存中。

可以看到,如果所有缓冲区的访问计数都不为零,“时钟指针”就必须转过一圈以上,不断降低计数,直到某个计数终于变成零。为避免长时间“绕圈”,访问计数的最大值被限制为 5。

找到缓冲区后,需要从哈希表中删除仍位于该缓冲区内的旧页面的引用。

但如果缓冲区是脏的,也就是包含已修改的数据,就不能直接丢弃旧页面;缓冲区管理器必须先把它保存到磁盘。(第 226 页)

缓冲区查找与驱逐:空闲缓冲区;“时钟指针”
图 9.3 缓冲区查找与驱逐

无论找到的是空闲缓冲区还是已占用缓冲区,缓冲区管理器都会把新页面读入其中。这里使用的是缓冲输入输出,因此只有在操作系统自己的缓存中找不到页面时,才会从磁盘读取页面。

说明

使用直接输入输出且不依赖操作系统缓存的数据库系统,会区分逻辑读取(从内存,也就是缓冲区缓存读取)和物理读取(从磁盘读取)。从 PostgreSQL 的角度看,页面要么从缓冲区缓存读取,要么向操作系统请求;但数据库系统无法知道后一种情况下页面是从内存中找到的,还是从磁盘读取的。

新页面的引用会放入哈希表,缓冲区也会被钉住。这会使访问计数增加,也就是把它设为 1。这样,在“时钟指针”绕缓冲区缓存转圈时,缓冲区就有时间逐步增加自己的访问计数。

9.5. 批量驱逐

执行大量读取或写入的操作可能会用“一次性”数据迅速驱逐缓冲区缓存中的有用页面。

为避免这种情况,缓冲区缓存会为批量操作使用相对较小的缓冲区环(buffer ring),驱逐只在环内进行,不影响其他缓冲区。

说明

代码中除了“buffer ring”还使用“ring buffer”,也就是“环形缓冲区”。但这样翻译会造成混淆,因为这个(环形)缓冲区本身由缓冲区(缓冲区缓存)组成。“环”这个译法没有这个问题。

指定大小的缓冲区环是一个缓冲区数组,缓冲区会依次循环使用。开始时缓冲区环为空,缓冲区按普通方式从缓冲区缓存中加入环;之后驱逐开始生效,但只在环内进行17。

环中的缓冲区并不会从缓冲区缓存中移除,原则上也可以被其他操作使用。因此,如果下一个缓冲区处于钉住状态或访问计数大于 1,它就会从缓冲区环中移除,并换成另一个缓冲区。

一共有三种驱逐策略18。

**批量读取策略(bulkread)**用于顺序扫描大型表,前提是表大小超过缓冲区缓存的四分之一。(第 374 页)

使用该策略时,不会为了释放缓冲区而把脏页写入磁盘;相反,缓冲区会从环中移除并换成另一个缓冲区。这样读取无需等待写入,可以更快完成。

如果进程开始扫描表时发现该表已经在被扫描,就会加入已有的缓冲区环,从而在不产生多余输入输出的情况下获得表的一部分19。第一个进程完成扫描后,第二个进程会单独读取之前漏掉的表开头部分。

环必须足够大,使缓冲区在再次需要之前有时间释放;同时又要在环中停留足够长的时间,让多个读取进程能够同步。启用异步输入输出时,可以同时执行 effective_io_concurrency 个操作,每个操作可以读取 io_combine_limit 字节,这决定了所需的大小。此外,还要加上 256 KB(32 个标准页面);如果禁用了异步输入输出,前面的乘积就会变为零。但为了避免环过大,其大小还受缓冲区缓存容量与系统最大进程数之比限制:缓存必须够所有进程使用。(v. 18)(第 183 页)(默认值: 16)(默认值: 128K)

因此,在默认设置下,如果缓冲区缓存足够大,缓冲区环的大小为 32 + 16 × 16 = 288 个缓冲区。

**批量写入策略(bulkwrite)**用于 COPY FROM、CREATE TABLE AS SELECT、CREATE MATERIALIZED VIEW,以及会重写表的某些 ALTER TABLE 操作。它会分配一个相当大的缓冲区环,大小为 16 MB(2048 个标准页面),但不会超过整个缓冲区缓存的 18\frac18。

**清理策略(vacuum)**由清理和分析进程在不考虑可见性映射、完整扫描表时使用。缓冲区环的大小由 vacuum_buffer_usage_limit 参数决定,也可以在 VACUUM 或 ANALYZE 命令中覆盖。(默认值: 2MB)(v. 16)

缓冲区环并不总能防止不必要的驱逐。使用 UPDATE 或 DELETE 修改大量行时,表会采用批量读取策略进行扫描;但由于页面会被修改,缓冲区环会退化,实际上无法发挥作用。

另一个重要情况是数据库中存储使用 TOAST 的大值。尽管读取的数据量可能很大,访问 TOAST 表时总是通过索引进行,因此不会使用缓冲区环。(第 33 页)

现在观察批量读取策略。为简化计算,我们创建一张表,使插入时每一行占据一个完整页面。默认缓冲区缓存大小为 16,384 个 8 KB 页面。因此,要让表扫描使用缓冲区环,表必须超过 4096 个页面。

=> CREATE TABLE big(
  id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  s char(1000)
) WITH (fillfactor = 10);
=> INSERT INTO big(s) SELECT 'FOO' FROM generate_series(1,4096+1);

分析这张表:

=> ANALYZE big;
=> SELECT relname, relfilenode, relpages
FROM pg_class
WHERE relname IN ('big', 'big_pkey');
 relname  | relfilenode | relpages
----------+-------------+----------
 big      |       16569 |     4097
 big_pkey |       16575 |       14
(2 rows)

清除分析期间读取的表页面,然后读取整张表:(v. 18)

=> SELECT pg_buffercache_evict_relation('big'::regclass);
 pg_buffercache_evict_relation
-------------------------------
 (4101,4099,0)
(1 row)
=> EXPLAIN (analyze, costs off, timing off, summary off, buffers off)
SELECT * FROM big;
                  QUERY PLAN
-----------------------------------------------
 Seq Scan on big (actual rows=4097.00 loops=1)
(1 row)

由于缓冲区缓存较小,缓冲区环的大小受到限制:表页面只占用了 94 个缓冲区,而不是预期的 288 个:

=> SELECT count(*)
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('big'::regclass);
 count
-------
    94
(1 row)

如果使用索引读取表,就不会使用缓冲区环:

=> EXPLAIN (analyze, costs off, timing off, summary off, buffers off)
SELECT * FROM big
ORDER BY id;
                           QUERY PLAN
----------------------------------------------------------------
 Index Scan using big_pkey on big (actual rows=4097.00 loops=1)
   Index Searches: 1
(2 rows)

结果是,整张表和整个索引都进入了缓冲区缓存:

=> SELECT relfilenode, count(*)
FROM pg_buffercache
WHERE relfilenode IN (
  pg_relation_filenode('big'), pg_relation_filenode('big_pkey')
)
GROUP BY relfilenode;
 relfilenode | count
-------------+-------
       16569 |  4097
       16575 |    14
(2 rows)

9.6. 缓冲区缓存大小设置

缓冲区缓存的大小由 shared_buffers 参数决定。默认值明显偏小,因此安装 PostgreSQL 后就适合立即增大该值。修改该参数需要重启,因为共享内存在服务器启动时为缓存分配。(默认值: 128MB)

应该根据什么来选择合适的值?

即使是最大的数据库,在任一时刻实际活跃使用的“热”数据也只有有限的一部分。理想情况下,这部分数据应该能放入缓冲区缓存(当然还要为不可避免的“一次性”数据预留空间)。如果缓存太小,活跃页面会不断相互驱逐,产生多余的输入输出。但盲目增大缓存也不对,因为维护缓存会增加开销,而内存还要满足其他需求。

不同系统的最佳缓冲区缓存大小也不同:它取决于可用内存总量、数据和负载特征。遗憾的是,不存在一个对所有系统都同样合适的神奇数值,甚至也不存在这样的公式。

还要注意,只要没有启用直接输入输出,PostgreSQL 的缓存未命中就不一定会导致物理输入输出。缓冲区缓存较小时,空闲内存会被操作系统缓存利用,在一定程度上缓解问题。但操作系统不像数据库系统那样了解已读取数据的含义,因此会采用不同的驱逐策略。

通常建议先取物理内存的 14\frac14 作为初始值,然后根据实际情况调整。

最好的方法是做实验:增大或减小缓存大小,并比较系统性能。当然,这需要有一个与生产系统相似的测试环境,并且能够重现典型负载。

不过,仍然可以做一些分析。

首先,可以使用熟悉的 pg_buffercache 扩展从不同角度查看缓存的当前状态。例如,研究缓冲区按使用程度的分布:

=> SELECT usagecount, count(*)
FROM pg_buffercache
GROUP BY usagecount
ORDER BY usagecount;
 usagecount | count
------------+-------
          1 |  4148
          2 |   146
          3 |    21
          4 |    15
          5 |   209
            | 11845
(6 rows)

计数器为空表示空闲缓冲区。在本例中这并不奇怪,因为服务器几乎处于空闲状态。大多数已占用缓冲区包含系统表页面;服务进程读取这些页面,是为了填充自己的系统目录缓存和执行查询。

还可以查看每个关系有多少内容被缓存,以及这些数据的使用活跃程度(在这个查询中,使用计数大于 1 的页面被视为活跃页面):

=> SELECT c.relname,
  count(*) blocks,
  round( 100.0 * 8192 * count(*) /
    pg_table_size(c.oid) ) AS "% of rel",
  round( 100.0 * 8192 * count(*) FILTER (WHERE b.usagecount > 1) /
    pg_table_size(c.oid) ) AS "% hot"
FROM pg_buffercache b
  JOIN pg_class c ON pg_relation_filenode(c.oid) = b.relfilenode
WHERE b.reldatabase IN (
  0, -- общие объекты кластера
  (SELECT oid FROM pg_database
   WHERE datname = current_database())
)
AND b.usagecount IS NOT NULL
GROUP BY c.relname, c.oid
ORDER BY 2 DESC
LIMIT 5;
   relname    | blocks | % of rel | % hot
--------------+--------+----------+-------
 big          |   4097 |      100 |     2
 pg_attribute |     58 |       94 |    50
 pg_proc      |     24 |       20 |    15
 pg_class     |     17 |       94 |    67
 big_pkey     |     14 |      100 |   100
(5 rows)

可以看到,表 big 和它的索引都已完全缓存,但它们的页面并没有被积极使用。

需要注意,对 pg_buffercache 的查询:

  • 应重复执行几次,因为数值会在一定范围内变化;

  • 不应连续执行,因为视图会锁定正在查看的缓冲区,虽然锁定时间很短。

因此,监控时最好使用扩展的另外两个函数:pg_buffercache_summary 和 pg_buffercache_usage_counts。它们需要的锁更少,因此可以快速评估整体情况(虽然结果可能略有不一致)。(v. 16)

其次,pg_stat_io 视图会收集经过缓冲区缓存的输入输出详细信息。例如,可以按缓冲区环使用策略查看输入输出操作数(normal 上下文对应不使用缓冲区环的普通操作):(v. 16)

=> SELECT context,
  sum(reads) AS reads, sum(writes) AS writes,
  sum(hits) AS hits, sum(evictions) AS evictions, sum(reuses) AS reuses
FROM pg_stat_io
WHERE object = 'relation'
  AND context in ('normal','bulkread','bulkwrite','vacuum')
GROUP BY context;
  context  | reads  | writes |   hits   | evictions | reuses
-----------+--------+--------+----------+-----------+--------
 vacuum    | 138091 | 539140 |    89134 |      1846 | 656633
 normal    |  95057 | 342503 | 71883823 |    271326 |
 bulkwrite |      0 |      0 |        8 |         0 |      0
 bulkread  |  30506 | 155238 |    77778 |    175469 |  99285
(4 rows)

该查询还显示缓存命中数(hits)和缓存驱逐数:“普通”驱逐计入 evictions 字段,而缓冲区环中的页面驱逐计入 reuses 字段。根据这些数字,可以对缓存的使用方式得出一些结论。

除了读取和写入操作数(reads 和 writes),视图还可以提供输入输出量(read\_bytes 和 write\_bytes,单位为字节):(v. 18)

=> SELECT context,
  pg_size_pretty(read_bytes) AS read_bytes,
  round(read_bytes/reads/8192, 1) AS pages_per_read,
  pg_size_pretty(write_bytes) AS write_bytes,
  round(write_bytes/writes/8192, 1) AS pages_per_write
FROM (
  SELECT context,
    sum(reads) AS reads,
    sum(read_bytes) AS read_bytes,
    sum(writes) AS writes,
    sum(write_bytes) AS write_bytes
  FROM pg_stat_io
  WHERE object = 'relation'
  GROUP BY context
  HAVING sum(reads) + sum(writes) > 0
);
 context  | read_bytes | pages_per_read | write_bytes | pages_per_write
----------+------------+----------------+-------------+-----------------
 vacuum   | 5147 MB    |            4.8 | 4212 MB     |             1.0
 normal   | 760 MB     |            1.0 | 2676 MB     |             1.0
 bulkread | 2155 MB    |            9.0 | 1213 MB     |             1.0
(3 rows)

可以看到,清理和顺序扫描每次输入输出都会读取多个页面,而写入目前始终是单页的。

启用 track_io_timing 后,同一个视图还会跟踪输入输出耗时。不过,正如前面所说,在某些平台上,这种跟踪可能带来很大的开销。(第 166 页)

pg_stat_io 视图按进程类型对数据分组。函数 pg_stat_get_backend_io 可以进一步查看某个服务进程(只要它仍在运行)的详细信息;该函数返回包含同一组字段的表。(v. 18)

从不同角度研究 pg_buffercache 和 pg_stat_io 的数据,可以得到有用的线索。

9.7. 缓存预热

服务器重启后,需要经过一段时间,缓存才会“预热”,也就是积累起当前活跃使用的数据。有时,立即把某些表的数据读入缓存会很有用,这正是 pg_prewarm 扩展的用途:

=> CREATE EXTENSION pg_prewarm;

除了把表正常读入缓冲区缓存(或只读入操作系统缓存),该扩展还可以把缓存的当前状态保存到磁盘,并在服务器重启后恢复。为此,需要把扩展库加入 shared_preload_libraries 参数,然后重启服务器:

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

重启后,如果 pg_prewarm.autoprewarm 的值没有变化,系统会自动启动 autoprewarm leader 后台进程。它每隔 pg_prewarm.autoprewarm_interval 个时间单位把缓存中的页面列表刷到磁盘(该进程使用 max_parallel_processes 的一个槽位)。(默认值: on)(默认值: 300s)

说明

这个进程最初名为 autoprewarm master,但在第 13 版中也改名为 autoprewarm leader。

postgres$ ps -o pid,command \
--ppid `head -n 1 /usr/local/pgsql/data/postmaster.pid` | \
grep prewarm
  57518 postgres: autoprewarm leader

现在重启后,表 big 不在缓存中:

=> SELECT count(*)
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('big'::regclass);
 count
-------
     0
(1 row)

如果有充分理由认为表的全部内容都会被积极使用,而访问磁盘会把查询响应时间拉长到不可接受的水平,就可以提前把表读入缓冲区缓存:

=> SELECT pg_prewarm('big');
 pg_prewarm
------------
       4097
(1 row)
=> SELECT count(*)
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('big'::regclass);
 count
-------
  4097
(1 row)

页面列表会写入 PGDATA/autoprewarm.blocks 文件。可以等待 autoprewarm leader 第一次运行,但这里手动触发写入:

=> SELECT autoprewarm_dump_now();
 autoprewarm_dump_now
----------------------
                 4262
(1 row)

刷出的页面数大于 4097,因为其中包含所有已占用的缓冲区。文件使用文本格式写入,其中包含数据库、表空间和文件的标识符,以及层号和块号:

postgres$ head -n 10 /usr/local/pgsql/data/autoprewarm.blocks
<<4262>>
0,1664,1262,0,0
0,1664,1260,0,0
16391,1663,1259,0,0
16391,1663,1259,0,1
16391,1663,1259,0,2
16391,1663,1259,0,3
16391,1663,1259,0,4
16391,1663,1259,0,5
16391,1663,1259,0,6

现在再次重启服务器。

postgres$ pg_ctl restart -l /home/postgres/logfile

表立即出现在缓存中:

=> SELECT count(*)
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('big'::regclass);
 count
-------
  4097
(1 row)

这同样由 autoprewarm leader 进程完成:它读取文件,按数据库划分页面并排序(使磁盘读取尽可能连续),然后把页面交给单独的 autoprewarm worker 工作进程处理。

9.8. 本地缓存

临时表是一般规则的例外。由于临时数据只对一个进程可见,它们不应放入共享缓冲区缓存。因此,临时数据使用拥有该表的进程本地内存中的缓存20。

总的来说,本地缓冲区缓存的结构与共享缓存相同:

  • 使用自己的哈希表查找页面;

  • 按普通算法驱逐页面(但不使用缓冲区环);

  • 可以钉住页面,以避免页面被驱逐。

不过,它的实现大大简化了,因为不再需要内存锁(只有一个进程访问缓冲区),也不需要防止崩溃导致的数据丢失(数据无论如何最多只存在到会话结束)。(第 306 页)(第 211 页)

本地缓存的内存会按需逐步分配,因为临时表并非在所有会话中使用。单个会话的缓存最大容量由 temp_buffers 参数限制。(默认值: 8MB)

说明

名称相近的 temp_file_limit 参数针对的不是临时表,而是查询执行过程中为临时保存中间数据而创建的文件。

在 EXPLAIN 命令的输出中,对本地缓冲区缓存的访问标记为 local,而不是 shared:

=> CREATE TEMPORARY TABLE tmp AS SELECT 1;
=> EXPLAIN (analyze, costs off, timing off, summary off)
  SELECT * FROM tmp;
                 QUERY PLAN
--------------------------------------------
 Seq Scan on tmp (actual rows=1.00 loops=1)
   Buffers: local hit=1
 Planning:
   Buffers: shared hit=12 read=7
(4 rows)

熟悉的 pg_stat_io 视图可以提供进程本地缓存页面的输入输出数据:(v. 16)

=> SELECT context, sum(reads) reads, sum(writes) writes,
  sum(hits) hits, sum(evictions) evictions, sum(reuses) reuses
FROM pg_stat_io
WHERE object = 'temp relation'
GROUP BY context;
 context | reads | writes | hits | evictions | reuses
---------+-------+--------+------+-----------+--------
 normal  |     0 |      0 |    1 |         0 |
(1 row)