PostgreSQL 中的 WAL:1. 缓冲区缓存


本文抄自:

https://mp.weixin.qq.com/s/YdOEHFVxiOkIp6Blcly2lQ

本系列将由四部分组成:

  • 缓冲区缓存(本文)。

  • 预写日志 ——它的结构和用于恢复数据的方式。

  • 检查点和后台写进程——我们为什么需要它们以及我们如何设置它们。

  • WAL 的设置和调优 ——问题级别和解决方案、可靠性和性能。

为什么我们需要预写日志?

数据库管理系统(DBMS)使得部分数据存储在缓存中并异步写入磁盘(或其他非易失性存储),即,写入存储被推迟了一段时间。这种情况发生得越少,输入/输出就越少,系统运行得越快。

但是如果出现故障,例如停电或 DBMS 或操作系统的代码错误,会发生什么?缓存中的所有内容都将丢失,只有写入磁盘的数据会保留下来(磁盘也不能免受某些故障的影响,如果磁盘上的数据受到影响,则只有备份副本可以提供帮助)。一般来说,可以以磁盘上的数据始终一致的方式组织输入/输出,但这很复杂且效率不高(据我所知,只有 Firebird 是这样做的)。

通常,特别是在PostgreSQL中,写入磁盘的数据会出现不一致的情况,并且在失败后恢复时,需要采取特殊措施来恢复数据一致性。预写日志 (WAL) 只是使之成为可能的一项功能。

缓冲区缓存

我们将从讨论缓冲区缓存(buffer cache)来开始讨论 WAL。缓冲区缓存不是存储在内存中的唯一结构,而是其中最关键和最复杂的结构之一。了解它的工作原理本身很重要;此外,我们将使用它作为示例,以熟悉内存和磁盘如何交换数据。

内存在现代计算机系统中无处不在;一个处理器本身就有三到四级缓存。一般情况下需要内存来缓解两种内存的性能差异,一种比较快,但是不够,另一种比较慢,但是足够。并且缓冲区缓存缓解了访问内存(纳秒)和磁盘存储(毫秒)之间的时间差异。

请注意,操作系统也有解决相同问题的磁盘缓存。因此,数据库管理系统通常通过直接访问磁盘而不是通过操作系统内存来尝试避免双重缓存。但 PostgreSQL 并非如此:所有数据都是使用正常的文件操作读取和写入的。

此外,磁盘阵列的控制器甚至磁盘本身也有自己的缓存。当我们讨论可靠性时,这将很有用。

但是让我们回到 DBMS 的缓冲区缓存。

之所以这样称呼是因为它表示为一个缓冲区数组。每个缓冲区(buffer)由一个数据页面(块)加上文件头的空间组成。文件头中包括:

  • 页在缓冲区中的位置(那里的文件和块号)。

  • 页上数据更改的指针,这些数据迟早需要写入磁盘(这种缓冲区称为脏的)。

  • 缓冲区的使用计数。

  • 缓冲区的引脚计数。

缓冲区缓存位于共享缓存中,可供所有进程访问。为了处理数据,即读取或更新它们,进程将页面读入缓存。当页面在缓存中时,我们在内存中处理它并节省磁盘访问

缓存最初包含空缓冲区(free buffers),并且所有这些缓冲区都链接到空闲缓冲区列表中。指向“next victim”的指针的含义稍后会清楚。缓存中的哈希表(hash table)用于快速找到您需要的页面

在缓存中搜索页面

当一个进程需要读取一个页面时,它首先尝试通过哈希表在缓冲区缓存中找到它。文件号和文件中的页面数用作哈希键。该进程在适当的hash bucket中找到缓冲区编号,并检查它是否真的包含所需的页面。与任何哈希表一样,这里可能会出现冲突,在这种情况下,进程将不得不检查多个页面。

长期以来,哈希表的使用一直被人们所诟病。像这样的结构可以按页面快速找到缓冲区,但是如果您需要查找某个表占用的所有缓冲区,那么哈希表绝对没有用。但是还没有人提出好的替代品。

如果在缓存中找到所需的页面,则进程必须通过增加引脚计数来“固定”缓冲区(多个进程可以同时执行此操作)。当一个缓冲区被固定(计数值大于零)时,它被认为是被使用的,且其内容不能“急剧”改变。例如:一个新的元组可以出现在页面上——由于多版本并发和可见性规则,这对任何人都没有伤害。但是不能将不同的页面读入固定缓冲区。

回收

可能会发生在缓存中找不到所需页面的情况。在这种情况下,页面将需要从磁盘读取到某个缓冲区中。

如果缓存中仍有空缓冲区可用,则选择第一个空缓冲区。但是这些空缓冲区迟早会被占用(数据库的大小通常大于为缓存分配的内存),然后我们将不得不选择一个被占用的缓冲区,回收位于那里的页面并将新的页面读入被释放的缓冲区。

回收技术基于这样一个事实,即每次访问缓冲区时,进程都会增加缓冲区文件头的使用计数(usage count)。因此,与其他缓冲区相比,使用频率较低的缓冲区具有较小的计数值,因此是回收的良好候选者。

时钟扫描算法循环遍历所有缓冲区(使用指针“next victim”),并将其使用计数减少一。回收会选择的缓冲区:

  1. 使用计数为零

  2. 引脚数为零(即未固定)

请注意,如果所有缓冲区的使用计数都非零,则该算法将不得不在缓冲区执行不止一个循环,减少计数值,直到其中一些减少为零。为了避免大量的循环,使用计数的最大值限制为5。但是,对于大尺寸的缓冲区缓存,该算法会造成相当大的开销成本。

一旦找到缓冲区,就会发生以下情况。

缓冲区被固定(pinned buffer)以显示它被其他进程使用。除了固定之外,还使用了其他锁定技术,我们将在稍后更详细地讨论这一点。

如果缓冲区看起来很“脏”,也就是说,一旦缓冲区包含更改的数据,则不能直接删除页面——它需要先保存到磁盘。这不是一个好的情况,因为要读取页面的进程必须等到其他进程的数据被写入,但这种影响可以通过检查点和后台写进程缓解,稍后将讨论。

然后将新页面从磁盘读取到选定的缓冲区中。使用计数被设置为1。此外,必须将已加载页面的引用写入哈希表,以便将来能够找到该页面。

对“next victim”的引用现在指向下一个缓冲区,刚刚加载的缓冲区有时间增加使用计数,直到指针循环遍历整个缓冲区缓存并再次返回。

实验观察

PostgreSQL 有一个扩展,使我们能够查看缓冲区缓存的内部。

=> CREATE EXTENSION pg_buffercache;

让我们创建一个表并在那里插入一行。

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

缓冲区缓存将包含什么?至少,必须出现上面添加了一行的页面。让我们使用以下查询来检查,它仅查询与我们的表相关的缓冲区(按relfilenode)并查询relforknumber

=> SELECT bufferid,
  CASE relforknumber
    WHEN 0 THEN 'main'
    WHEN 1 THEN 'fsm'
    WHEN 2 THEN 'vm'
  END relfork,
 relblocknumber,
 isdirty,
 usagecount,
 pinning_backends
 FROM pg_buffercache
 WHERE relfilenode = pg_relation_filenode('cacheme'::regclass);
bufferid  | relfork | relblocknumber | isdirty | usagecount | pinning_backends
----------+---------+----------------+---------+------------+------------------
    15735 | main    |              0 | t       |          1 |                0
(1 row)

正如我们所想:缓冲区包含一页。它是脏的 ( isdirty),使用计数 ( usagecount) 等于 1,并且该页面未被任何进程 ( pinning_backends)固定。

现在让我们再添加一行并重新运行查询。为了保存前面的查询结果,我们在另一个会话中插入一行并使用\g命令重新运行该长查询。

=> INSERT INTO cacheme VALUES (2);
=> \g
 bufferid | relfork | relblocknumber | isdirty | usagecount | pinning_backends
----------+---------+----------------+---------+------------+------------------
    15735 | main    |              0 | t       |          2 |                0
(1 row)

没有添加新的缓冲区:第二行适合同一页。注意增加的使用次数。

=> SELECT * FROM cacheme;
   id
  ----
    1
    2
  (2 rows)
=> \g 
 bufferid | relfork | relblocknumber | isdirty | usagecount | pinning_backends
----------+---------+----------------+---------+------------+------------------
    15735 | main    |              0 | t       |          3 |                0
(1 row)

读页面后计数也会增加。

但是如果我们做vacuum呢?

=> VACUUM cacheme;
=> \g
 bufferid | relfork | relblocknumber | isdirty | usagecount | pinning_backends
----------+---------+----------------+---------+------------+------------------
    15731 |fsm      |              1 | t       |          1 |                0
    15732 |fsm      |              0 | t       |          1 |                0
    15733 |fsm      |              2 | t       |          2 |                0
    15734 |vm       |              0 | t       |          2 |                0
    15735 |main     |              0 | t       |          3 |                0
(5 rows)

VACUUM 创建了visibility map(一页)和free space map(有三页,这是此类地图的最小尺寸)

调整大小

我们可以使用shared_buffers参数设置缓存大小。默认值是 128 MB。这是安装 PostgreSQL 后立即增加的参数之一。

=> SELECT setting, unit FROM pg_settings WHERE name = 'shared_buffers';
 setting | unit
---------+------
 16384   | 8kB
(1 row)

请注意,更改此参数需要重新启动数据库服务,因为缓存的所有内存在服务器启动时都已分配。

我们需要考虑什么来选择合适的值?

即使是最大的数据库也有一组有限的“热”数据,这些数据一直在进行密集处理。理想情况下,这个数据集必须适合缓冲区缓存(加上一些一次性数据的空间)。如果缓存较小,那么密集使用的页面将不断相互驱逐,这将导致过多的输入/输出。但是盲目增加缓存也不好。当缓存很大时,其维护的开销成本会增加,此外,内存也需要用于其他用途。

因此,您需要为特定系统选择最佳缓冲区缓存大小:这取决于数据、应用程序和负载。不幸的是,没有什么神奇的、一刀切的值。

通常建议使用 1/4 的系统内存作为粗略估算(PostgreSQL 版本低于 10 建议在 Windows 中使用较小的内存)。

然后我们想得到最好的值,应该进行试验:增加或减少缓存大小并比较性能。为此,您当然需要一个测试设备,并且您应该能够重现工作负载。

请务必查看 Nikolay Samokhvalov 在 PostgresConf Silicon Valley 2018 上的演讲:数据库实验的艺术

但是您可以通过相同的pg_buffercache扩展直接在您的实时系统上获得一些关于正在发生的事情的信息。最重要的是从正确的角度看。

例如:您可以通过缓冲区的使用来探索缓冲区的分布:

=> SELECT usagecount, count(*)
FROM pg_buffercache
GROUP BY usagecount
ORDER BY usagecount;
usagecount
| count -----------+------- 1 | 221 2 | 869 3 | 29 4 | 12 5 | 564 | 14689 (6 rows)

在这种情况下,count的大多数空值对应于空缓冲区。对于一个什么都没有发生的系统来说,这并不奇怪。

我们可以看到我们的数据库中哪些表被缓存的份额以及这些数据的使用强度(“密集使用”缓冲区体现在这个查询中使用计数大于 3 (usagecount > 3)的缓冲区):

=> SELECT c.relname,
  count(*) blocks,
  round( 100.0 * 8192 * count(*) / pg_table_size(c.oid) ) "% of rel",
  round( 100.0 * 8192 * count(*) FILTER (WHERE b.usagecount > 3) / pg_table_size(c.oid) ) "% 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 10;
          relname          | blocks | % of rel | % hot
---------------------------+--------+----------+-------
 vac                       |    833 |      100 |     0
 pg_proc                   |     71 |       85 |    37
 pg_depend                 |     57 |       98 |    19
 pg_attribute              |     55 |      100 |    64
 vac_s                     |     32 |        4 |     0
 pg_statistic              |     27 |       71 |    63
 autovac                   |     22 |      100 |    95
 pg_depend_reference_index |     19 |       48 |    35
 pg_rewrite                |     17 |       23 |     8
 pg_class                  |     16 |      100 |   100
(10 rows)

例如:我们在这里可以看到vac表占据了大部分空间,但是它没有被访问很长时间了,并且还没有被回收,因为空缓冲区仍然可用。

你可以考虑其他观点,这会为你提供思路:

  • 您需要多次重新运行此类查询:数字将在一定范围内变化。

  • 您不应连续运行此类查询(作为监控的一部分),因为扩展会暂时阻止对缓冲区缓存的访问。

还有一点需要注意。不要忘记 PostgreSQL 通常通过操作系统调用处理文件,因此会发生双重缓存:页面同时进入 数据库管理系统缓冲区缓存和操作系统缓存。因此,不命中缓冲区缓存并不总是需要真正的输入/输出。但是操作系统的回收策略与数据库管理系统不同:操作系统对读取数据的意义一无所知。

大规模回收

批量读取和写入操作容易出现有用页面被“一次性”的从缓冲区缓存中回收的风险。

为了避免这种情况,使用了所谓的缓冲区环(buffer rings):每个操作只分配缓冲区缓存的一小部分。回收仅在环内进行,因此缓冲区缓存中的其余数据不受影响。

对于大表(其大小大于缓冲区缓存的四分之一)的顺序扫描,分配了 32 个页面。如果在一个表的扫描过程中,另一个进程也需要这些数据,它不会从头开始读取表,而是连接到已经可用的缓冲环。完成扫描后,该过程继续读取表头。

让我们检查一下。为此,让我们创建一个表格,让一行占据一整页——这样计数更方便。缓冲区缓存的默认大小为 128 MB = 16384 页*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 relpages FROM pg_class WHERE oid = 'big'::regclass;
relpages
---------- 4097 (1 row)

现在我们将不得不重新启动数据库以清除ANALYZE已读取的表数据的缓存。

student$ sudo pg_ctlcluster 11 main restart

让我们在重启后读取整个表:

=> EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM big;
QUERY
PLAN --------------------------------------------------------------------- Aggregate (actual time=14.472..14.473 rows=1 loops=1) -> Seq Scan on big (actual time=0.031..13.022 rows=4097 loops=1) Planning Time: 0.528 ms Execution Time: 14.590 ms (4 rows)

让我们确保表页仅占用缓冲区缓存中的 32 个缓冲区:

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

但是如果我们禁止顺序扫描,表将使用索引扫描读取:

=> SET enable_seqscan = off;
=> EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM big;
                                       QUERY PLAN                                         
-------------------------------------------------------------------------------------------
 Aggregate (actual time=50.300..50.301 rows=1 loops=1)
   ->  Index Only Scan using big_pkey on big (actual time=0.098..48.547 rows=4097 loops=1)
         Heap Fetches: 4097
 Planning Time: 0.067 ms
 Execution Time: 50.340 ms
(5 rows)

在这种情况下,不使用缓冲区环,整个表将进入缓冲区缓存(以及几乎整个索引):

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

缓冲环以类似的方式用于vacuum进程 (也是 32 页)和批量写入操作 COPY INCREATE TABLE AS SELECT(通常为 2048 页,但不超过缓冲区缓存的 1/8)。

临时表

临时表是通用规则的一个例外。由于临时数据仅对一个进程可见,因此共享缓冲区缓存中不需要它们。此外,临时数据仅存在于一个会话中,因此不需要针对故障进行保护。

临时数据使用拥有该表的进程的本地内存中的缓存。由于此类数据仅对一个进程可用,因此不需要用锁保护它们。本地缓存使用正常的逐出算法。

与共享缓冲区缓存不同,本地缓存的内存是根据需要分配的,因为临时表远未在许多会话中使用。单个会话中临时表的最大内存大小受temp_buffers参数的限制。

预热缓存

数据库重新启动后,缓存必须经过一段时间才能“预热”,即填充实时使用的数据。有时将某些表的内容立即读入缓存似乎很有用,为此可以使用专门的扩展:

=> CREATE EXTENSION pg_prewarm;

早些时候,扩展只能将某些表读入缓冲区缓存(或仅读入操作系统缓存)。但是 PostgreSQL 11 使它能够将缓存的最新状态保存到磁盘并在服务器重启后恢复它。要使用它,您需要将库添加到shared_preload_libraries,并重新启动数据库集群。

=> ALTER SYSTEM SET shared_preload_libraries = 'pg_prewarm';
student$ sudo pg_ctlcluster 11 main restart

重启后,如果pg_prewarm.autoprewarm参数的值没有改变,就会启动autoprewarm master后台进程,它会每隔pg_prewarm.autoprewarm_interval秒刷新一次缓存中存储的页面列表(设置max_parallel_process(最大并行进程)的值时,不要忘记计算新进程的数量)。

该进程在 PostgreSQL 13 中重命名为autoprewarm leader

 
=> SELECT name, setting, unit FROM pg_settings WHERE name LIKE 'pg_prewarm%';
name
| setting | unit ---------------------------------+---------+------ pg_prewarm.autoprewarm | on | pg_prewarm.autoprewarm_interval | 300 | s (2 rows)
postgres$
ps -o pid,command --ppid `head -n 1 /var/lib/postgresql/11/main/postmaster.pid` | grep prewarm 10436 postgres: 11/main: autoprewarm master

现在缓存不包含表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)

块列表被刷新到autoprewarm.blocks文件中。要查看列表,我们可以等待 autoprewarm master 进程第一次完成,或者我们可以像这样手动启动刷新:

SELECT autoprewarm_dump_now();
autoprewarm_dump_now
---------------------- 4340 (1 row)

刷新页数已经超过4097;数据库已经读取的系统目录的页面被计算在内。这是文件:

postgres$ ls -l /var/lib/postgresql/11/main/autoprewarm.blocks
-rw------- 1 postgres postgres 102078 jun 29 15:51 /var/lib/postgresql/11/main/

现在让我们再次重新启动数据库。

student$ sudo pg_ctlcluster 11 main restart

数据库启动后,我们的表将再次位于缓存中。

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

autoprewarm master主进程为此提供了:读取文件,按数据库划分页面,对它们进行排序(尽可能从磁盘顺序读取)并将它们传递给单独的自动autoprewarm worker进程进行处理。

pg