PostgreSQL Internals之路 Part-II 第10章 WAL日志

文摘   科技   2024-09-25 07:47   北京  

10. WAL (Write-Ahead Log)

10.1 日志(Logging)

在遇到如电源断点、操作系统错误、或者数据库服务器宕机等失败的情况下,RAM里所有的内容都会丢失;只有写回磁盘的内容会保存下来。要让服务器在失败以后启起来,你必须把数据恢复到一致状态。如果磁盘也毁坏了,相同的问题必须通过备份恢复来解决。

理论上,你可以在所有时间里维护数据在磁盘上的一致性。但在实践当中,它意味着服务器必须不断地写入随机页到磁盘(即使顺序写更廉价),这些写的顺序必须保证一致性在任何时刻都是严格的(难以达到,特别是处理复杂的索引结构的时候)。

就像大多数数据库系统一样,PG使用了不同的方法。

在服务器运行的时候,一些当前数据只在RAM里头,往持久存储里写被推迟。因此,存到磁盘上的数据在服务器操作期间总是不一致的,因为数据页并不是立即刷回磁盘。但是发生在RAM里的每个变化(像执行在buffer cache里头的页更新)都会记上日志:PG创建一个日志项,包含所有能重复该操作(如果要发生[1])所需要的信息。

与页修改相关的日志项必须在页修改之前提前写到磁盘。因为这个日志的名字叫做:write-ahead log或WAL(预写日志)。这需要保证一旦失败的情况下,PG能从磁盘中读取WAL项,重放它们,并重复结果在RAM中已经完成但宕机之前没有写回磁盘的操作。

保留一个WAL通常比随机写页数据到磁盘效率更高。WAL日志项形成一个连接的数据流,它们甚至可能被HDD处理。另外,WAL日志项通常也比页要小。

有必要用日志记录所有的可能引起失败情况下潜在的打破数据一致性的操作。特别地,WAL中会记录下述动作:

  • 因为写被推迟,要记录在buffer cache中的页修改
  • 因为状态改变在CLOG缓冲中发生,而且没有立即写回磁盘,要记录事务的提交与回退
  • 文件操作(像在添加或移除表时,文件和目录的创建与删除)---因为这样的操作必须要与数据的改变进行同步

下边的动作则不被记录:

  • UNLOGGED表(非日志表)上的操作

  • 临时表上的操作---因为它们的生命周期只被创建它们的会话所限制

    在PG10以前,hash索引也不被记录。它们的目的只是使hash函数与不同的数据类型进行匹配

除了宕机恢复,WAL也被用于从备份和复制当中进行时间点恢复。

10.2 WAL结构

逻辑结构

谈到逻辑结构,我们可以将WAL[2]描述成变长的日志项的流。每一项包含一个标准的头部[3],后边跟着一个特定操作的一些数据。除了其它一些不重要的东西以外,WAL头部提供了下述信息:

  • 与日志项相关的事务ID

  • 解释日志项的资源管理器[4]

  • 用于发现数据损坏的校验和

  • 日志项的长度

  • 对前一个日志项的引用

    WAL通常是往前读,但是有些工具,像pg_rewind可能会向后扫描。

WAL数据自己也会有不同的格式和含义。例如,它可以是一个页片段,用以在指定的偏移处替换页的某一部分。对应的资源管理器必须知道如何解释和重放指定的日志项。基于表、不同的索引类型、事务状态和其它日期项,都有独立的资源管理器。

WAL文件使用的是服务器共享内存中特殊的缓冲。WAL使用的缓存的大小由参数wal_buffers参数确定。缺省情况下,该值是总的buffer cache大小的1/32。

WAL缓存与buffer cache(缓冲缓存)非常类似,但它通常只以环形缓冲模式操作:新的项加到头部,老的项是从尾部开始,存到磁盘。如果WAL缓存太小,磁盘同步就会执行的更频繁。

在低负载下,插入位置(缓冲的头部)几乎总是与已经保存至磁盘的日志项(缓冲的尾部)的位置相同:

SELECT pg_current_wal_lsn(), pg_current_wal_insert_lsn();
pg_current_wal_lsn | pg_current_wal_insert_lsn
--------------------+---------------------------
0/65016838 | 0/65016838
(1 row)

在PG10以前,所有相关函数名包含XLOG缩写词,而不是WAL。

要引用一个指定的日志项,PG使用了特殊的数据类型:pg_lsn (log seuqnce number, LSN, 日志序列号)。它使用64位的字节串来描述从WAL起始位置到日志项的偏移。LSN使用16进制的方式显示成两个32位的数,用“/"符号分隔开。

让我们创建一张表:

CREATE TABLE wal(id integer);
INSERT INTO wal VALUES (1);

启动一个事务,并记下WAL插入的位置:

BEGIN;
SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/6502DD48
(1 row)

再随便运行一些命令,如,更新一行:

UPDATE wal SET id = id + 1;

页修改在RAM中的缓冲缓存里头执行。这个变化会被记录在WAL页里,同时也在RAM中。结果,insert LSN的值会往前推进:

SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/6502DF10
(1 row)

为了确保修改的数据页在相应的WAL项之后刷到磁盘,数据页页头会存储与该页相关的最近一个WAL项的LSN。你可以通过pageinspect看到这个LSN:

SELECT lsn FROM page_header(get_raw_page('wal',0));
lsn
------------
0/6502DED8
(1 row)

这是整个数据库实例中唯一的一个WAL,并且新的日志项不断地往后追加。因为这个原因,存储在页里的LSN最后可能比pg_current_wal_insert_lsn函数返回的值要小。但是如果系统没有发生什么事情,这两个值应该相等。

现在,我们提交下事务:

COMMIT;

提交操作也会被记录,insert的LSN再次发生改变:

SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/6502E070
(1 row)

一个提交会更新CLOG页里头的事务状态,会保存到它们自己的缓存[5]里。这个CLOG缓存在共享内存里通常占有128页[6]。为保证CLOG页不在相应的WAL项之前被刷到磁盘,最近的WAL项的LSN也必须在CLOG页里进行跟踪。但是这个信息是存储在RAM中,不会放到CLOG磁盘页。

在某个点上,WAL项将写到磁盘;接着它可能会从磁盘中替换CLOG和数据页。如果它们不得不在更早的时候替换,那应该已经被发现,而且WAL项会先于它们被刷回磁盘[7]

如果你知道两个LSN位置,你就可以通过相减运算计算WAL项的大小。你只需要将它们转换成pg_lsn类型:

SELECT '0/3E820B38'::pg_lsn - '0/3E820AC8'::pg_lsn;
?column?
----------
112
(1 row)

在这个例子中,与UPDATE和COMMIT相关的WAL项大概占用了100来个字节。

你可以使用相同的方法来估算每个单位时间在某个负载下产生的WAL日志的总的大小。这个信息可以用于检查点的设置。

物理结构

在磁盘上,WAL以独立的文件或段存储在PGDATA/pg_wal目录下。它们的大小由只读的wal_segment_size参数确定。

在高负载系统下,增大段大小是有意义的,因为它会降低负载,但是这个设置只能在实例初始化的时候进行修改(initdb --wal-segsize)。

WAL日志项写入当前文件,直到空间用完为止。在用完之后,PG会创建一个新文件。

我们可以知道一个特定的日志项的位置以及它离文件开始的位置:

SELECT file_name, upper(to_hex(file_offset)) file_offset
mydb-# FROM pg_walfile_name_offset('0/3E820AC8');
file_name | file_offset
--------------------------+-------------
00000001000000000000003E | 820AC8
(1 row)

00000001,   【000000000000003E,  820AC8】

分别是时间线,和LSN。

文件名由两部分组成。高8位定义了从备份中进行恢复的时间线,而剩余部分则描述了LSN的最高位的二进制位(最低位的LSN由在file_offset字段里头)。

要查看当前的WAL文件,可以用下述函数:

SELECT *
FROM pg_ls_waldir()
WHERE name = '00000001000000000000003E';
name | size | modification
−−−−−−−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−
00000001000000000000003E | 16777216 | 2022−09−19 14:52:22+03
(1 row)

我们使用pg_waldump工具再看下新创建的日志项的头部,它可以通过LSN范围以及通过指定的事务ID进行过滤。

pg_waldump工具是代表PG操作系统用户进行操作的,它需要WAL文件的访问权限。

postgres$ /usr/local/pgsql/bin/pg_waldump \
-p /usr/local/pgsql/data/pg_wal -s 0/3E820AC8 -e 0/3E820B38#
rmgr: Heap len (rec/tot): 69/ 69, tx: 887, lsn:
0/3E820AC8, prev 0/3E820AA0, desc: HOT_UPDATE off 1 xmax 887 flags
0x40 ; new off 2 xmax 0, blkref #0: rel 1663/16391/16562 blk 0
rmgr: Transaction len (rec/tot): 34/ 34, tx: 887, lsn:
0/3E820B10, prev 0/3E820AC8, desc: COMMIT 2022−09−19 14:52:22.552253
MSK

这里我们能看到两个日志项的头部。

第一个是一个HOT_UPDATE操作,由Heap资源管理器处理。blkref字段显示的是文件名和更新的堆页的页ID:

SELECT pg_relation_filepath('wal');
pg_relation_filepath
−−−−−−−−−−−−−−−−−−−−−−
base/16391/16562
(1 row)

第2项是一个由事务资源管理器监督的COMMIT操作。

10.3 检查点

在失败以后,恢复数据的一致性,PG必须要前向回放WAL,将所有的选择的日志项应用到相应的页当中。要找到哪些是丢失的,存储在磁盘上的页的LSN要与WAL日志项中的LSN进行比较。但是我们要从哪个点进行恢复呢?如果开始的太迟,在这个点之前写到磁盘上的页就没能接收到所有的改变,这样会导致不可避免的数据损坏。从最开始的时候进行恢复也是不现实的:不可能存储潜在的巨大容量的数据,也不太可能接受特别长的恢复时间。我们需要一个检查点(checkpoint)来逐渐往前移动,这样也能安全地从这一点开始恢复,并将前边的WAL日志项移除。

创建检查点最直观的方式就是周期性的挂起系统操作,将所有脏页刷盘。这个方法当然不可接受,因为系统会挂起一段不可确定还有可能是非常长的时间。

因为这个原因,检查点会贯穿始终,形成一个时间间隔。检查点的执行是由一个特殊的后台进程:checkpointer[8]来执行的。

检查点启动 检查点进程会把能写入的那些内容立即刷盘:包括CLOG事务状态、子事务元信息和其它一些结构。

检查点执行 绝大多数检查点执行时间是花在刷脏页到磁盘上[9], 首先,一个特殊的标签在检查点开始的时候会在所有脏缓冲的头部设置。它发生的非常快,因为没有相关的I/O操作。

接着,检查点进程遍历所有的缓冲,将打了标签的项写到磁盘。它们的页还在缓存中没被替换:因为只是简单的回写,因此使用计数和引用计数都可以被忽略。

页会按照它们的ID顺序处理,以尽量避免随机写。为得到更好的负载均衡,PG会切换不同的表空间(因为它们可以位于不同的物理设备上)。

后端进程也可以将打了标签的缓冲写到磁盘---如果它们先访问到那些缓冲。任何情况下,缓冲标签在这个阶段会被移除,以确保检查点对每个缓冲只写一次。

当然,在检查点处理过程中,buffer cache仍然可以对页进行修改。但是新的脏缓冲是不会打标签的,检查点会忽略它们。

检查点完成 当所有的脏缓冲在检查点开始的时候被写入磁盘,检查点认为已经完成。从现在起(而不是更早!),检查点的开始将被用作恢复的一个新的起始点。在这个时间点之前的所有被刷盘的WAL日志项就不再需要了。

最后,检查点会创建一个日志项,标明检查点的完成,并指定检查点的起始LSN。因为检查点在开始的时候,记录日志,但是没有什么内容(logs nothing),这个LSN可以是任意类型的WAL日志。

控制文件:PGDATA/global/pg_control在最近完成检查点时也会得到更新。(在处理完当前检查点前,pg_control将保留着以前的检查点。)

为了更好的说明问题,我们看一个简单的例子。我们将把若干个缓存页变”脏“:

UPDATE big SET s = 'FOO';
UPDATE 4097
mydb=# SELECT count(*) FROM pg_buffercache WHERE isdirty;
count
-------
4098
(1 row)

记下当前的WAL位置:

SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/65930CC0
(1 row)

现在我们手动完成检查点,所有脏页将被刷盘;因为系统没做其它事情,新的脏页不会出现:

CHECKPOINT;
CHECKPOINT
mydb=# SELECT count(*) FROM pg_buffercache WHERE isdirty;
count
-------
0
(1 row)

我们看看检查点是如何在WAL中反映出来的?

SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/65930DE0
(1 row)

[20:55:46-postgres@centos1:/var/lib/pgsql/14/data]$ pg_waldump -p $PGDATA/pg_wal -s 0/65930CC0 -e 0/65930DE0
rmgr: Standby len (rec/tot): 50/ 50, tx: 0, lsn: 0/65930CC0, prev 0/65930C68, desc: RUNNING_XACTSnextXid 902 latestCompletedXid 901 oldestRunningXid 902
rmgr: Standby len (rec/tot): 50/ 50, tx: 0, lsn: 0/65930CF8, prev 0/65930CC0, desc: RUNNING_XACTSnextXid 902 latestCompletedXid 901 oldestRunningXid 902
rmgr: XLOG len (rec/tot): 114/ 114, tx: 0, lsn: 0/65930D30, prev 0/65930CF8, desc: CHECKPOINT_ONLINE redo 0/65930CF8; tli 1; prev tli 1; fpw true; xid 0:902; oid 33060; multi 1; offset 0; oldest xid 726 in DB 1; oldest multi 1 in DB 1; oldest/newest commit timestamp xid: 0/0; oldest running xid 902; online
rmgr: Standby len (rec/tot): 50/ 50, tx: 0, lsn: 0/65930DA8, prev 0/65930D30, desc: RUNNING_XACTSnextXid 902 latestCompletedXid 901 oldestRunningXid 902

最近的WAL日志项与检查点完成相关(CHECKPOINT_ONLINE)。这个检查点的起始LSN在redo这个词之后指定;该位置在检查点开始时,与最后插入的WAL日志项相对应。

相同的信息也可以在控制文件当中发现:

[05:24:17-postgres@centos1:/var/lib/pgsql/14/data]$ pg_controldata -D $PGDATA  | grep -E 'Latest.*location'
Latest checkpoint location:           0/65930D30
Latest checkpoint's REDO location:    0/65930CF8

10.4 恢复

服务器启动时,第一个进程是postmaster。反过来,postmaster又派生出启动进程[10]。它负责失败情况下的数据恢复。

要确定是否需要进行恢复操作,启动进程要读取控制文件,检查实例的状态。pg_controldata命令工具让我们能够查看控制文件的内容:

[05:24:28-postgres@centos1:/var/lib/pgsql/14/data]$ pg_controldata -D $PGDATA  | grep -E 'state'
Database cluster state:               in production

一个关停的服务器,状态值将为”shut down"; 而“in production"状态对于一个不在运行的服务器而言,则意味着失败。在这种情况下,启动进程会自动启动恢复,从控制文件中找到最近完成的检查点的起始LSN处进行恢复。

如果PGDATA目录包含有与备份相关的文件:backup_label,起如LSN则会从它那里获取。

启动进程一项项读取日志项,从定义的位置开始,如果该页的LSN小于WAL日志项的LSN,则将WAL日志项应用到数据页。如果数据页包含更大的LSN,WAL将不会应用到数据页。事实上,它不应该被应用,因为它的日志项都是严格顺序回放的。

但是,有些日志项是由整页镜像组成的(FPI)。这种类型的日志项可以用于任何状态的数据页,因为页中所有内容将要被擦去。这种修改被称作是幂等的(idempotent)。另一个幂等的操作就是注册事务状态的改变:每个事务状态都是在CLOG中定义,由特定的二进制位来设置,不管它们的前边的值是什么。所以,没有必要保留CLOG页中最后变化的LSN。

WAL日志会被应用到buffer cache中的页里头,就像普通操作期间常规的页更新一样。

从WAL中恢复文件,方式类似:例如,如果一个WAL日志显示文件必须退出,但是因为某原因,它不存在,则会创建一个新文件。

一旦恢复结束,所有的非日志关系将被对应的初始分叉所覆盖。

最后,检查点被执行用以保护磁盘上的恢复状态。

启动进程的工作这才完成。

在它的典型形式下,恢复进程分两个阶段。在前滚阶段,WAL日志被回放,重复丢失的操作。在后向回滚阶段,服务器放弃那些在失败的时间段没能提交的事务。

在PG中,第二个阶段是不需要的。在恢复以后,CLOG对所有未结束的事务,都没有设置commit和abort状态位,但是因为已经知道该事务再也没会运行,它将被视为是放弃[11]

我们可以通过将服务器以-mi的方式关停来模拟一次失败。

[05:27:52-postgres@centos1:/var/lib/pgsql/14/data]$ pg_ctl stop -mi
waiting for server to shut down.... done
server stopped

这里看下实例的状态:

[05:46:36-postgres@centos1:/var/lib/pgsql/14/data]$ pg_controldata -D $PGDATA  | grep -E 'state'
Database cluster state:               in production

我们再启动一下服务器,启动进程看到失败发生,会进入恢复模式:

postgres$ pg_ctl start -l /home/postgres/logfile
postgres$ tail -n 6 /home/postgres/logfile
LOG: database system was interrupted; last known up at 2022−09−19
14:52:23 MSK
LOG: database system was not properly shut down; automatic recovery
in progress
LOG: redo starts at 0/3F09F9D0
LOG: invalid record length at 0/3F09FA80: wanted 24, got 0
LOG: redo done at 0/3F09FA08 system usage: CPU: user: 0.00 s,
system: 0.00 s, elapsed: 0.00 s
LOG: database system is ready to accept connections

如果服务器以正常模式关闭,postmaster会断掉所有的客户端,并且执行最终的检查点,并将所有脏页刷回磁盘。

现在我们看下当前WAL的位置:

SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/659373C0
(1 row)

现在我们正常关停服务器:

[05:51:54-postgres@centos1:/var/lib/pgsql/14/data]$ pg_ctl stop

下边是新的实例状态:

[05:51:58-postgres@centos1:/var/lib/pgsql/14/data]$ pg_controldata -D $PGDATA  | grep -E 'state'
Database cluster state:               shut down

在WAL的末尾,我们可以看到日志项:CHECKPOINT_SHUTDOWN, 即是最终的检查点:

pg_waldump $PGDATA/pg_wal/000000010000000000000066
rmgr: XLOG        len (rec/tot):    114/   114, tx:          0, lsn: 0/66000028, prev 0/659373C0, desc: CHECKPOINT_SHUTDOWN redo 0/66000028; tli 1; prev tli 1; fpw true; xid 0:903; oid 33060; multi 1; offset 0; oldest xid 726 in DB 1; oldest multi 1 in DB 1; oldest/newest commit timestamp xid: 0/0; oldest running xid 0; shutdown
rmgr: Standby     len (rec/tot):     50/    50, tx:          0, lsn: 0/660000A0, prev 0/66000028, desc: RUNNING_XACTSnextXid 903 latestCompletedXid 902 oldestRunningXid 903
pg_waldump: fatal: error in WAL record at 0/660000A0: invalid record length at 0/660000D8: wanted 24, got 0

最后的pg_waldump消息显示这个命令将WAL读到最后了。

我们再次启动实例:

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

10.5 后台写(background writing)

如果后端需要替换缓冲中的脏页,它必须将该页写回磁盘。这种情况不太好,因为它导致了等待----最好是能在后台执行异步的写操作。

这个工作有一部分是检查点完成的,但是仍然不够。

因此,PG提供了另一个进程,称为bgwriter[12], 特别用于后台写操作。它依赖于相同的缓冲查找替换算法,只有丙个地方不同:

  • bgwriter进程使用自己的时钟指针,永远不会在替换之后,通常会提前
  • 因为缓冲正被访问,所以使用计数器不会减小。

如果缓冲不被引用,脏页就会刷盘,使用计数器也将置为0. 这样,bgwriter会在页替换之关运行,并且将那些很有可能马上被替换的页写回磁盘。

它引发一些可能的缓冲被选择出来替换并清理。(It raises the odds of the buffers selected for eviction being clean.)

10.6 WAL设置

配置检查点

检查点持续时间(精确地说,是刷脏到磁盘的持续时间)是由参数checkpoint_completion_target确定(缺省为0.9),它的值指定了两个相邻的指派用来写操作的检查点开始之间的时间比例。不要将此参数设置为1:一旦设置为1,下一个检查点可能会直接到期,因为前一个检查点已经完成了。不会有灾难发生,因为不可能在同一时间执行多个检查点,但是正常的操作有可能被打断。

当配置其它参数时,我们可以使用下述方法。首先,我们定义两个相邻检查点间WAL文件合适的大小。容易越大,开销越小,但是这个值会受限于可用的空闲空间以及可以接受的恢复时间。

数据库杂记
数据库技术专家,PostgreSQL ACE,SAP HANA,Sybase ASE/ASA,Oracle,MySQL,SQLite各类数据库, SAP BTP云计算技术, 以及陈式太极拳教学倾情分享。出版过三本技术图书,武术6段。
 最新文章