10 预写式日志
PostgreSQL 18 · 基于俄文 PG18 原版的简体中文译本
10.1. 日志记录
发生故障时,例如断电、数据库管理系统或操作系统发生故障,内存中的全部内容都会丢失;只有已经写入磁盘的信息会保留下来。要让服务器在故障后启动,就必须恢复数据的一致性。如果磁盘本身受损,从备份恢复时也需要解决同样的一致性问题。
一种理论上可行的方法,是始终保持磁盘上的数据一致。实际上,这意味着必须不断把单个页面写入磁盘(随机写入的代价高于顺序写入),而且写入顺序还必须保证一致性在任何时刻都不被破坏(这很难,尤其是索引组织复杂时)。
PostgreSQL 和大多数数据库管理系统一样,采用了另一种方法。
运行过程中,一部分最新数据只保存在内存中,延迟写入磁盘(或其他非易失性存储)。因此,在服务器运行期间,磁盘上的数据始终处于不一致状态:不同页面反映的是不同时间点的状态。不过,内存中的每个操作(例如修改缓冲区缓存中的页面)都会被记录:系统会创建日志记录并保存到磁盘,其中包含必要时重复执行同一操作所需的最少信息1。
页面修改的日志记录必须先于修改后的页面写入磁盘。这就是它被称为预写式日志(write-ahead log)的原因。这样发生故障时,就可以从磁盘读取日志,重复执行故障前已经完成、但结果尚未从内存写入磁盘而丢失的操作。
通常,记录日志比写入单个页面更高效。日志是连续的数据流,即使 HDD 也能较好地处理;而且一条日志记录可能比一个页面小。
所有在故障时可能导致磁盘上数据不一致的操作都必须记录。具体来说,日志中会记录以下操作:
修改缓冲区缓存中的关系页面——修改后的页面会延迟写入磁盘;
提交和中止事务——状态变化发生在 clog 缓冲区中,也不会立即写入磁盘;
文件操作(例如创建表时创建和删除文件、目录)——这些操作必须与数据修改同步。
日志中不会记录:
对无日志表(
UNLOGGED)的操作;对无日志序列的操作(通常属于无日志表);(v. 15)
对临时表和序列的操作——这些对象的生命周期无论如何都不会超过创建它们的会话生命周期。
日志不仅用于故障恢复,也用于从备份恢复到任意时间点,以及用于复制。
10.2. 日志的结构
逻辑结构
从逻辑结构看,日志2可以表示为一系列长度不同的记录。每条记录包含某个操作的数据,前面有一个标准的头部3。头部还包含以下信息:
记录所属事务的编号;
负责解释该记录的资源管理器4;
用于检测数据损坏的校验和;
记录长度;
指向上一条日志记录的引用。
通常日志按正向读取,不过例如 pg_rewind 工具就会反向扫描。
数据本身的格式和含义各不相同。例如,它可以是一个页面片段,需要在指定偏移处覆盖页面的一部分。相应的资源管理器“知道”如何解析和重放这条记录。表、每种索引类型、事务状态及其他实体各有自己的资源管理器。完整列表可用命令 pg_waldump -r list 获取。
服务器共享内存中为日志分配了专用缓冲区。日志缓存大小由 wal_buffers 参数设置。默认值表示自动设置:分配 16 MB(一个日志段的大小);如果缓冲区缓存非常小,则分配更少。(默认值: -1)(第 216 页)
日志缓存的结构与缓冲区缓存相似,但主要以环形缓冲区方式工作:记录添加到“头部”,从“尾部”开始写入磁盘。日志缓存太小会导致与磁盘同步的频率高于必要程度。
在负载很低的系统中,已经保存到磁盘的记录位置(缓冲区“尾部”)与插入位置(缓冲区“头部”)几乎总是相同:
要引用某条记录,可以使用 pg_lsn 数据类型(log sequence number,LSN)。它表示相对于日志开头、到该日志记录为止的 64 位字节偏移量。LSN 以两个用斜线分隔的 32 位十六进制数字显示。
创建一张表:
开始一个事务,并记下日志插入位置:
现在执行某个操作,例如更新一行:
修改在内存中的缓冲区缓存页面上执行。修改信息也会保存到内存中的日志页面。因此,插入位置会向前移动:
为了保证修改后的数据页面严格晚于相应日志记录写入磁盘,页头会保存与该页面相关的最后一条日志记录的 LSN。可以使用 pageinspect 扩展检查这个位置:
整个数据库集簇共用一个日志,新的记录会不断追加。因此,页面中的 LSN 可能小于函数 pg_current_wal_insert_lsn 刚才返回的值。不过,在系统没有发生任何事情时,这两个数字会相同。
现在提交事务:
提交记录也会写入日志,插入位置再次发生变化:
提交会改变 SLRU 缓存中 clog 页面里的事务状态。为了防止 clog 页面早于相应日志记录被写入磁盘,也会跟踪 clog 页面对应的最后一条日志记录的 LSN。不过,这个信息不存储在页面本身,而是保存在内存中。(第 85 页)(第 309 页)
某个时刻,创建的日志记录会写入磁盘。之后,数据页面和 clog 页面就可以从缓存中逐出。如果必须提前逐出,系统会检测到这一点,并先强制把日志记录写入磁盘5。(第 235 页)
知道两个 LSN 位置后,只需用一个位置减去另一个位置,就可以得到它们之间日志记录的大小(字节)。只需先将位置转换为 pg_lsn 类型:
在这个例子中,更新行和提交事务约占用日志中的一百字节。
同样,可以估算特定负载下服务器每单位时间生成的日志量。这一信息用于设置检查点。
物理结构
在磁盘上,日志以段文件形式存储在 PGDATA/pg_wal 目录中。只读参数 wal_segment_size 显示段大小。(默认值: 16MB)
对于高负载系统,增大段大小可能降低开销,但只能在初始化集簇时设置(initdb --wal-segsize)。
日志记录写入当前文件;文件写满后开始使用下一个文件。
可以知道某个位置位于哪个文件,以及它相对于文件开头的偏移量:
文件名由两部分组成。前八个十六进制位表示从备份恢复时使用的时间线编号,其余部分对应 LSN 的高位(LSN 的低位由 file_offset 字段显示)。
可以使用专门的函数查看日志文件:
可以使用 pg_waldump 工具或 pg_walinspect 扩展查看已创建日志记录的头部:(v. 15)
这里可以看到两条记录的头部。
第一条是由 Heap 资源管理器处理的 HOT_UPDATE 操作。block_ref 列显示更新后的表页面的文件名和页号:(第 115 页)
第二条是由 Transaction 资源管理器处理的 COMMIT 操作。
10.3. 检查点
为了在故障后恢复一致性,必须依次读取日志,并把涉及丢失修改的记录应用到相应页面。要找出这些记录,需要比较磁盘上页面的 LSN 与日志记录的 LSN。不过,仍然不清楚应该从哪条记录开始恢复。如果开始得太晚,在这一时刻之前写入磁盘的页面就不会得到全部修改,从而导致不可逆的数据损坏。但从最开始恢复也不现实:既无法保存如此巨量的数据,也无法接受同样巨大的恢复时间。因此需要一个逐渐向前推进的检查点,从这里安全地开始恢复;检查点之前的日志记录则可以删除。
获得检查点最简单的方法,是定期暂停系统工作,把缓冲区缓存和其他缓存中的所有脏页写入磁盘。当然,这种方法完全不可行,因为系统会在这些时刻停顿一段不确定但相当长的时间。
因此在实践中,检查点会随时间展开,实际上成为一个时间段。检查点由名为 checkpointer 的专用后台进程执行6。
检查点开始。 首先,检查点进程将那些由于体积小而可以一次性写入的结构刷到磁盘。这包括 SLRU 缓存中的对象(例如 clog 事务状态和嵌套事务)以及其他一些结构。(第 309 页)
执行检查点。 检查点执行的大部分时间用于写入缓冲区缓存中的脏页7。
首先,在检查点开始时为所有当时脏的缓冲区头部设置一个特殊标志。这个过程很快,因为不涉及输入输出。
然后,检查点进程逐步遍历所有缓冲区,把带标记的缓冲区刷到磁盘。页面不会从缓存中逐出,只是写入,因此无需关注缓冲区的访问计数或钉住状态。
页面按编号顺序刷写,使写入尽可能连续而不是随机。同时,不同表空间(它们可能对应不同的物理设备)交替写入,以均匀分摊负载。(第 26 页)
带标记的缓冲区也可能由服务进程写入,取决于谁先到达该缓冲区。无论谁执行写入,都会清除此前设置的标志,因此就检查点而言每个缓冲区只会写入一次。
检查点执行期间,缓冲区缓存中的页面当然还会继续变化。但新变脏的缓冲区没有设置标志,检查点进程不会写入它们。
检查点完成。 当检查点开始时已经脏的所有缓冲区都写完后,检查点就算完成。此时(不能更早)检查点的开始时刻会被用作恢复起点。之前的所有日志记录都不再需要。
进程结束时会创建一条表示检查点结束的日志记录,并写入检查点开始时刻的 LSN。
此外,PGDATA/global/pg_control 文件会更新为指向最近完成的检查点。(进程完成前,pg_control 仍指向前一个检查点。)
为了彻底弄清楚各个位置之间的指向关系,我们看一个小例子。
使缓冲区缓存中的一些页面变脏:
记下当前日志位置:
现在手动执行检查点。所有脏页都会写入磁盘;由于系统没有其他活动,不会出现新的脏页:
查看检查点如何反映在日志中。CHECKPOINT_REDO 记录检查点的开始,CHECKPOINT_ONLINE 记录检查点的结束:(v. 17)
这两个位置之间会有服务器继续工作产生的其他记录。
在 PostgreSQL 10 之前,预写式日志通常使用 XLOG(transaction log)这个缩写。这个名称的痕迹不仅留在资源管理器名称中,也留在源代码里。
在控制文件中也可以找到相同的信息:
10.4. 恢复
服务器启动时,首先启动 postmaster 进程。它随后启动 startup 进程8,其任务是在发生故障时执行恢复。
startup 读取控制文件 pg_control 并检查集簇状态,以确定是否需要恢复。pg_controldata 工具可以查看该文件的内容:
干净停止的服务器状态是“shut down”。未运行的服务器却显示“in production”,说明发生了故障。此时 startup 会从同一个 pg_control 文件中记录的最后完成检查点的起点开始自动恢复。
如果 PGDATA 目录中存在备份中的 backup_label 文件,恢复起点会从该文件读取。
进程从找到的起点开始依次读取日志;如果页面 LSN 小于日志记录的 LSN,就将记录应用到数据页。如果页面 LSN 更大,就不应、准确地说不能应用这条记录,因为记录必须严格按顺序应用。
不过,有些记录是整页镜像(full page image,FPI)。整页镜像可以应用于页面的任何状态,因为页面内容无论如何都会被覆盖。这类修改称为幂等。另一个幂等操作是记录事务状态的变化:每个事务在 clog 中对应固定的若干位,设置这些位不依赖它们原来的值。因此,clog 页面中无需保存最后一次修改的 LSN。
日志记录应用于缓冲区缓存中的页面,和正常工作时一样。
同样,日志记录也应用于文件:例如,如果记录表明某个文件必须存在,而它不存在,就会创建该文件。
恢复结束后,所有无日志关系都会使用 init 文件中的镜像重写。(第 30 页)
最后执行检查点,把恢复后的状态固定到磁盘。
至此 startup 进程的工作结束。
传统上,恢复过程分为两个阶段。第一阶段(roll forward)重放日志记录,服务器重复故障时丢失的工作。第二阶段(roll back)回滚故障时尚未提交的事务。
PostgreSQL 不需要第二阶段。恢复后,clog 中既不会设置提交位,也不会设置中止位(从形式上看,这对应于正在执行的事务);但由于可以确定该事务已经不再运行,它会被视为已中止9。
可以通过以 immediate 模式强制停止服务器来模拟故障:
检查集簇状态:
启动服务器时,startup 发现发生了故障,于是启动恢复:
正常停止时,postmaster 会断开所有客户端,然后执行最后检查点,把脏页面写入磁盘。
记下当前日志位置:
现在正常停止服务器:
检查集簇状态:
查看日志,会在末尾发现最后检查点的记录(CHECKPOINT_SHUTDOWN):
pg_waldump 的最后一条消息表示该工具已经读到日志末尾。
再次启动实例:
10.5. 后台写入
如果服务进程需要从缓冲区逐出脏页,就必须自己把它写到磁盘。这种情况并不理想,因为会导致等待;最好让写入异步地在后台进行。
检查点进程承担一部分脏页刷写工作,但还不够。
因此还有后台写入进程 bgwriter10。它使用与逐出机制相同的缓冲区查找算法,但有两个区别:
使用自己的“时钟指针”,可以领先于逐出时钟指针,但绝不落后于它;
遍历缓冲区时不减少访问计数。
如果缓冲区未被钉住且访问计数为零,脏页就会写入磁盘。这样,后台写入进程走在逐出之前,预先写入很可能很快被逐出的缓冲区。
这提高了逐出选中的缓冲区不是脏页的概率。
10.6. 配置
检查点配置
检查点的持续时间(更准确地说,是写入脏缓冲区这一阶段的持续时间)由 checkpoint_completion_target 参数决定。它表示两个相邻检查点开始时刻之间,有多大比例的时间用于写入。不建议把它增大到 1,因为下一次检查点可能在上一次完全结束之前就必须开始。不会发生灾难,因为任何时刻都只执行一个检查点,但正常的工作节奏可能被打乱。(默认值: 0.9)(v. 14)
可以按如下方式配置其他参数。首先确定两个连续检查点之间允许保留的日志文件量。容量越大,开销越小,但这个值始终受可用空间和可接受恢复时间的限制。
可以计算在正常负载下生成这部分日志需要多长时间。为此,记下日志的初始插入位置,并定期检查当前位置与记住的位置之间的差值。
把得到的时间视为通常的检查点间隔,并将其写入 checkpoint_timeout 参数。默认值很可能太小;通常会把时间增加到例如半小时。(默认值: 5min)(第 245 页)
不过,有时(甚至很可能)负载会更高,在参数规定的时间内生成过多的日志记录。在这种情况下,检查点必须更频繁地执行。因此,可以用 max_wal_size 参数限制恢复所需的日志文件量。超过这个上限后11,服务器会发起一次计划外检查点。(默认值: 1GB)
恢复所需的日志文件既包含上一轮已完成检查点期间的记录,也包含当前尚未完成检查点期间的记录。因此,要估算总量,就要把已知的检查点间日志量乘以(1 + checkpoint_completion_target)。
按这种方式配置后,大多数检查点会按计划每隔 checkpoint_timeout 个时间单位执行一次。但负载升高时,达到 max_wal_size 的日志量后,检查点会更频繁地执行。
检查点进程会持续把实际进度与预期进度进行比较12:
实际进度由已经遍历的缓冲区缓存页面所占比例决定;
按时间计算的预期进度由已经消耗的时间比例决定,前提是检查点应在 checkpoint_timeout × checkpoint_completion_target 这一时间间隔内完成;
按容量计算的预期进度由已经填满的日志文件所占比例决定,计划使用的日志文件数量根据 max_wal_size × checkpoint_completion_target 计算。
如果脏页写入领先于计划,写入就会暂停;如果在两个参数中的至少一个方面落后,就会无延迟地追赶13。同时考虑时间和容量,就可以用同一种方式控制按计划和按需执行的检查点速度。
检查点完成后,服务器会清理恢复(以及其他任务)不再需要的日志记录14。其中一部分文件会被删除,其他文件会被重命名并重新使用。文件数量取决于日志记录的生成速率,但无论如何,PGDATA/pg_wal 中都会保留总量不少于 min_wal_size 的日志文件15。(默认值: 80MB)
通常,重命名可以避免不断创建和删除文件,从而节省开销;如有需要,也可以通过 wal_recycle 参数禁用这种行为。(v. 12)(默认值: on)
图中展示了正常条件下服务器所需日志文件量的变化。
需要理解的是,服务器上的日志文件实际总量可能更大,在某些情况下甚至可能超过 max_wal_size:
max_wal_size 参数提供目标值,但不是硬限制。如果负载增加,写入可能落后于计划。
服务器无权删除尚未通过复制槽传送出去的日志文件,以及持续归档中尚未归档的日志文件。这个功能需要持续监控,因为服务器磁盘很容易因此被填满。
管理员可以通过 wal_keep_size 参数保留一定数量的文件。(v. 12)(默认值:
0MB)
后台写入配置
后台写入进程应在检查点配置之后进行配置。两个进程必须共同确保在服务进程需要这些缓冲区之前写完脏缓冲区。
后台写入进程按周期工作,在两个周期之间休眠 bgwriter_delay 指定的时长。(默认值: 200ms)
每个周期要写入的页面数量,根据服务进程自上次运行以来请求的缓冲区平均数量确定(使用移动平均来平滑各次运行之间的不均匀性,同时不受久远历史的影响)。计算出的数量还会乘以 bgwriter_lru_multiplier 系数。但无论如何,每个周期写入的页面数都不会超过 bgwriter_lru_maxpages。(默认值: 2)(默认值: 100)
如果进程没有发现任何脏缓冲区(也就是系统中没有发生任何事情),它就会“进入睡眠”。服务器进程第一次请求缓冲区后,它会醒来并继续正常工作。
监控
可以、也应该根据监控反馈调整检查点和后台写入的配置。
如果因日志文件量超过限制而触发的检查点,比 checkpoint_warning 参数指定的频率更高,消息日志中就会输出警告。应根据预计的峰值负载调整该参数。(默认值: 30s)
启用 log_checkpoints 参数后,服务器消息日志会输出检查点执行过程的详细信息。(默认值: on)
修改一些数据并发起检查点:
在消息日志中可以看到写入的缓冲区数量、检查点后日志文件组成的变化、检查点耗时,以及相邻检查点起点之间的距离(以字节计):
不过,对于配置来说,最有用的信息是 pg_stat_checkpointer 视图中检查点进程的工作统计。(v. 17)
在 PostgreSQL 17 之前,检查点和后台写入共用一个 pg_stat_bgwriter 视图,因为过去这两项工作都由后台写入进程执行;在 9.2 版中,检查点被拆分为独立进程,但视图长期保持共用。
该视图还会显示已完成的检查点数量:
num_timed——按计划执行(时间间隔 checkpoint_timeout 到期);num_requested——按需执行(包括日志量达到 max_wal_size 时触发的检查点)。
如果 num_requested 列的值相对于 num_timed 很大,说明实际检查点发生得比预期更频繁。
以 restartpoints 开头的字段只对副本有意义。
视图 pg_stat_bgwriter 显示后台写入的统计信息:
对于配置来说,maxwritten_clean 的值很有用:它表示后台写入进程因超过 bgwriter_lru_maxpages 而停止工作周期的次数。
在配置良好的系统中,服务进程几乎不执行写入;写入的主要部分由检查点进程(buffers_written)和后台写入进程(buffers_clean)完成。
除了检查点和后台写入的详细信息,还需要了解预写式日志的整体情况。日志记录数量、整页镜像数量及其占用空间等最基本的统计信息,可以从 pg_stat_wal 视图获得:(v. 14)
与日志相关的输入输出信息中,最详细的部分(包括按进程类型划分的信息,本文不展开)位于熟悉的 pg_stat_io 视图中:(v. 18)(第 204 页)
对于日志,context 字段可以取以下值:
init——初始化 WAL 段(如果设置了 wal_init_zero,还会把段填零;因此每次操作写入 16 MB 数据);(默认值:on)normal——对已有段执行操作。
可以使用 pg_stat_get_backend_wal 函数获取单个服务进程运行期间的统计信息。(v. 18)
postgrespro.ru/docs/postgresql/18/wal-internals;backend/access/transam/README。 ↩︎
backend/storage/buffer/bufmgr.c,函数 FlushBuffer。 ↩︎
backend/postmaster/checkpointer.c;backend/access/transam/xlog.c,函数 CreateCheckPoint。 ↩︎
backend/storage/buffer/bufmgr.c,函数 BufferSync。 ↩︎
backend/postmaster/startup.c;backend/access/transam/xlog.c,函数 StartupXLOG。 ↩︎
backend/access/heap/heapam_visibility.c,函数 HeapTupleSatisfiesMVCC。 ↩︎
backend/access/transam/xlog.c,函数 XLogCheckpointNeeded 和 CalculateCheckpointSegments。 ↩︎
backend/postmaster/checkpointer.c,函数 IsCheckpointOnSchedule。 ↩︎
backend/postmaster/checkpointer.c,函数 CheckpointWriteDelay。 ↩︎
backend/access/transam/xlog.c,函数 RemoveOldXlogFiles。 ↩︎
backend/access/transam/xlog.c,函数 XLOGfileslop。 ↩︎