WAL的一个基本假设是,日志条目必须在它们所描述的数据页更改之前到达稳定存储。这确保了这确保了将日志重放到其末尾会让我们进入一个一致性的状态而不是事务被部分执行的状态。简单地说,就是保证了事务的原子性。

为了保证这一点,每个数据页(堆或索引)都标有影响该页的最新 XLOG 记录的 LSN(log sequence numbe,实际上就是 WAL 文件位置)。在 bufmgr 写出脏页面之前,它必须确保 xlog 已刷新到磁盘,至少页面的对应的LSN已经刷新了。这种低级交互可以提高性能,因为在必要时才等待 XLOG IO。LSN 检查仅存在于共享缓冲区管理器中,而不存在于用于临时表的本地缓冲区管理器中;因此,对临时表的操作不得进行 WAL 记录。

通常,日志条目包含的信息刚好足以对页面(或一小组页面)重做单个增量更新。只有当文件系统和硬件将数据页写入实现为原子操作时,这才有效,这样页面就不会处于损坏的部分写入状态。但是这个假设在实际当中并站不住脚,所以为了能够重建整个页还会记录额外的信息。在checkpoint之后的一个页面的第一个WAL记录就是记录整个页面的副本,通过恢复该副本而不是重做更新来实现回放。(这比数据存储本身更可靠,因为我们可以检查 WAL 记录的 CRC 的有效性)。

最后一个WAL检查点的位置通过RedoRecPtr来表示,一个页面“checkpoint后的首次变更”可以这样判断: page的old lsn在RedoRecPtr之前,说明页面在最后一个checkpoint之后没有发生变更;否则说明page在最后一个checkpoint之后又做了修改

WAL日志记录的执行流程

  1. 固定并独占锁定包含要修改的数据页的共享缓冲区。
  2. START_CRIT_SECTION() (接下来的三个步骤中的任何错误都必须导致 PANIC,因为共享缓冲区将包含未记录的更改,我们必须确保这些更改不会进入磁盘。显然,在开始关键部分之前,您应该检查页面上是否有足够的可用空间等条件
  3. 将所需的更改应用于共享缓冲区。
  4. 使用 MarkBufferDirty() 将共享缓冲区标记为脏缓冲区。(这必须在插入 WAL 记录之前发生;请参阅 SyncOneBuffer() 中的注释)。请注意,使用 MarkBufferDirty() 将缓冲区标记为脏应该仅在您编写 WAL 记录时发生;请参阅下面的写作提示。
  5. 如果关系需要 WAL 日志记录,请使用 XLogBeginInsert XLogRegister* 函数生成 WAL 记录,并插入它。(参见下文 “构建WAL记录”)。然后使用返回的 XLOG 位置更新页面的 LSN
XLogBeginInsert();  
XLogRegisterBuffer(...)  
XLogRegisterData(...)  
recptr = XLogInsert(rmgr_id, info);  
  
PageSetLSN(dp, recptr);
  1. END_CRIT_SECTION()
  2. 释放开始加锁的共享缓冲区

复杂的修改(例如多级索引插入)必需使用一系列原子作用的WAL记录来描述中间状态必需是自洽的,这样就算回放的时候在任意两个动作之间中断了,系统也能够完全正常运行。

例如,在 btree 索引中,页面拆分需要分配一个新页面,并在父 btree 当中插入一个新键,但出于锁的原因,这个变更由两个单独的 WAL 记录反映。回放第一条记录的时候,会分配一个新页面并将数据记录移动到其中,并且会在页面上设置一个标志,指示还没有插入key到父btree当中。回放第二条记录才会清除这个标志。 正常操作的情况下,由于锁是在这两个操作之间都保持着的,所以其它的backend不会看到这个中间状态。但是如果在写入第二个WAL的时候发生中断,那么其他backend就会看到这个中断了。搜索过程能够基于这个中间状态继续工作,而插入操作的过程中遇到了这个中间状态,那么会由这个插入操作来将这个key插入父btree当中,完成遗留的工作,然后再继续执行自己的插入。

WAL记录的构造

WAL 记录由所有 WAL 记录类型通用的==标头特定于记录的数据以及有关修改的数据块的信息组成==。每个修改过的数据块都由一个 ID 号标识,并且根据具体的记录可能关联的还有更多信息。如果 XLogInsert 决定需要获取块的整页图像,则不包括与该块关联的数据。

用于构造 WAL 记录的 API 由五个函数组成:XLogBeginInsertXLogRegisterBufferXLogRegisterDataXLogRegisterBufDataXLogInsert。首先,调用 XLogBeginInsert()。然后,使用XLogRegister*的几个函数 函数注册所有修改的缓冲区以及重播更改所需的数据。最后,通过调用 XLogInsert() 将构造的记录插入到 WAL 中。

例如:

XLogBeginInsert();
 
// 注册修改的buffer
XLogRegisterBuffer(0, lbuffer, REGBUF_STANDARD);
XLogRegisterBuffer(1, rbuffer, REGBUF_STANDARD);
 
// 注册WAL记录的通用数据部分
XLogRegisterData(&xlrec, SizeOfFictionalActoin);
 
// 注册buffer关联的数据。如果生成了整个页的快照,那么不会被包含在记录当中
XLogRegisterBufData(0, tuple->data, tuple->len);
// buffer相关的更多的数据
XLogRegisterBufData(0, data2, len2);
// 所有的data和buffer都已经注册到WAL记录当中,现在插入记录
recptr = XLogInsert(RM_FOO_ID, XLOG_FOOBAR_DO_STUFF);

相关API的详细描述

  • void XLogBeginInsert(void):必须在XlogRegisterBufferXLogRegisterData之前调用
  • void XLogRegisterInsertion(void):清除WAL记录中注册的数据,在已经调用了XLogBeginInsert但是决定不插入的时候才需要调用。
  • void XLogEnsureRecordSpace(int max_block_id, int ndatas):通常,WAL记录构造buffer的时候有如下的限制:1.block ID最大为4(允许引用5个块)。2.最多注册20个数据块。默认的限制对于绝大部分记录类型是足够的。对于需要更大data或buffer的情况,需要调用这个函数来提高限制。此函数必须在XLogBeginInsert之前调用。
  • void XLogRegisterBuffer(uint8 block_id, Buffer buf, uint8 flags):将数据块有关的信息添加到WAL记录当中。
    • block_id是一个任意的数字,用于在redo例程当中标识一个页面的引用。在redo过程中重新查找页面所需要的信息(relfilelocator、fork 和block number)包含在WAL记录当中。
    • XLogInsert会自动包含页面内容的完整副本,如果这是自上次的checkpoint依赖对buffer的第一次修改,务必使用XLogRegisterBuffer注册修改的每个buffer,以避免页面撕裂的风险。
    • flags用于控制buffer的内容何时以及如何包含在WAL记录当中。通常,仅当页面在最近的checkpoint之后被修改时,并且仅当full_page_write=on或正在进行在线备份的时候,才会拍摄full-page image。REGBUF_FORCE_IMAGE用于强制始终包含full-page image,例如对于重写大部分页面的操作,这样很有用而不是跟踪页面的详细信息。对于不需要防止页面撕裂的极少数情况,可以使用REGBUF_NO_IMAGE来禁止full-page image。REGBUF_WILL_INIT也会禁止full-page image,但是redo例程必须重头开始重新生成page而不需要查询旧的page,重新初始化可以防止页面撕裂的危险,就像full-page image一样。
    • REGBUF_STANDARD可以和其他flag一起指定,来指示页面遵循遵循标准页面的布局。它会导致image中省略pd_lower和pd_upper之间的区域,从而减少WAL的体积。
    • REGBUF_KEEP_DATA:使用此flag,则在XLogRegisterBufData注册的每个缓冲区数据都会包含在WAL记录当中,即使生成了full-page image也是如此。
  • void XLogRegisterData(char *data, int len):用于添加包含在WAL记录当中的任意数据。可以被多次调用,数据以追加的方式添加,在redo例程当中会作为一个连续块。
  • void XLogRegisterBufData(uint8 block_id, char *data, int len):为前面使用XLogRegisterBuffer注册的buffer添加关联的数据。这个函数同样可以被调用多次,并且作为一个连续的块提供给redo例程。

REDO例程

REDO例程用于从WAL记录当中包含的数据和页面引用来重建页面的新状态。xlogreader.c/h中的记录解码函数和宏可以用来从记录的当中提取数据。

重放涉及多个页面修改的WAL记录的时候,需要小心地正确地给页面加锁,以防止并发的热备查询请求看到不一致的数据状态。如果在并发操作当中需要为2个或者更多的buffer页面加上锁,那么必须按照适当的顺序加锁,并且在所有的修改完成前不要释放。

需要注意的是,只有当操作已经被序列化之后,才能调用PageSetLSN/PageGetLSN()。只有启动进程可以在recovery期间修改数据块,可以调用PageGetLSN()而不用担心序列化的问题。但是除此之外的其他进程要想修改数据块要么对buffer加上排他锁,要么加上共享锁+buffer header锁,要么在关系上持有AccessExclusiveLock锁直接写数据块而不是通过共享缓冲区。

Writing Hints

在某些情况下,我们会将附加信息写入数据块,而无需先写入 WAL 记录。这应该只发生在崩溃后可以重建数据的情况下,并且该操作只是优化性能的一种方式。当写入hint时,我们使用 MarkBufferDirtyHint() 将buffer标记为脏。

当buffer是干净的并且正在使用校验和,则 MarkBufferDirtyHint() 会插入一条XLOG_FPI_FOR_HINT记录,以确保我们获取包含hint的full-page image。这样做是为了避免写入脏页的时候出现部分写入的问题。在recovery期间不会写WAL,因此在recovery过程中只会简单地跳过hint标记的页。

如果您决定优化 WAL 记录,则对 MarkBufferDirty() 的任何调用都必须替换为 MarkBufferDirtyHint(),否则将暴露部分页面写入的风险。

 PostgreSQL 中堆页(Heap Page)中的 all-visible 提示(PD_ALL_VISIBLE)。它在某些方面被视为持久化更改,在其他方面被视为提示。它必须满足以下不变式:如果堆页的关联可见性映射(Visibility Map)位被设置,则堆页本身必须设置 PD_ALL_VISIBLE。为了维护这个不变式,清除 PD_ALL_VISIBLE 总是被视为完全持久化更改。此外,如果启用了校验和(checksums)或 wal_log_hints,则设置 PD_ALL_VISIBLE 也被视为完全持久化更改来保护免受页的损坏。

 # WAL的文件系统操作  上一节介绍了如何对仅更改共享缓冲区内的页面内容的 WAL 日志操作进行 WAL。对于此类操作,通常可以在开始进行实际更改之前检查所有可能的错误情况(例如页面上的空间不足)。因此,我们可以通过将关联的 WAL 日志记录包装到一个关键部分来使它们的更改和创建“原子化”,---中途失败的几率足够低,如果确实发生 PANIC 是可以接受的。

但是如何要记录日志的操作失败的可能性很大的话那么这种方式就不合适了。有几种基本类型的文件系统操作存在此问题。以下是我们如何处理每个问题:

  1. 添加一个磁盘页到已存在的表中:这个操作是不记录WAL的,要想给表扩展一个磁盘页,那么必须实际执行一个写入操作来确保让文件系统分配空间,这是通过在表的末尾写一个全0的来实现的。空间分配好了, 就可以使用WAL记录来填充了,但是在写全0页分配磁盘空间和写入WAL日志之间可能会崩溃,所以必须将表/索引当中的全0页视为非错误条件,可以进行回收再利用。
  2. 需要创建新文件的新表:尝试创建文件,成功的话会制作一个 WAL 记录,说明我们做到了。如果不成功,我们可以抛出一个错误。请注意,有一个窗口期,我们已经在其中创建了文件,但尚未将有关它的任何 WAL 写入磁盘。如果我们在此窗口期间崩溃,则该文件将作为“孤儿”保留在磁盘上。可以通过让数据库重新启动搜索pg_class中没有任何已提交条目的文件来清理此类孤立文件,但目前尚未这样做,因为可能会删除对崩溃的取证分析有用的数据。孤儿文件是无害的---在最坏的情况下,它们会浪费一些磁盘空间---因为我们在分配新的 relfilenumber OID 时会检查磁盘上的冲突。因此,清理并不是真正必要的。
  3. 删除表可能unlink失败:我们的方法是首先对操作进行 WAL 日志,但将实际 unlink() 调用的失败视为警告而不是错误情况。同样,这可能会留下一个孤儿文件,但与替代方案相比,这很便宜。由于在提交 DROP TABLE 事务之前,我们实际上无法执行 unlink(),因此无论如何都不可能抛出错误。(值得注意的是,有关文件删除的 WAL 条目实际上是删除事务的提交记录的一部分。
  4. 创建和删除数据库和表空间,这需要创建和删除目录和整个目录树:这些情况的处理方式与创建单个文件类似,即,我们尝试先执行该操作,如果成功,则编写 WAL 条目。当然,潜在的磁盘空间浪费量要大得多。在创建的情况下,如果创建失败,我们会尝试再次删除目录树,以降低浪费空间的风险。删除操作中途失败会导致数据库损坏:DROP 失败,但某些数据仍会丢失。然而,我们对此无能为力,无论如何,它可能是用户不再需要的数据。在所有这些情况下,如果 WAL 重播无法重做原始操作,我们必须panic并中止recovery。DBA 必须手动清理(例如,释放一些磁盘空间或修复目录权限),然后重新启动恢复。这也是在我们成功完成原始操作之前不编写 WAL 条目的部分原因。

异步提交

postgres从8.3开始可以执行异步提交,即不会等待提交的WAL记录fsync结束。当 synchronous_commit = off 时,启用异步提交。不是执行XLogFlush来推进到提交的LSN,而是将LSN记录到共享内存当中。backend就可以继续执行其他的逻辑了。这里是记录要异步提交的记录,而不是中止提交。

我们总是在事务删除关系时强制同步提交,以确保在从文件系统中删除关系之前,提交记录已落盘。此外,某些具有不可回滚副作用(如文件系统更改)的实用程序命令会强制同步提交,以最小化文件系统已更改但不能保证已提交事务的这个时间窗口。

WAL Writer 通过设置一个延迟时间(wal_writer_delay)或者通过等待其它后台进程(通过设置一个标识,即 “latch”)来唤醒自己。一旦被唤醒,它会行 XLogBackgroundFlush() 操作。

XLogBackgroundFlush() 操作会检查最后一个完全填满的 WAL 页面的位置。如果该位置已经向前移动,那么 WAL Writer 会将所有已更改的缓冲区写入到该位置之前,以便在高负载情况下只写入完整的缓冲区。

如果发生了一段时间的空闲,当前的 WAL 页面与之前的页面相同,那么 WAL Writer 会获取最近异步提交的位置(LSN),如果需要(即在当前 WAL 页面上),则将数据写入到该位置。

如果自上次刷新以来经过了超过 wal_writer_delay 的时间,或者自上次刷新以来已经写入了超过 wal_writer_flush_after 个块,那么也会将 WAL 刷新到当前位置。

在异步提交过程中,有一些细节需要考虑:对于 CLOG(Commit Log)的每个页面,我们必须记住最近影响该页面的提交的LSN(Log Sequence Number),以便我们可以强制执行与普通关系页面相同的“在写入之前先刷新WAL”的规则。否则,提交的记录可能在WAL记录之前到达磁盘。需要注意的是,中止(abort)记录不需要考虑在内。

在异步提交中,为了保证事务的一致性,我们存储了多个LSN(Log Sequence Number)与每个clog(Commit Log)页面相关联。在可见性测试期间,我们会设置事务状态的提示位(hint bit),以进行可见性判断。关键是,我们不能在关系页面上设置一个事务已提交的提示位,并在WAL记录之前将其记录写入磁盘。由于通常在持有缓冲区共享锁时进行可见性测试,我们无法更改页面的LSN以保证WAL同步。因此,如果我们尚未将WAL刷新到与事务相关联的LSN位置,我们会推迟设置提示位。这就需要跟踪每个未刷新的异步提交的LSN。将这些数据与clog缓冲区关联是方便的,因为我们会在写入clog页面之前刷新WAL,所以我们知道我们不需要在内存中保留与提交状态相关的LSN的时间超过保存其提交状态的clog页面。

在PostgreSQL中,事务的提交被记录在WAL(Write-Ahead Logging)中,这是一种持久化日志,用于确保数据库的一致性和持久性。LSN(Log Sequence Number),这是WAL中的一个标识符,用于标记WAL中的不同位置。在某些情况下,系统可能会将多个事务的提交标记为相同的LSN,以提高性能。这些提交被称为“hinted”,因为它们暗示着它们已经被提交,但实际上尚未写入磁盘。

建议将多个事务的提交共享相同的缓存LSN是合理的。如果系统的工作负载主要由小型的异步提交事务组成,那么与walwriter周期中的事务数量相似的N值是合理的。这是因为事务将会以这样的粒度真正地提交(并且成为可以暗示的),因此设置N为相似的数量将有助于性能。walwriter是周期写入的,那么可以设置其一次写入N个事务,N就是这段时间内需要异步提交的事务数量,通过批量操作来提高性能

最坏的情况是,当一个同步提交事务与稍后提交的异步提交事务共享缓存LSN时。即使我们已经付出同步第一个事务到磁盘的代价,但在第二个事务同步之前,我们仍然无法暗示其输出,最多需要三个walwriter周期。因此,作者主张将N(组大小)保持尽可能小。当前设置的组大小为32,这使得LSN缓存空间与实际的clog缓冲空间大小相同(与BLCKSZ无关)。

如果一个事务能够看到另一个事务所做的更改,那么当第二个事务提交时,它的LSN必须在第一个事务的LSN之后。这意味着当第二个事务提交时,可以确信第一个事务所做的所有更改都已经被记录在WAL中。

除非WAL已经刷新到数据块LSN的点,否则对数据块的更改不会达到磁盘。任何试图将不安全数据写入磁盘的尝试都会触发写操作,以确保由该事务及之前的事务写入的所有数据的安全性。数据块和clog页都受LSN的保护。

Recovery期间的事务模拟

在数据库恢复期间模拟事务行为的过程。

首先,在恢复期间按照事务发生的顺序重放事务更改。作为这种重播的一部分,它模拟了一些事务行为,以便只读backend能够获取MVCC(多版本并发控制)快照。为了实现这一点,系统维护了一个属于正在重播的事务的XID列表,以确保每个已记录数据库写入WAL记录的事务在提交之前都存在于该数组中。关于此过程的更多细节在procarray.c的注释中有所说明。

其次,文中指出了一些操作根本不会写WAL记录,比如只读事务。这些操作对恢复期间的MVCC没有影响,因此我们可以假装它们根本没有发生。子事务的提交也不会写WAL记录,而且影响非常小,因为锁等待者需要等待父事务完成。

然后,文中提到并非所有的事务行为都被模拟。例如,系统不会在锁表中插入事务条目,也不会在内存中维护事务栈。但是,Clog、multixact和commit_ts条目会正常生成。在恢复期间,Subtrans是被维护的,但是事务树的细节被忽略,所有的子事务都直接引用顶级TransactionId。由于提交是原子性的,这提供了正确的锁等待行为,并且大大简化了子事务的模拟。

最后,文中提到了有关恢复期间锁定机制的更多细节可以在Lock rmgr代码的注释中找到

PG代码实现

按照功能WAL还划分出了下面的源文件:

  • xloginsert.c:构造WAL记录
  • xlogrecovery.c:WAL恢复和备份代码
  • xlogreader.c:读取WAL文件和解析WAL记录。
  • xlogutils.c:WAL redo例程所用的辅助函数。

除此之外还有xlog.c文件,这个文件包含了数据库启动、checkpoint以及管理WAL buffers的函数。

  • StartupXLOG():启动进程的入口,协调数据库启动,执行WAL recovery以及从WAL recovery状态到正常状态的过度操作。
  • XLogInsertRecord():插入一条记录到WAL buffer当中,大多数调用放不应该直接调用此函数,而是使用 xloginsert.c 中的函数来构造 WAL 记录
  • XLogFlush():用于强制将WAL刷到磁盘。
  • 除此之外,还有许多其他功能用于询问当前系统状态和启动停止备份。

WAL记录的构造实现

这里需要先理清三个概念:

  • buffer:磁盘中的数据在内存当中的缓存,当然事务对数据的修改也是基于buffer的,每个buffer对应磁盘上的一个数据页
  • data:这是WAL记录当中存入的数据,也是buffer中的数据写入到WAL记录的部位。
  • block:block是数据库中固定大小的数据单位,每个块通常对应磁盘上的一个文件块或磁盘页。

可以这样理解,一个事务修改了n个buffer,在提交的时候,这n个buffer都要写入到WAL记录的registered_buffer的data当中,WAL日志也是要持久化的,最终转为了block。

WAL记录可以理解由这几部分组成:WAL header、WAL data,注册的buffer,buffer data。

WAL工作区初始化

WAL最终是要构造一个XLogRecData的链作为最终的WAL记录。

typedef struct XLogRecData  
{  
    struct XLogRecData *next;  /* next struct in chain, or NULL */  
    char      *data;        /* start of rmgr data to include */  
    uint32    len;         /* length of rmgr data to include */  
} XLogRecData;

WAL子系统中预先分配了一些内存空间用于注册buffer,Buffer Data和WAL data。

// 1.用于注册buffer引用的数组
// 一个数组,用于记录已经注册的buffer  
static registered_buffer *registered_buffers;  
// 允许注册的buffer的最大值,可以看作是registered_buffers数组的容量,默认是5,即id从0~4  
static int  max_registered_buffers; /* allocated size */  
// 当前注册的最大block_id,默认是4  
static int  max_registered_block_id = 0;
 
// 2.以及用于注册data的数组,包括buffer data和WAL data
static XLogRecData *rdatas;  
static int  num_rdatas;          /* entries currently used */  
static int  max_rdatas;          /* allocated size */
 
// 3.为了区分WAL data,将它们之间建立了链式关系,并使用下面的头尾指针作为WAL data链表的开始和结尾
static XLogRecData *mainrdata_head;  
static XLogRecData *mainrdata_last = (XLogRecData *) &mainrdata_head;  
static uint64 mainrdata_len;
 
// 4.WAL header
static XLogRecData hdr_rdt;  
static char *hdr_scratch = NULL;

这些内存资源在InitXLogInsert中进行初始化。

#define XLR_NORMAL_MAX_BLOCK_ID    4  
#define XLR_NORMAL_RDATAS         20
void  
InitXLogInsert(void)
{
	// 默认数组容量为5,表示block_id为0~4下标范围内。想要注册更多block,需要先滴调用XLogEnsureRecordSpace()来分配内存
	if (registered_buffers == NULL)  
	{  
	    registered_buffers = (registered_buffer *)  
	       MemoryContextAllocZero(xloginsert_cxt,  
	                         sizeof(registered_buffer) * (XLR_NORMAL_MAX_BLOCK_ID + 1));  
	    max_registered_buffers = XLR_NORMAL_MAX_BLOCK_ID + 1;  
	}  
	// 默认data数组容量为20
	if (rdatas == NULL)  
	{  
	    rdatas = MemoryContextAlloc(xloginsert_cxt,  
	                         sizeof(XLogRecData) * XLR_NORMAL_RDATAS);  
	    max_rdatas = XLR_NORMAL_RDATAS;  
	}
	// 分配buffer以保存 WAL 记录的header信息
	if (hdr_scratch == NULL)  
	    hdr_scratch = MemoryContextAllocZero(xloginsert_cxt,  
                                HEADER_SCRATCH_SIZE);
}

WAL记录插入的准备工作

现在相关资源已经准备好了,可以开始进行WAL记录的构造了,不过在构造之前还需要做一些准备工作,以此来保证WAL系统处于期望的状态以及能够正常构造WAL记录的期望状态。

这里涉及两个函数,XLogBeginInsertXLogEnsureRecordSpace。其中前者是确保开始WAL记录构造的时候,内存资源都处于初始化的状态。后者则是当默认申请的内存资源不足的情况下再额外申请空间。目的很明确就是保证WAL记录的构造能够顺利执行。具体的内容就不详细介绍了。

WAL数据插入

即使用XLogRegister*系列函数来注册数据。到这里对构造流程比较清晰了的话,那么这几个函数就很好理解了。

// 注册buffer引用信息,也就是根据block_id,初始化registered_buffers[block_id]结构体字段
void  
XLogRegisterBuffer(uint8 block_id, Buffer buffer, uint8 flags)  
{  
    registered_buffer *regbuf;  
  
    /* NO_IMAGE doesn't make sense with FORCE_IMAGE */  
    // REGBUF_FORCE_IMAGE和REGBUF_NO_IMAGE不会同时使用,没有意义  
    Assert(!((flags & REGBUF_FORCE_IMAGE) && (flags & (REGBUF_NO_IMAGE))));  
    // 确保已经调用过了XLogBginInsert  
    Assert(begininsert_called);  
    // 更新一注册的buffer_id的最大值,同时也检测是否超过了允许注册的buffer数量  
    if (block_id >= max_registered_block_id)  
    {  
       if (block_id >= max_registered_buffers)  
          elog(ERROR, "too many registered buffers");  
       max_registered_block_id = block_id + 1;  
    }  
    // 取出一个registered_buffer用于当前buffer的注册  
    regbuf = &registered_buffers[block_id];  
    // 从buffer当中取出relfilelocator, fork number, block number写入到regbuf中对应的字段  
    BufferGetTag(buffer, &regbuf->rlocator, &regbuf->forkno, &regbuf->block);  
    regbuf->page = BufferGetPage(buffer);  
    regbuf->flags = flags;  
    regbuf->rdata_tail = (XLogRecData *) &regbuf->rdata_head;  
    regbuf->rdata_len = 0;  
  
    /*  
     * Check that this page hasn't already been registered with some other     * block_id.     * 检查这个page还没有被其他block_id注册  
     */#ifdef USE_ASSERT_CHECKING  
    {  
       int          i;  
  
       for (i = 0; i < max_registered_block_id; i++)  
       {  
          registered_buffer *regbuf_old = &registered_buffers[i];  
  
          if (i == block_id || !regbuf_old->in_use)  
             continue;  
  
          Assert(!RelFileLocatorEquals(regbuf_old->rlocator, regbuf->rlocator) ||  
                regbuf_old->forkno != regbuf->forkno ||  
                regbuf_old->block != regbuf->block);  
       }  
    }  
#endif  
    // 设置registered_buffer的使用标志位位true  
    regbuf->in_use = true;  
}

还有一个XLogRegisterBlock,与前者的区别在于Buffer时内存中的对象,而Block不在内存中。其他的逻辑基本上都是一样的。 XLogRegisterData用来注册WAL data,在WAL系统中又叫做main data。即放入rdatas数组当中并添加到mainrdata链表单中

void  
XLogRegisterData(char *data, uint32 len)  
{  
    XLogRecData *rdata;  
    // 进行前置检查  
    Assert(begininsert_called);  
  
    if (num_rdatas >= max_rdatas)  
       ereport(ERROR,  
             (errmsg_internal("too much WAL data"),  
              errdetail_internal("%d out of %d data segments are already in use.",  
                            num_rdatas, max_rdatas)));  
    // 将数据写入rdatas数组的当前位置  
    rdata = &rdatas[num_rdatas++];  
  
    rdata->data = data;  
    rdata->len = len;  
  
    /*  
     * we use the mainrdata_last pointer to track the end of the chain, so no     * need to clear 'next' here.     */    // 维护写入data链表的尾指针  
    mainrdata_last->next = rdata;  
    mainrdata_last = rdata;  
  
    mainrdata_len += len;  
}

XLogRegisterBufData用于注册buffer data,相比于前者,不会添加到mainrdata链表当中,但是会向registered_buffers中注册的block建立联系

XLogRegisterBufData(uint8 block_id, char *data, uint32 len)  
{  
    registered_buffer *regbuf;  
    XLogRecData *rdata;  
  
    Assert(begininsert_called);  
  
    /* find the registered buffer struct */  
    // 找到block_id对应的registered_buffer  
    regbuf = &registered_buffers[block_id];  
    if (!regbuf->in_use)  
       elog(ERROR, "no block with id %d registered with WAL insertion",  
           block_id);  
  
    /*  
     * Check against max_rdatas and ensure we do not register more data per     * buffer than can be handled by the physical data format; i.e. that     * regbuf->rdata_len does not grow beyond what     * XLogRecordBlockHeader->data_length can hold.     */    // 检查预留的data写入位置是否足够  
    if (num_rdatas >= max_rdatas)  
       ereport(ERROR,  
             (errmsg_internal("too much WAL data"),  
              errdetail_internal("%d out of %d data segments are already in use.",  
                            num_rdatas, max_rdatas)));  
    if (regbuf->rdata_len + len > UINT16_MAX || len > UINT16_MAX)  
       ereport(ERROR,  
             (errmsg_internal("too much WAL data"),  
              errdetail_internal("Registering more than maximum %u bytes allowed to block %u: current %u bytes, adding %u bytes.",  
                            UINT16_MAX, block_id, regbuf->rdata_len, len)));  
    // 将数据写入rdata当前位置,注意没有设置链表  
    rdata = &rdatas[num_rdatas++];  
  
    rdata->data = data;  
    rdata->len = len;  
  
    regbuf->rdata_tail->next = rdata;  
    regbuf->rdata_tail = rdata;  
    regbuf->rdata_len += len;  
}

WAL记录写入

WAL记录构造完成之后,就可以调用XLogInsert进行插入了。

XLogRecPtr  
XLogInsert(RmgrId rmid, uint8 info)  
{  
    XLogRecPtr EndPos;  
  
    /* XLogBeginInsert() must have been called. */  
    if (!begininsert_called)  
       elog(ERROR, "XLogBeginInsert was not called");  
  
    /*  
     * The caller can set rmgr bits, XLR_SPECIAL_REL_UPDATE and     * XLR_CHECK_CONSISTENCY; the rest are reserved for use by me.     *     * 调用者只能设置XLR_RMGR_INFO_MASK,XLR_SPECIAL_REL_UPDATE,XLR_CHECK_CONSISTENCY这三个标志位  
     */    if ((info & ~(XLR_RMGR_INFO_MASK |  
               XLR_SPECIAL_REL_UPDATE |  
               XLR_CHECK_CONSISTENCY)) != 0)  
       elog(PANIC, "invalid xlog info mask %02X", info);  
  
    TRACE_POSTGRESQL_WAL_INSERT(rmid, info);  
  
    /*  
     * In bootstrap mode, we don't actually log anything but XLOG resources;     * return a phony record pointer.     * 在bootstrap模式下,出了XLOG资源不会实际记录任何内容。  
     * 返回一个虚拟的记录指针  
     */    if (IsBootstrapProcessingMode() && rmid != RM_XLOG_ID)  
    {  
       XLogResetInsertion();  
       EndPos = SizeOfXLogLongPHD; /* start of 1st chkpt record */ // 第一个checkpoint记录的开始  
       return EndPos;  
    }  
  
    do  
    {  
       XLogRecPtr RedoRecPtr;  
       bool      doPageWrites;  
       bool      topxid_included = false;  
       XLogRecPtr fpw_lsn;  
       XLogRecData *rdt;  
       int          num_fpi = 0;  
  
       /* 获取决定是否要做full-page writes的判断值。 会在XLogInsertRecord当中加上WAL插入锁之后再次获取最新值并判断
        */       GetFullPageWriteInfo(&RedoRecPtr, &doPageWrites);  
        // 将注册的data和buffer组装程XLogRecData链  
       rdt = XLogRecordAssemble(rmid, info, RedoRecPtr, doPageWrites,  
                          &fpw_lsn, &num_fpi, &topxid_included);  
        // 写入WAL日志记录到磁盘  
       EndPos = XLogInsertRecord(rdt, fpw_lsn, curinsert_flags, num_fpi,  
                           topxid_included);  
    } while (EndPos == InvalidXLogRecPtr);  
  
    XLogResetInsertion();  
  
    return EndPos;  
}

这个函数的核心逻辑就是do-while循环体当中的部分,这个循环本身也只是说明会不断重试直到成功。 去掉前置的检查,插入的过程可以分为这几步:

  1. WAL记录的组装
  2. WAL记录的插入
  3. 重置WAL工作区,为下一次WAL记录的插入做准备 下面便按照这几步再详细介绍

WAL记录的组装

可以了解

WAL记录结构图.excalidraw

⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠ You can decompress Drawing data with the command palette: ‘Decompress current Excalidraw file’. For more info check in plugin settings under ‘Saving’

Excalidraw Data

Text Elements

XLogRecData

hdr_rdt

  1. 首先WAL记录是以XLogRecData链的结构组装的, 其中第一个节点为XLogRecord,即WAL记录的头部部分

WAL header结构

固定头部

可变头部

xl_tot_len:记录的总长度

xl_xid:事务id

xl_prev:指向前一个记录的指针

xl_info:一些标志位 xl_rmid:记录的资源管理器 xl_crc:记录的CRC校验和

很明显,虽然是头部, 但是很多信息是需要在数据写完之后才能填充的

可变头部就得详细说明了,毕竟数据部分的元数据就是组织在这里的

XLogRecordBlockHeader

固定

XLogRecordBlockHeader

XLogRecordBlockHeader

根据是否做full-page image,有一个可选的XLogRecordBlockImageHeader头

根据是否做了压缩,有一个可选的XLogRecordBlockCompressHeader头

虽然是有逻辑上的层级关系, 但是在内存当中是连续的

头部与XLogRecData中的节点基本上是对应的,虽然不是一对一的关系

Embedded Files

d9bfd3b48af8da0683ff65eda97b4ffb26b929f5: Pasted Image 20240620204611_486.png

Link to original
有个直观的映像。 WAL记录的构造阶段,实际上是将WAL记录所用到的数据都注册到了工作区当中的内存资源当中。但是WAL记录实际写入的时候是一个内存连续的二进制块,因此组装这里就是将工作区注册的数据组装为一个标准的WAL记录格式,一个二进制块的形式,然后提交写入到磁盘当中。

static XLogRecData *  
XLogRecordAssemble(RmgrId rmid, uint8 info,  
                XLogRecPtr RedoRecPtr, bool doPageWrites,  
                XLogRecPtr *fpw_lsn, int *num_fpi, bool *topxid_included)  
{
	// 使用scratch记录WAL header的写入位置
	char  *scratch = hdr_scratch;
	// 1.首先是固定大小的头部
	rechdr = (XLogRecord *) scratch;   // rechdr为WAL记录的头部
	scratch += SizeOfXLogRecord;  
	  
	hdr_rdt.next = NULL;  
	rdt_datas_last = &hdr_rdt;  
	hdr_rdt.data = hdr_scratch;
 
	// 遍历注册过的block  
	for (block_id = 0; block_id < max_registered_block_id; block_id++)  
	{
		// 获取注册的buffer
		registered_buffer *regbuf = &registered_buffers[block_id]; 
		if (!regbuf->in_use)  
		    continue;
		// 决定是否要备份page。设置强制备份和强制不备份的flag以及传入的doPageWwrites为false的情况下比较好处理。就是在没有这些flag以及doPageWrites为true的情况下还需要做一下额外的判断。
		if (regbuf->flags & REGBUF_FORCE_IMAGE)  
		    needs_backup = true;  
		else if (regbuf->flags & REGBUF_NO_IMAGE)  
		    needs_backup = false;  
		else if (!doPageWrites)  
		    needs_backup = false;  
		else  
		{     
			// 根据page上一次修改的lsn和最近一次RedoRecPtr即checkpoint的位置进行比较来判断。即上一次page的修改在最近一次checkpoint之前,那么本次的修改就是最近一次checkpoint后的第一个修改,这个修改需要进行full-page image以便备份和恢复,后续的WAL记录可以基于这个备份做一些增量修改的记录以提高性能和节省存储空间
		    XLogRecPtr page_lsn = PageGetLSN(regbuf->page);  
		    needs_backup = (page_lsn <= RedoRecPtr);  
		    if (!needs_backup)  
		    {  
		       if (*fpw_lsn == InvalidXLogRecPtr || page_lsn < *fpw_lsn)  
		          *fpw_lsn = page_lsn;
		    }  
		}
		// 决定WAL记录是否要包含buffer data。没有的话根本就不用包含,或者强制包含了那么也需要包含。再者就是看是否要做full-page image,做了自然就不需要包含了,已经有了
		if (regbuf->rdata_len == 0)  
		    needs_data = false;  
		else if ((regbuf->flags & REGBUF_KEEP_DATA) != 0)  
		    needs_data = true;  
		else  
		    needs_data = !needs_backup;
		include_image = needs_backup || (info & XLR_CHECK_CONSISTENCY) != 0;
		if (include_image)  
		{
			// wal_compression开启了的话就尝试将block进行压缩
			if (wal_compression != WAL_COMPRESSION_NONE)  
			{  
			    is_compressed =  
			       XLogCompressBackupBlock(page, bimg.hole_offset,  
			                         cbimg.hole_length,  
			                         regbuf->compressed_page,  
			                         &compressed_len);  
			}
			// 后面就是设置block header的一些数据,并将处理好的block加入到链中,使用rdt_datas_last更新链表尾部
			if (is_compressed)  
			{
				rdt_datas_last->data = regbuf->compressed_page;  
				rdt_datas_last->len = compressed_len;
			}
		}
		if (needs_data)  
		{  
		    Assert(regbuf->rdata_len <= UINT16_MAX);  
		  
		    bkpb.fork_flags |= BKPBLOCK_HAS_DATA;  
		    bkpb.data_length = (uint16) regbuf->rdata_len;  
		    total_len += regbuf->rdata_len;  
		  
		    rdt_datas_last->next = regbuf->rdata_head;  
		    rdt_datas_last = regbuf->rdata_tail;  
		}
		// 将添加的data对应的header拷贝到scratch buffer当中  
		memcpy(scratch, &bkpb, SizeOfXLogRecordBlockHeader);  
		scratch += SizeOfXLogRecordBlockHeader;  
		if (include_image)  
		{  
		    memcpy(scratch, &bimg, SizeOfXLogRecordBlockImageHeader);  
		    scratch += SizeOfXLogRecordBlockImageHeader;  
		    if (cbimg.hole_length != 0 && is_compressed)  
		    {  
		       memcpy(scratch, &cbimg,  
		             SizeOfXLogRecordBlockCompressHeader);  
		       scratch += SizeOfXLogRecordBlockCompressHeader;  
		    }  
		}  
		if (!samerel)  
		{  
		    memcpy(scratch, &regbuf->rlocator, sizeof(RelFileLocator));  
		    scratch += sizeof(RelFileLocator);  
		}  
		memcpy(scratch, &regbuf->block, sizeof(BlockNumber));  
		scratch += sizeof(BlockNumber);
	}
	// 每个data部分对应的header和data都已经添加完成了,剩下的就是补充WAL记录全局的一个header字段。这里就不详细说明了。
}

写入XLog记录

上一步当中已经组装了好了WAL记录,现在我们有了一个XLogRecData的链式结构,注意这里的插入不是写入磁盘,而是插入WAL记录缓冲区。

来看一下函数签名

XLogRecPtr  
XLogInsertRecord(XLogRecData *rdata,  
              XLogRecPtr fpw_lsn,  
              uint8 flags,  
              int num_fpi,  
              bool topxid_included)
  • 返回值:指向本次插入的XLOG的结尾(下一次插入的开始)。LSN指向的数据页写出之前必须要保证lsn的XLOG写入到磁盘了。这就是write ahead log。
  • 参数
    • rdata:前一步构造好的数据链
    • fpw_lsn:本次WAL记录的数据页当中没有做full-page image的最旧的lsn。fpw_lsn RedoRecPtr的话,那么插入就WAL记录就不会执行,fpw_lsn表示最近一次checkpoint之后非第一次更新的page的最小的lsn,这就要求其一定要大于RedoRecPtr的。这种情况就要求调用者重新计算需要full-page image的页并重试。
    • flags:用于对插入记录做更深入的控制。更详细的内容可以参照XLogSetRecordFlags。
    • num-fpi:full-page image的页面数量。
    • topxid_included:是否将top事务的id和当前子事务一起记录

整个插入的流程就可以简单划分为

  1. 获取WAL写入锁
  2. 预留足够的WAL记录缓冲区的空间
  3. 将构建的XLogRecData链中的数据拷贝到WAL缓冲区当中
  4. 释放WAL写锁 除此之外就是一些变量的更新。
if (isLogSwitch)  
    WALInsertLockAcquireExclusive();  
else  
    WALInsertLockAcquire();
if (isLogSwitch)  
    inserted = ReserveXLogSwitch(&StartPos, &EndPos, &rechdr->xl_prev);  
else  
{  
    ReserveXLogInsertLocation(rechdr->xl_tot_len, &StartPos, &EndPos,  
                        &rechdr->xl_prev);  
    inserted = true;  
}
CopyXLogRecordToWAL(rechdr->xl_tot_len, isLogSwitch, rdata,  
                StartPos, EndPos, insertTLI);
WALInsertLockRelease();

刷新WAL缓冲区的记录到磁盘

WAL记录目前只是插入到了WAL缓冲区,但是数据库系统当中事务的很多操作需要保证WAL被持久化到磁盘,这一点可以调用XLogFlush来完成。 确保WAL缓冲区中的记录刷盘到指定的位置。

出了这个函数能够手动调用保证指定位置的日志已经持久化到了磁盘,postgres当中还启动了walwriter进程,在这里进程当中也会定时将wal缓冲区的日志刷新到磁盘当中。可以参考WalWriterMain下的XLogBackgroundFlush