暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

深度解析HaishanDBWAL机制——背景与日志格式

原创 移动云HaishanDB 2026-07-28
30

(代码部分,参考 PostgreSQL 15 的源代码)

一、WAL 概述与崩溃恢复

1.1 WAL 日志的作用

Write-Ahead Logging(预写式日志)HaishanDB实现事务持久性和崩溃恢复的核心机制。其基本思想是:在任何数据页被写入磁盘之前,必须先将对该页的修改记录到持久化的日志中

1.1.1 无 WAL 日志场景

在没有 WAL 日志的场景下,数据库系统是很脆弱的,无法保证事务 ACID 特性:

  1. 发出第一个 INSERT 语句:PostgreSQL 将 TABLE_A 的页面从数据库文件加载到共享缓冲池中,并将一个元组插入到页面中。此页面不会立即写入数据库文件(修改后的页面称为脏页)。
  2. 发出第二个 INSERT 语句:PostgreSQL 在缓冲池的页面中插入一个新的元组。此页面也不会立即写入数据库文件。
  3. 系统崩溃:此时所有插入的数据都将丢失。

简单来说就是会丢数据。在系统故障时,没有 WAL 的数据库系统无法保证事务 ACID。

1.1.2 有 WAL 日志场景

为了解决数据丢失问题,并且不过于影响性能,PG 引入 WAL 机制。将所有修改作为历史数据写入持久化存储,以备故障时使用,这些历史数据称为 XLOG 或 WAL 记录。

当增删改等变更操作发生时,PG 会将 xlog 记录写入内存中的 WAL 缓冲区。当事务提交/回滚时,立即把 WAL 缓冲区中内容写入磁盘。XLOG 记录的 LSN(日志序列号,被用作 XLOG 记录的唯一标识符)标志记录在事务日志中的位置。

有 WAL 时的典型操作流程:

  1. checkpointer 后台进程定期执行检查点:每当 checkpointer 启动时,会将一条名为 checkpoint record 的 XLOG 记录写入当前 WAL 段。此记录包含最新重做点(REDO point)位置。
  2. 发出第一个 INSERT 语句:pg 将 TABLE_A 的数据页从数据库文件加载到共享缓冲池中,向该页中插入一个元组,向 WAL 缓冲区 LSN_1 位置写入一条的相应 xlog 记录,将 TABLE_A 的 LSN 从 LSN_0 更新到 LSN_1。
  3. 当此事务提交时:创建并向 WAL 缓冲区写入一条 commit 相应记录 LSN_2,将 WAL 缓冲区中到 LSN_2 的所有 XLOG 写入 WAL 文件中。
  4. 发出第二个 INSERT 语句:向 WAL 缓冲区 LSN_3 位置写入一条的相应 xlog 记录,将 TABLE_A 的 LSN 从 LSN_1 更新到 LSN_3。
  5. 当此事务提交时:PostgreSQL 以与步骤 3 相同的方式操作。
  6. 系统崩溃:即使共享缓冲池中的所有数据都丢失,但因为页面的所有修改都已作为历史数据写入 WAL 段文件,这些修改可以恢复回来。

WAL 核心理念:数据修改和事务提交均会记录 WAL 日志,事务提交需确保提交对应 WAL 日志的 LSN 前所有日志均刷入磁盘,以确保系统故障时能通过数据页和 WAL 日志恢复最新修改的数据。

1.1.3 带有 WAL 的崩溃恢复

  1. PostgreSQL 从相应的 WAL 文件中读取第一个 INSERT 语句的 XLOG 记录,并将 TABLE_A 的页面从数据库文件加载到共享缓冲池中。
  2. 在重放 XLOG 记录之前,pg 会比较 XLOG 记录的 LSN 和相应页面的 LSN。重放 XLOG 记录的规则如下:
    • 如果 XLOG 记录的 LSN 大于页面的 LSN(xlog 中数据较新,还未写入到数据文件),则 XLOG 记录中的数据部分将插入页面,并将页面 LSN 更新为 XLOG 记录的 LSN。
    • 如果 XLOG 记录的 LSN 较小(数据文件已是最新数据),不用做任何操作,直接读取下一个 WAL 数据。
  3. pg 按照同样的方式重放其余 XLOG 记录。

1.2 全页写(Full Page Write)机制

1.2.1 部分写问题

PG 默认页大小 8K,操作系统数据库块是 4K(操作系统每次原子写 4K),极端情况下,极有可能部分 PG 数据页只写到 4K 系统就已经崩溃,这就是”部分写”(Torn Page)问题。

1.2.2 全页写

PG 支持一种称为全页写的功能来处理部分写问题。如果启用(默认启用),PG 会为每个检查点之后每个页面第一次发生变更时,将头数据和整个页面作为一条 XLOG 记录写入 WAL 缓冲区。在 PG 中,这种包含整个页面的 XLOG 记录称为备份块或全页镜像(backup block or full-page image)。

启用全页写的插入流程:

  1. 检查点启动一个检查点进程。
  2. 在第一个 INSERT 语句插入时,XLOG 记录的是头数据+整个页面的备份块(因为这是在最新的检查点之后首次写入这个页面)。
  3. 当此事务提交时,PostgreSQL 的操作方式与普通场景相同。
  4. 在第二个 INSERT 语句插入时,XLOG 记录只是头数据+插入的元组,而不是备份块。
  5. 当此事务提交时,PostgreSQL 的操作方式与普通场景相同。

1.2.3 启用全页写的崩溃恢复

  1. pg 读取第一个 INSERT 语句的 XLOG 记录,并将损坏的 TABLE_A 页面从数据库文件加载到共享缓冲池中。XLOG 记录是备份块,根据全页写规则,每个页面的第一个 XLOG 记录始终是备份块。
  2. 当 XLOG 记录是备份块时,会使用另一个重放规则:XLOG 记录的数据部分(即页面本身)会直接覆盖当前页面而不去比较 LSN 值,并且页面的 LSN 更新为 XLOG 记录的 LSN。
  3. 第二个 XLOG 记录是非备份块,pg 的操作方式与普通场景相同。

此时,即使发生了一些数据写入错误,也可以恢复 pg。当然,如果发生的是文件系统或介质故障,就不能恢复了。

1.2.4 全页写的不足和优化

full_page_write 需要在 xlog 中记录整个数据页,会写更多 xlog 文件,不仅有数据变化信息,还有数据页本身信息,这会额外增加 IO 和磁盘消耗,同时也会引起主备延迟变大。

为了优化 full_page_write,社区提供了一个类似 MySQL 的双写 patch,它的主要设计是创建两个共享内存块队列:checkpoint 专用 buffer 队列和非 checkpoint 专用 buffer 队列,同时关闭 full_page_write。当用户 DML 产生的数据 buffer 需要刷盘时,并不是立即刷到磁盘,而是先进入 double write 的 buffer 队列,当 buffer 队列满时,则将 buffer 队列里面的数据首先刷到特别的 double write 文件,然后再将数据刷到数据库文件。

通过这种设计就不需要在 checkpoint 之后对数据页面的第一次写时将整个数据页面写到 xlog 里面。当数据库需要恢复的时候,遍历所有 double write 文件里面的记录块,找到每个记录块对应的数据库 page,然后对这个 page 进行 checksum,如果 page 损坏,那么直接把记录块里面的内容覆盖到 buffer 数据。最后把 double write 文件删除,重新初始化 buffer 队列。

二、WAL 文件结构及管理

2.1 事务日志和 WAL 文件

2.1.1 命名规则

逻辑上,PG 用一个地址空间长度为 8B 的虚拟文件表示事务日志(最大可达 16EB)。

物理上,PG 中事务日志默认切分为 16MB 的文件,每个文件称为 WAL 段。PG11 开始,支持初始化时使用 –wal-segsize 选项配置 WAL 段文件大小。

WAL 文件名由 24 个字符组成(每个字符以十六进制数表示),命名规则如下:

  • 前 8 位:timelineId,即时间线 id
  • 中间 8 位:WAL 的逻辑 id,每个逻辑 id 默认大小为 256×16M,即每个逻辑 id 包含 256 个 16M 的物理 WAL 文件
  • 最后 8 位:当前 WAL 文件是本逻辑 id 的第几个

例如 000000010000000000000001 这个文件,就表示时间线=1,逻辑 id=0,是本逻辑 id 的第 1 个 WAL 文件。

2.1.2 查看当前日志与文件名

默认日志大小:

postgres=# show wal_segment_size;
wal_segment_size
------------------
16MB
(1 row)

查看当前 LSN 日志:

postgres=# select pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/48EC728
(1 row)

日志转换为文件名:

postgres=# select pg_walfile_name('0/48EC728');
pg_walfile_name
--------------------------
000000010000000000000004
(1 row)

2.2 WAL 的内部结构

默认情况下,WAL 段是一个 16MB 的文件,内部切分为 8192 字节(8K)的页面。第一个 page 包含由结构体 XLogLongPageHeaderData 定义的头数据,而其他的 page 包含结构体 XLogPageHeaderData 定义的头数据。在页头之后,则是以降序写入 page 的 XLOG 记录。

2.3 WAL 段文件切换与管理

PostgreSQL 将 XLOG 记录写入在 pg_wal 目录的 WAL 段文件中。旧文件写满时则切换到新文件。WAL 文件的数量由参数配置而定。

2.3.1 WAL 文件切换

发生以下任一情况时,会发生 WAL 文件切换:

  • WAL 文件已经写满
  • 执行 pg_switch_wal() 函数
  • 启用 archive_mode 且超过 archive_timeout 设置值

切换后的段文件通常会被回收(重命名或重用)以供将来使用,如果不需要,也可能会被删除。

2.3.2 WAL 段管理(9.5 开始)

每当检查点启动时,PostgreSQL 都会预估并准备下一个检查点周期所需的 WAL 段文件数。这种估计基于前一个检查点周期中消耗的文件数量,即从包含上一个重做点的段文件开始计数,这个值范围在 min_wal_size(默认 80 MB)和 max_wal_size(默认 1GB)之间。如果检查点进程启动,必要的文件会被保留或回收,不必要的文件会被删除。

任何比包含上一个重做点的 WAL 文件更旧的 WAL 文件都可以被删除,因为它们在崩溃恢复时已不会被用到。

如果出现了 WAL 活动尖峰,需要更多 WAL 文件,新的文件就会被创建,但 WAL 文件的总大小不能超过 max_wal_size 的值。

WAL 文件的数量会根据数据库的繁忙程度自适应地改变。如果 WAL 数据写入量不断增加,则 WAL 文件的预估数及 WAL 文件的总大小也会逐渐增加。反之则减少。

如果 WAL 文件的总大小超过 max_wal_size,将启动检查点。检查点将会创建一个新的重做点,之前最新的重做点将变为前一个重做点,不必要的文件会被回收。

2.4 持续归档和归档日志

持续归档是当 WAL 文件发生切换时自动将其复制至归档目录的一项功能,归档由 archiver process 执行,复制出来的文件称为归档日志。此功能常用于物理热备份和 PITR(时间点恢复)。

归档目录的路径设置由 archive_command 参数配置:

# %p 被复制 WAL 文件目录占位符,%f 是归档日志文件的占位符
archive_command = 'cp %p /home/postgres/archives/%f'

archive_command 可以配置为任何 Unix 命令或程序,因此也可以通过 scp 命令将归档日志传输到其他主机,或使用任意的文件备份工具来代替普通复制命令。

pg 不会自动清理归档日志,因此打开归档时需要妥善管理。pg_archivecleanup 是一个实用的归档日志管理工具。

三、WAL 日志格式

3.1 日志组成结构

每个 WAL 文件(日志段)大小为 16M,它在内部划分为多个页面,每个页大小为 8K(这也是 pg 需要全页写的原因)。

每个日志页由页头信息(Header)+ 日志记录(Record)组成。

Header 分为两类:

  • XLogLongPageHeaderData:日志段第一个页的 Header 信息(每个段只有一个),存放日志段的长度、段页面大小等信息
  • XLogPageHeaderData:日志段其他页的 Header 信息(除第一个页外每个日志页都有一个),存放事务日志对应的版本、时间线等信息
  • XLogLongPageHeaderData 包含 XLogPageHeaderData 及一些额外信息

每条日志记录又由 XLogRecord 结构体 + 数据(XLOG Record data)组成,它是事务日志的最小单元,每个日志记录都表示修改数据库的一个动作。

日志数据又可以再分为:块头(XLogRecordBlockHeader)+ 日志头(XLogRecordDataHeader)+ 块数据(Block Data)+ 主数据(Main Data)。

3.2 日志页头信息

3.2.1 通用页头信息 XLogPageHeaderData

日志段其他页的 Header 信息,存放事务日志对应的版本、时间线等信息。

#define XLOG_PAGE_MAGIC 0xD10D

typedef struct XLogPageHeaderData
{
uint16 xlp_magic; /* magic value for correctness checks */
uint16 xlp_info; /* flag bits, see below */
TimeLineID xlp_tli; /* TimeLineID of first record on page */
XLogRecPtr xlp_pageaddr; /* XLOG address of this page */
uint32 xlp_rem_len; /* total len of remaining data for record */
} XLogPageHeaderData;

#define SizeOfXLogShortPHD MAXALIGN(sizeof(XLogPageHeaderData))
typedef XLogPageHeaderData *XLogPageHeader;

当页面剩余空间不足以保存整条记录时,需要保存到下一个日志页,xlp_rem_len 就用来记录剩余需要保存的记录长度。

3.2.2 首页头信息 XLogLongPageHeaderData

日志段第一个页的 Header 信息,存放日志段的长度、段页面大小等信息。XLogLongPageHeaderData 包含 XLogPageHeaderData 及一些额外信息。

typedef struct XLogLongPageHeaderData
{
XLogPageHeaderData std; /* standard header fields */
uint64 xlp_sysid; /* system identifier from pg_control */
uint32 xlp_seg_size; /* just as a cross-check */
uint32 xlp_xlog_blcksz; /* just as a cross-check */
} XLogLongPageHeaderData;

#define SizeOfXLogLongPHD MAXALIGN(sizeof(XLogLongPageHeaderData))
typedef XLogLongPageHeaderData *XLogLongPageHeader;

3.2.3 相关宏定义

#define XLP_FIRST_IS_CONTRECORD 0x0001 /* 日志记录跨页时设置 */
#define XLP_LONG_HEADER 0x0002 /* 是 long header 信息 */
#define XLP_BKP_REMOVABLE 0x0004 /* FPW 进入可选状态 */
#define XLP_ALL_FLAGS 0x0007 /* 所有 flag 标记位 */

#define XLogPageHeaderSize(hdr) \
(((hdr)->xlp_info & XLP_LONG_HEADER) ? SizeOfXLogLongPHD : SizeOfXLogShortPHD)

#define WalSegMinSize 1024 * 1024
#define WalSegMaxSize 1024 * 1024 * 1024

3.3 日志记录部分

我们按层次分别介绍:

  • 日志记录通用头 XLogRecord
  • 日志记录头信息:日志记录块头 XLogRecordBlockHeader + 日志记录数据头 XLogRecordDataHeader
  • 日志记录数据:块数据 Block Data + 主数据 Main Data

3.3.1 日志记录通用头 XLogRecord

typedef struct XLogRecord
{
uint32 xl_tot_len; /* total len of entire record */
TransactionId xl_xid; /* xact id */
XLogRecPtr xl_prev; /* ptr to previous record in log */
uint8 xl_info; /* flag bits, see below */
RmgrId xl_rmid; /* resource manager for this record */
pg_crc32c xl_crc; /* CRC for this record */
} XLogRecord;

xl_info 记录标记位和产生这个记录的动作:

  • 第 4 位存储两种标记信息:XLR_SPECIAL_REL_UPDATE 和 XLR_CHECK_CONSISTENCY

#define XLR_SPECIAL_REL_UPDATE 0x01
#define XLR_CHECK_CONSISTENCY 0x02

  • 高 4 位表示产生这个记录的动作(最多 16 种),以 Heap 操作为例:

#define XLOG_HEAP_INSERT 0x00
#define XLOG_HEAP_DELETE 0x10
#define XLOG_HEAP_UPDATE 0x20
#define XLOG_HEAP_TRUNCATE 0x30
#define XLOG_HEAP_HOT_UPDATE 0x40
#define XLOG_HEAP_CONFIRM 0x50
#define XLOG_HEAP_LOCK 0x60
#define XLOG_HEAP_INPLACE 0x70
#define XLOG_HEAP_OPMASK 0x70
#define XLOG_HEAP_INIT_PAGE 0x80

3.3.2 日志记录块头

1. XLogRecordBlockHeader

typedef struct XLogRecordBlockHeader
{
uint8 id; /* block reference ID */
uint8 fork_flags; /* fork within the relation, and flags */
uint16 data_length; /* number of payload bytes */
} XLogRecordBlockHeader;

#define SizeOfXLogRecordBlockHeader \
(offsetof(XLogRecordBlockHeader, data_length) + sizeof(uint16))

从结构中可以看到,XLogRecordBlockHeader 可能包括几个可选项:

  • XLogRecordBlockImageHeader:包含 full page image(全页镜像),用于全页写
  • XLogRecordBlockCompressHeader:启用压缩
  • RelFileNode:如果没有设置 BKPBLOCK_SAME_REL

2. XLogRecordBlockImageHeader

当包含 full-page image(即设置了 BKPBLOCK_HAS_IMAGE)时,附加的头信息。

typedef struct XLogRecordBlockImageHeader
{
uint16 length; /* number of page image bytes */
uint16 hole_offset; /* number of bytes before "hole" */
uint8 bimg_info; /* flag bits, see below */
} XLogRecordBlockImageHeader;

#define BKPIMAGE_HAS_HOLE 0x01
#define BKPIMAGE_IS_COMPRESSED 0x02
#define BKPIMAGE_APPLY 0x04

XLOG 代码知道 PG 数据页通常在中间包含一些未使用的 hole,大小为零字节。既然我们知道 hole 都是零,因此可以从存储的数据中删除它。另外,在启用 wal_compression 时,会在去掉 hole 后,尝试使用 PGLZ 压缩算法压缩 full page image。

3. XLogRecordBlockCompressHeader

typedef struct XLogRecordBlockCompressHeader
{
uint16 hole_length; /* number of bytes in "hole" */
} XLogRecordBlockCompressHeader;

4. RelFileNode

typedef struct RelFileNode
{
Oid spcNode; /* tablespace */
Oid dbNode; /* database */
Oid relNode; /* relation */
} RelFileNode;

5. MaxSizeOfXLogRecordBlockHeader

XLogRecordBlockHeader 最大 size,就是每部分都有然后加起来:

#define MaxSizeOfXLogRecordBlockHeader \
(SizeOfXLogRecordBlockHeader + \
SizeOfXLogRecordBlockImageHeader + \
SizeOfXLogRecordBlockCompressHeader + \
sizeof(RelFileNode) + \
sizeof(BlockNumber))

3.3.3 日志记录数据头 XLogRecordDataHeaderShort/Long

main data 部分的头信息,分为长短两种。如果数据长度小于 256 bytes 则用短的,并用一个字节保存长度,否则用长的。

typedef struct XLogRecordDataHeaderShort
{
uint8 id; /* XLR_BLOCK_ID_DATA_SHORT */
uint8 data_length; /* number of payload bytes */
} XLogRecordDataHeaderShort;

#define SizeOfXLogRecordDataHeaderShort (sizeof(uint8) * 2)

typedef struct XLogRecordDataHeaderLong
{
uint8 id; /* XLR_BLOCK_ID_DATA_LONG */
/* followed by uint32 data_length, unaligned */
} XLogRecordDataHeaderLong;

#define SizeOfXLogRecordDataHeaderLong (sizeof(uint8) + sizeof(uint32))

3.3.4 日志记录真正的数据部分

XLOG Record 按存储的数据内容来划分,大体可以分为三类:

  • Record for backup block(备份区块):存储 full-write-page 的 block,是为了解决日志页部分写的问题
  • Record for tuple data block(非备份区块):在 full-write-page 后,记录相应的 page 中的 tuple 变更
  • Record for Checkpoint:checkpoint 发生时,在事务日志文件中记录 checkpoint 信息(其中包括 Redo point)

每种类型具体包含的头和数据信息都不完全相同,可以结合前面结构体介绍去看。

四、总结

本文详细介绍了 HaishanDB WAL 机制的背景与日志格式:

  • WAL 的核心作用:通过先写日志后写数据的机制,确保事务的持久性和崩溃恢复能力
  • 全页写机制:解决部分写问题,通过在检查点后首次修改时记录完整页面镜像来保证数据一致性
  • WAL 文件结构:从段文件命名规则到内部页面组织,理解 WAL 的物理存储方式
  • 日志格式详解:从页头信息到记录结构,逐层解析 WAL 日志的二进制格式

理解 WAL 的背景与格式,是深入学习 WAL 注册、组装与写入流程的基础。在后续文章中,我们将继续探讨 WAL 日志的初始化、注册机制、组装流程以及写入缓存的详细实现。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论