代码改变世界

PostgreSQL的WAL(1)--Buffer Cache

2020-09-17 12:05  abce  阅读(895)  评论(0编辑  收藏  举报

为什么需要提前写日志

DBMS处理的数据部分存储在RAM中,并异步写入磁盘(或其他非易失性存储)中。即写延迟了一段时间。这种情况发生的频率越低,输入/输出越少,系统运行越快。

但是,如果发生故障(例如断电或DBMS或操作系统的代码错误),会发生什么? RAM的所有内容都会丢失,只有写入磁盘的数据才能幸存(磁盘也无法幸免于某些故障,如果磁盘上的数据受到影响,则只有备份可以提供帮助)。通常,可以以磁盘上的数据始终保持一致的方式来组织输入/输出,但这很复杂且效率不高(据我所知,只有Firebird选择了此选项)。

通常,尤其是在PostgreSQL中,写入磁盘的数据似乎不一致,并且在故障后恢复时,需要采取特殊措施来恢复数据一致性。预写日志记录(WAL)只是一项使之成为可能的功能。

buffer cache

buffer cache不是存储在RAM中的唯一结构,而是其中最关键和最复杂的结构之一。理解其工作原理本身很重要;此外,我们将以它为例以熟悉RAM和磁盘如何交换数据。

缓存在现代计算机系统中无处不在。一个处理器仅具有三级或四级缓存。通常,需要缓存来减轻两种内存之间的性能差异,其中一种相对较快,但是容量较小,循环不足;另一种相对较慢,但是容量足够。缓冲区缓存减轻了访问RAM的时间(纳秒)和磁盘存储的时间(毫秒)之间的差异。

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

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

但是,让我们回到DBMS的buffer cache。

每个buffer由数据页(块)的空间和header组成。header中包含:

·page在buffer中的位置(文件和块号)。

·page上数据更改的指示符,更改迟早需要将其写入磁盘(这样的缓冲区称为脏缓冲区)。

·buffer的使用计数。

·buffer的pin计数。

buffer cache位于服务器的共享内存中,所有进程都可以访问它。为了处理数据,即读取或更新数据,这些进程会将页面读取到缓存中。当页面在缓存中时,我们在RAM中使用它并在磁盘访问中保存它。

缓存最初包含空缓冲区,并且所有缓冲区都链接到空闲缓冲区列表中。缓存的哈希表用于快速找到您需要的页面。

在cache中寻找一个page

当进程需要读取页面时,它首先尝试通过哈希表在buffer cache中找到它。文件号和文件中的页面号用作哈希键。该进程在适当的哈希桶中找到buffer编号,并检查它是否确实包含所需的页面。像任何哈希表一样,此处可能会发生冲突,在这种情况下,该过程将不得不检查多个页面。

哈希表的使用长期以来一直是人们抱怨的源头。像这样的结构可以快速按页查找缓冲区,但是,例如,如果您需要查找某个表占用的所有缓冲区,则哈希表绝对是无用的。但是还没有人建议好的替代品。

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

Eviction

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

如果缓存中的空缓冲区仍然可用,则选择第一个空缓冲区。但是它们迟早会不够(数据库的大小通常大于为缓存分配的内存),然后我们将不得不选择一个已占用的缓冲区,将位于那里的页清除出去,并将新的页读入已释放的空间。

清除技术基于这样一个事实:对于每次访问缓冲区,进程都会增加缓冲区header中的使用计数。因此,与其他缓冲区相比,使用频率较低的缓冲区的计数值较小,因此是清除的良好候选对象。

时钟扫描算法循环地遍历所有缓冲区(使用指向«next victim»的指针),并将它们的使用量减少1。 为清除选择的第一个缓冲区要满足:

·使用计数是0

·pin计数也是0

请注意,如果所有缓冲区都有一个非零的使用计数,那么算法将不得不在缓冲区中进行多次循环,减少计数的值算,直到其中一些减少到零为止。算法为了避免«做重叠»的操作,使用计数的最大值被限制为5。然而,对于大型的buffer cache,该算法可能会造成相当大的开销。

找到缓冲区后,将对它执行以下操作。

缓冲区被固定以显示使用它的其他进程。除了固定之外,还使用了其他锁定技术,但是我们将在后面更详细地讨论。

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

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

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

自己验证一下

和往常一样,PostgreSQL有一个扩展,可以让我们查看缓冲区缓存的内部。

=> CREATE EXTENSION pg_buffercache;

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

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

buffer cache将包含什么?至少,必须出现只添加了一行的页面。让我们用下面的查询来检查这个,它只选择与我们的表相关的缓冲区(通过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创建了可见性map(一页)和空闲空间map(有三页,这是这样一个map的最小尺寸)。

调优buffer cache的大小

我们可以使用shared_buffers参数设置缓存大小。默认值是128mb,这是安装PostgreSQL后应该马上增加的参数之一。

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

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

即使是最大的数据库也只有一组有限的“热”数据,这些数据一直在被集中处理。理想情况下,必须在缓冲区缓存中容纳这个数据集(加上一些用于一次性数据的空间)。如果缓存大小较小,那么频繁使用的页面将不断地相互清除,这将导致过多的输入/输出。但是盲目地增加缓存也不好。当缓存很大时,维护它的开销将增加,除此之外,其他用途也需要RAM。

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

通常建议使用1/4的内存作为第一个近似(低于10的PostgreSQL版本建议Windows使用更小的内存)。

然后我们最好进行实验:增加或减少缓存大小,并比较系统特性。为此,您当然需要测试,并且应该能够重新生成工作负载。在生产环境中进行这样的实验是一种可疑的乐趣。

但是,您可以通过相同的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)

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

我们可以看到在我们的数据库中哪些表被缓存了,以及这些数据的使用频率有多高(在这个查询中,使用次数大于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通过常规的操作系统调用来处理文件,因此会发生双重缓存:页面同时进入DBMS和操作系统的缓存。因此,没有命中缓冲区缓存并不总是导致需要实际的输入/输出。但是操作系统的驱逐策略不同于DBMS:操作系统不知道读取数据的意义。

Massive eviction

批量读和写操作容易产生这样的风险,即有用的页面可能会被«一次性»从缓冲区缓存中快速驱逐。

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

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

让我们验证一下。创建一个表,以便一行占据整个页面——这样计数更方便。缓冲区缓存的默认大小为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)

现在我们必须重新启动服务器以清除分析中读取的表数据的缓存。

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和create table as select(通常为2048页,但不超过缓冲区缓存的1/8)。

临时表

临时表是常见规则中的一个例外。由于临时数据只对一个进程可见,因此不需要在共享缓冲区缓存中使用它们。 此外,临时数据只存在于一个会话中,因此不需要防止失败的保护。

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

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

为cache预热

在服务器重启后,缓存必须经过一段时间才能“预热”,也就是说,要填充活跃使用的数据。它可能有时看起来有用,立即读取某些表的内容到缓存中,一个专门的扩展是可用的:

=> 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主后台进程将启动,每隔pg_prewarm.autoprewarm_interval秒数完成一次刷新缓存中存储的页面列表。(在设置max_parallel_processes值时,不要忘记将新进程计算在内)。

=> 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主进程第一次完成,或者我们可以手动启动刷新,如下所示:

=> 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/autoprewarm.blocks

现在让我们重新启动服务器。

student$ sudo pg_ctlcluster 11 main restart

在服务器启动后,我们的表将再次位于缓存中。

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

相同的autoprewarm主进程提供了这一点:它读取文件,按数据库划分页面,对它们进行排序(尽可能使从磁盘顺序读取),并将它们传递到一个单独的autoprewarm worker进程进行处理。

 

 

 

原文地址:

https://habr.com/en/company/postgrespro/blog/491730/