为什么我们需要预写日志?
DBMS 使用的部分数据存储在 RAM 中并异步写入磁盘(或其他非易失性存储),即。即,写入被推迟了一段时间。这种情况发生得越少,输入/输出就越少,系统运行得越快。
但是如果出现故障,例如停电或 DBMS 或操作系统的代码错误,会发生什么?RAM 中的所有内容都将丢失,只有写入磁盘的数据才能存活(磁盘也不能免受某些故障的影响,如果磁盘上的数据受到影响,则只有备份副本可以提供帮助)。一般来说,可以以磁盘上的数据始终一致的方式组织输入/输出,但这很复杂且效率不高(据我所知,只有 Firebird 选择了此选项)。
通常,特别是在PostgreSQL中,写入磁盘的数据会出现不一致的情况,并且在失败后恢复时,需要采取特殊措施来恢复数据一致性。预写日志记录 (WAL) 只是使之成为可能的一项功能。
缓冲区缓存
奇怪的是,我们将从讨论缓冲区缓存开始讨论 WAL。缓冲区缓存不是存储在 RAM 中的唯一结构,而是其中最关键和最复杂的结构之一。了解它的工作原理本身很重要;此外,我们将使用它作为示例,以熟悉 RAM 和磁盘如何交换数据。
缓存在现代计算机系统中无处不在;一个处理器本身就有三到四级缓存。一般情况下需要缓存来缓解两种内存的性能差异,一种比较快,但是没有足够的循环,另一种比较慢,但是有足够的它。并且缓冲区缓存缓解了访问 RAM(纳秒)和磁盘存储(毫秒)之间的时间差异。
请注意,操作系统也有解决相同问题的磁盘缓存。因此,数据库管理系统通常通过直接访问磁盘而不是通过操作系统缓存来尝试避免双重缓存。但 PostgreSQL 并非如此:所有数据都是使用正常的文件操作读取和写入的。
此外,磁盘阵列的控制器甚至磁盘本身也有自己的缓存。当我们讨论可靠性时,这将很有用。
但是让我们回到 DBMS 缓冲区缓存。
之所以这样称呼是因为它表示为一个缓冲区数组。每个缓冲区由一个数据页(块)加上标题的空间组成。标题,其中包括:
- 页在缓冲区中的位置(那里的文件和块号)。
- 页面上数据更改的指示器,这些数据迟早需要写入磁盘(这种缓冲区称为dirty)。
- 缓冲区的使用计数。
- 缓冲区的引脚数。
缓冲区缓存位于服务器的共享内存中,可供所有进程访问。为了处理数据,即读取或更新它们,进程将页面读入缓存。当页面在缓存中时,我们在 RAM 中使用它并节省磁盘访问。
缓存最初包含空缓冲区,并且所有这些缓冲区都链接到空闲缓冲区列表中。指向“下一个受害者”的指针的含义稍后会清楚。缓存中的哈希表用于快速找到您需要的页面。
在缓存中搜索页面
当一个进程需要读取一个页面时,它首先尝试通过哈希表在缓冲区缓存中找到它。文件号和文件中的页数用作散列键。该过程在适当的散列桶中找到缓冲区编号,并检查它是否真的包含所需的页面。与任何哈希表一样,这里可能会出现冲突,在这种情况下,进程将不得不检查多个页面。
长期以来,哈希表的使用一直是一个抱怨的来源。像这样的结构可以按页快速找到缓冲区,但是如果您需要查找某个表占用的所有缓冲区,那么哈希表绝对没用。但是还没有人提出好的替代品。
如果在缓存中找到所需的页面,则进程必须通过增加引脚计数来“固定”缓冲区(多个进程可以同时执行此操作)。当一个缓冲区被固定(计数值大于零)时,它被认为是被使用的并且具有不能“剧烈”改变的内容。例如:一个新的元组可以出现在页面上——由于多版本并发和可见性规则,这对任何人都没有伤害。但是不能将不同的页面读入固定缓冲区。
驱逐
可能会发生在缓存中找不到所需页面的情况。在这种情况下,页面将需要从磁盘读取到某个缓冲区中。
如果缓存中仍有空缓冲区可用,则选择第一个空缓冲区。但是它们迟早会结束(数据库的大小通常大于为缓存分配的内存),然后我们将不得不选择一个被占用的缓冲区,驱逐位于那里的页面并将新的页面读入释放的空间。
驱逐技术基于这样一个事实,即每次访问缓冲区时,进程都会增加缓冲区标头中的使用计数。因此,与其他缓冲区相比,使用频率较低的缓冲区具有较小的计数值,因此是驱逐的良好候选者。
该时钟扫描算法循环通过所有的缓冲区(使用指针“下一个受害者”)去和一个降低他们的使用计数。为驱逐选择的缓冲区是第一个:
- 使用次数为零
- 引脚数为零(即未固定)
请注意,如果所有缓冲区的使用计数都为非零,则算法将不得不通过缓冲区执行不止一个循环,减少计数值,直到其中一些减少为零。对于避免“做圈”的算法,使用计数的最大值限制为5。但是,对于大尺寸的缓冲区缓存,该算法会造成相当大的开销成本。
一旦找到缓冲区,就会发生以下情况。
缓冲区被固定以显示它被使用的其他进程。除了固定之外,还使用了其他锁定技术,但我们将在稍后更详细地讨论这一点。
如果缓冲区看起来很脏,也就是说,要包含更改的数据,则不能直接删除页面——它需要先保存到磁盘。这几乎不是一个好的情况,因为要读取页面的进程必须等到其他进程的数据被写入,但这种影响可以通过检查点和后台写入进程缓解,稍后将讨论。
然后将新页面从磁盘读取到选定的缓冲区中。使用计数被设置为等于一。此外,必须将已加载页面的引用写入哈希表,以便将来能够找到该页面。
对“下一个受害者”的引用现在指向下一个缓冲区,刚刚加载的缓冲区有时间增加使用计数,直到指针循环遍历整个缓冲区缓存并再次返回。
自己看看
像往常一样,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 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 创建了可见性地图(一页)和可用空间地图(有三页,这是此类地图的最小尺寸)。
调整大小
我们可以使用shared_buffers参数设置缓存大小。默认值是荒谬的 128 MB。这是安装 PostgreSQL 后立即增加的参数之一。
=> SELECT setting, unit FROM pg_settings WHERE name = 'shared_buffers';
复制
setting | unit
---------+------
16384 | 8kB
(1 row)
复制
请注意,更改此参数需要重新启动服务器,因为缓存的所有内存在服务器启动时都已分配。
我们需要考虑什么来选择合适的值?
即使是最大的数据库也有一组有限的“热”数据,这些数据一直在进行密集处理。理想情况下,这个数据集必须适合缓冲区缓存(加上一些一次性数据的空间)。如果缓存大小较小,那么密集使用的页面将不断相互驱逐,这将导致过多的输入/输出。但是盲目增加缓存也不好。当缓存很大时,其维护的开销成本会增加,此外,还需要 RAM 用于其他用途。
因此,您需要为特定系统选择最佳缓冲区缓存大小:这取决于数据、应用程序和负载。不幸的是,没有什么神奇的、一刀切的价值。
通常建议使用 1/4 的 RAM 进行一次近似(PostgreSQL 版本低于 10 建议 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 通过通常的 OS 调用处理文件,因此会发生双重缓存:页面同时进入 DBMS 的缓冲区缓存和 OS 缓存。因此,不命中缓冲区缓存并不总是需要实际的输入/输出。但是OS的驱逐策略与DBMS不同:OS对读取数据的意义一无所知。
大规模驱逐
批量读取和写入操作容易出现有用页面被“一次性”数据从缓冲区高速缓存中快速逐出的风险。
为了避免这种情况,使用了所谓的缓冲区环:每个操作只分配缓冲区缓存的一小部分。驱逐仅在环内进行,因此缓冲区缓存中的其余数据不受影响。
对于大表(其大小大于缓冲区缓存的四分之一)的顺序扫描,分配了 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)
复制
现在我们将不得不重新启动服务器以清除分析已读取的表数据的缓存。
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)
复制
缓冲环以类似的方式用于真空过程(也是 32 页)和批量写入操作 COPY IN 和 CREATE 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_processes的值时的新进程)。
该进程在 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文件中。要查看列表,我们可以等待自动预热主进程第一次完成,或者我们可以像这样手动启动刷新:
=> 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)
复制
同一个自动预热主进程为此提供了:它读取文件,按数据库划分页面,对它们进行排序(尽可能从磁盘读取顺序)并将它们传递给单独的自动预热工作进程进行处理。