本文档根据 GNU 通用公共许可证 v2 的条款获得许可。主要作者是 Darrick J. Wong。
本设计文档分为七个部分。第 1 部分定义了 fsck 工具是什么,以及编写新工具的动机。第 2 部分和第 3 部分概述了在线 fsck 进程的工作原理以及如何对其进行测试以确保功能正确。第 4 部分讨论了用户界面以及新程序的预期使用模式。第 5 部分和第 6 部分展示了高级组件以及它们如何组合在一起,然后提出了每个修复功能实际工作方式的案例研究。第 7 部分总结了到目前为止所讨论的内容,并推测了可以在在线 fsck 之上构建的其他内容。
Unix 文件系统有四个主要职责:
直接支持这些功能的元数据(例如,文件、目录、空间映射)有时称为主要元数据。辅助元数据(例如,反向映射和目录父指针)支持文件系统内部的操作,例如内部一致性检查和重组。顾名思义,摘要元数据压缩主要元数据中包含的信息,以提高性能。
文件系统检查 (fsck) 工具检查文件系统中的所有元数据,以查找错误。除了查找明显的元数据损坏之外,fsck 还会将不同类型的元数据记录相互交叉引用,以查找不一致之处。人们不喜欢丢失数据,因此大多数 fsck 工具还包含一些纠正发现的任何问题的能力。作为一个警告 - 大多数 Linux fsck 工具的主要目标是将文件系统元数据恢复到一致状态,而不是最大化恢复的数据。这个先例不会在这里受到挑战。
20 世纪的文件系统通常在磁盘格式中缺少任何冗余,这意味着 fsck 只能通过擦除文件直到不再检测到错误来响应错误。最近的文件系统设计在其元数据中包含足够的冗余,现在可以在发生非灾难性错误时重新生成数据结构;这种能力有助于两种策略。
注意: |
系统管理员通过创建备份来增加独立存储系统的数量来避免数据丢失;他们通过创建 RAID 阵列来增加每个存储系统的冗余来避免停机。fsck 工具仅解决第一个问题。 |
代码发布到 kernel.org git 树,如下所示:内核更改、用户空间更改和 QA 测试更改。每个添加在线修复功能的内核补丁集将在内核、xfsprogs 和 fstests git 存储库中使用相同的分支名称。
当前的 XFS 工具留下了一些未解决的问题:
当由于元数据中的静默损坏而发生意外关闭时,**用户程序** 会突然 **失去对文件系统的访问权限**。这些发生是 **不可预测的**,通常没有警告。
在 **意外关闭** 发生后的恢复期间,**用户** 会遇到 **完全的服务中断**。
如果文件系统脱机以 **主动查找问题**,**用户** 会遇到 **完全的服务中断**。
**数据所有者** 无法 **检查** 其存储数据的 **完整性**,而无需读取所有数据。当存储系统管理员执行的线性媒体扫描可能就足够时,这可能会使他们承担大量的计费成本。
如果 **缺乏** 在文件系统在线时评估文件系统健康状况的 **手段**,**系统管理员** 无法 **计划** 一个维护窗口来处理损坏。
当这样做需要 **手动干预** 和停机时,**舰队监控工具** 无法 **自动化定期检查** 文件系统健康状况。
当恶意行为者 **利用 Unicode 的怪癖** 在目录中放置误导性名称时,**用户** 可能会被欺骗 **做他们不希望做的事情**。
鉴于要解决的问题的定义以及将受益的行为者,建议的解决方案是第三个 fsck 工具,它作用于正在运行的文件系统。
这个新的第三个程序有三个组件:一个用于检查元数据的内核设施,一个用于修复元数据的内核设施,以及一个用于驱动活动文件系统上的 fsck 活动的用户空间驱动程序。xfs_scrub
是驱动程序程序的名称。本文档的其余部分介绍了新 fsck 工具的目标和用例,描述了与其目标相关的其主要设计要点,并讨论了与现有工具的异同。
注意: |
在本文档中,现有的离线 fsck 工具也可以用其当前名称“xfs_repair ”来称呼。用于新的在线 fsck 工具的用户空间驱动程序可以被称为“xfs_scrub ”。在线 fsck 的内核部分,用于验证元数据,称为“在线清理”,而内核中用于修复元数据的部分称为“在线修复”。 |
命名层次结构被分解为称为目录和文件的对象,物理空间被分成称为分配组的块。分片使高度并行系统能够获得更好的性能,并有助于控制发生损坏时的损失。将文件系统划分为主要对象(分配组和 inode)意味着有很多机会可以对文件系统的子集执行定向检查和修复。
当这种情况发生时,其他部分会继续处理 IO 请求。即使只能通过扫描整个系统来重新生成一条文件系统元数据,也可以在后台完成扫描,而其他文件操作继续进行。
总之,在线 fsck 利用资源分片和冗余元数据来启用在系统运行时对目标进行检查和修复操作。此功能将与自动系统管理相结合,以便 XFS 的自主自愈能够最大限度地提高服务可用性。
如前所述,fsck 工具具有三个主要目标
检测元数据中的不一致;
消除这些不一致;和
尽量减少进一步的数据丢失。
证明正确操作对于建立用户对软件在预期范围内运行的信心是必要的。不幸的是,直到引入具有高 IOPS 存储的低成本虚拟机之前,对 fsck 工具的每个方面执行常规的详尽测试实际上是不可行的。考虑到充足的硬件可用性,在线 fsck 项目的测试策略涉及针对现有 fsck 工具进行差异分析,以及对每种类型的元数据对象的每个属性进行系统测试。测试可以分为四个主要类别,如下所述。
任何免费软件 QA 工作的首要目标是使测试尽可能便宜和广泛,以最大限度地提高社区的规模优势。换句话说,测试应最大限度地扩大文件系统配置场景和硬件设置的范围。这通过使在线 fsck 的作者能够尽早发现和修复错误来提高代码质量,并帮助新功能的开发人员在开发工作中尽早发现集成问题。
Linux 文件系统社区共享一个通用的 QA 测试套件,fstests,用于功能和回归测试。甚至在开始在线 fsck 的开发工作之前,fstests(在 XFS 上运行时)会在每次测试之间对测试和临时文件系统运行 xfs_check
和 xfs_repair -n
命令。这提供了一定程度的保证,即内核和 fsck 工具在什么构成一致的元数据方面保持一致。在在线检查代码的开发过程中,fstests 被修改为在每次测试之间运行 xfs_scrub -n
,以确保新的检查代码产生与两个现有 fsck 工具相同的结果。
要开始在线修复的开发,fstests 被修改为运行 xfs_repair
以在测试之间重建文件系统的元数据索引。这确保了离线修复不会崩溃,在退出后不会留下损坏的文件系统,也不会触发来自在线检查的投诉。这也建立了可以和不能离线修复的基线。要完成在线修复的第一个开发阶段,fstests 被修改为能够以“强制重建”模式运行 xfs_scrub
。这使得可以比较在线修复与现有离线修复工具的有效性。
在线 fsck 的一个独特要求是能够在与常规工作负载并发的文件系统上运行。尽管当然不可能在运行系统上以 零 可观察到的影响运行 xfs_scrub
,但在线修复代码永远不应将不一致引入文件系统元数据,并且常规工作负载永远不应注意到资源匮乏。为了验证是否满足这些条件,fstests 已通过以下方式得到增强
对于每个清理项目类型,创建一个测试以在运行 fsstress
时执行检查该项目类型。
对于每个清理项目类型,创建一个测试以在运行 fsstress
时执行修复该项目类型。
竞争 fsstress
和 xfs_scrub -n
以确保检查整个文件系统不会导致问题。
竞争 fsstress
和 xfs_scrub
以强制重建模式确保强制修复整个文件系统不会导致问题。
在冻结和解冻文件系统时,竞争以检查和强制修复模式的 xfs_scrub
与 fsstress
。
在以只读和读写方式重新挂载文件系统时,竞争以检查和强制修复模式的 xfs_scrub
与 fsstress
。
相同,但运行 fsx
而不是 fsstress
。(尚未完成?)
成功定义为能够在不观察到由于损坏的元数据、内核挂起检查警告或任何其他类型的恶作剧而导致的任何意外的文件系统关闭的情况下运行所有这些测试。
提议的补丁集包括 常规压力测试 和 现有每个功能的压力测试的演变。
与离线修复一样,在线 fsck 的主要用户是系统管理员。在线 fsck 向管理员提供两种操作模式:按需在线 fsck 的前台 CLI 进程,以及执行自主检查和修复的后台服务。
对于想要了解文件系统中元数据的最新信息的管理员,可以在命令行上作为前台进程运行 xfs_scrub
。该程序检查文件系统中的每一段元数据,同时管理员等待报告结果,就像现有的 xfs_repair
工具一样。两种工具都共享一个 -n
选项来执行只读扫描,以及一个 -v
选项来增加报告的信息的详细程度。
xfs_scrub
的一个新功能是 -x
选项,它利用硬件的纠错功能来检查数据文件内容。默认情况下不启用介质扫描,因为它可能会大大增加程序运行时并消耗旧存储硬件上的大量带宽。
前台调用的输出捕获在系统日志中。
xfs_scrub_all
程序遍历已挂载文件系统的列表,并并行启动每个文件系统的 xfs_scrub
。它序列化任何解析为同一顶级内核块设备的文件系统的扫描,以防止过度消耗资源。
为了减少系统管理员的工作量,xfs_scrub
软件包提供了一套 systemd 定时器和服务,默认情况下在周末自动运行在线 fsck。后台服务配置为以尽可能少的权限、最低的 CPU 和 IO 优先级以及受 CPU 限制的单线程模式运行清理。系统管理员可以随时对其进行调整,以适应客户工作负载的延迟和吞吐量要求。
后台服务的输出也捕获在系统日志中。如果需要,可以通过在以下服务文件中设置 EMAIL_ADDR
环境变量来自动通过电子邮件发送故障报告(由于不一致或仅仅是运行时错误)
启用后台扫描的决定留给系统管理员。这可以通过启用以下任何服务来完成
这种自动每周扫描配置为每月对所有文件数据执行一次额外的介质扫描。这不如存储文件数据块校验和那么可靠,但如果应用程序软件提供自己的完整性检查,则性能要好得多,冗余可以在文件系统上方的其他地方提供,或者存储设备的完整性保证被认为是足够的。
systemd 单元文件定义已经过安全审核(截至 systemd 249),以确保 xfs_scrub 进程尽可能少地访问系统的其余部分。这是通过 systemd-analyze security
执行的,之后权限被限制为所需的最小值,沙盒被设置为尽可能的最大程度,并进行沙盒和系统调用过滤;并且对文件系统树的访问被限制为启动程序和访问正在扫描的文件系统所需的最小值。服务定义文件将 CPU 使用率限制为 80% 的一个 CPU 内核,并尽可能对 IO 和 CPU 调度应用不错的优先级。采取此措施是为了最大限度地减少文件系统其余部分的延迟。没有为 cron 作业执行此类强化。
提议的补丁集:启用 xfs_scrub 后台服务。
XFS 在内存中缓存每个文件系统健康状况的摘要。每当运行 xfs_scrub
时,或者在常规操作期间检测到文件系统元数据中的不一致时,都会更新信息。系统管理员应使用 xfs_spaceman
的 health
命令将此信息下载到人类可读的格式中。如果观察到问题,管理员可以安排一个缩短的服务窗口来运行在线修复工具以纠正问题。否则,管理员可以决定安排一个维护窗口来运行传统的离线修复工具以纠正问题。
未来工作问题:健康状况报告是否应与新的 inotify fs 错误通知系统集成?对于系统管理员来说,拥有一个守护程序来侦听损坏通知并启动修复是否有帮助?
答案:这些问题仍未得到解答,但应成为与 XFS 的早期采用者和潜在的下游用户对话的一部分。
提议的补丁集包括 将健康状况报告连接到更正返回 和 在内存回收期间保留疾病信息。
本节讨论内核代码的关键算法和数据结构,这些代码提供在系统运行时检查和修复元数据的能力。本节的前几章揭示了为检查元数据提供基础的部分。本节的其余部分介绍了 XFS 自我再生的机制。
XFS 的原始设计(大约 1993 年)是对 1980 年代 Unix 文件系统设计的改进。在那些日子里,存储密度昂贵,CPU 时间稀缺,过度的寻道时间可能会扼杀性能。出于性能原因,文件系统作者不愿向文件系统添加冗余,即使以牺牲数据完整性为代价。21 世纪初的文件系统设计者选择不同的策略来增加内部冗余——存储几乎相同的元数据副本,或更节省空间的编码技术。
对于 XFS,选择了一种不同的冗余策略来使设计现代化:一种辅助空间使用索引,将分配的磁盘范围映射回其所有者。通过添加一个新的索引,文件系统保留了其大部分良好扩展到涉及大型数据集的重线程工作负载的能力,因为主要文件元数据(目录树、文件块映射和分配组)保持不变。与任何提高冗余的系统一样,反向映射功能会增加空间映射活动的开销成本。但是,它有两个关键优势:首先,反向索引是启用在线 fsck 和其他请求的功能(例如空闲空间碎片整理、更好的介质故障报告和文件系统收缩)的关键。其次,反向映射 btree 的不同磁盘上存储格式会阻止设备级重复数据删除,因为文件系统需要真正的冗余。
侧边栏: |
对添加辅助索引的批评是它对提高用户数据存储本身的健壮性没有任何作用。这是一个有效的观点,但是添加用于文件数据块校验和的新索引会通过将数据覆盖转换为复制写入来增加写入放大,这会使文件系统过早老化。与三十年的先例保持一致,想要文件数据完整性的用户可以提供他们所需的强大解决方案。至于元数据,添加空间使用的新辅助索引的复杂性远小于将卷管理和存储设备镜像添加到 XFS 本身。RAID 和卷管理的完善最好留给内核中的现有层。 |
反向空间映射记录中捕获的信息如下
struct xfs_rmap_irec {
xfs_agblock_t rm_startblock; /* extent start block */
xfs_extlen_t rm_blockcount; /* extent length */
uint64_t rm_owner; /* extent owner */
uint64_t rm_offset; /* offset within the owner */
unsigned int rm_flags; /* state flags */
};
前两个字段以文件系统块为单位捕获物理空间的位置和大小。所有者字段告诉清理程序哪个元数据结构或文件 inode 已被分配了该空间。对于分配给文件的空间,偏移量字段告诉清理程序空间在文件 fork 中映射的位置。最后,标志字段提供有关空间使用的额外信息——这是属性 fork 范围吗?文件映射 btree 范围?还是未写入的数据范围?
在线文件系统检查通过将其信息与所有其他空间索引进行比较来判断每个主要元数据记录的一致性。反向映射索引在一致性检查过程中起着关键作用,因为它包含所有空间分配信息的集中式备用副本。程序运行时和资源获取的容易程度是限制在线检查可以查阅的唯一真正限制。例如,可以根据以下内容检查文件数据范围映射
关于反向映射索引,有几点需要注意:
如果对上述任何主要元数据有疑问,反向映射可以提供正确的肯定确认。大多数主要元数据的检查代码都遵循与上述类似的路径。
证明次要元数据与主要元数据的一致性是困难的,因为这需要全面扫描所有主要空间元数据,这非常耗时。例如,检查文件范围映射 btree 块的反向映射记录需要锁定文件并搜索整个 btree 以确认该块。相反,scrub 依赖于在主要空间映射结构检查期间进行严格的交叉引用。
如果所需的锁定顺序与常规文件系统操作使用的顺序不同,则一致性扫描必须使用非阻塞锁获取原语。例如,如果文件系统通常在获取 AGF 缓冲区锁之前获取文件 ILOCK,但 scrub 想要在持有 AGF 缓冲区锁的同时获取文件 ILOCK,则 scrub 不能阻塞第二次获取。这意味着,如果系统负载很重,则无法保证反向映射数据扫描的这一部分能够向前推进。
总之,反向映射在主要元数据的重建中起着关键作用。这些记录如何暂存、写入磁盘并提交到文件系统的详细信息将在后续章节中介绍。
检查元数据结构的第一步是检查结构中包含的每个记录及其与系统其余部分的关系。XFS 包含多个检查层,以防止不一致的元数据对系统造成严重破坏。每一层都提供信息,帮助内核对元数据结构的健康状况做出三个决定:
此结构的某一部分是否明显损坏 (XFS_SCRUB_OFLAG_CORRUPT
)?
此结构是否与系统的其余部分不一致 (XFS_SCRUB_OFLAG_XCORRUPT
)?
文件系统周围是否存在太多损坏,以至于无法进行交叉引用 (XFS_SCRUB_OFLAG_XFAIL
)?
是否可以优化结构以提高性能或减小元数据的大小 (XFS_SCRUB_OFLAG_PREEN
)?
结构是否包含不不一致但值得系统管理员审查的数据 (XFS_SCRUB_OFLAG_WARNING
)?
以下各节介绍元数据清理过程的工作原理。
在缓冲区缓存之后,下一级别的元数据保护是内置于文件系统中的内部记录验证代码。这些检查在缓冲区验证器、缓冲区缓存的内置文件系统用户和 scrub 代码本身之间进行拆分,具体取决于所需的高级上下文的数量。检查的范围仍然在块内部。这些更高级别的检查函数回答以下问题:
此类别的记录检查更加严格且耗时更多。例如,检查块指针和 inumber,以确保它们指向分配组的动态分配部分和文件系统内。检查名称是否存在无效字符,并检查标志是否存在无效组合。还会检查其他记录属性是否存在合理的值。检查跨越 btree 键空间间隔的 Btree 记录是否具有正确的顺序和缺少可合并性(文件 fork 映射除外)。出于性能原因,除非启用调试或即将发生写入,否则常规代码可能会跳过其中一些检查。当然,Scrub 函数必须检查所有可能的问题。
各种文件系统元数据由用户空间直接控制。由于这种性质,验证工作不能比检查值是否在可能的范围内更精确。这些字段包括:
由挂载选项控制的超级块字段
文件系统标签
文件时间戳
文件权限
文件大小
文件标志
目录条目、扩展属性键和文件系统标签中存在的名称
扩展属性键命名空间
扩展属性值
文件数据块内容
配额限制
配额计时器到期(如果资源使用量超过软限制)
扩展属性实现了一个键值存储,使数据片段可以附加到任何文件。内核和用户空间都可以访问键和值,但受到命名空间和权限限制。最典型的是,这些片段是有关文件的元数据——来源、安全上下文、用户提供的标签、索引信息等。
名称最长可达 255 字节,并且可以存在于多个不同的命名空间中。值的大小可达 64KB。文件的扩展属性存储在 attr fork 映射的块中。映射指向叶子块、远程值块或 dabtree 块。属性 fork 中的块 0 始终是结构的顶部,但否则可以在 attr fork 中的任何偏移处找到这三种类型的块中的每一种。叶子块包含指向名称和值的属性键记录。名称始终存储在同一叶子块中的其他位置。小于文件系统块大小的 3/4 的值也存储在同一叶子块中的其他位置。远程值块包含太大而无法放入叶子中的值。如果叶子信息超过单个文件系统块,则会创建一个 dabtree(也以块 0 为根)以将属性名称的哈希值映射到 attr fork 中的叶子块。
由于 attr 块和索引块之间缺乏分离,因此检查扩展属性结构并不那么简单。Scrub 必须读取 attr fork 映射的每个块并忽略非叶子块。
遍历 attr fork 中的 dabtree(如果存在),以确保块或不指向 attr 叶子块的 dabtree 映射中没有不规则之处。
遍历 attr fork 的块以查找叶子块。对于叶子中的每个条目:
验证名称是否不包含无效字符。
读取 attr 值。这将执行 attr 名称的命名查找,以确保 dabtree 的正确性。如果该值存储在远程块中,这也会验证远程值块的完整性。
文件系统目录树是一个有向无环图结构,其中文件构成节点,目录条目 (dirent) 构成边。目录是一种特殊类型的文件,包含从 255 字节序列(名称)到 inumber 的一组映射。这些称为目录条目,或简称为 dirent。每个目录文件必须只有一个指向该文件的目录。根目录指向自身。目录条目指向任何类型的文件。每个非目录文件可能具有指向它的多个目录。
在 XFS 中,目录实现为一个文件,包含多达三个 32GB 分区。第一个分区包含目录条目数据块。每个数据块都包含可变大小的记录,这些记录将用户提供的名称与 inumber 和(可选)文件类型相关联。如果目录条目数据增长超过一个块,则第二个分区(以 EOF 后范围的形式存在)将填充一个包含空闲空间信息和一个索引的块,该索引将 dirent 名称的哈希映射到第一个分区中的目录数据块。这使得目录名称查找非常快。如果第二个分区增长超过一个块,则第三个分区将填充一个空闲空间信息的线性数组,以便更快地扩展。如果空闲空间已分离并且第二个分区再次增长超过一个块,则使用 dabtree 将 dirent 名称的哈希映射到目录数据块。
检查目录非常简单:
遍历第二个分区中的 dabtree(如果存在),以确保块或不指向 dirent 块的 dabtree 映射中没有不规则之处。
遍历第一个分区中的块以查找目录条目。按如下方式检查每个 dirent:
名称是否不包含无效字符?
inumber 是否对应于实际分配的 inode?
子 inode 是否具有非零链接计数?
如果 dirent 中包含文件类型,它是否与 inode 的类型匹配?
如果子项是一个子目录,子项的 dotdot 指针是否指向父项?
如果目录具有第二个分区,请执行 dirent 名称的命名查找以确保 dabtree 的正确性。
遍历第三个分区中的空闲空间列表(如果存在),以确保它描述的空闲空间确实未使用。
涉及 父级 和 文件链接计数 的检查操作将在后面的章节中进行更详细的讨论。
如前几节所述,目录/属性 btree (dabtree) 索引映射用户提供的名称,以通过避免线性扫描来提高查找时间。在内部,它将名称的 32 位哈希值映射到相应文件 fork 中的块偏移量。
dabtree 的内部结构与记录固定大小元数据记录的 btree 非常相似——每个 dabtree 块都包含一个幻数、一个校验和、兄弟指针、一个 UUID、一个树级别和一个日志序列号。叶子和节点记录的格式相同——每个条目都指向层次结构中的下一级别,其中 dabtree 节点记录指向 dabtree 叶子块,而 dabtree 叶子记录指向 fork 中其他位置的非 dabtree 块。
检查和交叉引用 dabtree 与对空间 btree 执行的操作非常相似:
块中存储的数据类型是否与 scrub 期望的匹配?
该块是否属于请求读取的所属结构?
记录是否适合该块?
块内包含的记录是否没有明显的损坏?
名称哈希是否按正确的顺序排列?
dabtree 中的节点指针是否指向 dabtree 块的有效 fork 偏移量?
dabtree 中的叶子指针是否指向目录或 attr 叶子块的有效 fork 偏移量?
子指针是否指向叶子?
兄弟指针是否在同一级别上指向?
对于每个 dabtree 节点记录,记录键是否准确地反映了子 dabtree 块的内容?
对于每个 dabtree 叶子记录,记录键是否准确地反映了目录或 attr 块的内容?
XFS 维护三类汇总计数器:可用资源、配额资源使用情况和文件链接计数。
从理论上讲,可以通过遍历整个文件系统来找到可用资源量(数据块、inode、实时范围)。这将使报告速度非常慢,因此事务性文件系统可以在超级块中维护此信息的摘要。将这些值与文件系统元数据交叉引用应该是遍历每个 AG 中的空闲空间和 inode 元数据以及实时位图的简单问题,但存在复杂性,将在 稍后更详细地讨论。
配额使用情况 和 文件链接计数 检查非常复杂,值得单独章节介绍。
执行修复后,检查代码将再次运行以验证新结构,并且健康评估的结果将在内部记录并返回到调用进程。此步骤对于使系统管理员能够监视文件系统的状态和任何修复的进度至关重要。对于开发人员,这是一种有用的手段,可以判断在线和离线检查工具中的错误检测和纠正的有效性。
复杂操作可以使用事务链对多个按 AG 数据结构进行修改。一旦这些链提交到日志,如果在处理链时系统崩溃,则会在日志恢复期间重新启动这些链。由于 AG 标头缓冲区在链中的事务之间未锁定,因此在线检查必须与正在进行的链式操作协调,以避免由于挂起的链而错误地检测到不一致。此外,在线修复不得在操作挂起时运行,因为元数据彼此暂时不一致,并且无法重建。
只有在线 fsck 具有对 AG 元数据的完全一致性的要求,与文件系统更改操作相比,这应该相对罕见。在线 fsck 与事务链的协调方式如下:
这可能会导致在线 fsck 花费很长时间才能完成,但常规文件系统更新优先于后台检查活动。有关发现这种情况的详细信息将在下一节中介绍,有关解决方案的详细信息将在之后介绍。
在在线清理开发的中途,fsstress 测试发现在线 fsck 和其他编写器线程创建的复合事务链之间存在错误的交互,从而导致了对元数据不一致性的错误报告。这些报告的根本原因是引入反向映射和 reflink 时,延迟工作项和复合事务链的扩展所引入的最终一致性模型。
最初,事务链被添加到 XFS 中,以避免从文件中取消映射空间时出现死锁。避免死锁的规则要求仅以递增顺序锁定 AG,这使得(例如)不可能使用单个事务释放 AG 7 中的空间范围,然后尝试释放 AG 3 中现在多余的块映射 btree 块。为了避免这些类型的死锁,XFS 创建范围释放意图 (EFI) 日志项以提交以在一个事务中释放一些空间,同时将实际元数据更新推迟到新事务。事务序列如下所示:
第一个事务包含对文件块映射结构的物理更新,以从 btree 块中删除映射。然后,它将一个操作项附加到内存中事务,以安排延迟释放空间。具体来说,每个事务都维护一个 struct xfs_defer_pending
对象的列表,每个对象都维护一个 struct xfs_extent_free_item
对象的列表。回到上面的示例,操作项跟踪 AG 7 中未映射空间的释放和 AG 3 中块映射 btree (BMBT) 块的释放。以这种方式记录的延迟释放通过从 struct xfs_extent_free_item
对象创建 EFI 日志项并将日志项附加到事务来提交到日志中。当日志持久保存到磁盘时,EFI 项将写入磁盘事务记录中。EFI 最多可以列出 16 个要释放的范围,所有范围都按 AG 顺序排序。
第二个事务包含对 AG 3 的空闲空间 btree 的物理更新,以释放之前的 BMBT 块,并对 AG 7 的空闲空间 btree 进行第二次物理更新,以释放未映射的文件空间。请注意,物理更新在可能的情况下会以正确的顺序重新排序。附加到事务的是一个范围释放完成 (EFD) 日志项。EFD 包含一个指向在事务 #1 中记录的 EFI 的指针,以便日志恢复可以判断是否需要重播 EFI。
如果系统在事务 #1 写回文件系统之后但在提交 #2 之前出现故障,则扫描文件系统元数据将显示不一致的文件系统元数据,因为未映射空间似乎没有任何所有者。幸运的是,日志恢复为我们纠正了这种不一致——当恢复找到 intent 日志项但未找到相应的 intent 完成项时,它将重建 intent 项的核心状态并完成它。在上面的示例中,日志必须重播在恢复的 EFI 中描述的所有释放才能完成恢复阶段。
XFS 的事务链接策略有一些微妙之处需要考虑:
必须以正确的顺序将日志项添加到事务中,以防止与事务未持有的主要对象发生冲突。换句话说,必须在最后一次更新以释放范围之前完成对未映射块的所有按 AG 元数据更新,并且在最后一次更新提交到日志之前不应重新分配范围。
AG 标头缓冲区在链中的每个事务之间释放。这意味着其他线程可以在中间状态观察 AG,但只要处理了第一个微妙之处,这就不应影响文件系统操作的正确性。
卸载文件系统会将所有挂起的工作刷新到磁盘,这意味着离线 fsck 永远不会看到由延迟工作项处理引起的临时不一致。
以这种方式,XFS 采用一种形式的最终一致性来避免死锁并提高并行性。
在反向映射和 reflink 功能的设计阶段,决定将单个文件系统更改的所有反向映射更新塞入单个事务是不切实际的,因为单个文件映射操作可能会爆炸成许多小更新:
块映射更新本身
块映射更新的反向映射更新
修复空闲列表
空闲列表修复的反向映射更新
块映射 btree 的形状更改
btree 更新的反向映射更新
修复空闲列表(再次)
空闲列表修复的反向映射更新
引用计数信息的更新
refcount 更新的反向映射更新
修复空闲列表(第三次)
空闲列表修复的反向映射更新
释放未映射且不属于任何其他文件的任何空间
修复空闲列表(第四次)
空闲列表修复的反向映射更新
释放块映射 btree 使用的空间
修复空闲列表(第五次)
空闲列表修复的反向映射更新
对于每个事务链,每个 AG 通常不需要修复空闲列表超过一次,但如果空间非常紧张,理论上是可能的。对于写时复制更新,这甚至更糟,因为必须执行一次才能从暂存区域中删除空间,然后再次将其映射到文件中!
为了以冷静的方式处理这种爆炸式增长,XFS 扩展了其延迟工作项的使用范围,以覆盖大多数反向映射更新和所有 refcount 更新。这通过将工作分解为一长串小更新来减少事务预留的最坏情况大小,从而增加了系统中最终一致性的程度。同样,这通常不是问题,因为 XFS 会仔细排序其延迟工作项,以避免不知情的线程之间的资源重用冲突。
但是,在线 fsck 更改了规则——请记住,虽然对按 AG 结构的物理更新通过锁定 AG 标头的缓冲区来协调,但缓冲区锁会在事务之间删除。一旦 scrub 获取资源并获取数据结构的锁,它必须在不释放锁的情况下完成所有验证工作。如果空间 btree 的主锁是 AG 标头缓冲区锁,则 scrub 可能中断了另一个线程,该线程正处于完成链的中途。例如,如果执行写时复制的线程已完成反向映射更新但未完成相应的 refcount 更新,则两个 AG btree 对于 scrub 而言将显得不一致,并且将记录对损坏的观察。此观察结果将不正确。如果在这种状态下尝试修复,结果将是灾难性的!
在发现此缺陷后,评估了其他几种解决方案并被拒绝:
向分配组添加更高级别的锁,并要求编写器线程在进行任何更改之前以 AG 顺序获取更高级别的锁。这在实践中很难实现,因为很难确定需要获取哪些锁以及以什么顺序获取,而无需模拟整个操作。执行文件操作的试运行以发现必要的锁将使文件系统变得非常慢。
使延迟工作协调器代码知道针对同一 AG 的连续 intent 项,并使其在更新之间的事务滚动中保持 AG 标头缓冲区的锁定状态。这将给协调器带来很多复杂性,因为它仅与实际的延迟工作项松散耦合。它也无法解决该问题,因为延迟工作项可以生成新的延迟子任务,但是必须在新的同级任务开始工作之前完成所有子任务。
指导在线 fsck 遍历所有等待保护正在清理的数据结构的锁的事务,以查找挂起的操作。检查和修复操作必须将这些挂起的操作考虑在执行的评估中。此解决方案不可行,因为它对主文件系统的侵入性非常强。
在线 fsck 使用原子意图项计数器和锁循环来与事务链进行协调。drain 机制有两个关键属性。首先,当一个延迟工作项排队到一个事务时,计数器会递增,而在相关的意图完成日志项提交到另一个事务后,计数器会递减。第二个属性是,可以将延迟工作添加到事务中,而无需持有 AG 头部锁,但是,如果没有锁定该 AG 头部缓冲区以记录物理更新和意图完成日志项,则无法将每个 AG 的工作项标记为完成。第一个属性使 scrub 能够让位于正在运行的事务链,这是在线 fsck 的显式降级,以利于文件操作。drain 的第二个属性是正确协调 scrub 的关键,因为 scrub 将始终能够确定是否可能发生冲突。
对于常规文件系统代码,drain 的工作方式如下
调用适当的子系统函数以将延迟工作项添加到事务中。
该函数调用 xfs_defer_drain_bump
以增加计数器。
当延迟项管理器想要完成延迟工作项时,它会调用 ->finish_item
以完成它。
->finish_item
实现记录一些更改,并调用 xfs_defer_drain_drop
以减少松散计数器,并唤醒任何等待 drain 的线程。
子事务提交,这将解锁与意图项关联的资源。
对于 scrub,drain 的工作方式如下
锁定与要 scrub 的元数据关联的资源。例如,扫描 refcount btree 将锁定 AGI 和 AGF 头部缓冲区。
如果计数器为零(xfs_defer_drain_busy
返回 false),则没有正在进行的链,并且该操作可以继续。
否则,释放在步骤 1 中获取的资源。
等待意图计数器达到零 (xfs_defer_drain_intents
),然后返回到步骤 1,除非已捕获信号。
为避免在步骤 4 中进行轮询,drain 提供了一个等待队列,以便在意图计数降至零时唤醒 scrub 线程。
提出的补丁集是 scrub intent drain 系列。
XFS 的在线 fsck 尽可能地将常规文件系统与检查和修复代码分开。但是,在线 fsck 的一些部分(例如意图 drain,以及稍后的实时更新钩子)中,在线 fsck 代码了解文件系统其余部分中发生的事情很有用。由于预计在线 fsck 不会一直在后台运行,因此在将在线 fsck 编译到内核中但没有代表用户空间主动运行时,最大限度地减少这些钩子带来的运行时开销非常重要。在写入器线程的热路径中获取锁以访问数据结构,结果却发现不需要采取进一步的措施是很昂贵的——在作者的计算机上,这会产生每次访问 40-50 纳秒的开销。幸运的是,内核支持动态代码修补,这使 XFS 能够在在线 fsck 未运行时,用 nop
sleds 替换静态分支到钩子代码。此 sled 的开销取决于指令解码器跳过该 sled 所需的时间,这似乎在小于 1 纳秒的量级上,并且不访问指令获取之外的内存。
当在线 fsck 启用静态键时,sled 将被替换为对钩子代码的无条件分支。切换非常昂贵(约 22000 纳秒),但完全由调用在线 fsck 的程序支付,并且如果多个线程同时进入在线 fsck,或者如果同时检查多个文件系统,则可以进行摊销。更改分支方向需要获取 CPU 热插拔锁,并且由于 CPU 初始化需要内存分配,因此在线 fsck 必须小心,不要在持有任何可以在内存回收路径中访问的锁或资源时更改静态键。为了最大限度地减少 CPU 热插拔锁的争用,应注意不要不必要地启用或禁用静态键。
由于静态键旨在最大限度地减少 xfs_scrub 未运行时常规文件系统操作的钩子开销,因此预期的使用模式如下
XFS 的钩子部分应声明一个静态范围的静态键,默认为 false。DEFINE_STATIC_KEY_FALSE
宏处理此问题。静态键本身应声明为 static
变量。
在决定调用仅由 scrub 使用的代码时,如果未启用静态键,则常规文件系统应调用 static_branch_unlikely
谓词以避免仅限 scrub 的钩子代码。
常规文件系统应导出调用 static_branch_inc
以启用和 static_branch_dec
以禁用静态键的辅助函数。如果内核分发者在构建时关闭在线 fsck,则包装函数可以轻松地编译出相关代码。
想要启用仅限 scrub 的 XFS 功能的 Scrub 函数应从设置函数调用 xchk_fsgates_enable
以启用特定钩子。必须在获取内存回收使用的任何资源之前完成此操作。调用者最好确定他们是否真的需要静态键限制的功能;TRY_HARDER
标志在这里很有用。
在线 scrub 具有资源获取辅助函数(例如 xchk_perag_lock
)来处理所有 scrubber 函数的锁定 AGI 和 AGF 缓冲区。如果它检测到 scrub 和正在运行的事务之间存在冲突,它将尝试等待意图完成。如果辅助函数的调用者未启用静态键,则辅助函数将返回 -EDEADLOCK,这应导致 scrub 使用 TRY_HARDER
标志重新启动。scrub 设置函数应检测到该标志,启用静态键,然后再次尝试 scrub。Scrub 拆卸会禁用 xchk_fsgates_enable
获取的所有静态键。
有关更多信息,请参见 静态键 的内核文档。
一些在线检查函数通过扫描文件系统以在内存中构建磁盘元数据结构的影子副本并比较两个副本来进行工作。对于在线修复以重建元数据结构,它必须在将新结构持久保存到磁盘之前,计算将存储在新结构中的记录集。理想情况下,修复应以单个原子提交完成,该提交会引入新的数据结构。为实现这些目标,内核需要在不需要文件系统正确运行的地方收集大量信息。
内核内存不适合,因为
分配连续的内存区域以创建 C 数组非常困难,尤其是在 32 位系统上。
记录的链表引入了双指针开销,这非常高,并且消除了索引查找的可能性。
内核内存被固定,这可能会将系统驱动到 OOM 条件中。
系统可能没有足够的内存来暂存所有信息。
在任何给定时间,在线 fsck 都不需要将整个记录集保留在内存中,这意味着必要时可以分页输出各个记录。在线 fsck 的持续开发表明,执行索引数据存储的能力也将非常有用。幸运的是,Linux 内核已经具有用于字节可寻址和可分页存储的工具:tmpfs。内核图形驱动程序(最著名的是 i915)利用 tmpfs 文件来存储不需要始终位于内存中的中间数据,因此已经建立了使用先例。因此,xfile
诞生了!
历史侧边栏: |
在线修复的第一版在找到记录时将其插入到新的 btree 中,这失败了,因为文件系统可能会关闭并构建数据结构,这将会在恢复完成后生效。
第二版通过将所有内容存储在内存中来解决了一半重建的结构问题,但经常导致系统内存不足。
第三版通过使用链表解决了 OOM 问题,但是列表指针的内存开销非常大。
|
对 xfile 预期用途的调查表明了以下用例
固定大小记录的数组(空间管理 btree、目录和扩展属性条目)
固定大小记录的稀疏数组(配额和链接计数)
可变大小的大型二进制对象 (BLOB)(目录和扩展属性名称和值)
在内存中暂存 btree(反向映射 btree)
任意内容(实时空间管理)
为了支持前四个用例,高级数据结构包装 xfile 以共享在线 fsck 函数之间的功能。本节的其余部分讨论 xfile 向这五个更高级别的数据结构中的四个呈现的接口。第五个用例将在 实时摘要 案例研究中讨论。
XFS 是基于记录的,这表明加载和存储完整记录的能力很重要。为了支持这些情况,提供了一对 xfile_load
和 xfile_store
函数,用于将对象读取和持久保存到将任何错误视为内存不足错误的 xfile 中。对于在线修复,以这种方式压缩错误情况是可以接受的行为,因为唯一的反应是中止操作返回到用户空间。
但是,如果没有回答“但是 mmap 呢?”这个问题,那么对文件访问习惯用法的讨论就不完整了。就像用户空间代码使用常规内存一样,使用指针直接访问存储是很方便的。在线 fsck 绝不能将系统驱动到 OOM 条件中,这意味着 xfile 必须对内存回收做出响应。如果 folio 既未固定也未锁定,则 tmpfs 只能将 pagecache folio 推送到交换缓存,这意味着 xfile 绝不能固定过多的 folio。
对 xfile 内容的短期直接访问是通过锁定 pagecache folio 并将其映射到内核地址空间来完成的。对象加载和存储使用此机制。Folio 锁不应持有很长时间,因此对 xfile 内容的长期直接访问是通过提升 folio 引用计数,将其映射到内核地址空间,然后删除 folio 锁来完成的。这些长期用户必须通过挂接到收缩器基础结构来了解何时释放 folio,从而对内存回收做出响应。
提供了 xfile_get_folio
和 xfile_put_folio
函数来检索支持 xfile 部分的(已锁定)folio 并释放它。唯一使用这些 folio 租用函数的代码是 xfarray 排序 算法和 内存 btree。
出于安全原因,xfile 必须由内核私有拥有。它们被标记为 S_PRIVATE
以防止安全系统的干扰,绝不能映射到进程文件描述符表中,并且它们的页面绝不能映射到用户空间进程中。
为避免 VFS 出现锁定递归问题,对 shmfs 文件的所有访问都是通过直接操作页面缓存来执行的。xfile 写入器调用 xfile 地址空间的 ->write_begin
和 ->write_end
函数来获取可写页面,将调用者的缓冲区复制到页面中,然后释放页面。xfile 读取器调用 shmem_read_mapping_page_gfp
以在将内容复制到调用者的缓冲区之前直接获取页面。换句话说,xfile 忽略 VFS 读取和写入代码路径,以避免创建虚拟 struct kiocb
以及避免获取 inode 和冻结锁。tmpfs 无法冻结,并且 xfile 不得暴露给用户空间。
如果 xfile 在线程之间共享以暂存修复,则调用者必须提供自己的锁来协调访问。例如,如果 scrub 函数将扫描结果存储在 xfile 中,并且需要其他线程提供对扫描数据的更新,则 scrub 函数必须提供一个锁供所有线程共享。
在 XFS 中,每种类型的索引空间元数据(可用空间、inode、引用计数、文件 fork 空间和反向映射)都由一组使用经典 B+ 树索引的固定大小记录组成。目录有一组指向名称的固定大小的目录项记录,扩展属性有一组指向名称和值的固定大小的属性键。配额计数器和文件链接计数器使用数字索引记录。在修复期间,scrub 需要在收集步骤中暂存新记录,并在 btree 构建步骤中检索它们。
尽管可以通过直接调用 xfile 的读取和写入方法来满足此要求,但是对于调用者来说,通过更高级别的抽象来处理计算数组偏移量、提供迭代器函数以及处理稀疏记录和排序会更简单。xfarray
抽象在字节可访问的 xfile 之上呈现固定大小记录的线性数组。
在线 fsck 中的数组访问模式倾向于分为三类。假定所有情况下都需要记录迭代,并且将在下一节中介绍。
第一种类型的调用者处理按位置索引的记录。记录之间可能存在间隙,并且在收集步骤中可能会多次更新记录。换句话说,这些调用者需要稀疏的线性寻址表文件。典型的用例是配额记录或文件链接计数记录。对数组元素的访问是通过 xfarray_load
和 xfarray_store
函数以编程方式执行的,这些函数包装了类似命名的 xfile 函数,以提供在任意数组索引处加载和存储数组元素。间隙被定义为空记录,空记录被定义为全零字节的序列。通过调用 xfarray_element_is_null
来检测空记录。可以通过调用 xfarray_unset
以使现有记录为空,或者通过从不向数组索引存储任何内容来创建空记录。
第二种类型的调用者处理不由位置索引的记录,并且不需要对记录进行多次更新。此处的典型用例是重建空间 btree 和键/值 btree。这些调用者可以通过 xfarray_append
函数将记录添加到数组中,而无需关心数组索引,该函数将记录存储在数组的末尾。对于需要以特定顺序呈现记录的调用者(例如,重建 btree 数据),xfarray_sort
函数可以排列排序的记录;该函数将在稍后介绍。
第三种类型的调用者是 bag,这对于计算记录很有用。此处的典型用例是根据反向映射信息构造空间范围引用计数。可以将记录以任何顺序放入 bag 中,可以随时从 bag 中删除它们,并且记录的唯一性留给调用者处理。xfarray_store_anywhere
函数用于在 bag 中的任何空记录槽中插入记录;xfarray_unset
函数从 bag 中删除记录。
提出的补丁集是 大的内存数组。
xfarray 的大多数用户都需要能够迭代存储在数组中的记录。调用者可以使用以下内容探测每个可能的数组索引
xfarray_idx_t i;
foreach_xfarray_idx(array, i) {
xfarray_load(array, i, &rec);
/* do something with rec */
}
此习惯用法的所有用户都必须准备好处理空记录,或者必须已经知道没有任何空记录。
对于想要迭代稀疏数组的 xfarray 用户,xfarray_iter
函数会忽略 xfarray 中从未通过调用 xfile_seek_data
(内部使用 SEEK_DATA
)写入过的索引,以跳过数组中未填充内存页面的区域。找到页面后,它将跳过页面的归零区域。
xfarray_idx_t i = XFARRAY_CURSOR_INIT;
while ((ret = xfarray_iter(array, &i, &rec)) == 1) {
/* do something with rec */
}
在在线修复的第四次演示中,一位社区审查员评论说,出于性能原因,在线修复应该将批量的记录加载到 btree 记录块中,而不是一次将记录插入到新的 btree 中。XFS 中的 btree 插入代码负责维护记录的正确排序,因此,xfarray 自然也必须支持在批量加载之前对记录集进行排序。
xfarray 中使用的排序算法实际上是自适应快速排序和堆排序子算法的组合,其精神与 Sedgewick 和 pdqsort 相同,并针对 Linux 内核进行了自定义。为了在合理的时间内对记录进行排序,xfarray
利用了快速排序提供的二进制子分区,但它也使用堆排序来对冲性能崩溃(如果选择的快速排序枢轴较差)。这两种算法通常都是 O(n * lg(n)),但是两种实现之间存在很大的性能差距。
Linux 内核已经包含堆排序的相当快的实现。它仅对常规 C 数组进行操作,这限制了其有用范围。xfarray 使用它的两个关键位置是
换句话说,xfarray
使用堆排序来约束快速排序的嵌套递归,从而减轻快速排序的最坏运行时行为。
选择快速排序枢轴是一项棘手的事情。好的枢轴将要排序的集合分成两半,从而导致对于 O(n * lg(n)) 性能至关重要的分而治之行为。不良的枢轴几乎不会拆分子集,从而导致 O(n2) 运行时。xfarray 排序例程尝试通过将九个记录采样到内存缓冲区中,并使用内核堆排序来标识这九个记录的中值,从而避免选择不良的枢轴。
大多数现代快速排序实现都使用 Tukey 的“ninther”从经典 C 数组中选择枢轴。典型的 ninther 实现选择三个唯一的记录三元组,对每个三元组进行排序,然后对每个三元组的中间值进行排序以确定 ninther 值。但是,如前所述,xfile 访问并非完全便宜。事实证明,将九个元素读入内存缓冲区,在缓冲区上运行内核的内存中堆排序,然后选择该缓冲区的第 4 个元素作为枢轴,性能更高。Tukey 的 ninthers 在 J. W. Tukey 的 The ninther, a technique for low-effort robust (resistant) location in large samples 中进行了描述,该文章位于 Contributions to Survey Sampling and Applied Statistics 中,由 H. David 编辑,(Academic Press, 1978),第 251–257 页。
快速排序的分区非常教科书式——围绕枢轴重新排列记录子集,然后设置当前堆栈帧和下一个堆栈帧以分别与枢轴的较大一半和较小一半进行排序。这使堆栈空间要求保持在 log2(记录计数)。
作为最后的性能优化,快速排序的 hi 和 lo 扫描阶段使检查的 xfile 页面尽可能长时间地映射在内核中,以减少映射/取消映射周期。令人惊讶的是,在考虑将堆排序直接应用于 xfile 页面之后,这再次使整体排序运行时减少了近一半。
扩展属性和目录为暂存记录添加了额外的要求:有限长度的任意字节序列。每个目录条目记录都需要存储条目名称,并且每个扩展属性都需要存储属性名称和值。名称、键和值可能会占用大量内存,因此创建了 xfblob
抽象以简化 xfile 之上这些 blob 的管理。
Blob 数组提供 xfblob_load
和 xfblob_store
函数来检索和持久保存对象。store 函数为它持久保存的每个对象返回一个魔术 cookie。稍后,调用者将提供此 cookie 给 xblob_load
以调用该对象。xfblob_free
函数释放特定的 blob,并且 xfblob_truncate
函数释放所有 blob,因为不需要压缩。
修复目录和扩展属性的详细信息将在有关原子文件内容交换的后续章节中讨论。但是,应注意的是,这些修复函数仅使用 blob 存储来缓存少量条目,然后将其添加到临时磁盘文件中,这就是为什么不需要压缩的原因。
提出的补丁集位于 扩展属性修复 系列的开头。
关于 辅助元数据 的章节提到,辅助元数据的检查和修复通常需要在文件系统的实时元数据扫描与更新该元数据的写入器线程之间进行协调。保持扫描数据最新需要能够将元数据更新从文件系统传播到扫描收集的数据中。这可以通过将并发更新附加到单独的日志文件中,并在将新元数据写入磁盘之前应用它们来完成,但是如果系统的其余部分非常繁忙,这会导致无限的内存消耗。另一个选择是跳过 side-log 并将来自文件系统的实时更新直接提交到扫描数据中,这以更多的开销换取更低的最大内存需求。在这两种情况下,保存扫描结果的数据结构都必须支持索引访问才能表现良好。
鉴于这两种策略都需要扫描数据的索引查找,因此在线 fsck 采用第二种策略,即将实时更新直接提交到扫描数据中。但是,由于 xfarray 未索引且不强制执行记录排序,因此它们不适合此任务。但是,方便的是,XFS 有一个库可以创建和维护排序的反向映射记录:现有的 rmap btree 代码!如果只有一种在内存中创建它的方法。
回想一下,xfile 抽象将内存页面表示为常规文件,这意味着内核可以随意创建字节或块可寻址的虚拟地址空间。XFS 缓冲区缓存专门用于将 IO 抽象为面向块的地址空间,这意味着将缓冲区缓存适应为与 xfile 接口可以重用整个 btree 库。构建在 xfile 之上的 btree 统称为 xfbtree
。接下来的几节描述了它们是如何实际工作的。
提出的补丁集是 内存 btree 系列。
需要进行两项修改才能支持 xfile 作为缓冲区缓存目标。首先,要使 struct xfs_buftarg
结构能够托管 struct xfs_buf
rhashtable,因为通常这些结构由每个 AG 结构持有。第二个更改是修改缓冲区 ioapply
函数以从 xfile“读取”缓存的页面,并将缓存的页面“写入”回 xfile。对单个缓冲区的多次访问由 xfs_buf
锁控制,因为 xfile 本身不提供任何锁定。通过这种适应,xfile 支持的缓冲区缓存的用户使用的 API 与磁盘支持的缓冲区缓存的用户完全相同。xfile 和缓冲区缓存之间的分离意味着更高的内存使用率,因为它们不共享页面,但是此属性有一天可能会使对内存中 btree 的事务性更新成为可能。但是,今天,它只是消除了对新代码的需求。
xffile 的空间管理非常简单——每个 btree 块都是一个内存页面大小。这些块使用与磁盘 btree 相同的头部格式,但是内存中的块验证器会忽略校验和,假设 xfile 内存与常规 DRAM 一样不易受到损坏。重用此处现有的代码比绝对内存效率更重要。
支持 xfbtree 的 xfile 的第一个块包含一个头部块。头部描述了所有者、高度和根 xfbtree 块的块号。
要分配 btree 块,请使用 xfile_seek_data
在文件中找到一个间隙。如果没有间隙,请通过扩展 xfile 的长度来创建一个。使用 xfile_prealloc
预分配块的空间,然后返回该位置。要释放 xfbtree 块,请使用 xfile_discard
(内部使用 FALLOC_FL_PUNCH_HOLE
)从 xfile 中删除内存页面。
想要创建 xfbtree 的在线 fsck 函数应按如下方式进行
调用 xfile_create
以创建一个 xfile。
调用 xfs_alloc_memory_buftarg
以创建一个指向 xfile 的缓冲区缓存目标结构。
将缓冲区缓存目标、缓冲区操作和其他信息传递给 xfbtree_init
以初始化传入的 struct xfbtree
,并将初始根块写入 xfile。每种 btree 类型都应该定义一个包装器,将必要的参数传递给创建函数。例如,rmap btree 定义了 xfs_rmapbt_mem_create
,以处理调用者所需的所有细节。
将 xfbtree 对象传递给 btree 游标创建函数,用于该 btree 类型。按照上面的例子,xfs_rmapbt_mem_cursor
负责为调用者处理此操作。
将 btree 游标传递给常规 btree 函数,以便对内存中的 btree 进行查询和更新。例如,rmap xfbtree 的 btree 游标可以像任何其他 btree 游标一样传递给 xfs_rmap_*
函数。有关处理记录到事务的 xfbtree 更新的信息,请参见 下一节。
完成后,删除 btree 游标,销毁 xfbtree 对象,释放缓冲区目标,并销毁 xfile 以释放所有资源。
尽管重用 rmap btree 代码来处理暂存结构是一个巧妙的技巧,但内存中 btree 块存储的短暂性也带来了一些挑战。XFS 事务管理器不得为由 xfile 支持的缓冲区提交缓冲区日志项,因为日志格式不理解对数据设备以外的设备的更新。临时 xfbtree 可能在 AIL 将日志事务检查点写回文件系统时不存在,并且肯定不会在日志恢复期间存在。因此,任何在事务上下文中更新 xfbtree 的代码都必须在提交或取消事务之前,从事务中删除缓冲区日志项并将更新写入支持 xfile。
xfbtree_trans_commit
和 xfbtree_trans_cancel
函数按如下方式实现此功能
查找每个缓冲区日志项,其缓冲区以 xfile 为目标。
记录日志项的 dirty/ordered 状态。
从缓冲区分离日志项。
将缓冲区排队到特殊的 delwri 列表。
如果唯一的 dirty 日志项是在步骤 3 中分离的那些,则清除事务 dirty 标志。
如果正在提交更新,则提交 delwri 列表以将更改提交到 xfile。
以这种方式从事务中删除 xfile 已记录缓冲区后,可以提交或取消事务。
如前所述,在线修复的早期迭代通过创建一个新的 btree 并单独添加观察结果来构建新的 btree 结构。一次加载一个记录的 btree 的一个优点是不需要在提交之前对 incore 记录进行排序,但是速度非常慢,并且如果系统在修复过程中关闭,则会泄漏块。一次加载一个记录也意味着修复无法控制新 btree 中块的加载因子。
幸运的是,历史悠久的 xfs_repair
工具有一种更有效的方法可以从记录集合中重建 btree 索引 -- 批量 btree 加载。由于 xfs_repair
对每种 btree 类型都有单独的复制粘贴实现,因此这在代码方面实现得相当低效。
为了准备在线 fsck,研究了每个批量加载器,记下了笔记,并将这四个加载器重构为单个通用 btree 批量加载机制。这些笔记也进行了更新,并在下面列出。
批量加载的第零步是组装将存储在新 btree 中的整个记录集,并对记录进行排序。接下来,调用 xfs_btree_bload_compute_geometry
以从记录集、btree 类型和任何加载因子偏好来计算 btree 的形状。此信息是资源预留所必需的。
首先,几何计算从 btree 块的大小和块头部的大小来计算适合叶子块的最小和最大记录。粗略地说,记录的最大数量是
maxrecs = (block_size - header_size) / record_size
XFS 设计指定应该尽可能合并 btree 块,这意味着记录的最小数量是 maxrecs 的一半
要确定的下一个变量是所需的加载因子。它必须至少为 minrecs,并且不超过 maxrecs。选择 minrecs 是不可取的,因为它浪费了块的一半。选择 maxrecs 也是不可取的,因为向每个新重建的叶子块添加单个记录将导致树分裂,这会导致性能立即下降。默认加载因子选择为 maxrecs 的 75%,这提供了一个合理紧凑的结构,没有任何立即的分裂惩罚
default_load_factor = (maxrecs + minrecs) / 2
如果空间紧张,加载因子将设置为 maxrecs,以尽量避免耗尽空间
leaf_load_factor = enough space ? default_load_factor : maxrecs
使用 btree 键和指针的组合大小作为记录大小来计算 btree 节点块的加载因子
maxrecs = (block_size - header_size) / (key_size + ptr_size)
minrecs = maxrecs / 2
node_load_factor = enough space ? default_load_factor : maxrecs
完成后,可以将存储记录集所需的叶子块数量计算为
leaf_blocks = ceil(record_count / leaf_load_factor)
指向树中下一级的所需节点块数量计算为
n_blocks = (n == 0 ? leaf_blocks : node_blocks[n])
node_blocks[n + 1] = ceil(n_blocks / node_load_factor)
整个计算递归执行,直到当前级别只需要一个块。生成的几何形状如下
对于以 AG 为根的 btree,此级别是根级别,因此新树的高度为 level + 1
,所需的空间是每个级别的块数的总和。
对于根在 inode 中的 btree,如果顶层的记录不适合 inode 分叉区域,则高度为 level + 2
,所需的空间是每个级别的块数的总和,并且 inode 分叉指向根块。
对于根在 inode 中的 btree,如果顶层的记录可以存储在 inode 分叉区域中,则根块可以存储在 inode 中,高度为 level + 1
,所需的空间比每个级别的块数的总和少一个。只有当非 bmap btree 获得在 inode 中扎根的能力时,这才会变得相关,这是一个未来的补丁集,仅在此处为了完整性而包含。
一旦修复知道新 btree 所需的块数,它将使用可用空间信息来分配这些块。每个预留范围由 btree 构建器状态数据单独跟踪。为了提高崩溃弹性,预留代码还在与每个空间分配相同的事务中记录一个范围释放意图 (EFI) 项,并将其内存中的 struct xfs_extent_free_item
对象附加到空间预留。如果系统关闭,日志恢复将使用未完成的 EFI 来释放未使用的空间,从而使文件系统保持不变。
每次 btree 构建器从预留范围中声明一个用于 btree 的块时,它都会更新内存中的预留以反映已声明的空间。块预留会尝试分配尽可能多的连续空间,以减少正在使用的 EFI 数量。
当修复正在写入这些新的 btree 块时,为空间预留创建的 EFI 会固定磁盘上日志的尾部。系统的其他部分可能会保持繁忙并将日志的头部推向固定的尾部。为了避免文件系统发生死锁,EFI 不得将日志的尾部固定太长时间。为了缓解这个问题,此处重用了延迟操作机制的动态重新记录功能,以提交一个位于日志头部的事务,该事务包含旧 EFI 的 EFD 和头部的新 EFI。这使日志能够释放旧 EFI 以保持日志向前移动。
EFI 在提交和收割阶段都发挥着作用;请参见下一节和关于 收割 的部分了解更多详细信息。
提议的补丁集是 位图重做 和 为批量加载 btree 做准备。
这部分非常简单 -- btree 构建器 (xfs_btree_bulkload
) 从预留列表中声明一个块,写入新的 btree 块头部,用记录填充块的其余部分,并将新的叶子块添加到已写入块的列表
┌────┐
│leaf│
│RRR │
└────┘
每次向级别添加新块时,都会设置兄弟指针
┌────┐ ┌────┐ ┌────┐ ┌────┐
│leaf│→│leaf│→│leaf│→│leaf│
│RRR │←│RRR │←│RRR │←│RRR │
└────┘ └────┘ └────┘ └────┘
当它完成写入记录叶子块时,它会移动到节点块。为了填充节点块,它会遍历树中下一级的每个块,以计算相关的键并将它们写入父节点
┌────┐ ┌────┐
│node│──────→│node│
│PP │←──────│PP │
└────┘ └────┘
↙ ↘ ↙ ↘
┌────┐ ┌────┐ ┌────┐ ┌────┐
│leaf│→│leaf│→│leaf│→│leaf│
│RRR │←│RRR │←│RRR │←│RRR │
└────┘ └────┘ └────┘ └────┘
当它到达根级别时,它已准备好提交新的 btree!
┌─────────┐
│ root │
│ PP │
└─────────┘
↙ ↘
┌────┐ ┌────┐
│node│──────→│node│
│PP │←──────│PP │
└────┘ └────┘
↙ ↘ ↙ ↘
┌────┐ ┌────┐ ┌────┐ ┌────┐
│leaf│→│leaf│→│leaf│→│leaf│
│RRR │←│RRR │←│RRR │←│RRR │
└────┘ └────┘ └────┘ └────┘
提交新 btree 的第一步是将 btree 块同步持久化到磁盘。这有点复杂,因为新的 btree 块可能在最近的过去被释放,因此构建器必须使用 xfs_buf_delwri_queue_here
从 AIL 列表中删除(过时的)缓冲区,然后才能将新块写入磁盘。使用 delwri 列表将块排队以进行 IO,并使用 xfs_buf_delwri_submit
在一个大批处理中写入块。
一旦新的块被持久化到磁盘,控制权将返回到调用批量加载器的单个修复函数。修复函数必须在事务中记录新根的位置,清理为新 btree 所做的空间预留,并收割旧的元数据块
提交新 btree 根的位置。
对于每个 incore 预留
为 btree 构建器使用的所有空间记录范围释放完成 (EFD) 项。新的 EFD 必须指向附加到预留的 EFI,以防止日志恢复释放新的块。
对于 incore 预留的未声明部分,创建一个常规的延迟范围释放工作项,以便稍后在事务链中释放未使用的空间。
在步骤 2a 和 2b 中记录的 EFD 和 EFI 不得超出提交事务的预留。如果 btree 加载代码怀疑这可能即将发生,它必须调用 xrep_defer_finish
以清除延迟工作并获得新的事务。
第二次清除延迟工作以完成提交并清理修复事务。
步骤 2c 和 3 中滚动的事务代表了修复算法中的一个弱点,因为在收割步骤结束之前进行日志刷新和崩溃可能会导致空间泄漏。在线修复函数通过使用非常大的事务来最大限度地减少这种情况发生的可能性,每个事务都可以容纳数千个块释放指令。修复会继续收割旧块,这将在以下 部分 中在几个批量加载的案例研究之后进行介绍。
重建 inode 索引 btree 的高级过程是
遍历反向映射记录以从 inode 块信息和旧 inode btree 块的位图生成 struct xfs_inobt_rec
记录。
按 inode 顺序将记录附加到 xfarray。
使用 xfs_btree_bload_compute_geometry
函数来计算 inode btree 所需的块数。如果启用了可用空间 inode btree,则再次调用它以估计 finobt 的几何形状。
分配上一步中计算的块数。
使用 xfs_btree_bload
将 xfarray 记录写入 btree 块并生成内部节点块。如果启用了可用空间 inode btree,则再次调用它以加载 finobt。
将新 btree 根块的位置提交到 AGI。
使用在步骤 1 中创建的位图收割旧的 btree 块。
详细信息如下。
inode btree 将 inumber 映射到关联的 inode 记录在磁盘上的位置,这意味着 inode btree 可以从反向映射信息重建。所有者为 XFS_RMAP_OWN_INOBT
的反向映射记录标记了旧 inode btree 块的位置。所有者为 XFS_RMAP_OWN_INODES
的每个反向映射记录标记了至少一个 inode 集群缓冲区的位置。集群是在单个事务中可以分配或释放的最小数量的磁盘上 inode;它永远不小于 1 个 fs 块或 4 个 inode。
对于每个 inode 集群代表的空间,请确保在可用空间 btree 中没有任何记录,并且在引用计数 btree 中也没有任何记录。如果有,则空间元数据不一致足以中止操作。否则,读取每个集群缓冲区以检查其内容是否显示为磁盘上 inode,并确定该文件是否已分配 (xfs_dinode.i_mode != 0
) 或已释放 (xfs_dinode.i_mode == 0
)。累积连续 inode 集群缓冲区读取的结果,直到有足够的信息来填充单个 inode 块记录,即 inumber 键空间中的 64 个连续数字。如果块是稀疏的,则块记录可能包含空洞。
一旦修复函数累积了一个块的数据值,它将调用 xfarray_append
以将 inode btree 记录添加到 xfarray。在 btree 创建步骤中,此 xfarray 被遍历两次 -- 第一次使用所有 inode 块记录填充 inode btree,第二次使用具有空闲非稀疏 inode 的块的记录填充空闲 inode btree。inode btree 的记录数是 xfarray 记录数,但必须在 xfarray 中存储 inode 块记录时计算空闲 inode btree 的记录计数。
提议的补丁集是 AG btree 修复 系列。
反向映射记录用于重建引用计数信息。引用计数对于共享文件数据的写时复制的正确操作是必需的。将反向映射条目想象成代表物理块范围的矩形,并且可以放置矩形以允许它们相互重叠。从下面的图中可以明显看出,引用计数记录必须在堆栈的高度发生变化的任何位置开始或结束。换句话说,记录发射刺激是电平触发的
█ ███
██ █████ ████ ███ ██████
██ ████ ███████████ ████ █████████
████████████████████████████████ ███████████
^ ^ ^^ ^^ ^ ^^ ^^^ ^^^^ ^ ^^ ^ ^ ^
2 1 23 21 3 43 234 2123 1 01 2 3 0
磁盘上的引用计数 btree 不存储 refcount == 0 的情况,因为可用空间 btree 已经记录了哪些块是空闲的。用于暂存写时复制操作的范围应该是唯一 refcount == 1 的记录。单个所有者的文件块不会记录在可用空间或引用计数 btree 中。
重建引用计数 btree 的高级过程是
遍历反向映射记录以生成 struct xfs_refcount_irec
记录,用于任何具有多个反向映射的空间,并将它们添加到 xfarray。所有者为 XFS_RMAP_OWN_COW
的任何记录也会添加到 xfarray,因为这些范围是分配用于暂存写时复制操作的范围,并在 refcount btree 中进行跟踪。
使用所有者为 XFS_RMAP_OWN_REFC
的任何记录来创建旧 refcount btree 块的位图。
按物理范围顺序对记录进行排序,将 CoW 暂存范围放在 xfarray 的末尾。这与 refcount btree 中记录的排序顺序相匹配。
使用 xfs_btree_bload_compute_geometry
函数来计算新树所需的块数。
分配上一步中计算的块数。
使用 xfs_btree_bload
将 xfarray 记录写入 btree 块并生成内部节点块。
将新 btree 根块的位置提交到 AGF。
使用在步骤 1 中创建的位图收割旧的 btree 块。
详细信息如下;xfs_repair
使用相同的算法从反向映射记录生成 refcount 信息。
在这种情况下,类似包的结构是 xfarray 访问模式 部分中讨论的类型 2 xfarray。使用 xfarray_store_anywhere
将反向映射添加到包中,并使用 xfarray_unset
删除。通过 xfarray_iter
循环检查包成员。
提议的补丁集是 AG btree 修复 系列。
重建数据/属性分叉映射 btree 的高级过程是
遍历反向映射记录以从该 inode 和分叉的反向映射记录生成 struct xfs_bmbt_rec
记录。将这些记录附加到 xfarray。从 BMBT_BLOCK
记录计算旧 bmap btree 块的位图。
使用 xfs_btree_bload_compute_geometry
函数来计算新树所需的块数。
按文件偏移量顺序对记录进行排序。
如果范围记录适合 inode 分叉立即区域,则将记录提交到该立即区域并跳到步骤 8。
分配上一步中计算的块数。
使用 xfs_btree_bload
将 xfarray 记录写入 btree 块并生成内部节点块。
将新的 btree 根块提交到 inode 分叉立即区域。
使用在步骤 1 中创建的位图收割旧的 btree 块。
这里有一些复杂性:首先,如果数据和属性分叉都不是 BMBT 格式,则可以移动分叉偏移量以调整立即区域的大小。其次,如果分叉映射足够少,则可以使用 EXTENTS 格式而不是 BMBT,这可能需要转换。第三,必须小心地重新加载 incore 范围映射,以避免干扰任何延迟分配范围。
提议的补丁集是 文件映射修复 系列。
Inode记录必须小心处理,因为它们既有磁盘上的记录(“dinodes”),也有内存中的(“缓存”)表示形式。如果在线fsck在访问磁盘上的元数据时,没有小心谨慎地访问磁盘上的元数据,因为磁盘上的元数据损坏严重,文件系统无法加载内存中的表示形式,那么存在很高的缓存一致性问题风险。当在线fsck想要打开一个损坏的文件进行清理时,它必须使用专门的资源获取函数,该函数返回内存中的表示形式或一个锁,该锁对于阻止对磁盘上位置的任何更新是必需的。
应该对磁盘上的inode缓冲区进行的唯一修复是为了加载核心结构所必需的任何操作。这意味着修复inode集群缓冲区和inode fork验证程序捕获的任何内容,并重试iget
操作。如果第二次iget
失败,则修复失败。
一旦加载了内存中的表示形式,修复程序可以锁定inode,并可以对其进行全面的检查、修复和优化。大多数inode属性都很容易检查和约束,或者是用户控制的任意位模式;这些都容易修复。处理数据和attr fork范围计数以及文件块计数更为复杂,因为计算正确的值需要遍历fork,或者如果失败,则使字段无效并等待fork fsck函数运行。
建议的补丁集是inode修复系列。
与inode类似,配额记录(“dquots”)也具有磁盘上的记录和内存中的表示形式,因此也受到相同的缓存一致性问题的约束。有些令人困惑的是,在XFS代码库中,两者都称为dquots。
应该对磁盘上的配额记录缓冲区进行的唯一修复是为了加载核心结构所必需的任何操作。一旦加载了内存中的表示形式,唯一需要检查的属性是明显错误的限制和计时器值。
配额使用计数器的检查、修复和讨论在关于动态quotacheck的章节中单独进行。
建议的补丁集是配额修复系列。
文件系统摘要计数器跟踪文件系统资源的可用性,例如自由块、自由inode和已分配inode。此信息可以通过遍历自由空间和inode索引来编译,但这是一个缓慢的过程,因此XFS在磁盘上的超级块中维护了一个副本,该副本应反映磁盘上的元数据,至少在文件系统已干净卸载时是这样。出于性能原因,XFS还维护这些计数器的核心副本,这些副本是为活动事务启用资源保留的关键。编写器线程从核心计数器保留最坏情况下的资源数量,并在提交时返回他们不使用的任何资源。因此,只有在将超级块提交到磁盘时,才需要对超级块进行序列化。
XFS v5中引入的惰性超级块计数器功能通过训练日志恢复以从AG标头重新计算摘要计数器,从而更进一步,这消除了大多数事务甚至触摸超级块的需求。XFS提交摘要计数器的唯一时间是在文件系统卸载时。为了进一步减少争用,核心计数器被实现为percpu计数器,这意味着每个CPU都从全局核心计数器分配一批块,并且可以满足来自本地批的小分配。
摘要计数器的高性能性质使得在线fsck难以检查它们,因为无法在系统运行时停止percpu计数器。尽管在线fsck可以读取文件系统元数据来计算摘要计数器的正确值,但无法保持percpu计数器的值稳定,因此在遍历完成时,计数器很可能已过期。早期版本的在线清理会将不完整的扫描标志返回给用户空间,但这对于系统管理员来说不是一个令人满意的结果。对于修复,在遍历文件系统元数据以获得准确的读取并将其安装在percpu计数器中时,必须稳定内存中的计数器。
为了满足此要求,在线fsck必须阻止系统中其他程序启动新的文件系统写入,它必须禁用后台垃圾收集线程,并且它必须等待现有编写器程序退出内核。一旦建立,清理就可以遍历AG自由空间索引、inode B树和实时位图,以计算所有四个摘要计数器的正确值。这与文件系统冻结非常相似,尽管并非所有部分都是必需的。
有了这段代码,现在可以暂停文件系统,只需足够长的时间来检查和更正摘要计数器。
历史侧边栏: |
最初的实现使用了实际的VFS文件系统冻结机制来停止文件系统活动。在文件系统冻结的情况下,可以精确地解析计数器值,但是直接调用VFS方法存在许多问题
其他程序可以在我们不知情的情况下解冻文件系统。这导致不正确的扫描结果和不正确的修复。
添加额外的锁以防止其他人解冻文件系统需要在freeze_fs() 周围添加一个->freeze_super 函数。反过来,这导致了其他微妙的问题,因为事实证明VFS freeze_super 和thaw_super 函数可以删除对VFS超级块的最后一个引用,并且任何后续访问都会成为UAF错误!如果底层块设备冻结了文件系统,则可能发生这种情况。这个问题可以通过获取对超级块的额外引用来解决,但是鉴于此方法的其他不足之处,感觉并不理想。
无需停止日志即可检查摘要计数器,但是VFS冻结无论如何都会启动一个。这会给动态fscounter fsck操作增加不必要的运行时。
停止日志意味着XFS会将(可能不正确的)计数器刷新到磁盘,作为清理日志的一部分。
VFS中的一个错误意味着即使sync_filesystem无法刷新文件系统并返回错误,冻结也可能完成。此错误已在Linux 5.17中修复。
|
建议的补丁集是摘要计数器清理系列。
某些类型的元数据只能通过遍历整个文件系统中的每个文件来记录观察结果,并将观察结果与磁盘上记录的内容进行比较来检查。与任何其他类型的在线修复一样,修复是通过将这些观察结果写入替换结构并以原子方式提交来进行的。但是,关闭整个文件系统以检查数千亿个文件是不切实际的,因为停机时间会过长。因此,在线fsck必须构建基础结构来管理对文件系统中所有文件的动态扫描。要执行动态遍历,需要解决两个问题
清理程序在收集数据时如何管理扫描?
扫描如何跟上其他线程对系统进行的更改?
在1970年代的原始Unix文件系统中,每个目录条目都包含一个索引号(inumber),该索引号用作固定大小记录(inodes)的磁盘上数组(itable)的索引,这些记录描述了文件的属性及其数据块映射。J. Lions在Lions’ Commentary on UNIX, 6th Edition,(Dept. of Computer Science, the University of New South Wales, November 1977),pp. 18-2; 中描述了该系统。后来由D. Ritchie和K. Thompson在The UNIX Time-Sharing System,(The Bell System Technical Journal, July 1978),pp. 1913-4. 中的“inode (5659)”和“Implementation of the File System”中进行了描述。
XFS保留了此设计的大部分内容,只是现在inumber是数据部分文件系统中所有空间上的搜索键。它们形成一个连续的密钥空间,可以用64位整数表示,尽管inode本身在密钥空间中是稀疏分布的。扫描以线性方式在inumber密钥空间中进行,从0x0
开始,到0xFFFFFFFFFFFFFFFF
结束。自然地,通过密钥空间的扫描需要一个扫描光标对象来跟踪扫描进度。由于此密钥空间是稀疏的,因此该光标包含两个部分。此扫描光标对象的第一个部分跟踪下一个将要检查的inode;称其为检查光标。不太明显的是,扫描光标对象还必须跟踪密钥空间的哪些部分已经被访问过,这对于确定是否需要将并发文件系统更新合并到扫描数据中至关重要。称其为已访问inode光标。
推进扫描光标是一个多步骤过程,封装在xchk_iscan_iter
中
锁定包含由已访问inode光标指向的inode的AGI缓冲区。这保证了在推进光标时,此AG中的inode无法被分配或释放。
使用per-AG inode B树查找刚访问的inode之后的下一个inumber,因为它可能不是密钥空间相邻的。
如果此AG中没有剩余的inode
将检查光标移动到与下一个AG的开头相对应的inumber密钥空间点。
调整已访问inode光标,以指示它已“访问”当前AG的inode密钥空间中的最后一个可能的inode。XFS inumber是分段的,因此光标需要标记为已访问直到刚好在下一个AG的inode密钥空间开始之前的所有密钥空间。
解锁AGI,如果文件系统中存在未检查的AG,则返回到步骤1。
如果没有更多AG要检查,请将两个光标都设置为inumber密钥空间的末尾。扫描现已完成。
否则,此AG中至少还有另一个inode要扫描
将检查光标向前移动到inode B树标记为已分配的下一个inode。
调整已访问inode光标以指向刚好在检查光标当前位置之前的inode。因为扫描仪持有AGI缓冲区锁,所以无法在已访问inode光标刚刚推进的inode密钥空间的一部分中创建任何inode。
获取检查光标的inumber的核心inode。通过将AGI缓冲区锁保持到此时,扫描仪知道可以安全地跨整个密钥空间推进检查光标,并且它已经稳定了下一个inode,因此直到扫描释放核心inode,它才可能从文件系统中消失。
放下AGI锁,并将核心inode返回给调用方。
在线fsck函数按以下方式扫描文件系统中的所有文件
通过调用xchk_iscan_start
启动扫描。
推进扫描光标(xchk_iscan_iter
)以获取下一个inode。如果提供了一个inode
锁定inode以防止在扫描期间进行更新。
扫描inode。
在仍持有inode锁定的情况下,调整已访问inode光标(xchk_iscan_mark_visited
)以指向此inode。
解锁并释放inode。
调用xchk_iscan_teardown
以完成扫描。
inode缓存存在一些细微之处,这使得为调用方抓取核心inode变得复杂。显然,inode元数据必须足够一致才能将其加载到inode缓存中。其次,如果核心inode卡在某个中间状态,则扫描协调器必须释放AGI并推动主文件系统以使inode恢复到可加载状态。
建议的补丁是inode扫描仪系列。新功能的第一个用户是在线quotacheck系列。
在常规文件系统代码中,对已分配XFS核心inode的引用始终在事务上下文之外获取(xfs_iget
),因为为现有文件创建核心上下文不需要元数据更新。但是,重要的是要注意,作为文件创建一部分获取的对核心inode的引用必须在事务上下文中执行,因为文件系统必须确保磁盘上的inode B树索引更新和实际磁盘上的inode初始化的原子性。
对核心inode的引用始终在事务上下文之外释放(xfs_irele
),因为有一些活动可能需要磁盘上的更新
这些活动统称为inode失活。失活有两个部分——VFS部分,它启动所有脏文件页的回写,以及XFS部分,它清理XFS特定的信息并在inode未链接时释放inode。如果inode未链接(或在文件句柄操作后未连接),则内核会立即将inode放入失活机制中。
在正常操作期间,更新的资源获取遵循此顺序以避免死锁
Inode引用(iget
)。
文件系统冻结保护,如果在修复(mnt_want_write_file
)。
Inode IOLOCK
(VFS i_rwsem
)锁来控制文件IO。
Inode MMAPLOCK
(页面缓存invalidate_lock
)锁用于可以更新页面缓存映射的操作。
日志功能启用。
事务日志空间授予。
数据和实时设备上的事务空间。
如果正在修复文件,则为核心dquot引用。请注意,它们没有被锁定,只是被获取。
Inode ILOCK
用于文件元数据更新。
AG标头缓冲区锁/实时元数据inode ILOCK。
实时元数据缓冲区锁,如果适用。
范围映射B树块,如果适用。
资源通常以相反的顺序释放,尽管这不是必需的。但是,在线fsck与常规XFS操作不同,因为它可能会检查通常在锁定顺序的后期阶段获取的对象,然后决定将该对象与在顺序中较早获取的对象进行交叉引用。接下来的几个部分详细介绍了在线fsck如何小心避免死锁的具体方法。
代表清理操作执行的inode扫描在事务上下文中运行,并且可能已经锁定了资源并将其绑定到该事务上下文中。对于iget
来说,这并不是什么大问题,因为它可以在现有事务的上下文中运行,只要所有绑定资源都在常规文件系统中的inode引用之前获取即可。
当VFS iput
函数被赋予一个没有其他引用的链接inode时,它通常会将inode放在LRU列表中,希望如果另一个进程在系统耗尽内存并释放inode之前重新打开该文件,则可以节省时间。文件系统调用方可以通过在inode上设置DONTCACHE
标志来使LRU过程短路,以导致内核尝试立即将inode放入失活机制中。
过去,失活总是从删除inode的进程完成的,这对于清理来说是一个问题,因为清理可能已经持有一个事务,并且XFS不支持嵌套事务。另一方面,如果没有清理事务,则最好立即删除未使用的inode,以避免污染缓存。为了捕捉这些细微之处,在线fsck代码有一个单独的xchk_irele
函数来设置或清除DONTCACHE
标志以获得所需的释放行为。
建议的补丁集包括修复清理iget用法和dir iget用法。
在常规文件系统代码中,VFS和XFS将以众所周知的顺序获取多个IOLOCK锁:更新目录树时为父→子,否则按其struct inode
对象的地址的数字顺序获取。对于常规文件,可以在获取IOLOCK后获取MMAPLOCK以停止页面错误。如果必须获取两个MMAPLOCK,则按其struct address_space
对象的地址的数字顺序获取。由于现有文件系统代码的结构,必须在分配事务之前获取IOLOCK和MMAPLOCK。如果必须获取两个ILOCK,则按inumber顺序获取。
在协调的inode扫描期间,必须小心执行Inode锁获取。在线fsck不能遵守这些约定,因为对于目录树扫描仪,清理进程持有正在扫描的文件的IOLOCK,并且需要获取目录链接另一端文件的IOLOCK。如果目录树已损坏,因为它包含一个循环,则xfs_scrub
无法使用常规inode锁定函数并避免陷入ABBA死锁。
解决这两个问题很简单——任何时候在线fsck需要获取同一类的第二个锁时,它都会使用trylock来避免ABBA死锁。如果trylock失败,则清理会删除所有inode锁,并使用trylock循环来(重新)获取所有必需的资源。Trylock循环使清理可以检查挂起的致命信号,这就是清理避免死锁文件系统或成为无响应进程的方式。但是,trylock循环意味着在线fsck必须准备好测量锁定周期之前和之后的清理资源,以检测更改并做出相应的反应。
考虑目录父指针修复代码作为一个例子。在线fsck必须验证目录的dotdot dirent是否指向父目录,并且父目录是否包含仅一个指向子目录的dirent。完全验证这种关系(并在可能的情况下修复它)需要在持有子级锁定的情况下遍历文件系统上的每个目录,并且在目录树正在更新时进行。协调的inode扫描提供了一种遍历文件系统的方法,而不会遗漏inode。子目录保持锁定以防止更新dotdot dirent,但是如果扫描仪无法锁定父级,则可以删除并重新锁定子级和预期的父级。如果在目录解锁时dotdot条目发生更改,则移动或重命名操作必须已更改子级的父级,并且扫描可以提前退出。
建议的补丁集是目录修复系列。
在线fsck函数在完整文件系统扫描期间需要的第二个支持是能够随时了解文件系统中其他线程所做的更新,因为与过去的比较在动态环境中毫无用处。两个Linux内核基础结构使在线fsck能够监视常规文件系统操作:文件系统挂钩和静态键。
文件系统挂钩将有关正在进行的文件系统操作的信息传递给下游使用者。在这种情况下,下游使用者始终是在线fsck函数。由于多个fsck函数可以并行运行,因此在线fsck使用Linux通知程序调用链工具将更新分派给任何数量的感兴趣的fsck进程。调用链是一个动态列表,这意味着可以在运行时对其进行配置。由于这些挂钩是XFS模块私有的,因此传递的信息仅包含检查函数更新其观察结果所需的内容。
当前XFS挂钩的实现使用SRCU通知程序链来减少对高度线程化工作负载的影响。常规阻塞通知程序链使用rwsem,并且对于单线程应用程序而言,开销似乎要低得多。但是,事实证明,阻塞链和静态键的组合是更高效的组合;此处需要更多研究。
要挂钩文件系统中的某个点,需要以下部分
一个struct xfs_hooks
对象必须嵌入在方便的位置,例如众所周知的核心文件系统对象中。
每个挂钩必须定义一个操作代码和一个包含有关操作的更多上下文的结构。
挂钩提供程序应在xfs_hooks
和xfs_hook
对象周围提供适当的包装器函数和结构,以利用类型检查来确保正确使用。
必须选择常规文件系统代码中的调用站点来使用操作代码和数据结构调用xfs_hooks_call
。此位置应与文件系统更新提交到事务的位置相邻(且不早于该位置)。通常,当文件系统调用挂钩链时,它应该能够处理休眠并且不应容易受到内存回收或锁定递归的影响。但是,确切的要求在很大程度上取决于挂钩调用方和被调用方的上下文。
在线fsck函数应定义一个结构来保存扫描数据,一个锁来协调对扫描数据的访问,以及一个struct xfs_hook
对象。扫描仪函数和常规文件系统代码必须以相同的顺序获取资源;有关详细信息,请参见下一节。
在线fsck代码必须包含一个C函数来捕获挂钩操作代码和数据结构。如果正在更新的对象已被扫描访问过,则必须将挂钩信息应用于扫描数据。
在解锁inode以开始扫描之前,在线fsck必须调用xfs_hooks_setup
来初始化struct xfs_hook
,并调用xfs_hooks_add
来启用挂钩。
在线fsck必须调用xfs_hooks_del
以在扫描完成后禁用挂钩。
应将挂钩数量保持在最低限度以降低复杂性。静态键用于在在线fsck未运行时将文件系统挂钩的开销降低到几乎为零。
在线 fsck 扫描代码和hook住的文件系统代码的代码路径如下所示
other program
↓
inode lock ←────────────────────┐
↓ │
AG header lock │
↓ │
filesystem function │
↓ │
notifier call chain │ same
↓ ├─── inode
scrub hook function │ lock
↓ │
scan data mutex ←──┐ same │
↓ ├─── scan │
update scan data │ lock │
↑ │ │
scan data mutex ←──┘ │
↑ │
inode lock ←────────────────────┘
↑
scrub function
↑
inode scanner
↑
xfs_scrub
必须遵循以下规则,以确保检查代码和对文件系统进行更新的代码之间的正确交互
在调用通知器调用链之前,被 hook 住的文件系统函数必须获取与 scrub 扫描函数扫描 inode 时获取的相同的锁。
扫描函数和 scrub hook 函数必须通过获取扫描数据上的锁来协调对扫描数据的访问。
Scrub hook 函数不得将实时更新信息添加到扫描观察结果中,除非正在更新的 inode 已经过扫描。 扫描协调器为此提供了一个辅助谓词 (xchk_iscan_want_live_update
)。
Scrub hook 函数不得更改调用者的状态,包括它正在运行的事务。 它们不得获取任何可能与被 hook 住的文件系统函数冲突的资源。
hook 函数可以中止 inode 扫描以避免违反其他规则。
inode 扫描 API 非常简单
xchk_iscan_start
启动扫描
xchk_iscan_iter
获取对扫描中的下一个 inode 的引用,如果没有剩余的要扫描的内容,则返回零
xchk_iscan_want_live_update
用于确定 inode 是否已在扫描中访问过。 这对于 hook 函数决定是否需要更新内存中的扫描信息至关重要。
xchk_iscan_mark_visited
用于将 inode 标记为已在扫描中访问过
xchk_iscan_teardown
用于完成扫描
此功能也是 inode 扫描器 系列的一部分。
比较挂载时间配额检查代码与在线修复配额检查代码非常有用。 挂载时间配额检查不必与并发操作竞争,因此它执行以下操作
确保 ondisk dquot 的状态足够好,所有 incore dquot 都可以实际加载,并将 ondisk 缓冲区中的资源使用计数器归零。
遍历文件系统中的每个 inode。 将每个文件的资源使用情况添加到 incore dquot。
遍历每个 incore dquot。 如果 incore dquot 没有被刷新,则将支持 incore dquot 的 ondisk 缓冲区添加到延迟写入 (delwri) 列表。
将缓冲区列表写入磁盘。
与大多数在线 fsck 函数一样,在线配额检查在新的收集的元数据反映所有文件系统状态之前,无法写入常规文件系统对象。 因此,在线配额检查将文件资源使用情况记录到使用稀疏 xfarray
实现的影子 dquot 索引中,并且仅在扫描完成后才写入真正的 dquot。 处理事务性更新很棘手,因为配额资源使用情况更新分阶段处理,以最大限度地减少对 dquot 的争用
所涉及的 inode 被连接并锁定到事务。
对于附加到文件的每个 dquot
dquot 被锁定。
配额预留被添加到 dquot 的资源使用情况中。 预留记录在事务中。
dquot 被解锁。
实际配额使用情况的变化在事务中被跟踪。
在事务提交时,再次检查每个 dquot
dquot 再次被锁定。
配额使用情况更改被记录,未使用的预留被返回给 dquot。
dquot 被解锁。
对于在线配额检查,hook 被放置在步骤 2 和 4 中。步骤 2 的 hook 创建事务 dquot 上下文 (dqtrx
) 的影子版本,其操作方式与常规代码类似。 步骤 4 的 hook 将影子 dqtrx
更改提交到影子 dquot。 请注意,这两个 hook 都使用锁定的 inode 调用,这就是实时更新与 inode 扫描器协调的方式。
配额检查扫描如下所示
设置一个协调的 inode 扫描。
对于 inode 扫描迭代器返回的每个 inode
获取并锁定 inode。
确定 inode 的资源使用情况(数据块、inode 计数、实时块),并将其添加到与 inode 关联的用户、组和项目 ID 的影子 dquot 中。
解锁并释放inode。
对于系统中的每个 dquot
获取并锁定 dquot。
根据扫描创建并由实时 hook 更新的影子 dquot 检查 dquot。
实时更新是能够遍历每个配额记录的关键,而无需长时间持有任何锁。 如果需要修复,则锁定真实 dquot 和影子 dquot,并且它们的资源计数被设置为影子 dquot 中的值。
提议的补丁集是 在线配额检查 系列。
文件链接计数检查也使用实时更新 hook。 协调的 inode 扫描器用于访问文件系统上的所有目录,并且每个文件的链接计数记录存储在按 inumber 索引的稀疏 xfarray
中。 在扫描阶段,目录中的每个条目都会生成观察数据,如下所示
如果条目是根目录的 dotdot ('..'
) 条目,则目录的父链接计数会增加,因为根目录的 dotdot 条目是自引用的。
如果条目是子目录的 dotdot 条目,则父目录的反向引用计数会增加。
如果条目既不是 dot 也不是 dotdot 条目,则目标文件的父计数会增加。
如果目标是子目录,则父目录的子链接计数会增加。
要理解链接计数 inode 扫描器如何与实时更新 hook 交互的一个关键点是,扫描光标跟踪哪些父目录已被扫描。 换句话说,当 A 尚未被扫描时,即使 B 已被扫描,实时更新也会忽略有关 A → B
的任何更新。 此外,具有指向 B 的 dotdot 条目的子目录 A 被计为 A 的影子数据中的反向引用计数器,因为子 dotdot 条目会影响父目录的链接计数。 实时更新 hook 被谨慎地放置在文件系统中创建、更改或删除目录条目的所有部分,因为这些操作涉及 bumplink 和 droplink。
对于任何文件,正确的链接计数是父目录的数量加上子目录的数量。 非目录永远没有任何类型的子目录。 反向引用信息用于检测指向子目录的链接数量和指向后方的 dotdot 条目数量中的不一致。
扫描完成后,可以通过锁定 inode 和影子数据并比较链接计数来检查每个文件的链接计数。 第二个协调的 inode 扫描光标用于比较。 实时更新是能够在 inode 之间无需持有任何锁的情况下遍历每个 inode 的关键。 如果需要修复,则 inode 的链接计数被设置为影子信息中的值。 如果没有找到父目录,则必须将该文件重新指向孤儿院,以防止该文件永远丢失。
提议的补丁集是 文件链接计数修复 系列。
大多数修复函数遵循相同的模式:锁定文件系统资源,遍历现存的 ondisk 元数据以查找替换元数据记录,并使用内存中的数组来存储收集到的观察结果。 这种方法的主要优点是修复代码的简单性和模块化 - 代码和数据完全包含在 scrub 模块中,不需要主文件系统中的 hook,并且通常在内存使用方面最有效。 这种修复方法的第二个优点是原子性 - 一旦内核确定某个结构已损坏,在内核完成修复和重新验证元数据之前,没有其他线程可以访问该元数据。
对于在文件系统的分片中进行的修复,这些优点超过了在修复分片的部分时锁定分片所固有的延迟。 不幸的是,反向映射 btree 的修复不能使用“标准” btree 修复策略,因为它必须扫描文件系统中每个文件的每个分支的每个空间映射,并且文件系统不能停止。 因此,rmap 修复放弃了 scrub 和修复之间的原子性。 它结合了协调的 inode 扫描器、实时更新 hook和内存中的 rmap btree来完成反向映射记录的扫描。
设置一个 xfbtree 来暂存 rmap 记录。
在持有 scrub 期间获取的 AGI 和 AGF 缓冲区上的锁时,为所有 AG 元数据生成反向映射:inodes、btrees、CoW 暂存 extent 和内部日志。
设置一个 inode 扫描器。
hook 到正在修复的 AG 的 rmap 更新中,以便实时扫描数据可以在文件扫描期间接收来自文件系统其余部分的反向映射 btree 的更新。
对于在扫描的每个文件的任一分支中找到的每个空间映射,确定该映射是否与感兴趣的 AG 匹配。 如果是,则
为内存中的 btree 创建一个 btree 光标。
使用 rmap 代码将记录添加到内存中的 btree。
使用特殊提交函数将 xfbtree 更改写入 xfile。
对于通过 hook 接收到的每个实时更新,确定所有者是否已经被扫描。 如果是,则将实时更新应用于扫描数据中
为内存中的 btree 创建一个 btree 光标。
将操作重放到内存中的 btree 中。
使用特殊提交函数将 xfbtree 更改写入 xfile。 这是使用空事务执行的,以避免更改调用者的状态。
当 inode 扫描完成时,创建一个新的 scrub 事务并重新锁定两个 AG 标头。
像所有其他 btree 重建函数一样,使用影子 btree 中的 rmap 记录数计算新的 btree 几何。
分配上一步中计算的块数。
执行通常的 btree 批量加载并提交以安装新的 rmap btree。
如关于如何在 rmap btree 修复后进行收割的案例研究中所讨论的那样,收割旧的 rmap btree 块。
释放 xfbtree,因为它现在不需要了。
提议的补丁集是 rmap 修复 系列。
XFS 在文件分支中存储大量的元数据:目录、扩展属性、符号链接目标、实时卷的可用空间位图和摘要信息以及配额记录。 文件分支将 64 位逻辑文件分支空间 extent 映射到物理存储空间 extent,类似于内存管理单元将 64 位虚拟地址映射到物理内存地址的方式。 因此,基于文件的树结构(例如目录和扩展属性)使用映射在文件分支偏移地址空间中的块,这些块指向映射在同一地址空间内的其他块,而基于文件的线性结构(例如位图和配额记录)则计算文件分支偏移地址空间中的数组元素偏移量。
由于文件分支可以消耗与整个文件系统一样多的空间,因此即使有分页方案可用,也无法在内存中暂存修复。 因此,基于文件的元数据的在线修复会在 XFS 文件系统中创建一个临时文件,将新的结构以正确的偏移量写入临时文件,并以原子方式交换所有文件分支映射(以及分支内容)以提交修复。 修复完成后,可以根据需要收割旧的分支; 如果系统在收割期间关闭,则 iunlink 代码将在日志恢复期间删除这些块。
注意:文件系统中的所有空间使用情况和 inode 索引必须一致才能安全地使用临时文件! 此依赖关系是在线修复只能使用可分页内核内存来暂存 ondisk 空间使用情况信息的原因。
使用临时文件交换元数据文件映射要求块标头的 owner 字段与正在修复的文件匹配,而不是与临时文件匹配。 目录、扩展属性和符号链接函数都经过修改,允许调用者显式指定所有者编号。
收割过程存在一个缺点——如果在收割阶段系统崩溃并且分支 extent 是交叉链接的,则 iunlink 处理将失败,因为释放空间会找到额外的反向映射并中止。
为修复创建的临时文件类似于用户空间创建的 O_TMPFILE
文件。 它们未链接到目录中,并且当对该文件的最后一个引用丢失时,将收割整个文件。 主要区别在于,这些文件必须完全没有内核外部的访问权限,它们必须被特别标记以防止被句柄打开,并且它们绝不能链接到目录树中。
历史侧边栏: |
在文件元数据修复的初始迭代中,将扫描损坏的元数据块以查找可挽救的数据; 将收割文件分支中的 extent; 然后将在其位置构建新的结构。 该策略并未在本文档前面表达的原子修复要求的引入中幸存下来。
第二次迭代探索了从挽救数据在分支的高偏移量处构建第二个结构,收割旧的 extent,并使用 COLLAPSE_RANGE 操作将新的 extent 滑动到位。
这有很多缺点
数组结构是线性寻址的,并且常规文件系统代码库没有可以应用于记录偏移量计算以构建备用副本的线性偏移量的概念。
扩展属性允许使用整个 attr 分支偏移地址空间。
即使修复可以在分支地址空间的不同部分构建数据结构的备用副本,原子修复提交要求也意味着在线修复必须能够执行日志辅助 COLLAPSE_RANGE 操作以确保旧结构被完全替换。
在构建辅助树之后但在范围折叠之前崩溃将导致文件分支中存在无法访问的块。 这可能会进一步混淆事情。
修复后收割块不是一个简单的操作,并且在日志恢复期间从重新启动的范围折叠操作启动收割操作令人望而却步。
目录条目块和配额记录在每个块的标头区域中记录文件分支偏移量。 原子范围折叠操作必须重写每个块标头的这一部分。 重写块标头中的单个字段不是一个大问题,但需要注意。
目录或扩展属性 btree 索引中的每个块都包含同级块和子块指针。 如果原子提交要使用范围折叠操作,则必须非常小心地重写每个块以保留图形结构。 作为范围折叠的一部分执行此操作意味着重复重写大量块,这不利于快速修复。
这导致引入了临时文件暂存。
|
在线修复代码应使用 xrep_tempfile_create
函数在文件系统内创建临时文件。 这会分配一个 inode,将 incore inode 标记为私有,并将其附加到 scrub 上下文。 这些文件对用户空间隐藏,可能不会添加到目录树中,并且必须保持私有。
临时文件仅使用两个 inode 锁:IOLOCK 和 ILOCK。 此处不需要 MMAPLOCK,因为数据分支块的用户空间不得存在页面错误。 这两个锁的使用模式与任何其他 XFS 文件相同——对文件数据的访问通过 IOLOCK 控制,对文件元数据的访问通过 ILOCK 控制。 提供了锁定帮助程序,以便 scrub 上下文可以清理临时文件及其锁定状态。 为了符合inode 锁定部分中规定的嵌套锁定策略,建议 scrub 函数使用 xrep_tempfile_ilock*_nowait 锁定帮助程序。
可以通过两种方式将数据写入临时文件
xrep_tempfile_copyin
可用于从 xfile 设置常规临时文件的内容。
常规目录、符号链接和扩展属性函数可用于写入临时文件。
一旦在临时文件中构建了数据文件的良好副本,就必须将其传送到正在修复的文件,这是下一节的主题。
提议的补丁位于 修复临时文件 系列中。
一旦修复构建了临时文件并将新的数据结构写入其中,它必须将新的更改提交到现有文件中。 无法交换两个文件的 inumber,因此必须替换新的元数据来代替旧的元数据。 这表明需要能够交换 extent,但是文件碎片整理工具 xfs_fsr
使用的现有 extent 交换代码不足以进行在线修复,因为
启用反向映射 btree 后,交换代码必须通过每次映射交换使反向映射信息保持最新。 因此,它只能在每个事务中交换一个映射,并且每个事务都是独立的。
反向映射对于在线 fsck 的操作至关重要,因此旧的碎片整理代码(它在单个操作中交换了整个 extent 分支)在此处没有用处。
假定碎片整理发生在两个内容相同的文件之间。 对于此用例,即使操作中断,不完整的交换也不会导致用户可见的文件内容更改。
在线修复需要交换定义为不相同的两个文件的内容。 对于目录和 xattr 修复,用户可见的内容可能相同,但是各个块的内容可能非常不同。
文件中的旧块可能与其他结构交叉链接,并且如果系统在修复过程中关闭,则不得重新出现。
这些问题通过创建一个新的延迟操作和一种新的日志意图项来解决,该日志意图项用于跟踪交换两个文件范围的操作的进度。 新的交换操作类型将反向映射 extent 交换代码使用的相同事务链接在一起,但是在日志中记录了中间进度,以便可以在崩溃后重新启动操作。 此新功能称为文件内容交换 (xfs_exchrange) 代码。 底层实现交换文件分支映射 (xfs_exchmaps)。 新的日志项记录了交换的进度,以确保一旦交换开始,它将始终运行到完成,即使有中断也是如此。 超级块中的新 XFS_SB_FEAT_INCOMPAT_EXCHRANGE
不兼容功能标志可防止在旧内核上重放这些新的日志项记录。
提议的补丁集是 文件内容交换 系列。
侧栏:使用日志不兼容功能标志 |
从 XFS v5 开始,超级块包含一个 sb_features_log_incompat 字段,用于指示日志包含某些内核可能无法读取的记录,这些内核可以挂载此文件系统。 简而言之,日志不兼容功能可以保护日志内容免受无法理解内容的内核的侵害。 与其他超级块功能位不同,日志不兼容位是短暂的,因为空(干净)日志不需要保护。 日志在将其内容提交到文件系统后自行清理,无论是在卸载时还是因为系统处于空闲状态。 由于上层代码可能在清理日志的同时处理事务,因此上层代码需要在使用日志不兼容功能时通知日志。
日志通过对每个功能使用一个 struct rw_semaphore 来协调对不兼容功能的访问。 日志清理代码尝试以独占模式获取此 rwsem 以清除该位; 如果锁尝试失败,则功能位保持设置状态。 支持日志不兼容功能的代码应创建包装函数以获取日志功能并调用 xfs_add_incompat_log_feature 以在主超级块中设置功能位。 超级块更新是事务性执行的,因此必须在创建使用该功能的事务之前立即调用获取日志辅助功能的包装程序。 对于文件操作,此步骤必须在获取 IOLOCK 和 MMAPLOCK 之后,但在分配事务之前进行。 事务完成后,调用 xlog_drop_incompat_feat 函数以释放该功能。 在日志变得干净之前,不会从超级块中清除该功能位。
日志辅助扩展属性更新和文件内容交换都使用日志不兼容功能并提供围绕该功能的便捷包装程序。
|
在文件分支之间交换内容是一项复杂的任务。 目标是在两个文件分支偏移范围之间交换所有文件分支映射。 每个分支中都可能存在许多 extent 映射,并且映射的边缘不一定对齐。 此外,在交换之后可能需要进行其他更新,例如交换文件大小、inode 标志或将分支数据转换为本地格式。 这大致是新的延迟交换映射工作项的格式
struct xfs_exchmaps_intent {
/* Inodes participating in the operation. */
struct xfs_inode *xmi_ip1;
struct xfs_inode *xmi_ip2;
/* File offset range information. */
xfs_fileoff_t xmi_startoff1;
xfs_fileoff_t xmi_startoff2;
xfs_filblks_t xmi_blockcount;
/* Set these file sizes after the operation, unless negative. */
xfs_fsize_t xmi_isize1;
xfs_fsize_t xmi_isize2;
/* XFS_EXCHMAPS_* log operation flags */
uint64_t xmi_flags;
};
新的日志意图项包含足够的信息来跟踪两个逻辑分支偏移范围:(inode1, startoff1, blockcount)
和 (inode2, startoff2, blockcount)
。 交换操作的每个步骤都会将一个文件中可能的最大文件范围映射交换到另一个文件。 在交换操作的每个步骤之后,两个 startoff 字段都会递增,并且 blockcount 字段都会递减以反映所取得的进度。 flags 字段捕获行为参数,例如交换 attr 分支映射而不是数据分支以及交换后要完成的其他工作。 如果文件数据分支是操作的目标,则两个 isize 字段用于在操作结束时交换文件大小。
当启动交换时,操作顺序如下所示
为文件映射交换创建一个延迟工作项。 首先,它应该包含要交换的整个文件块范围。
调用 xfs_defer_finish
以处理交换。 这封装在用于 scrub 操作的 xrep_tempexch_contents
中。 这会将 extent 交换意图项记录到延迟映射交换工作项的事务中。
直到延迟映射交换工作项的 xmi_blockcount
为零,
分别读取从 xmi_startoff1
和 xmi_startoff2
开始的两个文件范围的块映射,并计算可以在单个步骤中交换的最长 extent。 这是映射中两个 br_blockcount
s 中的最小值。 继续在文件分支中前进,直到至少一个映射包含已写入的块。 不会交换相互的空洞、未写入的 extent 和到同一物理空间的 extent 映射。
对于接下来的几个步骤,本文档会将来自文件 1 的映射称为“map1”,并将来自文件 2 的映射称为“map2”。
创建一个延迟块映射更新以从文件 1 中取消映射 map1。
创建一个延迟块映射更新以从文件 2 中取消映射 map2。
创建一个延迟块映射更新以将 map1 映射到文件 2 中。
创建一个延迟块映射更新以将 map2 映射到文件 1 中。
记录两个文件的块、配额和 extent 计数更新。
如有必要,扩展任一文件的 ondisk 大小。
为在步骤 3 开始时读取的映射交换意图日志项记录映射交换完成日志项。
计算刚刚覆盖的文件范围量。 此数量为 (map1.br_startoff + map1.br_blockcount - xmi_startoff1)
,因为步骤 3a 可能跳过了空洞。
将 xmi_startoff1
和 xmi_startoff2
的起始偏移量增加上一步中计算的块数,并将 xmi_blockcount
减少相同的数量。 这会前进光标。
记录一个新的映射交换意图日志项,该日志项反映了工作项的推进状态。
将正确的错误代码 (EAGAIN) 返回给延迟操作管理器,以告知它还有更多工作要做。 操作管理器在返回到步骤 3 的开头之前完成步骤 3b-3e 中的延迟工作。
执行任何后处理。 将在后续章节中更详细地讨论这一点。
如果文件系统在操作过程中关闭,则日志恢复将找到最新的未完成映射交换日志意图项并从那里重新启动。 这就是原子文件映射交换如何保证外部观察者要么看到旧的损坏结构,要么看到新的结构,而永远不会看到两者的混合。
在启动原子文件映射交换操作之前,需要处理一些事情。 首先,常规文件需要先将页面缓存刷新到磁盘,然后才能开始操作,并且直接 I/O 写入要静止。 与任何文件系统操作一样,文件映射交换必须确定可以在操作中代表两个文件消耗的最大磁盘空间和配额量,并预留该资源量以避免一旦开始弄脏元数据就发生无法恢复的空间不足故障。 准备步骤会扫描两个文件的范围以估计
精确估计的需求增加了交换操作的运行时间,但是维护正确的帐户非常重要。 文件系统绝不能完全耗尽可用空间,并且映射交换永远不能向分支添加比它可以支持的 extent 映射更多。 常规用户需要遵守配额限制,尽管元数据修复可能会超过配额以解决其他地方的不一致元数据。
扩展属性、符号链接和目录可以将 fork 格式设置为“local”,并将 fork 视为数据存储的字面区域。元数据修复必须采取额外的步骤来支持这些情况。
如果两个 fork 都采用 local 格式,并且 fork 区域足够大,则交换通过复制内存中的 fork 内容、记录两个 fork 并提交来执行。由于可以使用单个事务完成此操作,因此不需要原子文件映射交换机制。
如果两个 fork 都映射块,则使用常规的原子文件映射交换。
否则,只有一个 fork 采用 local 格式。local 格式 fork 的内容将被转换为块以执行交换。转换为块格式必须在记录初始映射交换意图日志项的同一事务中完成。常规的原子映射交换用于交换元数据文件映射。在交换操作上设置特殊标志,以便事务可以再次回滚以将第二个文件的 fork 转换回 local 格式,以便第二个文件在 ILOCK 释放后即可使用。
扩展属性和目录将拥有的 inode 盖印到每个块中,但缓冲区验证器实际上并不检查 inode 编号!虽然没有验证,但维护引用完整性仍然很重要,因此在执行映射交换之前,在线修复会使用正在修复的文件的所有者字段构建新数据结构中的每个块。
在成功完成交换操作后,修复操作必须通过处理每个 fork 映射通过标准文件范围回收机制来回收旧的 fork 块,该机制在修复后执行。如果文件系统在修复的回收部分期间崩溃,则恢复结束时的 iunlink 处理将释放临时文件和未回收的任何块。但是,此 iunlink 处理省略了在线修复的交叉链接检测,并且并非完全可靠。
要修复元数据文件,在线修复按如下步骤进行:
创建一个临时修复文件。
使用暂存数据将新内容写入临时修复文件。必须写入与正在修复的相同的 fork。
提交清理事务,因为交换资源估计步骤必须在进行事务预留之前完成。
调用 xrep_tempexch_trans_alloc
以分配具有适当资源预留和锁的新清理事务,并使用交换操作的详细信息填充 struct xfs_exchmaps_req
。
调用 xrep_tempexch_contents
以交换内容。
提交事务以完成修复。
在 XFS 文件系统的“实时”部分中,空闲空间通过位图进行跟踪,类似于 Unix FFS。位图中的每个位代表一个实时范围,范围的大小是文件系统块大小的倍数,介于 4KiB 和 1GiB 之间。实时摘要文件将给定大小的空闲范围的数量索引到实时空闲空间位图中这些空闲范围开始的块的偏移量。换句话说,摘要文件通过长度帮助分配器查找空闲范围,类似于空闲空间计数 (cntbt) btree 对数据部分所做的事情。
摘要文件本身是一个平面文件(没有块标头或校验和!),分为 log2(总 rt 范围)
部分,其中包含足够的 32 位计数器来匹配 rt 位图中的块数。每个计数器记录从该位图块开始且可以满足 2 的幂分配请求的空闲范围的数量。
要针对位图检查摘要文件:
获取实时位图和摘要文件的 ILOCK。
对于位图中记录的每个空闲空间范围:
计算摘要文件中包含代表此空闲范围的计数器的位置。
从 xfile 中读取计数器。
递增计数器,然后将其写回 xfile。
将 xfile 的内容与磁盘上的文件进行比较。
要修复摘要文件,请将 xfile 内容写入临时文件,并使用原子映射交换来提交新内容。然后回收临时文件。
建议的补丁集是 实时摘要修复系列。
在 XFS 中,扩展属性被实现为命名空间的名称-值存储。值的大小限制为 64KiB,但名称的数量没有限制。属性 fork 未分区,这意味着属性结构的根始终位于逻辑块零中,但属性叶块、dabtree 索引块和远程值块是混合的。属性叶块包含可变大小的记录,这些记录将用户提供的名称与用户提供的值相关联。大于一个块的值被分配单独的范围并写入那里。如果叶信息扩展到单个块之外,则会创建一个目录/属性 btree (dabtree
) 以将属性名称的哈希映射到条目以进行快速查找。
抢救扩展属性按如下步骤进行:
遍历正在修复的文件的 attr fork 映射以查找属性叶块。当找到一个时:
遍历 attr 叶块以查找候选键。当找到一个时:
检查名称是否存在问题,如果存在问题则忽略该名称。
检索值。如果成功,则将名称和值添加到暂存 xfarray 和 xfblob。
如果 xfarray 和 xfblob 的内存使用量超过一定量的内存,或者没有更多 attr fork 块要检查,则解锁该文件并将暂存的扩展属性添加到临时文件。
使用原子文件映射交换来交换新的和旧的扩展属性结构。旧的属性块现在已附加到临时文件。
回收临时文件。
建议的补丁集是 扩展属性修复系列。
使用当前可用的文件系统功能修复目录很困难,因为目录条目不是冗余的。离线修复工具会扫描所有 inode 以查找链接计数不为零的文件,然后扫描所有目录以建立这些链接文件的父子关系。损坏的文件和目录将被删除,没有父文件的文件将被移动到 /lost+found
目录。它不会尝试抢救任何东西。
在线修复目前能做的最好的事情是读取目录数据块并抢救任何看起来合理的 dirent,纠正链接计数,并将孤儿移回目录树。文件链接计数 fsck代码负责修复链接计数并将孤儿移动到 /lost+found
目录。
与扩展属性不同,目录块的大小都相同,因此抢救目录很简单:
查找目录的父目录。如果点点条目不可读,请尝试确认声称的父目录是否具有指向正在修复的目录的子条目。否则,遍历文件系统以找到它。
遍历目录数据 fork 的第一个分区以查找目录条目数据块。当找到一个时:
遍历目录数据块以查找候选条目。当找到一个条目时:
检查名称是否存在问题,如果存在问题则忽略该名称。
检索 inumber 并获取 inode。如果成功,则将名称、inode 编号和文件类型添加到暂存 xfarray 和 xblob。
如果 xfarray 和 xfblob 的内存使用量超过一定量的内存,或者没有更多目录数据块要检查,则解锁该目录并将暂存的 dirent 添加到临时目录。截断暂存文件。
使用原子文件映射交换来交换新的和旧的目录结构。旧的目录块现在已附加到临时文件。
回收临时文件。
未来工作问题:在重建目录时,修复是否应该重新验证 dentry 缓存?
答案:是的,应该。
从理论上讲,有必要扫描目录的所有 dentry 缓存条目,以确保以下情况之一适用:
缓存的 dentry 反映了新目录中的磁盘上 dirent。
缓存的 dentry 在新目录中不再具有相应的磁盘上 dirent,并且可以从缓存中清除该 dentry。
缓存的 dentry 不再具有磁盘上的 dirent,但无法清除该 dentry。这是问题所在。
不幸的是,当前 dentry 缓存设计没有提供遍历特定目录的每个子 dentry 的方法,这使得这成为一个难题。目前没有已知的解决方案。
建议的补丁集是目录修复系列。
父指针是一段文件元数据,使用户无需从根目录遍历目录树即可找到文件的父目录。如果没有它们,目录树的重建将受到阻碍,就像过去缺乏反向空间映射信息阻碍了文件系统空间元数据的重建一样。但是,父指针功能使完全目录重建成为可能。
XFS 父指针包含识别父目录中相应目录条目所需的信息。换句话说,子文件使用扩展属性来存储指向父目录的指针,格式为 (dirent_name) → (parent_inum, parent_gen)
。可以加强目录检查过程,以确保每个 dirent 的目标还包含指向 dirent 的父指针。同样,可以通过确保每个父指针的目标都是目录并且包含与父指针匹配的 dirent 来检查每个父指针。在线和离线修复都可以使用此策略。
历史侧边栏: |
目录父指针最初由 SGI 在十多年前作为 XFS 功能提出。从父目录到子文件的每个链接都使用子目录中的扩展属性进行镜像,该属性可用于识别父目录。不幸的是,这种早期实现存在重大缺陷,从未合并到 Linux XFS 中。
2000 年代末的 XFS 代码库没有强制执行目录树中强引用完整性的基础结构。它不能保证前向链接中的更改始终会通过对反向链接的相应更改来跟进。
引用完整性未集成到离线修复中。在未挂载的文件系统上执行检查和修复,而不使用任何内核或 inode 锁来协调访问。目前尚不清楚这实际上是如何正常工作的。
扩展属性未记录父目录中目录条目的名称,因此 SGI 父指针实现不能用于重新连接目录树。
扩展属性 fork 仅支持 65,536 个范围,这意味着父指针属性的创建很可能在达到最大文件链接计数之前失败。
原始父指针设计对于依赖于文件系统修复之类的事情来说太不稳定了。Allison Henderson、Chandan Babu 和 Catherine Hoang 正在进行第二种实现,该实现解决了第一种实现的所有缺点。在 2022 年期间,Allison 引入了日志意图项来跟踪扩展属性结构的物理操作。这通过使在同一事务中提交 dirent 更新和父指针更新成为可能来解决了引用完整性问题。Chandan 增加了数据和属性 fork 的最大范围计数,从而确保扩展属性结构可以增长以处理任何文件的最大硬链接计数。
对于第二次尝试,最初提出的磁盘上父指针格式为 (parent_inum, parent_gen, dirent_pos) → (dirent_name) 。在开发过程中更改了格式,以消除修复工具需要确保在重建目录时 dirent_pos 字段始终匹配的要求。
还有一些其他方法可以解决该问题:
该字段可以被指定为建议性的,因为其他三个值足以在父目录中找到该条目。但是,这使得在修复正在进行时无法进行索引键查找。
我们可以允许在指定的偏移量处创建目录条目,这解决了引用完整性问题,但存在 dirent 创建将由于与目录中的空闲空间冲突而失败的风险。
可以通过追加目录条目并修改 xattr 代码以支持更新 xattr 键并重新索引 dabtree 来解决这些冲突,尽管这必须在父目录仍然锁定的情况下执行。
与上述相同,但原子地删除旧的父指针条目并添加新的父指针条目。
将磁盘上 xattr 格式更改为 (parent_inum, name) → (parent_gen) ,这将提供我们所需的 attr 名称唯一性,而无需强制修复代码更新 dirent 位置。不幸的是,这需要更改 xattr 代码以支持长达 263 字节的 attr 名称。
将磁盘上 xattr 格式更改为 (parent_inum, hash(name)) → (name, parent_gen) 。如果哈希具有足够的抗冲突性(例如,sha256),那么这应该提供我们所需的 attr 名称唯一性。小于 247 字节的名称可以直接存储。
将磁盘上 xattr 格式更改为 (dirent_name) → (parent_ino, parent_gen) 。此格式不需要前面建议的任何复杂的嵌套名称哈希。但是,发现具有相同文件名的相同 inode 的多个硬链接会导致哈希 xattr 查找出现性能问题,因此父 inumber 现在已 xor'd 到哈希索引中。
最后,决定解决方案 #6 是最紧凑和最有效的。为父指针设计了一个新的哈希函数。
|
目录重建使用协调的 inode 扫描和目录条目实时更新挂钩,如下所示:
设置一个临时目录来生成新的目录结构,一个 xfblob 来存储条目名称,以及一个 xfarray 来存储目录更新中涉及的固定大小字段:(子 inumber, 添加 与 删除, 名称 cookie, ftype)
。
设置一个 inode 扫描器并挂接到目录条目代码中以接收目录操作的更新。
对于在扫描的每个文件中找到的每个父指针,确定父指针是否引用感兴趣的目录。如果是:
分别将父指针名称和此 dirent 的 addname 条目存储在 xfblob 和 xfarray 中。
完成扫描该文件或内核内存消耗超过阈值时,将存储的更新刷新到临时目录。
对于通过挂钩收到的每个实时目录更新,确定是否已扫描该子目录。如果是:
稍后将父指针名称和此 dirent 更新的 addname 或 removename 条目存储在 xfblob 和 xfarray 中。我们无法直接写入临时目录,因为不允许挂钩函数修改文件系统元数据。相反,我们将更新存储在 xfarray 中,并依靠扫描器线程将存储的更新应用于临时目录。
扫描完成后,重播 xfarray 中的任何存储条目。
扫描完成后,原子地交换临时目录和正在修复的目录的内容。临时目录现在包含损坏的目录结构。
回收临时目录。
建议的补丁集是 父指针目录修复系列。
文件父指针信息的在线重建与目录重建类似:
设置一个临时文件来生成新的扩展属性结构,一个 xfblob 来存储父指针名称,以及一个 xfarray 来存储父指针更新中涉及的固定大小字段:(父 inumber, 父 生成, 添加 与 删除, 名称 cookie)
。
设置一个 inode 扫描器并挂接到目录条目代码中以接收目录操作的更新。
对于在扫描的每个目录中找到的每个目录条目,确定 dirent 是否引用感兴趣的文件。如果是:
分别将 dirent 名称和此父指针的 addpptr 条目存储在 xfblob 和 xfarray 中。
完成扫描目录或内核内存消耗超过阈值时,将存储的更新刷新到临时文件。
对于通过挂钩收到的每个实时目录更新,确定是否已扫描父目录。如果是:
稍后将 dirent 名称和此 dirent 更新的 addpptr 或 removepptr 条目存储在 xfblob 和 xfarray 中。我们无法直接将父指针写入临时文件,因为不允许挂钩函数修改文件系统元数据。相反,我们将更新存储在 xfarray 中,并依靠扫描器线程将存储的父指针更新应用于临时文件。
扫描完成后,重播 xfarray 中的任何存储条目。
将所有非父指针扩展属性复制到临时文件。
扫描完成后,原子地交换临时文件和正在修复的文件属性 fork 的映射。临时文件现在包含损坏的扩展属性结构。
回收临时文件。
建议的补丁集是 父指针修复系列。
离线修复中检查父指针的工作方式不同,因为损坏的文件在执行目录树连接检查之前很久就被擦除了。因此,父指针检查是添加到现有连接检查的第二遍。
在建立幸存文件集(第 6 阶段)之后,遍历文件系统中每个 AG 的幸存目录。这已经作为连接检查的一部分执行。
对于找到的每个目录条目:
如果该名称已存储在 xfblob 中,则使用该 cookie 并跳过下一步。
否则,将该名称记录在 xfblob 中,并记住 xfblob cookie。唯一的映射对于以下方面至关重要:
重复数据删除名称以减少内存使用量,以及
为父指针索引创建一个稳定的排序键,以便下面描述的父指针验证可以工作。
在每个 AG 的内存 Slab 中存储 (child_ag_inum, parent_inum, parent_gen, name_hash, name_len, name_cookie)
元组。本节中引用的 name_hash
是常规目录条目名称哈希,而不是用于父指针 xattr 的专用哈希。
对于文件系统中的每个 AG:
按 child_ag_inum
、parent_inum
、name_hash
和 name_cookie
的顺序对每个 AG 的元组集进行排序。为每个 name
提供单个 name_cookie
对于处理包含同一文件的多个硬链接(其中所有名称都哈希到相同值)的不常见情况至关重要。
对于 AG 中的每个 inode:
扫描 inode 中的父指针。对于找到的每个父指针:
验证磁盘上的父指针。如果验证失败,请继续处理文件中的下一个父指针。
如果该名称已存储在 xfblob 中,则使用该 cookie 并跳过下一步。
在每个文件的 xfblob 中记录该名称,并记住 xfblob cookie。
在每个文件的 Slab 中存储 (parent_inum, parent_gen, name_hash, name_len, name_cookie)
元组。
按 parent_inum
、name_hash
和 name_cookie
的顺序对每个文件的元组进行排序。
将一个 Slab 光标定位在每个 AG 元组 Slab 中 inode 记录的开头。由于每个 AG 元组按子 inumber 排序,因此这应该是微不足道的。
将第二个 Slab 光标定位在每个文件元组 Slab 的开头。
以锁定步骤迭代两个光标,比较每个光标下的记录的 parent_ino
、name_hash
和 name_cookie
字段:
如果每个 AG 光标在键空间中的位置低于每个文件光标,则每个 AG 光标指向缺失的父指针。将父指针添加到 inode 并前进每个 AG 光标。
如果每个文件光标在键空间中的位置低于每个 AG 光标,则每个文件光标指向悬空的父指针。从 inode 中删除父指针并前进每个文件光标。
否则,两个光标都指向相同的父指针。如有必要,更新 parent_gen 组件。前进两个光标。
继续检查链接计数,就像我们今天所做的那样。
建议的补丁集是 离线父指针修复系列。
从离线修复中的父指针重建目录将非常具有挑战性,因为 xfs_repair 当前在第 3 阶段和第 4 阶段使用文件系统的两次单遍扫描来确定哪些文件损坏到足以被删除。必须将此扫描转换为多遍扫描:
扫描的第一遍会像现在一样删除损坏的 inode、fork 和属性。损坏的目录将被记录,但不会被删除。
下一遍记录指向在第一遍中被记录为损坏的目录的父指针。如果第 4 阶段也能够删除目录,则此第二遍可能必须在第 4 阶段扫描重复块之后进行。
第三遍将损坏的目录重置为空的短格式目录。尚未确保空闲空间元数据,因此修复还不能使用 libxfs 中的目录构建代码。
在第 6 阶段开始时,空间元数据已被重建。使用在步骤 2 中记录的父指针信息重建 dirent 并将其添加到现在为空的目录中。
尚未构建此代码。
如前所述,文件系统目录树应该是一个有向无环图结构。但是,此图中的每个节点都是一个单独的 xfs_inode
对象,具有自己的锁,这使得验证树的质量很困难。幸运的是,非目录允许具有多个父目录,并且不能有子目录,因此只需要扫描目录。目录通常构成文件系统中文档的 5-10%,这大大减少了工作量。
如果可以冻结目录树,则可以通过从根目录向下运行深度(或广度)优先搜索并为找到的每个目录标记位图来轻松发现循环和断开连接的区域。在遍历中的任何时候,尝试设置已设置的位意味着存在一个循环。扫描完成后,将标记的 inode 位图与 inode 分配位图进行异或运算会显示断开连接的 inode。但是,在线修复的设计目标之一是避免锁定整个文件系统,除非绝对必要。目录树更新可以在实时文件系统上跨扫描器波前移动子树,因此无法应用位图算法。
目录父指针可以对树结构进行增量验证。多个线程可以从单个子目录向上移动到根目录,而不是使用一个线程来扫描整个文件系统。为此,所有目录条目和父指针必须在内部一致,每个目录条目必须具有父指针,并且所有目录的链接计数必须正确。每个扫描器线程必须能够在保持子目录的 IOLOCK 的同时获取声称的父目录的 IOLOCK,以防止任一目录在树中移动。这是不可能的,因为 VFS 在移动子目录时不会获取子目录的 IOLOCK,因此扫描器会通过获取 ILOCK 并安装 dirent 更新挂钩来检测更改来稳定父 -> 子关系。
扫描过程使用 dirent 挂钩来检测对扫描数据中提到的目录的更改。扫描工作原理如下:
对于文件系统中的每个子目录:
对于该子目录的每个父指针:
为该父指针创建一个路径对象,并在路径对象的位图中标记子目录 inode 编号。
在路径结构中记录父指针名称和 inode 编号。
如果声称的父目录是正在清理的子目录,则该路径是一个循环。标记要删除的路径,然后使用下一个子目录父指针重复步骤 1a。
尝试在路径对象中的位图中标记声称的父 inode 编号。如果已设置该位,则目录树中存在一个循环。将该路径标记为一个循环,然后使用下一个子目录父指针重复步骤 1a。
加载声称的父目录。如果声称的父目录不是链接目录,则中止扫描,因为父指针信息不一致。
对于此声称的祖先目录的每个父指针:
如果未为该级别设置任何父目录,则在路径对象中记录父指针名称和 inode 编号。
如果一个祖先有多个父目录,则将该路径标记为损坏。使用下一个子目录父指针重复步骤 1a。
对在步骤 1a6a 中标识的祖先重复步骤 1a3-1a6。重复此操作,直到到达目录树根目录或未找到任何父目录。
如果遍历在根目录处终止,则将该路径标记为 ok。
如果遍历在到达根目录之前终止,则将该路径标记为断开连接。
如果目录条目更新钩子触发,则检查扫描已找到的所有路径。如果条目匹配路径的一部分,则将该路径和扫描标记为过时。当扫描器线程看到扫描已被标记为过时时,它会删除所有扫描数据并重新开始。
修复目录树的工作方式如下
遍历目标子目录的每个路径。
损坏的路径和循环路径被认为是可疑的。
已标记为删除的路径被认为是坏的。
到达根目录的路径被认为是好的。
如果子目录是根目录或链接计数为零,则删除直接父目录中的所有传入目录条目。修复完成。
如果子目录只有一个路径,则将 dotdot 条目设置为父目录并退出。
如果子目录至少有一个好的路径,则删除直接父目录中的所有其他传入目录条目。
如果子目录没有好的路径且有多个可疑路径,则删除直接父目录中的所有其他传入目录条目。
如果子目录没有路径,则将其附加到 lost and found 目录。
提议的补丁位于目录树修复系列中。
文件系统将文件表示为有向的,并希望是无环的图。换句话说,是一棵树。文件系统的根目录是一个目录,目录中的每个条目都向下指向更多的子目录或非目录文件。不幸的是,目录图指针的中断会导致断开连接的图,这使得无法通过常规路径解析访问文件。
如果没有父指针,目录父指针在线清理代码可以检测到指向没有链接回到子目录的父目录的 dotdot 条目,并且文件链接计数检查器可以检测到文件系统中没有任何目录指向的文件。如果这样的文件具有正链接计数,则该文件是一个孤儿。
使用父指针,可以通过扫描父指针来重建目录,也可以通过扫描目录来重建父指针。这应该减少文件最终出现在 /lost+found
中的情况。
找到孤儿时,应将其重新连接到目录树。离线 fsck 通过创建一个目录 /lost+found
来作为孤儿院来解决这个问题,并使用 inode 号作为名称将孤儿文件链接到孤儿院中。将文件重新指定给孤儿院不会重置其任何权限或 ACL。
此过程在内核中比在用户空间中更复杂。目录和文件链接计数修复设置函数必须使用常规 VFS 机制来创建具有所有必要安全属性和 dentry 缓存条目的孤儿院目录,就像常规目录树修改一样。
孤立文件按以下方式被孤儿院收养
在清理设置函数的开头调用 xrep_orphanage_try_create
,以尝试确保丢失和找到的目录实际存在。这还将孤儿院目录附加到清理上下文中。
如果决定重新连接文件,则获取孤儿院和正在重新附加的文件的 IOLOCK。xrep_orphanage_iolock_two
函数遵循前面讨论的 inode 锁定策略。
使用 xrep_adoption_trans_alloc
来为修复事务保留资源。
调用 xrep_orphanage_compute_name
以计算孤儿院中的新名称。
如果收养即将发生,请调用 xrep_adoption_reparent
以将孤立的文件重新指定到 lost and found 目录中,并使 dentry 缓存无效。
调用 xrep_adoption_finish
以提交任何文件系统更新,释放孤儿院 ILOCK,并清除清理事务。调用 xrep_adoption_commit
以提交更新和清理事务。
如果发生运行时错误,请调用 xrep_adoption_cancel
以释放所有资源。
提议的补丁位于 orphanage adoption 系列中。
本节讨论用户空间程序 xfs_scrub
的关键算法和数据结构,该程序提供在内核中驱动元数据检查和修复、验证文件数据以及查找其他潜在问题的能力。
XFS 文件系统可以轻松地包含数亿个 inode。鉴于 XFS 面向具有大型高性能存储的安装,因此需要并行清理 inode 以最大程度地缩短运行时间,特别是如果该程序已从命令行手动调用。这需要仔细的调度,以使线程尽可能均匀地加载。
xfs_scrub
inode 扫描程序的早期迭代天真地创建了一个工作队列,并为每个 AG 安排了一个工作队列项。每个工作队列项都遍历 inode btree(使用 XFS_IOC_INUMBERS
)以查找 inode 块,然后调用 bulkstat(XFS_IOC_BULKSTAT
)以收集足够的信息来构造文件句柄。然后将文件句柄传递给一个函数,以为每个 inode 的每个元数据对象生成清理项。如果文件系统包含一个具有一些大型稀疏文件的 AG,而其余 AG 包含许多较小的文件,则这种简单的算法会导致阶段 3 中的线程平衡问题。inode 扫描调度函数不够精细;它应该在单个 inode 级别或 inode btree 记录级别进行调度,以限制内存消耗。
感谢 Dave Chinner,用户空间中的有界工作队列使 xfs_scrub
可以通过添加第二个工作队列轻松避免此问题。与以前一样,第一个工作队列使用每个 AG 一个工作队列项进行播种,并使用 INUMBERS 查找 inode btree 块。但是,第二个工作队列配置为可以等待运行的项数的上限。第一个工作队列的工作人员找到的每个 inode btree 块都排队到第二个工作队列,并且正是第二个工作队列查询 BULKSTAT、创建文件句柄并将其传递给一个函数,以为每个 inode 的每个元数据对象生成清理项。如果第二个工作队列太满,则工作队列添加函数会阻塞第一个工作队列的工作人员,直到积压缓解。这并不能完全解决平衡问题,但足以减少它以进行更紧迫的问题。
提议的补丁集是清理 性能调整 和 inode 扫描重新平衡系列。
在阶段 2 中,会立即修复任何 AGI 标头或 inode btree 中报告的损坏和不一致,因为阶段 3 依赖于 inode 索引的正常运行才能找到要扫描的 inode。失败的修复会重新调度到阶段 4。在任何其他空间元数据中报告的问题都将推迟到阶段 4。无论其来源如何,优化机会始终会推迟到阶段 4。
在阶段 3 中,如果在阶段 2 中验证了所有空间元数据,则会立即修复文件的任何元数据部分中报告的损坏和不一致。无法立即修复或无法修复的修复计划在阶段 4 中进行。
在 xfs_scrub
的原始设计中,人们认为修复非常少见,因此用于与内核通信的 struct xfs_scrub_metadata
对象也可以用作调度修复的主要对象。随着给定文件系统对象的可能优化数量最近的增加,使用单个修复项跟踪给定文件系统对象的所有符合条件的修复在内存方面变得更加有效。每个修复项代表一个可锁定的对象——AG、元数据文件、单个 inode 或一类摘要信息。
阶段 4 负责以尽可能快的速度安排大量的修复工作。之前概述的数据依赖关系仍然适用,这意味着 xfs_scrub
必须尝试完成阶段 2 安排的修复工作,然后再尝试阶段 3 安排的修复工作。修复过程如下
使用工作队列和足够的工作人员开始一轮修复,以使 CPU 保持用户所需的繁忙状态。
对于阶段 2 排队的每个修复项,
要求内核修复给定文件系统对象的修复项中列出的所有内容。
记下内核是否在减少此对象所需的修复数量方面取得了任何进展。
如果该对象不再需要修复,请重新验证与该对象关联的所有元数据。如果重新验证成功,请删除修复项。否则,重新排队该项以进行更多修复。
如果进行了任何修复,请跳回 1a 以重试所有阶段 2 项。
对于阶段 3 排队的每个修复项,
要求内核修复给定文件系统对象的修复项中列出的所有内容。
记下内核是否在减少此对象所需的修复数量方面取得了任何进展。
如果该对象不再需要修复,请重新验证与该对象关联的所有元数据。如果重新验证成功,请删除修复项。否则,重新排队该项以进行更多修复。
如果进行了任何修复,请跳回 1c 以重试所有阶段 3 项。
如果步骤 1 取得了任何类型的修复进展,请跳回步骤 1 以开始另一轮修复。
如果还有剩余的项目需要修复,请再次按顺序运行所有项目。如果修复不成功,请抱怨,因为这是修复任何内容的最后机会。
在阶段 5 和 7 中遇到的损坏和不一致会立即修复。阶段 6 报告的损坏文件数据块无法由文件系统恢复。
提议的补丁集是 修复警告改进、修复数据依赖关系 和 对象跟踪 的重构,以及 修复调度 改进系列。
如果 xfs_scrub
成功在阶段 4 结束时验证文件系统元数据,则它将继续进入阶段 5,该阶段检查文件系统中是否存在可疑名称。这些名称包括文件系统标签、目录条目中的名称和扩展属性的名称。与大多数 Unix 文件系统一样,XFS 对名称的内容施加了最严格的约束
目录条目中不允许使用斜杠和空字节。
用户空间可见的扩展属性中不允许使用空字节。
文件系统标签中不允许使用空字节。
目录条目和属性键在磁盘上显式存储名称的长度,这意味着空值不是名称终止符。对于本节,“命名域”是指名称一起呈现的任何位置——目录中的所有名称,或文件的所有属性。
尽管 Unix 命名约束非常宽松,但大多数现代 Linux 系统的现实是程序使用 Unicode 字符代码点来支持国际语言。这些程序通常在使用 C 库时以 UTF-8 编码这些代码点,因为内核期望以空值终止的名称。因此,在常见情况下,在 XFS 文件系统中找到的名称实际上是 UTF-8 编码的 Unicode 数据。
为了最大限度地提高其表现力,Unicode 标准为各种字符定义了单独的控制点,这些字符在世界各地的书写系统中以相似或相同的方式呈现。例如,字符“西里尔小写字母 A”U+0430 “а” 通常与“拉丁小写字母 A”U+0061 “a” 的呈现方式相同。
该标准还允许以多种方式构造字符——通过使用定义的代码点,或通过将一个代码点与各种组合标记组合在一起。例如,字符“埃符号 U+212B “Å” 也可以表示为“拉丁大写字母 A”U+0041 “A”,后跟“组合环上”U+030A “◌̊”。这两个序列的呈现方式相同。
与之前的标准一样,Unicode 还定义了各种控制字符来改变文本的呈现方式。例如,字符“从右到左覆盖”U+202E 可以欺骗某些程序将 “moo\xe2\x80\xaegnp.txt” 呈现为 “mootxt.png”。第二类呈现问题涉及空格字符。如果在文件名中遇到字符“零宽度空格”U+200B,则该名称的呈现方式与没有零宽度空格的名称相同。
如果命名域中的两个名称具有不同的字节序列但呈现方式相同,则用户可能会感到困惑。内核在其对上层编码方案的漠不关心下,允许这样做。大多数文件系统驱动程序会保留 VFS 赋予它们的字节序列名称。
检测容易混淆名称的技术在 Unicode 安全机制 文档的第 4 节和第 5 节中进行了详细说明。当 xfs_scrub
检测到系统上正在使用 UTF-8 编码时,它会将 Unicode 规范化形式 NFD 与 libicu 的可混淆名称检测组件结合使用,以识别目录中或文件的扩展属性中可能相互混淆的名称。还会检查名称中是否存在控制字符、非呈现字符和双向字符的混合。所有这些潜在问题都会在阶段 5 中报告给系统管理员。
希望本文档的读者已经遵循了本文档中提出的设计,并且现在对 XFS 如何在线重建其元数据索引以及文件系统用户如何与该功能交互有一定的了解。尽管这项工作的范围令人望而却步,但希望本指南能让代码读者更容易理解已构建的内容、为谁构建以及为什么构建。请随时通过 XFS 邮件列表提出问题。
如前所述,原子文件映射交换机制的第二个前端是一个新的 ioctl 调用,用户空间程序可以使用该调用原子地提交对文件的更新。这个前端已经发布供审查多年了,尽管对在线修复的必要改进以及缺乏客户需求意味着该提案没有被大力推动。
如前所述,XFS 长期以来都能够交换文件之间的区段,xfs_fsr
几乎专门使用它来整理文件。最早的形式是 fork 交换机制,通过交换每个 inode fork 的直接区域中的原始字节,可以在两个文件之间交换数据 fork 的全部内容。当 XFS v5 附带自描述元数据时,这种旧机制增加了一些日志支持,以便在日志恢复期间继续重写 BMBT 块的所有者字段。当反向映射 btree 稍后添加到 XFS 时,维护 fork 映射与反向映射索引的一致性的唯一方法是开发一种迭代机制,该机制使用延迟 bmap 和 rmap 操作一次交换一个映射。该机制与上述步骤 2-3 相同,除了新的跟踪项,因为原子文件映射交换机制是现有机制的迭代,而不是完全新颖的东西。对于文件整理的狭窄情况,文件内容必须相同,因此恢复保证并没有多大收获。
原子文件内容交换比现有的 swapext 实现更灵活,因为它可以保证即使在崩溃后,调用者也永远不会看到新旧内容的混合,并且它可以对两个任意的文件 fork 范围进行操作。额外的灵活性实现了几个新的用例
事务性文件更新:与上述机制相同,但只有在原始文件的内容没有更改时,调用者才希望发生提交。要实现这一点,调用进程会在将其数据重新链接到临时文件之前,快照原始文件的文件修改和更改时间戳。当程序准备好提交更改时,它会将时间戳作为原子文件映射交换系统调用的参数传递到内核中。只有在提供的时间戳与原始文件匹配时,内核才会提交更改。提供了一个新的 ioctl(XFS_IOC_COMMIT_RANGE
)来执行此操作。
模拟原子块设备写入:导出具有与文件系统块大小匹配的逻辑扇区大小的块设备,以强制所有写入都与文件系统块大小对齐。将所有写入暂存到临时文件,并在完成后,调用原子文件映射交换系统调用,并使用一个标志来指示应忽略临时文件中的空洞。这在软件中模拟了原子设备写入,并且可以支持任意分散的写入。
事实证明,前面提到的重构修复项是启用向量化清理系统调用的催化剂。自 2018 年以来,在某些系统上,调用内核的成本显着增加,以减轻推测执行攻击的影响。这激励程序作者尽可能少地进行系统调用,以减少执行路径跨越安全边界的次数。
使用向量化清理,用户空间将文件系统对象的标识、要针对该对象运行的清理类型列表以及所选清理类型之间的数据依赖关系的简单表示形式推送到内核。内核会执行调用者的计划,直到它遇到由于损坏而无法满足的依赖关系,并告诉用户空间完成了多少。希望 io_uring
将拾取足够多的此功能,以便在线 fsck 可以使用它,而不是向 XFS 添加单独的向量化清理系统调用。
相关的补丁集是 内核向量化清理 和 用户空间向量化清理 系列。
在线 fsck 代码的一个严重缺点是它可以在内核中保持资源锁的时间基本上是无限的。允许用户空间向该进程发送一个致命信号,该信号将导致 xfs_scrub
在它到达一个良好的停止点时退出,但是用户空间无法向内核提供时间预算。鉴于清理代码库具有检测致命信号的帮助程序,因此允许用户空间为清理/修复操作指定超时并在超过预算时中止该操作不应该花费太多精力。但是,大多数修复函数都具有一个属性,即一旦它们开始触及磁盘上的元数据,该操作就无法干净地取消,此后 QoS 超时不再有用。
多年来,许多 XFS 用户都要求创建一个程序来清除文件系统下的物理存储的一部分,以便它成为一个连续的可用空间块。为简单起见,将此可用空间整理程序称为 clearspace
。
clearspace
程序需要的第一个部分是从用户空间读取反向映射索引的能力。这已经以 FS_IOC_GETFSMAP
ioctl 的形式存在。它需要的第二个部分是一种新的 fallocate 模式(FALLOC_FL_MAP_FREE_SPACE
),用于分配区域中的可用空间并将其映射到文件。将此文件称为“空间收集器”文件。第三个部分是强制在线修复的能力。
要清除物理存储的一部分中的所有元数据,clearspace 使用新的 fallocate map-freespace 调用将该区域中的任何可用空间映射到空间收集器文件。接下来,clearspace 通过 GETFSMAP
查找该区域中的所有元数据块,并对数据结构发出强制修复请求。这通常会导致元数据在未被清除的其他地方重建。每次重定位后,clearspace 都会再次调用 “map free space” 函数,以收集区域中任何新释放的空间。
要清除物理存储的一部分中的所有文件数据,clearspace 使用 FSMAP 信息来查找相关的文件数据块。确定了一个好的目标后,它会对文件的那部分使用 FICLONERANGE
调用,以尝试与虚拟文件共享物理空间。克隆区段意味着原始所有者无法覆盖内容;任何更改都将通过写时复制写入其他位置。Clearspace 在未被清除的区域中创建自己的冻结区段副本,并使用 FIEDEUPRANGE
(或 原子文件内容交换 功能)来更改目标文件的数据区段映射,使其远离被清除的区域。当所有其他映射都已移动后,clearspace 会将空间重新链接到空间收集器文件中,以便它变得不可用。
还有一些可以应用于上述算法的进一步优化。要清除具有高共享因子的物理存储片段,强烈希望保留此共享因子。实际上,应首先移动这些区段,以在操作完成后最大限度地提高共享因子。为了使此操作顺利进行,clearspace 需要一个新的 ioctl(FS_IOC_GETREFCOUNTS
)以将引用计数信息报告给用户空间。通过公开的 refcount 信息,clearspace 可以快速找到文件系统中最长、共享最多的数据区段,并首先定位它们。
未来工作问题:文件系统如何移动 inode 块?
答案:要移动 inode 块,Dave Chinner 构造了一个原型程序,该程序创建一个具有旧内容的新文件,然后以无锁方式绕文件系统运行以更新目录条目。如果文件系统崩溃,则操作无法完成。这个问题并非完全无法克服:创建一个隐藏在跳转标签后面的 inode 重新映射表,以及一个跟踪内核遍历文件系统以更新目录条目的日志项。问题是,内核无法对打开的文件做任何事情,因为它无法撤销它们。
未来工作问题: 是否可以使用静态密钥来最小化在XFS文件上支持 revoke()
的成本?
答案: 是的。在第一次撤销之前,退出代码根本不需要在调用路径中。
相关的补丁集是 kernel freespace defrag 和 userspace freespace defrag 系列。
移除文件系统的末尾应该很简单,只需疏散文件系统末尾的数据和元数据,并将释放的空间交给缩小代码。 这需要疏散文件系统末尾的空间,这是对可用空间碎片整理的使用!