Postgrseql - transaction - WAL机制

翻译源码中的README。可以深入了解WAL的工作机制。包括了如何写,刷磁盘,同步等。

Write-Ahead Log Coding

-------------------------

WAL子系统(XLOG)用来保证崩溃之后系统恢复。它还可用于提供时间点恢复,以及通过日志传送进行热备用复制。

写AHEAD日志的基本假设是,日志条目必须在它们描述的数据页更改之前到达稳定的存储区。这确保了将日志重放到其末尾将使我们处于一致的状态,其中没有部分执行的事务。为了保证这一点,每个数据页(堆或索引)都用影响该页的最新XLOG记录的LSN(日志序列号——实际上,WAL文件位置)标记。在bufmgr写出一个脏页之前,它必须确保xlog已经被刷新到磁盘,至少达到该页的LSN。这种low-level 交互通过在必要之前不等待XLog I/O来提高性能。LSN检查只存在于共享缓冲区管理器中,而不存在于用于临时表的本地缓冲区管理器中;因此不能对临时表的操作进行WAL日志。

在WAL重放期间,我们可以检查页面的LSN,以检测当前日志条目记录的更改是否已经应用(如果页面LSN是>=日志条目的WAL位置,则已经应用了)。

通常,日志条目只包含足够的信息,以便在页面(或小组页面)上重做一次增量更新。只有当文件系统和硬件实现数据页作为原子操作进行写入时,才能实现这一点,从而页面永远不会处于损坏的部分写入状态。因为在实践中,这常常是一个站不住脚的假设,所以我们记录额外的信息,以允许对修改后的页面进行完全的重建。在检查点建立之后,影响给定页面的第一个WAL记录包含整个页面的副本,我们通过恢复该页面副本而不是重新执行更新来实现重放。(这比数据存储本身更可靠,因为我们可以检查WAL记录的CRC的有效性。)我们可以通过注意页面的旧LSN是否在WAL结束之前直到最后一个检查点(RedoRecPtr)来检测“检查点之后的第一次更改”。

执行WAL日志记录动作的一般模式是:

扫描二维码关注公众号,回复: 4945789 查看本文章

1. 将要修改的数据页,PIN并使用排他锁锁在共享数据缓冲区。

2. START_CRIT_SECTION()

3. 对共享缓冲区应用所需的更改。

4. 使用MarkBufferDirty()标记共享缓冲区中的脏块。(这必须在插入WAL记录之前发生。)使用MarkBufferDirty()标记缓冲区脏块应该只在编写WAL记录时发生;

5. 如果 relation(对象) 需要WAL日志记录,构建WAL记录则使用 XLogBeginInsert 和 XLogRegister*函数,并插入它。(然后使用返回的XLOG位置更新页面的LSN)

XLogBeginInsert();

XLogRegisterBuffer(...)

XLogRegisterData(...)

recptr = XLogInsert(rmgr_id, info);

PageSetLSN(dp, recptr);

6. END_CRIT_SECTION()

7. 解锁和解锁缓冲区。

复杂的变化(如多级索引插入)通常需要通过一系列原子作用WAL记录来描述。中间状态必须是自洽的,所以如果重放中断在任何两个动作之间,系统就完全正常。例如,在btree索引中,页分裂要求分配新页,并在父btree级别插入新密钥,但是由于锁定的原因,这必须由两个单独的WAL记录反映。重放第一条记录,为了分配新页面并将元组移动到该页面,在页面上设置一个标志,以指示键尚未插入父记录。重放第二条记录清除标志。这种中间状态在正常操作期间不会被其他后端看到,因为子页面上的锁在两个操作之间保持,但是在写入第二条WAL记录之前如果操作中断,则将看到。搜索算法正常地处理中间状态,但是如果插入遇到具有不完全分割标志集的页面,它将在继续之前通过向父级插入键来完成中断的分割。

=====================================================================================

Constructing a WAL record

---------------------------

WAL记录由所有WAL记录类型、特定于记录的数据和关于修改的数据块的信息共同的头部组成。每个修改的数据块都由ID号标识,并且可以选择性地具有更多与该块相关联的特定于记录的数据。如果XLogInsert确定需要拍摄块的整页图像,则不包括与该块相关联的数据。

用于构建WAL记录的API由五个函数组成

XLogBeginInsert, XLogRegisterBuffer, XLogRegisterData, XLogRegisterBufData, and XLogInsert.

首先调用XLogBeginInsert(),然后使用XLogRegister* 函数注册所有修改过的缓冲区以及重放更改所需的数据。最后通过调用XLogInsert() 将构建的记录插入到WAL中。

=====================================================================================

Writing a REDO routine

------------------------

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

在重放描述多个页面上的更改的WAL记录时,必须小心地正确锁定页面,以防止并发的热备用查询看到不一致的状态。如果这需要同时持有两个或多个缓冲锁,则必须按适当的顺序锁定页面,并且在完成所有更改之前不释放锁。

注意,当我们知道动作是序列化的时,我们必须只使用 PageSetLSN/PageGetLSN() 。只有启动进程可以在恢复期间修改数据块,因此启动进程可以执行 PageGetLSN() 而不必担心序列化问题。所有其他进程必须只在持有独占缓冲区锁或共享锁加上缓冲区头锁时调用PageSet/GetLSN,或者直接而不是通过共享缓冲区写入数据块,同时在关系上持有AccessExclusiveLock。

=====================================================================================

Writing Hints

-------------

在某些情况下,我们向数据块写入附加信息,而不写入前面的WAL记录。如果数据在崩溃之后可以重构,并且该操作只是优化性能的一种方式,那么这种情况才应该发生。当写入提示时,我们使用MarkBufferDirtyHint() 来标记块脏。

如果缓冲区是干净的,并且使用了校验和,则 MarkBufferDirtyHint() 插入 XLOG_FPI 记录,以确保我们获取包含提示的完整页面图像。我们这样做是为了避免部分页写入,当我们写脏页。WAL不是在恢复期间编写的,因此我们只是跳过脏块,因为在恢复期间有提示。

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

=====================================================================================

Write-Ahead Logging for Filesystem Actions

------------------------------------------

1. Adding a disk page to an existing table.

这个动作根本不是WAR记录的。我们通过在其末尾写入零页来扩展表。我们必须做这个写,这样我们才能确保文件系统已经分配了空间。如果写失败,我们可以正常地出错。一旦已知分配了空间,我们就可以通过一个或多个正常的WAL日志操作来初始化和填充页面。因为在扩展文件和写出WAL条目之间可能出现崩溃,因此必须将在表或索引中发现全零页视为无错误条件。在这种情况下,我们可以重新获得重新使用的空间。

2. Creating a new table, which requires a new file in the filesystem.

我们尝试创建文件,如果成功的话,我们做一个WAR记录,说我们做到了。如果不成功,我们可以抛出一个错误。请注意,有一个窗口,我们在其中创建了该文件,但是还没有将它的任何WAL写入磁盘。如果我们在这个窗口崩溃,文件将作为一个“孤立orphan”留自在磁盘上。通过重新启动数据库搜索在pg_class中没有任何提交条目的文件来清理这些Orphan是可能的,但是由于可能删除对崩溃进行分析有用的数据,所以目前没有这样做。Orphan文件是无害的——最糟糕的是它们浪费了一点磁盘空间——因为我们在分配新的relfilenode OIDs 时检查磁盘上的冲突。所以清理不是必须的。

3. Deleting a table, which requires an unlink() that could fail.

我们这里的方法是首先WAL记录操作,但是将实际unlink() 调用的失败作为警告而不是错误条件来处理。同样,这可以留下一个孤立文件,但这比其他方案便宜。因为我们在提交DROP TABLE事务之后才能真正执行unlink() ,所以无论如何,抛出错误是不可能的。(值得注意的是,关于文件删除的WAL条目实际上是删除事务的提交记录的一部分。)

4. Creating and deleting databases and tablespaces, which requires creating and deleting directories and entire directory trees.

这些情况的处理类似于创建单个文件,即,我们尝试先执行操作,然后如果成功,则编写WAL条目。当然,浪费磁盘空间的潜力相当大。在创建的情况下,如果创建失败,我们尝试再次删除目录树,以减少浪费空间的风险。删除操作中途的失败将导致数据库损坏:DROP失败,但某些数据仍然丢失。然而,我们对此几乎无能为力,无论如何,它可能是用户不再需要的数据。

在所有这些情况下,如果WAL重放未能重做原始操作,则必须恐慌并中止恢复。DBA必须手动清理(例如,释放一些磁盘空间或修复目录权限),然后重新启动恢复。这是我们在成功完成原始操作之前不编写WAL条目的部分原因。

=====================================================================================

Asynchronous Commit

-------------------

从PostgreSQL 8.3开始,可以执行异步提交——即,当提交的WAL记录被fsyncd时,我们不等待。当 synchronous_commit = off 时,我们执行异步提交。与其执行 XLogFlush() 直到提交的LSN,我们只需在共享内存中注意LSN。后端接着继续其他工作。我们记录LSN仅用于异步提交,而不是中止;不需要刷新中止记录,因为崩溃之后的假设是无论如何事务已经中止。

当事务删除 relations 时,我们总是强制进行同步提交,以确保在 relations 从文件系统中删除之前提交记录被下传到磁盘。此外,某些具有不可回滚副作用(如文件系统更改)的实用程序命令强制同步提交,以便最小化进行文件系统更改但不保证提交事务的窗口。

walwriter 定期唤醒 (通过 wal_writer_delay) 或被唤醒(通过它的锁存器,由后端异步提交设置)并执行XLogBackgroundFlush()。这是检查最后一个完全填充的WAL页面的位置。如果已经向前移动,那么直到此时,我们写入所有更改的缓冲区,以便在满载时只写入整个缓冲区。如果活动中断,并且当前WAL页与以前相同,那么我们找到最近一次异步提交的LSN,如果需要(即,如果它在当前WAL页中),则写到该点。自上次刷新以来,如果超过wal_writer_delay 或 wal_writer_flush_after 块,WAL也被刷新到当前位置。这种安排本身将保证异步提交记录最多在事务完成后两次 wal_writer_delay 之后到达磁盘。然而,我们还允许XLogFlush 灵活地写/刷新完整的缓冲区(即,不在循环WAL缓冲区的末尾环绕),以便当每个Walwriter周期填充多个WAL页面时,将高负载下发出的写入数量减到最小。这使得最坏情况下的延迟3个wal_writer_delay延迟周期。

异步提交还有一些其他微妙的问题需要考虑。首先,对于阻塞的每个页面,我们必须记住影响该页面的最新提交的LSN,以便我们能够执行与普通关系页面相同的flush-WAL-before-write 规则。否则,提交的记录可能会在WAR记录之前到达磁盘。同样,中止记录不需要考虑这一因素。

事实上,我们为每个阻塞页面存储了不止一个LSN。这涉及到在可见性测试期间设置事务状态提示位的方式。我们不能在relation 页面上设置事务提交的提示位,并且在提交的WAL记录之前让该记录写入磁盘。由于在保持缓冲区共享锁的同时通常进行可见性测试,因此我们不能选择更改页面的LSN来保证WAL同步。相反,如果我们还没有将WAL刷新到与事务相关联的LSN,则推迟设置提示位。这需要跟踪每个未刷新的异步提交的LSN。将此数据与阻塞缓冲区关联起来是很方便的:因为我们将在写入阻塞页之前刷新WAL,因此我们知道,与保持其提交状态的阻塞页保持在内存中相比,我们不需要记住事务的LSN的时间更长。然而,为每个阻塞位置存储LSN的简单方法并不吸引人:LSN比两位提交状态字段大32倍,因此我们需要为每个8K阻塞缓冲页增加256K的共享内存。相反,我们选择每页存储较少数量的LSN,其中每个LSN是与该页上连续事务ID范围内的任何事务提交相关联的最高LSN。这节省了存储设置事务提示位的一些可能不必要的延迟的代价。

有多少事务应该共享同一个缓存的LSN(n)?如果系统的工作负载仅由小的异步提交事务组成,那么N与每个walwriter周期的事务数量类似,这是合理的,因为这是事务真正提交(因此是可暗示的)的粒度。最坏的情况是sync-commit精确度与稍后提交的异步提交精确度共享缓存的LSN;即使我们将第一个精确度同步到磁盘,直到第二个精确度同步,直到三个walwriter周期之后,我们才能够提示它的输出。这主张保持N(组大小)尽可能小。目前我们将组大小设置为32,这使得LSN缓存空间与实际的阻塞缓冲空间大小相同(独立于BLCKSZ)。

我们可以同时运行同步和异步提交事务,这很有用,但是安全性可能并不明显。假设我们有两个事务T1和T2。日志序列号(LSN)是记录事务提交的WAL序列中的点,因此LSN1和LSN2是这些事务的提交记录。如果T2可以看到由T1所做的改变,那么当T2提交时,LSN2必须遵循LSN1。因此,当T2提交时,可以确定T1所做的所有更改现在也记录在WAL中。无论T1是异步的还是同步的,这都是正确的。因此,异步提交和同步提交可以安全地同时工作,而不会危及由同步提交编写的数据。子事务在这里并不重要,因为对磁盘的最终写入只发生在顶级事务的提交处。

除非将WAL刷新到数据块的LSN点,否则数据块的更改无法到达磁盘。任何向磁盘写入不安全数据的尝试都会触发写入,以确保由该磁盘写入的所有数据以及先前事务的安全。数据块和阻塞页都由LSN保护。

对临时表的更改不是WAL日志记录的,因此可以在T1提交之前到达磁盘,但我们并不关心,因为临时表内容无论如何都不会在崩溃中幸存。

通过我们引入的任何路径进行数据库写入以避免批量更新的WAL开销也是安全的。在这些情况下,数据在T1提交之前到达磁盘是完全可能的,因为T1会在完成批量更新后立即将其同步到磁盘,而不需要任何互锁。然而,所有这些路径都设计成写入在T1提交之后其他事务才能看到的数据。因此,情况与普通的WAL日志更新没有什么不同。

=====================================================================================

Transaction Emulation during Recovery

-------------------------------------

在恢复过程中,我们按照它们发生的顺序replay事务的更改。作为 replay 的一部分,我们模仿一些事务行为,以便只读后端可以获取MVCC快照。我们通过维护属于正在replay 的事务的XID列表来实现这一点,以便记录了用于数据库写入的WAL记录的每个事务都存在于数组中,直到它提交为止。进一步的细节给出在procarray.c的注释中。

许多动作根本不写WAL记录,例如只读事务。这些对MVCC的恢复没有影响,我们可以假装它们从来没有发生过。子事务提交也不写入WAL记录,并且影响很小,因为锁服务员需要等待父事务完成。

并非所有事务行为都是模拟的,例如,我们不将事务条目插入锁表中,也不在内存中维护事务堆栈。阻塞、多动作和提交条目通常是正常的。在恢复期间维护子事务,但是忽略事务树的细节,并且所有子事务直接引用顶级TransactionId。由于commit是原子的,因此它提供了正确的锁等待行为,但极大地简化了子事务的模拟。

猜你喜欢

转载自blog.csdn.net/chuckchen1222/article/details/84861919
WAL