1. XFS 日志设计

1.1. 引言

本文档描述了 XFS 日志子系统所基于的设计和算法,以便读者熟悉 XFS 中事务处理的一般概念。

我们首先概述 XFS 中的事务,接着描述事务预留的结构和核算方式,然后探讨如何为具有有限初始预留范围的长时间运行事务保证前向进展。此时,我们需要解释重新日志记录的工作原理。在涵盖了基本概念之后,本文档将阐述延迟日志记录机制的设计。

1.2. 简介

XFS 使用预写日志(Write Ahead Logging)来确保文件系统元数据的更改是原子性且可恢复的。出于空间和时间效率的考虑,日志记录机制多种多样且复杂,它结合了意图、逻辑和物理日志记录机制,以提供文件系统所需的必要恢复保证。

一些对象,例如 inode 和 dquot,以逻辑格式记录日志,其中记录的详细信息由内存结构的变化而非磁盘结构的变化组成。其他对象——通常是缓冲区——则记录其物理变化。长时间运行的原子修改通过意图(intents)将各个更改链接在一起,确保日志恢复能够重新启动并完成系统停止运行时只部分完成的操作。

这些差异的原因是为了尽可能减少处理被修改对象所需的日志空间和 CPU 时间,从而尽可能降低日志记录开销。有些条目修改非常频繁,对象的一些部分比其他部分修改更频繁,因此将元数据日志记录开销保持在低水平至关重要。

在本文档范围内,用于记录单个条目或将修改链在一起的方法并不是特别重要。只需知道记录特定对象或将修改链在一起的方法是不同的,并且取决于所执行的对象和/或修改。日志记录子系统只关心遵循某些特定规则,以保证前向进展并防止死锁。

1.3. XFS 中的事务

XFS 有两种高级事务类型,它们由所占用的日志空间预留类型定义。这两种类型被称为“一次性”(one shot)事务和“永久性”(permanent)事务。永久性事务预留可以跨越提交边界,而“一次性”事务则用于单个原子修改。

预留的类型和大小必须与正在进行的修改相匹配。这意味着永久性事务可用于一次性修改,但一次性预留不能用于永久性事务。

在代码中,一次性事务模式大致如下所示:

tp = xfs_trans_alloc(<reservation>)
<lock items>
<join item to transaction>
<do modification>
xfs_trans_commit(tp);

当事务中的条目被修改时,这些条目中的脏区域通过事务句柄进行跟踪。一旦事务被提交,所有与之关联的资源以及在事务分配时占用的剩余未使用的预留空间都会被释放。

相比之下,永久性事务由多个链接的独立事务组成,其模式如下所示:

tp = xfs_trans_alloc(<reservation>)
xfs_ilock(ip, XFS_ILOCK_EXCL)

loop {
        xfs_trans_ijoin(tp, 0);
        <do modification>
        xfs_trans_log_inode(tp, ip);
        xfs_trans_roll(&tp);
}

xfs_trans_commit(tp);
xfs_iunlock(ip, XFS_ILOCK_EXCL);

尽管这可能看起来与一次性事务相似,但存在一个重要区别:xfs_trans_roll() 执行一个特定操作,将两个事务链接在一起。

ntp = xfs_trans_dup(tp);
xfs_trans_commit(tp);
xfs_trans_reserve(ntp);

这导致了一系列“滚动事务”,其中 inode 在整个事务链中被锁定。因此,当这一系列滚动事务运行时,其他任何操作都无法读取或写入 inode,这为从外部观察者的角度来看复杂更改呈现原子性提供了一种机制。

重要的是要指出,永久性事务中的一系列滚动事务在日志中并不构成原子性更改。虽然每个单独的修改是原子的,但链不是原子的。如果我们在中途崩溃,那么恢复将只回放循环中已提交到日志的最后一个事务性修改。

这会影响长时间运行的永久性事务,因为无法预测长时间运行操作的实际恢复量,因为无法保证操作有多少已到达陈旧存储。因此,如果一个长时间运行的操作需要多个事务才能完全完成,那么高级操作必须使用意图和延迟操作来保证一旦第一个事务持久化到磁盘日志中,恢复就能完成该操作。

1.4. 事务是异步的

在 XFS 中,所有高级事务默认都是异步的。这意味着 xfs_trans_commit() 返回时并不保证修改已提交到稳定存储。因此,当系统崩溃时,并非所有已完成的事务都会在恢复期间被回放。

然而,日志记录子系统确实提供了全局排序保证,这意味着如果在恢复后看到某个特定更改,那么在该更改之前提交的所有元数据修改也将可见。

对于需要立即到达稳定存储的一次性操作,或者确保长时间运行的永久性事务在完成后完全提交,我们可以显式地将事务标记为同步。这将触发“日志强制刷新”(log force),将未完成的已提交事务刷新到日志中的稳定存储,并等待其完成。

然而,同步事务很少使用,因为它们会将日志记录吞吐量限制在底层存储的 IO 延迟限制之下。相反,我们倾向于仅当用户操作需要同步点(例如 fsync)时才使用日志强制刷新,以确保修改已写入稳定存储。

1.5. 事务预留

现在已经多次提到,日志记录子系统需要提供前向进展保证,以确保任何修改都不会因为日志中空间不足而无法写入日志而停滞。这通过在首次分配事务时进行的事务预留来实现。对于永久性事务,这些预留作为事务滚动机制的一部分进行维护。

事务预留保证在开始修改对象和条目之前,有足够的物理日志空间可用于将修改写入日志。因此,预留需要足够大,以考虑到在最坏情况下更改可能需要记录的元数据量。这意味着如果我们在事务中修改 btree,我们必须预留足够的空间来记录 btree 的完整叶到根分裂。因此,预留相当复杂,因为我们必须考虑到所有可能发生的隐藏更改。

例如,用户数据区段分配涉及从空闲空间分配一个区段,这会修改空闲空间树。这涉及到两个 btree。将区段插入到 inode 的区段映射中可能需要分割区段映射 btree,这又需要另一次分配,而这次分配可能会再次修改空闲空间树。然后我们可能需要更新反向映射,这会修改另一个 btree,可能需要更多空间。诸如此类。因此,“简单”操作可以修改的元数据量可能相当大。

这种“最坏情况”计算为我们提供了在挂载时计算的事务的静态“单位预留”。我们必须保证在允许事务进行之前,日志有这么多可用空间,这样当我们写入脏元数据到日志中时,才不会在写入过程中耗尽日志空间。

对于一次性事务,只需一个单位空间预留即可让事务进行。然而,对于永久性事务,我们还有一个“日志计数”(log count),它会影响要进行的预留大小。

虽然永久性事务可以使用单个单位空间预留来完成,但这样做效率有点低,因为它要求事务滚动机制在每次事务滚动时重新预留空间。我们从永久性事务的实现中了解到,对于需要进行的常见修改,事务滚动可能发生的次数。

例如,一个 inode 分配通常是两个事务——一个用于在磁盘上物理分配一个空闲 inode 块,另一个用于从包含空闲 inode 的 inode 块中分配一个 inode。因此,对于 inode 分配事务,我们可能会将预留日志计数设置为 2,以表示通用/快速路径事务将提交链中的两个链接事务。每次永久性事务滚动时,它都会消耗一个完整的单位预留。

因此,当永久性事务首次分配时,日志空间预留会从单个单位预留增加到多个单位预留。这个倍数由预留日志计数定义,这意味着我们可以在多次滚动事务后才需要重新预留日志空间。这确保了我们进行的常见修改只需要预留一次日志空间。

如果永久性事务的日志计数达到零,则需要重新预留日志中的物理空间。这有点复杂,需要了解日志如何核算已预留的空间。

1.6. 日志空间核算

日志中的位置通常被称为日志序列号(LSN)。日志是循环的,因此日志中的位置由循环号(日志被覆盖的次数)和日志中的偏移量组合定义。一个 LSN 在高 32 位中携带循环,在低 32 位中携带偏移量。偏移量以“基本块”(512 字节)为单位。因此,我们可以进行相对简单的基于 LSN 的数学运算来跟踪日志中的可用空间。

日志空间核算通过一对称为“授权头”(grant heads)的构造完成。授权头的位置是一个绝对值,因此日志中可用空间的数量由授权头位置与当前日志尾部之间的距离定义。也就是说,在授权头完全环绕日志并超过尾部位置之前,可以预留/消耗多少空间。

第一个授权头是“预留”头。它跟踪当前活动事务持有的预留字节数。这纯粹是内存中的空间预留核算,因此实际上跟踪的是日志中的字节偏移量而不是基本块。因此,它技术上没有使用 LSN 来表示日志位置,但为了跟踪预留空间的目的,它仍然被视为一个分裂的 {cycle,offset} 元组。

预留授权头用于精确核算精确的事务预留量以及修改实际产生并需要写入日志的精确字节数。当预留头到达当前尾部时,预留头用于阻止新事务进行新的预留。它将在 FIFO 队列中阻塞新的预留,并且随着日志尾部向前移动,一旦有足够的空间可用,它将按顺序唤醒这些预留。这种 FIFO 机制确保在日志空间短缺时,没有事务会因资源匮乏而停滞。

另一个授权头是“写入”头。与预留头不同,这个授权头包含一个 LSN,它跟踪日志中的物理空间使用情况。虽然这听起来好像它与预留授权头核算相同的状态——而且它在大多数情况下确实跟踪与预留授权头完全相同的位置——但它们之间存在关键的行为差异,这些差异提供了滚动永久性事务所需的前向进展保证。

当永久性事务滚动且内部“日志计数”达到零并且初始单位预留已用尽时,就会出现这些差异。此时,我们仍然需要日志空间预留来继续序列中的下一个事务,但我们已经没有剩余的预留。我们不能在事务提交过程中休眠等待新的日志空间可用,因为我们可能会最终处于 FIFO 队列的末尾,并且我们在休眠期间锁定的条目可能会在日志中有足够的可用空间来满足所有待处理的预留并唤醒正在进行的事务提交之前,最终固定日志的尾部。

要在不休眠的情况下进行新的预留,我们需要能够在当前没有预留空间可用时也能够进行预留。也就是说,我们需要能够“超额提交”日志预留空间。正如已经详细说明的,我们不能超额提交物理日志空间。然而,预留授权头不跟踪物理空间——它只核算我们当前未完成的预留量。因此,如果预留头超过日志尾部,这意味着新的预留将立即受到限制,并一直受到限制,直到日志尾部向前移动足够远,以消除超额提交并开始接受新的预留。换句话说,我们可以在不违反物理日志头尾规则的情况下超额提交预留头。

因此,永久性事务仅在 xfs_trans_commit() 调用期间“重新授予”预留空间,而物理日志空间预留(由写入头跟踪)则在提交完成后通过调用 xfs_log_reserve() 单独预留。一旦提交完成,我们可以休眠等待从写入授权头预留物理日志空间,但前提是必须遵守一个关键规则:

Code using permanent reservations must always log the items they hold
locked across each transaction they roll in the chain.

在每次事务滚动时“重新记录”锁定的条目,确保连接到正在滚动的事务链的条目始终重新定位到日志的物理头部,从而不会固定日志的尾部。如果我们休眠等待写入预留时,一个锁定的条目固定了日志的尾部,那么我们将死锁日志,因为我们无法获取将该条目写回并向前移动日志尾部以释放写入授权空间所需的锁。重新记录锁定的条目可避免这种死锁,并保证我们正在进行的日志预留不会发生自死锁。

如果所有滚动事务都遵守此规则,那么它们都可以独立地向前推进,因为没有任何东西会阻碍日志尾部向前移动,从而确保无论永久性事务滚动多少次,写入授权空间始终(最终)对它们可用。

1.7. 重新日志记录的解释

XFS 允许在任何给定时间将对单个对象的多个独立修改保存在日志中。这使得日志无需在记录对对象的新更改之前将每个更改刷新到磁盘。XFS 通过一种称为“重新日志记录”(re-logging)的方法来实现这一点。从概念上讲,这相当简单——它只要求对对象的任何新更改都与写入日志的新事务中所有现有更改的新副本一起记录。

也就是说,如果我们有一个从 A 到 F 的更改序列,并且在更改 D 之后对象被写入磁盘,那么我们将在日志中看到以下一系列事务、它们的内容以及事务的日志序列号(LSN):

Transaction             Contents        LSN
   A                       A               X
   B                      A+B             X+n
   C                     A+B+C           X+n+m
   D                    A+B+C+D         X+n+m+o
    <object written to disk>
   E                       E               Y (> X+n+m+o)
   F                      E+F             Y+p

换句话说,每次对象被重新日志记录时,新事务都会包含当前仅保存在日志中的所有先前更改的聚合。

这种重新日志记录技术允许对象在日志中向前移动,从而使正在重新日志记录的对象不会阻止日志尾部向前移动。这可以从上表中每个后续事务不断变化(增加)的 LSN 中看出,正是这种技术使我们能够实现长时间运行、多次提交的永久性事务。

滚动事务的一个典型例子是从 inode 中删除区段,由于预留大小限制,每次事务只能删除两个区段。因此,滚动区段删除事务会在每次删除操作中修改 inode 和 btree 缓冲区时对其进行重新日志记录。这使得它们随着操作的进行在日志中向前移动,确保如果日志循环,当前操作永远不会被自身阻塞。

因此可以看出,重新日志记录操作对于 XFS 日志子系统的正常工作至关重要。从上面的描述中,大多数人应该能够明白为什么 XFS 元数据操作会写入如此多的日志——对相同对象的重复操作会一次又一次地将相同的更改写入日志。更糟糕的是,对象在重新日志记录时会变得更“脏”,因此每个后续事务都会向日志写入更多的元数据。

现在也应该很明显,重新日志记录和异步事务是相辅相成的。也就是说,事务不会被写入物理日志,除非日志缓冲区被填满(一个日志缓冲区可以容纳多个事务),或者同步操作强制将持有事务的日志缓冲区写入磁盘。这意味着 XFS 正在内存中进行事务聚合——如果你愿意,可以称之为批处理——以最大程度地减少日志 IO 对事务吞吐量的影响。

异步事务吞吐量的限制在于日志管理器提供的日志缓冲区的数量和大小。默认情况下,有 8 个日志缓冲区可用,每个缓冲区的大小为 32kB——通过使用挂载选项,大小可以增加到 256kB。

实际上,这为我们提供了在任何给定时间对文件系统进行未完成元数据更改的最大界限——如果所有日志缓冲区都已满并且正在进行 IO,那么在当前批处理完成之前,无法提交更多事务。现在,单个当前 CPU 核心通常能够发出足够的事务,以使日志缓冲区永久保持满载并处于 IO 状态。因此,XFS 日志子系统可以被认为是 IO 瓶颈型的。

1.8. 延迟日志记录:概念

关于 XFS 使用的异步日志记录结合重新日志记录技术的关键点是,我们可以在更改的对象被提交到日志缓冲区磁盘之前对其进行多次重新日志记录。如果我们回到之前的重新日志记录示例,事务 A 到 D 完全有可能在同一个日志缓冲区中被提交到磁盘。

也就是说,单个日志缓冲区可能包含同一对象的多个副本,但其中只有一个副本是必需的——即最后一个“D”,因为它包含所有先前更改的全部内容。换句话说,日志缓冲区中有一个必需的副本,以及三个只是浪费空间的陈旧副本。当我们对同一组对象进行重复操作时,这些“陈旧对象”可以占用日志缓冲区中超过 90% 的空间。显然,减少写入日志的陈旧对象数量将大大减少我们写入日志的元数据量,这也是延迟日志记录的根本目标。

从概念上讲,XFS 已经在内存中进行重新日志记录(其中内存 == 日志缓冲区),只是效率极低。它使用逻辑到物理的格式化进行重新日志记录,因为在将事务中的更改物理格式化到日志缓冲区之前,没有基础设施来跟踪内存中的逻辑更改。因此,我们无法避免日志缓冲区中堆积陈旧对象。

延迟日志记录是我们对在日志缓冲区基础设施之外,在内存中保存和跟踪对象事务性更改所取的名称。由于重新日志记录概念是 XFS 日志子系统的基础,这实际上相对容易实现——所有对已记录条目的更改都已在当前基础设施中跟踪。主要问题是如何以一致、可恢复的方式累积它们并将其写入日志。本文档的重点是描述这些问题以及它们是如何解决的。

延迟日志记录对日志子系统操作进行的关键改变之一是,它将未完成的元数据更改量与可用日志缓冲区的大小和数量分离。换句话说,在任何给定时间,内存中累积的事务更改量可能远大于未写入日志的最大 2MB 事务更改。因此,崩溃时元数据丢失的可能性比现有日志记录机制大得多。

需要指出的是,这并没有改变日志恢复将导致文件系统一致的保证。它确实意味着,就已恢复的文件系统而言,可能存在数千个因崩溃而根本未发生的事务。这使得关心其数据的应用程序在需要确保应用程序级数据完整性时使用 fsync() 变得更加重要。

需要指出的是,延迟日志记录并非一项需要严格证明其正确性的创新概念。在将更改写入日志之前,在内存中累积更改一段时间的方法已在包括 ext3 和 ext4 在内的许多文件系统中有效使用。因此,本文档不花时间试图说服读者该概念是可靠的。相反,它被简单地认为是一个“已解决的问题”,因此在 XFS 中实现它纯粹是软件工程方面的一项实践。

XFS 中延迟日志记录的基本要求很简单:

  1. 将写入日志的元数据量至少减少一个数量级。

  2. 提供足够的统计数据来验证要求 #1。

  3. 提供足够的新追踪基础设施,以便能够调试新代码中的问题。

  4. 无磁盘格式更改(元数据或日志格式)。

  5. 通过挂载选项启用和禁用。

  6. 同步事务工作负载无性能退化。

1.9. 延迟日志记录:设计

1.9.1. 存储更改

在逻辑层面累积更改(即仅使用现有日志项脏区域跟踪)的问题是,当将更改写入日志缓冲区时,我们需要确保我们正在格式化的对象在此过程中不会发生更改。这需要锁定对象以防止并发修改。因此,将逻辑更改刷新到日志将需要我们锁定每个对象,对其进行格式化,然后再解锁。

这为与已运行事务的死锁引入了很大的可能性。例如,一个事务锁定了对象 A 并对其进行了修改,但需要延迟日志记录跟踪锁来提交事务。然而,刷新线程已经持有了延迟日志记录跟踪锁,并试图获取对象 A 上的锁以将其刷新到日志缓冲区。这似乎是一个无法解决的死锁条件,而解决这个问题是长期以来阻碍实现延迟日志记录的障碍。

解决方案相对简单——只是花了很长时间才认识到。简单来说,当前的日志记录代码将每个条目的更改格式化为一个指向条目中已更改区域的向量数组。日志写入代码在事务提交期间,当条目在事务中被锁定时,简单地将这些向量指向的内存复制到日志缓冲区中。我们可以不使用日志缓冲区作为格式化代码的目标,而是使用一个足够大以容纳格式化向量的已分配内存缓冲区。

如果我们随后将向量复制到内存缓冲区中,并将向量重写为指向内存缓冲区而不是对象本身,那么我们现在就有了一份以与日志缓冲区写入代码兼容的格式存储的更改副本,并且不需要我们锁定条目即可访问。这种格式化和重写都可以在事务提交期间对象被锁定时完成,从而生成一个事务一致的向量,并且无需锁定所有者条目即可访问。

因此,当我们需要将未完成的异步事务刷新到日志时,我们避免了锁定条目的需要。现有格式化方法与延迟日志记录格式化之间的差异可见下图。

当前格式的日志向量

Object    +---------------------------------------------+
Vector 1      +----+
Vector 2                    +----+
Vector 3                                   +----------+

格式化后

Log Buffer    +-V1-+-V2-+----V3----+

延迟日志记录向量

Object    +---------------------------------------------+
Vector 1      +----+
Vector 2                    +----+
Vector 3                                   +----------+

格式化后

Memory Buffer +-V1-+-V2-+----V3----+
Vector 1      +----+
Vector 2           +----+
Vector 3                +----------+

内存缓冲区和相关向量需要作为一个单一对象传递,但仍然需要与父对象关联,这样如果对象被重新日志记录,我们可以用包含最新更改的新内存缓冲区替换当前内存缓冲区。

在我们格式化内存缓冲区之后保留向量的原因是为了正确支持跨日志缓冲区边界的向量分割。如果我们不保留向量,我们就不知道条目中的区域边界在哪里,因此在日志缓冲区写入时,我们需要一种新的区域封装方法(即双重封装)。这将是磁盘格式的更改,因此是不希望看到的。这也意味着我们必须在格式化阶段写入日志区域头,这很麻烦,因为在日志写入期间需要将每个区域的状态放入头中。

因此,我们需要保留向量,但通过将内存缓冲区附加到它并重写向量地址以指向内存缓冲区,我们最终得到一个自描述对象,该对象可以传递给日志缓冲区写入代码,以与现有日志向量完全相同的方式进行处理。因此,我们避免了需要新的磁盘格式来处理内存中已重新日志记录的条目。

1.9.2. 跟踪更改

既然我们可以在内存中以不受限制的形式记录事务性更改,我们就需要能够跟踪和累积它们,以便稍后将它们写入日志。日志项是存储此向量和缓冲区的自然位置,并且作为跟踪已提交对象的对象也很有意义,因为它一旦包含在事务中就会一直存在。

日志项已经用于跟踪已写入日志但尚未写入磁盘的日志项。此类日志项被视为“活动”项,因此存储在活动项列表(AIL)中,这是一个按 LSN 排序的双向链表。条目在日志缓冲区 IO 完成期间插入此列表,之后它们被解除固定并可以写入磁盘。AIL 中的对象可以重新日志记录,这会导致对象再次被固定,然后在该事务的日志缓冲区 IO 完成时在 AIL 中向前移动。

本质上,这表明 AIL 中的条目仍然可以被修改和重新日志记录,因此任何跟踪都必须独立于 AIL 基础设施。因此,我们不能重用 AIL 列表指针来跟踪已提交的条目,也不能在任何受 AIL 锁保护的字段中存储状态。因此,已提交条目跟踪需要在日志项中拥有自己的锁、列表和状态字段。

与 AIL 类似,已提交条目的跟踪通过一个名为已提交项列表(CIL)的新列表进行。该列表跟踪已提交并附加有格式化内存缓冲区的日志项。它按照事务提交顺序跟踪对象,因此当一个对象被重新日志记录时,它会从列表中的原位置移除并重新插入到尾部。这完全是任意的,目的是为了方便调试——列表中最后的条目是最近修改的。CIL 的排序对于事务完整性不是必需的(如下一节所讨论),因此排序是为了开发人员的方便/健全性。

1.9.3. 延迟日志记录:检查点

当我们发生日志同步事件(通常称为“日志强制刷新”)时,CIL 中的所有条目必须通过日志缓冲区写入日志。我们需要按照它们在 CIL 中存在的顺序写入这些条目,并且它们需要作为原子事务写入。所有对象都必须作为原子事务写入的需求源于重新日志记录和日志重放的要求——给定事务中所有对象的所有更改必须要么在日志恢复期间完全重放,要么根本不重放。如果一个事务因为在日志中不完整而没有被重放,那么后续的事务也不应该被重放。

为了满足此要求,我们需要将整个 CIL 写入单个日志事务。幸运的是,XFS 日志代码对事务大小没有固定限制,日志回放代码也没有。唯一的根本限制是事务不能大于日志大小的一半。这个限制的原因是,为了找到日志的头部和尾部,在任何给定时间日志中必须至少有一个完整的事务。如果一个事务大于日志的一半,那么在写入此类事务期间发生崩溃可能会部分覆盖日志中唯一完整的先前事务。这将导致恢复失败和文件系统不一致,因此我们必须强制检查点的最大大小略小于日志的一半。

除了这个大小要求,检查点事务看起来与任何其他事务没有什么不同——它包含一个事务头、一系列格式化的日志项和一个位于尾部的提交记录。从恢复的角度来看,检查点事务也没有什么不同——只是更大,包含更多的条目。最坏情况的影响是,我们可能需要调整恢复事务对象哈希的大小。

因为检查点只是另一个事务,并且所有对日志项的更改都存储为日志向量,所以我们可以使用现有的日志缓冲区写入代码将更改写入日志。为了高效地做到这一点,我们需要最小化在写入检查点事务时锁定 CIL 的时间。当前的日志写入代码通过将事务内容(日志向量)的写入与事务提交记录分开,使我们能够轻松地做到这一点,但跟踪这需要我们有一个按检查点划分的上下文,该上下文贯穿日志写入过程直至检查点完成。

因此,检查点有一个上下文,用于跟踪当前检查点从启动到完成的状态。在启动检查点事务的同时,会初始化一个新的上下文。也就是说,当我们在检查点操作期间从 CIL 中移除所有当前项时,我们会将所有这些更改移动到当前检查点上下文。然后我们初始化一个新的上下文并将其附加到 CIL,以便聚合新的事务。

这使我们能够在传输所有已提交项后立即解锁 CIL,并有效允许在新事务进行格式化检查点到日志时发出新事务。它还允许在日志强制刷新(log force)密集型工作负载情况下,并发检查点写入日志缓冲区,就像现有事务提交代码所做的那样。然而,这要求我们严格排序日志中的提交记录,以便在日志回放期间保持检查点序列顺序。

为确保我们能够在写入检查点事务中的一个条目的同时,另一个事务修改该条目并将其插入新的 CIL,那么检查点事务提交代码不能使用日志项来存储需要写入事务的日志向量列表。因此,日志向量需要能够链接在一起,以便它们可以从日志项中分离出来。也就是说,当 CIL 被刷新时,附加到每个日志项的内存缓冲区和日志向量需要附加到检查点上下文,以便可以释放日志项。以图表形式表示,CIL 在刷新前将是这样的:

CIL Head
   |
   V
Log Item <-> log vector 1       -> memory buffer
   |                            -> vector array
   V
Log Item <-> log vector 2       -> memory buffer
   |                            -> vector array
   V
......
   |
   V
Log Item <-> log vector N-1     -> memory buffer
   |                            -> vector array
   V
Log Item <-> log vector N       -> memory buffer
                                -> vector array

刷新后,CIL 头部为空,检查点上下文日志向量列表将如下所示:

Checkpoint Context
   |
   V
log vector 1    -> memory buffer
   |            -> vector array
   |            -> Log Item
   V
log vector 2    -> memory buffer
   |            -> vector array
   |            -> Log Item
   V
......
   |
   V
log vector N-1  -> memory buffer
   |            -> vector array
   |            -> Log Item
   V
log vector N    -> memory buffer
                -> vector array
                -> Log Item

一旦完成此传输,CIL 即可解锁,新的事务可以开始,同时检查点刷新代码会处理日志向量链以提交检查点。

一旦检查点写入日志缓冲区,检查点上下文将连同完成回调一起附加到写入提交记录的日志缓冲区。日志 IO 完成将调用该回调,然后该回调可以对日志向量链中的日志项运行事务提交处理(即插入 AIL 并解除固定),然后释放日志向量链和检查点上下文。

讨论点:我不确定日志项是否是跟踪向量最有效的方式,尽管它看起来是自然而然的方式。我们遍历日志项(在 CIL 中)只是为了链接日志向量并断开日志项与日志向量之间的链接,这意味着我们会因为日志项列表修改而发生一次缓存行命中,然后又因为日志向量链接而发生另一次缓存行命中。如果我们通过日志向量进行跟踪,那么我们只需要断开日志项与日志向量之间的链接,这意味着我们只应该使日志项缓存行脏化。通常我不会担心一个还是两个脏缓存行的问题,但我在一个检查点事务中看到过超过 80,000 个日志向量。我猜测这是一种“测量和比较”的情况,可以在开发树中实现并审查工作版本后进行。

1.9.4. 延迟日志记录:检查点排序

XFS 事务子系统的一个关键方面是它使用事务提交的日志序列号标记已提交的事务。这允许事务异步发出,即使将来可能存在在该事务完全提交到日志之前无法完成的操作。在极少数情况下,如果发生依赖操作(例如,为数据区段重用已释放的元数据区段),可以发出特殊的优化日志强制刷新,以立即将依赖事务强制写入磁盘。

为此,事务需要记录事务提交记录的 LSN。此 LSN 直接来自事务写入的日志缓冲区。虽然这对于现有事务机制工作良好,但它不适用于延迟日志记录,因为事务不直接写入日志缓冲区。因此,需要其他方法来对事务进行排序。

正如检查点部分所讨论的,延迟日志记录使用每个检查点的上下文,因此为每个检查点分配一个序列号很简单。由于检查点上下文的切换必须原子地完成,因此可以很容易地确保每个新上下文都分配有一个单调递增的序列号,而无需外部原子计数器——我们只需取当前上下文序列号并为其新上下文加一。

然后,在提交期间,我们可以分配当前检查点序列,而不是将日志缓冲区 LSN 分配给事务提交 LSN。这使得跟踪尚未完成事务的操作能够知道需要提交哪个检查点序列才能继续。因此,现在强制日志到特定 LSN 的代码需要确保日志强制到特定检查点。

为了确保我们能做到这一点,我们需要跟踪当前所有正在向日志提交的检查点上下文。当我们刷新一个检查点时,该上下文会被添加到可供搜索的“正在提交”列表中。当一个检查点提交完成时,它会从“正在提交”列表中移除。因为检查点上下文记录了检查点提交记录的 LSN,我们也可以在包含提交记录的日志缓冲区上等待,从而使用现有的日志强制刷新机制来执行同步强制刷新。

需要指出的是,同步强制刷新可能需要用与当前日志缓冲区代码类似的缓解算法进行扩展,以便在已有同步事务正在刷新时,允许聚合多个同步事务。在做出任何决定之前,需要对当前设计的性能进行调查。

日志强制刷新的主要关注点是,确保在我们需要等待的检查点之前,所有先前的检查点也都已提交到磁盘。因此,在等待我们需要完成的检查点之前,我们需要检查“正在提交”列表中所有先前的上下文是否也已完成。我们在日志强制刷新代码中执行此同步,这样就不需要在其他任何地方等待此类序列化——它只在执行日志强制刷新时才重要。

唯一剩下的复杂性是,日志强制刷新现在还必须处理强制序列号与当前上下文相同的情况。也就是说,我们需要刷新 CIL 并可能等待它完成。这只是对现有日志强制刷新代码的一个简单添加,用于检查序列号并在需要时进行推送。事实上,将当前序列检查点刷新放置在日志强制刷新代码中,使得当前发出同步事务的机制保持不变(即,提交一个异步事务,然后在该事务的 LSN 处强制刷新日志),因此无论是否使用延迟日志记录,更高级别的代码行为都相同。

1.9.5. 延迟日志记录:检查点日志空间核算

检查点事务的最大问题是该事务的日志空间预留。我们事先不知道检查点事务会有多大,也不知道写入它需要多少日志缓冲区,更不知道会使用多少个拆分的日志向量区域。我们可以在向提交项列表添加项时跟踪所需的日志空间量,但我们仍然需要在日志中为检查点预留空间。

典型的事务会在日志中预留足够的空间,以应对事务最坏情况下的空间使用。预留考虑了日志记录头、事务和区域头、分割区域头、缓冲区尾部填充等,以及事务中所有已更改元数据的实际空间。虽然其中一部分是固定开销,但大部分取决于事务的大小以及正在记录的区域数量(事务中的日志向量数量)。

差异的一个例子是记录目录更改与记录 inode 更改。如果你修改了大量的 inode 核心(例如 chmod -R g+w *),那么会有很多事务只包含一个 inode 核心和一个 inode 日志格式结构。也就是说,两个向量总共大约 150 字节。如果我们修改 10,000 个 inode,我们将有大约 1.5MB 的元数据需要写入 20,000 个向量。每个向量 12 字节,因此要记录的总量约为 1.75MB。相比之下,如果我们要记录完整的目录缓冲区,它们通常每个 4KB,因此在 1.5MB 的目录缓冲区中,我们将有大约 400 个缓冲区,并且每个缓冲区都有一个缓冲区格式结构——大约 800 个向量或总共 1.51MB 的空间。由此可见,静态日志空间预留并非特别灵活,并且难以针对所有工作负载选择“最佳值”。

此外,如果我们要使用静态预留,那么它覆盖了整个预留的哪一部分?我们通过跟踪 CIL 中对象当前使用的空间,然后计算对象重新日志记录时空间使用的增加或减少来核算事务预留所使用的空间。这使得检查点预留只需核算日志缓冲区元数据(例如日志头记录)所使用的空间。

然而,即使仅为日志元数据使用静态预留也存在问题。通常,日志记录头每消耗 1MB 日志空间至少使用 16KB 日志空间(每 32KB 消耗 512 字节),并且预留需要足够大以处理任意大小的检查点事务。此预留需要在检查点启动之前进行,并且我们需要能够在不休眠的情况下预留空间。对于一个 8MB 的检查点,我们需要大约 150KB 的预留空间,这不是一个微不足道的量。

静态预留需要操作日志授权计数器——我们可以对空间进行永久预留,但我们仍然需要确保在每次检查点事务完成后刷新写入预留(事务可用的实际空间)。不幸的是,如果所需空间不可用,那么重新授权代码将休眠等待。

这样做的问题在于可能导致死锁,因为我们可能需要提交检查点才能释放日志空间(有关示例,请参阅滚动事务的描述)。因此,如果我们要使用静态预留,我们必须始终在日志中保留可用空间,而这非常困难和复杂。这是可以做到的,但有更简单的方法。

更简单的方法是跟踪 CIL 中各项所使用的整个日志空间,并使用此信息动态计算日志元数据所需的日志空间量。如果日志元数据空间因事务提交将新的内存缓冲区插入 CIL 而发生变化,则所需的空间差异将从导致更改的事务中移除。此级别的事务在其预留中始终有足够的空间来满足此要求,因为它们已经预留了所需的日志元数据空间的最大量,并且这种增量预留将始终小于或等于预留中的最大量。

因此,我们可以随着项被添加到 CIL 中,动态地增长检查点事务预留,并避免了预先预留和重新授予日志空间的需要。这避免了死锁,并消除了检查点刷新代码中的阻塞点。

如前所述,事务不能增长到超过日志大小的一半。因此,作为预留增长的一部分,我们还需要检查预留大小是否超过最大允许事务大小。如果达到最大阈值,我们需要将 CIL 推送到日志。这实际上是一种“后台刷新”,并按需执行。这与日志强制刷新触发的 CIL 推送相同,只是无需等待检查点提交完成。此后台推送由事务提交代码检查并执行。

如果事务子系统在 CIL 中仍有项目时进入空闲状态,它们将被 xfssyncd 发出的周期性日志强制刷新。这个日志强制刷新会将 CIL 推送到磁盘,如果事务子系统保持空闲,则允许空闲日志被覆盖(有效地标记为干净),这与现有日志记录方法的操作方式完全相同。一个讨论点是,这个日志强制刷新是否需要比当前每 30 秒一次的频率更频繁地执行。

1.9.6. 延迟日志记录:日志项固定

目前,日志项在事务提交期间,当它们仍然被锁定时,会被固定。这发生在条目格式化之后,尽管可以在条目解锁之前的任何时间完成。这种机制的结果是,每个提交到日志缓冲区的事务都会导致日志项被固定一次。因此,在日志缓冲区中重新日志记录的项,对于它们所涉及的每个未完成事务,都会有一个固定计数。当这些事务中的每一个完成时,它们将解除该项的固定一次。因此,只有当所有事务完成且没有待处理事务时,该项才会被解除固定。因此,日志项的固定和解除固定是对称的,因为事务提交和日志项完成之间存在 1:1 的关系。

然而,对于延迟日志记录,我们拥有不对称的事务提交到完成关系。每次在 CIL 中重新日志记录一个对象时,它都会经历提交过程而没有相应的完成记录。也就是说,我们现在在事务提交和日志项完成之间存在多对一的关系。其结果是,如果我们保留“事务提交时固定,事务完成时解除固定”的模型,日志项的固定和解除固定将变得不平衡。

为了保持固定/解除固定的对称性,算法需要更改为“插入 CIL 时固定,检查点完成时解除固定”。换句话说,固定和解除固定在检查点上下文周围变得对称。我们必须在对象首次插入 CIL 时固定它——如果在事务提交期间它已经存在于 CIL 中,则我们不再固定它。因为可能存在多个未完成的检查点上下文,我们仍然会看到升高的固定计数,但随着每个检查点完成,固定计数将根据其上下文保留正确的值。

为了使事情稍微复杂化,这个检查点级别的固定计数上下文意味着项的固定必须在 CIL 提交/刷新锁下进行。如果我们在该锁之外固定对象,我们无法保证固定计数与哪个上下文相关联。这是因为固定项取决于该项是否存在于当前 CIL 中。如果我们不在检查和固定对象之前先固定 CIL,那么在检查和固定之间(或者不固定,视情况而定),我们会遇到 CIL 被刷新的竞争条件。因此,我们必须持有 CIL 刷新/提交锁,以确保我们正确固定项目。

1.9.7. 延迟日志记录:并发可扩展性

CIL 的一个基本要求是,通过事务提交的访问必须能够扩展到许多并发提交。当前的事务提交代码即使在同时有来自 2048 个处理器的事务时也不会崩溃。当前的事务代码不会比只有一个 CPU 使用它时更快,但也不会变慢。

因此,延迟日志记录事务提交代码需要从头开始设计以支持并发。显然,设计中存在序列化点——三个重要的序列化点是:

  1. 在刷新 CIL 时锁定新的事务提交

  2. 向 CIL 添加项并更新项空间核算

  3. 检查点提交排序

从事务提交和 CIL 刷新交互来看,很明显这里存在多对一的交互。也就是说,对同时尝试提交的并发事务数量的唯一限制是日志中可用于其预留的空间量。实际的限制是对于一个 128MB 的日志,并发事务数量约为数百个,这意味着通常一台机器中每个 CPU 一个。

事务提交需要延迟刷新的时间相对较长——日志项的固定需要在我们延迟 CIL 刷新时完成,因此目前这意味着它在对象格式化为内存缓冲区期间(即在 memcpy() 进行期间)被持有。最终,可以使用一种两阶段算法,其中格式化与对象固定分开进行,以减少事务提交侧的持有时间。

由于潜在的事务提交侧持有者数量众多,该锁确实需要是一个休眠锁——如果 CIL 刷新获取了该锁,我们不希望机器中的其他所有 CPU 都不断尝试获取 CIL 锁。考虑到刷新 CIL 可能涉及遍历数万个日志项的列表,它将被持有很长时间,因此自旋争用是一个重大问题。防止大量 CPU 空转是选择休眠锁的主要原因,尽管事务提交或 CIL 刷新侧都没有在持有锁的情况下休眠。

还需要指出的是,与异步事务工作负载的事务提交相比,CIL 刷新也是一个相对不频繁的操作——只有时间才能证明,使用读写信号量进行互斥是否会因锁在读取侧的缓存行跳动而限制事务提交并发性。

第二个序列化点在事务提交侧,即条目被插入 CIL 的地方。由于事务可以并发进入此代码,因此 CIL 需要独立于上述提交/刷新互斥进行保护。它也需要是一个独占锁,但它只被持有很短的时间,因此自旋锁在这里是合适的。这个锁有可能成为一个争用点,但考虑到每个事务只持有很短的时间,我认为争用不太可能发生。

最终的序列化点是检查点提交记录排序代码,它是检查点提交和日志强制刷新排序的一部分。触发 CIL 刷新的代码路径(即触发日志强制刷新的任何操作)在将所有日志向量写入日志缓冲区之后、但在写入提交记录之前,将进入一个排序循环。此循环遍历正在提交的检查点列表,需要阻塞等待检查点完成其提交记录写入。因此,它需要一个锁和一个等待变量。日志强制刷新排序也需要相同的锁、列表遍历和阻塞机制来确保检查点的完成。

尽管这两种排序操作等待的事件不同,但它们可以使用相同的机制。检查点提交记录排序需要等待检查点上下文包含一个提交 LSN(通过完成提交记录写入获得),而日志强制刷新排序需要等待先前的检查点上下文从提交列表中移除(即它们已完成)。一个简单的等待变量和广播唤醒(雷鸣般的唤醒群)已被用于实现这两个序列化队列。它们也使用与 CIL 相同的锁。如果 CIL 锁上出现过多争用,或由于广播唤醒导致过多的上下文切换,这些操作可以放在一个新的自旋锁下,并给予单独的等待列表,以减少锁争用和因错误事件唤醒的进程数量。

1.9.8. 生命周期变化

现有日志项的生命周期如下:

1. Transaction allocate
2. Transaction reserve
3. Lock item
4. Join item to transaction
        If not already attached,
                Allocate log item
                Attach log item to owner item
        Attach log item to transaction
5. Modify item
        Record modifications in log item
6. Transaction commit
        Pin item in memory
        Format item into log buffer
        Write commit LSN into transaction
        Unlock item
        Attach transaction to log buffer

<log buffer IO dispatched>
<log buffer IO completes>

7. Transaction completion
        Mark log item committed
        Insert log item into AIL
                Write commit LSN into log item
        Unpin log item
8. AIL traversal
        Lock item
        Mark log item clean
        Flush item to disk

<item IO completion>

9. Log item removed from AIL
        Moves log tail
        Item unlocked

本质上,步骤 1-6 独立于步骤 7 运行,而步骤 7 也独立于步骤 8-9。在步骤 7 发生的同时,一个项可以在步骤 1-6 或步骤 8-9 中被锁定,但只能同时发生步骤 1-6 或 8-9。如果日志项在 AIL 中或在步骤 6 和 7 之间,并且步骤 1-6 再次进入,则该项将被重新日志记录。只有当步骤 8-9 进入并完成时,该对象才被认为是干净的。

使用延迟日志记录,生命周期中插入了新的步骤:

1. Transaction allocate
2. Transaction reserve
3. Lock item
4. Join item to transaction
        If not already attached,
                Allocate log item
                Attach log item to owner item
        Attach log item to transaction
5. Modify item
        Record modifications in log item
6. Transaction commit
        Pin item in memory if not pinned in CIL
        Format item into log vector + buffer
        Attach log vector and buffer to log item
        Insert log item into CIL
        Write CIL context sequence into transaction
        Unlock item

<next log force>

7. CIL push
        lock CIL flush
        Chain log vectors and buffers together
        Remove items from CIL
        unlock CIL flush
        write log vectors into log
        sequence commit records
        attach checkpoint context to log buffer

<log buffer IO dispatched>
<log buffer IO completes>

8. Checkpoint completion
        Mark log item committed
        Insert item into AIL
                Write commit LSN into log item
        Unpin log item
9. AIL traversal
        Lock item
        Mark log item clean
        Flush item to disk
<item IO completion>
10. Log item removed from AIL
        Moves log tail
        Item unlocked

由此可见,两种日志记录方法之间唯一的生命周期差异在于生命周期的中间部分——它们仍然具有相同的开始、结束和执行约束。唯一的区别在于日志项提交到日志本身以及完成处理。因此,延迟日志记录不应引入任何目前不存在的对日志项行为、分配或释放的约束。

由于这种对延迟日志记录基础设施的零影响“插入”以及内部结构设计避免了磁盘格式更改,我们基本上可以通过挂载选项在延迟日志记录和现有机制之间进行切换。从根本上讲,日志管理器没有理由不能根据负载特性自动透明地切换方法,但如果延迟日志记录按设计工作,这应该不是必需的。