1. XFS 日志设计¶
1.1. 前言¶
本文档描述了 XFS 日志子系统所基于的设计和算法。本文档描述了 XFS 日志子系统所基于的设计和算法,以便读者可以熟悉 XFS 中事务处理工作方式的总体概念。
我们首先概述 XFS 中的事务,然后描述事务保留是如何结构化和记账的,然后深入探讨如何使用有限的初始保留边界来保证长时间运行的事务的前向进度。此时,我们需要解释重日志的工作原理。在涵盖基本概念之后,将记录延迟日志机制的设计。
1.2. 简介¶
XFS 使用预写日志来确保对文件系统元数据的更改是原子且可恢复的。出于空间和时间效率的考虑,日志机制是多样且复杂的,它结合了意图、逻辑和物理日志机制,以提供文件系统所需的必要恢复保证。
一些对象,例如 inode 和 dquot,以逻辑格式记录,其中记录的详细信息由对内核结构而不是磁盘结构的更改组成。其他对象(通常是缓冲区)的物理更改会被记录。长时间运行的原子修改通过意图链接在一起的各个更改,确保在系统停止运行时,日志恢复可以重新启动并完成仅部分完成的操作。
这些差异的原因是为了尽可能减少处理修改的对象所需的日志空间和 CPU 时间,从而尽可能降低日志开销。某些项目经常被修改,并且对象的某些部分比其他部分更频繁地被修改,因此保持元数据日志开销较低至关重要。
在此文档的范围内,用于记录项目或将修改链接在一起的方法并不是特别重要。知道用于记录特定对象或将修改链接在一起的方法是不同的,并且取决于正在执行的对象和/或修改就足够了。日志子系统只关心是否遵循某些特定规则以保证前向进度并防止死锁。
1.3. XFS 中的事务¶
XFS 有两种高级事务类型,由它们采用的日志空间保留类型定义。这些被称为“一次性”和“永久”事务。永久事务保留可以跨越提交边界进行保留,而“一次性”事务用于单个原子修改。
保留的类型和大小必须与正在进行的修改相匹配。这意味着永久事务可以用于一次性修改,但一次性保留不能用于永久事务。
在代码中,一次性事务模式看起来有点像这样
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() 不能保证修改在返回时已提交到稳定存储。因此,当系统崩溃时,并非所有完成的事务都会在恢复期间被重放。
但是,日志子系统确实提供了全局排序保证,这样,如果在恢复后看到特定的更改,那么也会看到在该更改之前提交的所有元数据修改。
对于需要立即到达稳定存储的单次操作,或者确保长时间运行的永久事务在完成后完全提交,我们可以将事务显式标记为同步。这将触发“日志强制”将未完成的提交事务刷新到日志中的稳定存储并等待其完成。
但是,很少使用同步事务,因为它们将日志吞吐量限制为基础存储的 IO 延迟限制。相反,我们倾向于仅当用户操作需要同步点发生时才使用日志强制来确保修改位于稳定存储上(例如,fsync)。
1.5. 事务保留¶
现在已经多次提到,日志子系统需要提供前向进度保证,以便任何修改都不会因为日志中缺少空间而无法写入日志而停滞。这是通过在首次分配事务时进行的事务保留来实现的。对于永久事务,这些保留作为事务滚动机制的一部分进行维护。
事务保留保证在开始修改对象和项目之前有可用的物理日志空间将修改写入日志。因此,保留空间需要足够大,以考虑更改可能需要在最坏情况下记录的元数据量。这意味着如果我们在事务中修改 btree,我们必须保留足够的空间来记录 btree 的完整叶到根拆分。因此,保留非常复杂,因为我们必须考虑所有可能发生的隐藏更改。
例如,用户数据区段分配涉及从可用空间分配区段,这会修改可用空间树。这是两个 btree。将区段插入到 inode 的区段映射中可能需要拆分区段映射 btree,这需要另一个分配,该分配可以再次修改可用空间树。然后我们可能必须更新反向映射,这会修改另一个 btree,这可能需要更多空间。等等。因此,“简单”操作可以修改的元数据量可能相当大。
此“最坏情况”计算为我们提供了在挂载时计算的事务的静态“单元保留”。我们必须保证在允许事务继续进行之前,日志具有这么多可用空间,这样当我们来将脏元数据写入日志时,我们不会在写入过程中耗尽日志空间。
对于一次性事务,事务继续进行只需要一个单元空间保留。但是,对于永久事务,我们还有一个影响要进行的保留大小的“日志计数”。
虽然永久事务可以使用一个单位的空间保留来完成,但这有点低效,因为它要求事务滚动机制在每次事务滚动时重新保留空间。我们从永久事务的实现中知道,需要进行的常见修改可能有多少次事务滚动。
例如,inode 分配通常是两个事务 - 一个是物理分配磁盘上的空闲 inode 块,另一个是从具有空闲 inode 的 inode 块分配 inode。因此,对于 inode 分配事务,我们可能会将保留日志计数设置为值 2,以指示常见/快速路径事务将在链中提交两个链接的事务。每次永久事务滚动时,它都会消耗整个单位保留。
因此,当首次分配永久事务时,日志空间保留从单个单元保留增加到多个单元保留。该倍数由保留日志计数定义,这意味着我们可以在滚动事务时多次滚动事务,然后才必须在滚动事务时重新保留日志空间。这确保我们进行的常见修改只需要保留一次日志空间。
如果永久事务的日志计数达到零,则需要在日志中重新保留物理空间。这有点复杂,并且需要了解日志如何记录已保留的空间。
1.6. 日志空间核算¶
日志中的位置通常被称为日志序列号(LSN)。日志是循环的,因此日志中的位置由循环次数(即日志被覆盖的次数)和日志中的偏移量组合定义。一个 LSN 的高 32 位表示循环次数,低 32 位表示偏移量。偏移量的单位是“基本块”(512 字节)。因此,我们可以进行相对简单的基于 LSN 的数学运算来跟踪日志中的可用空间。
日志空间记账是通过一对称为“授权头”的结构完成的。授权头的位置是一个绝对值,因此日志中的可用空间由授权头的位置与当前日志尾部之间的距离定义。也就是说,在授权头完全包裹日志并超越尾部位置之前,可以预留/消耗多少空间。
第一个授权头是“预留”头。它跟踪当前活动事务持有的预留的字节数。这纯粹是对内存中空间预留的记账,因此实际上跟踪的是日志中的字节偏移量,而不是基本块。因此,从技术上讲,它不是使用 LSN 来表示日志位置,但为了跟踪预留空间,它仍然被视为一个分割的 {循环次数,偏移量} 元组。
预留授权头用于精确计算确切的事务预留量,以及修改实际产生并需要写入日志的确切字节数。当预留头到达当前尾部时,预留头用于阻止新事务进行新的预留。它将阻止 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 通过一种称为“重新记录”的方法来实现此目的。从概念上讲,这非常简单 - 它只需要将对对象的任何新更改与写入日志的新事务中所有现有更改的新副本一起记录。
也就是说,如果我们有一系列从 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。
提供足够的新跟踪基础设施,以便能够调试新代码中的问题。
不改变磁盘上的格式(元数据或日志格式)。
可以使用挂载选项启用和禁用。
对于同步事务工作负载,性能不会下降。
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,并有效地允许在我们将检查点格式化到日志时发出新的事务。它还允许在日志强制繁重的工作负载情况下,将并发检查点写入日志缓冲区,就像现有的事务提交代码一样。然而,这要求我们严格地对日志中的提交记录进行排序,以便在日志重放期间保持检查点顺序。
为了确保在另一个事务修改某项并将其日志项插入新的 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 的日志空间(每 32k 字节 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 使用它时快,但也不会减慢。
因此,延迟日志记录事务提交代码需要从头开始为并发性而设计。很明显,设计中存在序列化点 - 三个重要的序列化点是
在刷新 CIL 时锁定新的事务提交
将项目添加到 CIL 并更新项目空间记帐
检查点提交排序
查看事务提交和 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 运行。一个项目可以在步骤 1-6 或步骤 8-9 中被锁定,同时步骤 7 正在发生,但只有步骤 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
由此可见,两种日志记录方法之间的唯一生命周期差异在于生命周期的中间 - 它们仍然具有相同的开始和结束以及执行约束。唯一的区别在于将日志项目提交到日志本身和完成处理。因此,延迟日志记录不应引入任何对日志项目行为、分配或释放的约束,这些约束不是已经存在的。
由于这种延迟日志记录基础设施的零影响“插入”以及避免磁盘格式更改的内部结构设计,我们可以通过挂载选项在延迟日志记录和现有机制之间进行切换。从根本上讲,如果延迟日志记录按设计工作,则日志管理器没有理由不能根据负载特性自动透明地交换方法,但这不应该是必要的。