应用程序能从 fsync 故障中恢复吗?

标题:Can Applications Recover from fsync Failures?

日期:2020/07/15

作者:Anthony Rebello, Yuvraj Patel, Ramnathan Alagappan, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau

链接:https://www.usenix.org/system/files/atc20-rebello.pdf

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


摘要

我们分析了文件系统和现代数据密集型应用程序如何应对 fsync 故障。首先,我们描述了三种 Linux 文件系统(ext4、XFS、Btrfs)在出现故障时的行为。我们发现文件系统之间存在共性(页面总是被标记为干净,某些块写入总是导致不可用),也存在差异(页面内容和故障报告方式各异)。接着,我们研究了五种广泛使用的应用程序(PostgreSQL、LMDB、LevelDB、SQLite、Redis)如何处理 fsync 故障。我们的研究结果表明,尽管应用程序使用了许多故障处理策略,但没有一个是充分的:fsync 故障可能导致灾难性后果,如数据丢失和损坏。我们的发现对旨在提供强持久性保证的文件系统和应用程序的设计具有重要影响。

1 引言

关心数据的应用程序必须关心数据如何写入稳定存储。仅发出一系列 write 系统调用是不够的。write 调用仅将数据从应用程序内存传输到操作系统;操作系统通常惰性地将数据写入磁盘,通过批处理、调度和其他技术来提高性能 [25,44,52,53]。为了在出现故障时正确更新持久数据,刷新到稳定存储的顺序和时间必须由应用程序控制。这种控制通常通过调用 fsync [9,47] 提供给应用程序,该调用在将控制权返回给应用程序之前强制将未写入的(“脏”)数据写入磁盘。大多数更新协议,如预写日志或写时复制,依赖于以特定顺序强制数据到磁盘以保证正确性 [30,31,35,38,46,56]。

不幸的是,最近的研究表明,fsync 在故障事件期间的行为定义不清 [55] 且容易出错。例如,某些系统在 fsync 失败时将相关页面标记为干净,即使脏页尚未正确写入磁盘。简单的应用程序响应,例如重试失败的 fsync,将无法按预期工作,从而导致潜在的数据损坏或丢失。 在本文中,我们提出并回答了两个与这一关键问题相关的问题。第一个问题(§3)涉及文件系统本身:为什么 fsync 有时会失败?失败事件后对文件系统状态有什么影响?

为了回答第一个问题,我们在重要且流行的 Linux 文件系统(ext4 [43]、XFS [54]、Btrfs [50])上运行精心设计的微工作负载,并在 I/O 流中注入有针对性的块故障。然后,我们使用多种工具的组合来检查结果。我们的发现显示了文件系统之间的共性和差异。例如,所有三个文件系统在 fsync 失败后将页面标记为干净,这使得应用程序级重试等技术无效。然而,所述干净页中的内容因文件系统而异;ext4 和 XFS 在内存中包含最新副本,而 Btrfs 则恢复到之前的一致状态。故障报告方式因文件系统而异;例如,在某些情况下,ext4 数据模式不会立即报告 fsync 失败,而是(奇怪地)在后续调用中失败。在 fsync 期间对某些结构(例如,日志块)的更新失败可靠地导致文件系统不可用。最后,其他潜在有用的行为缺失;例如,没有文件系统在故障后提醒用户运行文件系统检查器。

我们提出的第二个问题是(§4):重要的数据密集型应用程序如何应对 fsync 故障?为了回答这个问题,我们构建了 CuttleFS,这是一个 FUSE 文件系统,可以模拟不同文件系统对我们定义的故障模型的 fsync 故障反应。CuttleFS 在用户空间内存中维护自己的页缓存,与内核页缓存分开,允许应用程序开发人员针对不同文件系统的特性执行持久性测试,而不会受到底层文件系统和内核的干扰。

利用这个测试基础设施,我们检查了五种广泛使用的数据管理应用程序的行为:Redis [18]、LMDB [15]、LevelDB [12]、SQLite [20](在 RollBack [1] 和 WAL 模式 [21] 下)以及 PostgreSQL [15](在默认和 DirectIO 模式下)。我们的发现再次包含了每个系统的具体情况,以及一些或全部系统共有的通用结果。一些应用程序(Redis)对 fsync 的处理出人意料地粗心,甚至在向应用程序级更新返回成功之前都没有检查其返回码;结果是数据库包含旧的、损坏的或缺失的键。其他应用程序(LMDB)表现出错误失败报告,即使磁盘状态正确也向用户返回错误。许多应用程序(Redis、LMDB、LevelDB、SQLite)表现出数据损坏;例如,当需要回滚事务时,SQLite 未能将数据写入其回滚日志,并通过从所述日志读取损坏了内存状态。虽然损坏会导致某些应用程序拒绝新插入的记录(Redis、LevelDB、SQLite),但在更新时新旧数据都可能丢失(PostgreSQL)。最后,应用程序(LevelDB、SQLite、PostgreSQL)有时似乎只要相关数据保留在文件系统缓存中,就能正常工作;然而,当所述数据由于缓存压力或操作系统重启而从缓存中清除时,应用程序随后会返回陈旧数据(从磁盘检索)。

我们还考虑了文件系统和应用程序行为,得出了高层次的结论。我们发现应用程序期望同一操作系统平台(例如 Linux)上的文件系统行为相似,但文件系统表现出细微而重要的差异。我们还发现应用程序采用了多种不同的技术来处理 fsync 故障,但迄今为止没有一种是充分的;即使在 PostgreSQL 的 fsync 问题被报告 [55] 之后,也没有应用程序能完美处理其失败。我们还确定应用程序恢复技术通常依赖于文件系统页缓存,而页缓存并不反映系统的持久状态,并可能导致数据丢失或损坏;应用程序应确保恢复协议仅使用现有的持久(磁盘上)状态进行恢复。最后,在比较 ext4 和 XFS(日志文件系统)与 Btrfs(写时复制文件系统)时,我们发现写时复制策略似乎更能抵御损坏,并在需要时恢复到旧状态。

本文的其余部分组织如下。首先,我们阐述进行此项研究的必要性(§2),接着是文件系统研究(§3)。然后,我们研究应用程序如何应对 fsync 故障(§4)。接着我们讨论研究结果的意义(§5),讨论相关工作(§6),并得出结论(§7)。

2 动机

管理数据的应用程序必须确保它们能够处理存储堆栈中发生的任何故障并从中恢复。最近,一位 PostgreSQL 用户在遇到存储错误后遭遇了数据损坏,而 PostgreSQL 在此损坏中起了一定作用 [17]。鉴于此错误的重要性和复杂性,我们详细描述该情况。

PostgreSQL 是一个 RDBMS,它将表存储在单独的文件中,并使用预写日志(wal)来确保数据完整性 [16]。在事务提交时,条目被写入日志,并通知用户成功。为了确保日志不会变得太大(因为它会增加启动时重放所有日志条目的时间),PostgreSQL 定期运行检查点操作,将日志中的所有更改刷新到磁盘上的不同文件。在每个文件上调用 fsync 后,如果 PostgreSQL 被告知所有内容都已成功持久化,则日志会被截断。

当然,对持久存储的操作并不总是成功完成。存储设备可能出现许多不同类型的部分和瞬时故障,例如潜在扇区错误 [27, 41, 51]、损坏 [26] 和写入错位 [42]。这些设备故障通过各种方式通过文件系统传播到应用程序 [40, 49],通常导致 read、write 和 fsync 等系统调用失败并返回简单的返回码。

当 PostgreSQL 被告知 fsync 失败时,它会重试失败的 fsync。不幸的是,重试失败的 fsync 时应发生什么的语义没有明确定义。虽然 POSIX 旨在标准化行为,但它只规定在 fsync 期间发生故障时不保证未完成的 IO 操作已完成 [14]。正如我们将看到的,在许多 Linux 文件系统上,未能写入的数据页在调用 fsync 并失败时,在页缓存中被简单地标记为干净。结果,当 PostgreSQL 第二次重试 fsync 时,文件系统没有脏页可写,导致第二次 fsync 成功但实际上没有将数据写入磁盘。PostgreSQL 假设第二次 fsync 已持久化数据并继续截断预写日志,从而丢失了数据。PostgreSQL 错误地使用 fsync 已有 20 年 [55]。

在识别出这个复杂的问题后,开发人员更改了 PostgreSQL,使其通过崩溃和重启来响应 fsync 错误,而不重试 fsync。因此,在重启时,PostgreSQL 通过从 walwal 读取并重试整个检查点过程来重建状态。期望和意图是这种崩溃和重启方法不会丢失数据。许多其他应用程序如 WiredTiger/MongoDB [24] 和 MySQL [3] 也效仿修复了它们的 fsync 重试逻辑。

这次经历引出了我们许多问题。由于应用程序开发人员不确定 fsync 失败时底层文件系统的状态,我们研究的第一部分回答了当 fsync 失败时会发生什么。文件系统在报告 fsync 失败后表现如何?不同的 Linux 文件系统行为是否相同?应用程序开发人员在 fsync 失败后可以对其数据状态做出什么假设?因此,我们对多个文件系统的 fsync 操作进行了深入研究。

我们研究的第二部分着眼于数据密集型应用程序如何应对 fsync 故障。PostgreSQL 的解决方案是否确实在所有情况下和所有文件系统上都有效?其他数据密集型应用程序如何应对 fsync 故障?例如,它们是重试失败的 fsync,避免依赖页缓存,崩溃并重启,还是采用不同的故障处理技术?总的来说,应用程序在不同文件系统上处理 fsync 故障的效果如何?

3 文件系统研究

我们的第一个研究探讨了文件系统在报告 fsync 调用失败后的行为。在简要介绍文件系统中的缓存背景之后,我们描述了我们的方法以及针对三种 Linux 文件系统的发现。

背景

文件系统为应用程序提供 open、read 和 write 系统调用以与底层存储介质交互。由于硬盘和固态硬盘等块设备比主存慢得多 [57],操作系统在主存的内核空间中维护一个文件常用页面的页缓存。

当应用程序调用 read 时,内核首先检查数据是否在页缓存中。如果不在,文件系统从底层存储设备检索数据并将其存储在页缓存中。当应用程序调用 write 时,内核仅在内存中弄脏页面,同时通知应用程序写入成功;现在内存和设备上的数据存在不匹配,数据可能丢失。为了持久性,文件系统通过刷新脏页并将其标记为干净,定期同步内存和磁盘之间的内容。需要更强持久性保证的应用程序可以使用 fsync 系统调用强制脏页写入磁盘。

应用程序可以选择通过使用 O_DIRECT (DirectIO) 打开文件来完全绕过页缓存。对于缓存,应用程序必须在用户空间自行执行。仍然需要调用 fsync,因为数据可能在底层存储介质中被缓存;fsync 向底层设备发出 FLUSH 命令,以便它将数据一直推送到稳定存储。

3.2 方法

为了理解文件系统在报告 fsync 失败后应如何表现,我们从可用的文档开始。fsync 手册页 [9] 报告 fsync 可能因多种原因失败:底层存储介质空间不足(ENOSPC 或 EDOUOT),文件描述符无效(EBADF),或文件描述符绑定到不支持同步的文件(EINVAL)。由于这些错误可以在启动写操作之前通过验证输入和元数据来发现,我们不再进一步研究它们。

我们关注仅在文件系统开始将脏页同步到磁盘后才遇到的错误;在这种情况下,fsync 发出 EIO 错误信号。EIO 错误难以处理,因为文件系统可能已经开始一个操作(或改变了状态),而它可能无法回滚。

为了触发 EIO 错误,我们考虑符合故障-部分故障模型 [48,49] 的单个、瞬时的写入故障。当文件系统向存储设备发送写请求时,我们为请求内的单个扇区或块注入故障。

具体来说,我们构建了一个内核模块设备映射器目标(device-mapper target),它拦截来自文件系统的块设备请求,并使对特定扇区或块的特定写请求失败,同时让所有其他请求成功;这使我们能够观察对未修改文件系统的影响。

3.2.1 工作负载

为了执行 fsync 路径,我们创建了两个简单的工作负载,它们代表了数据密集型应用程序中常见的写入模式。

单块更新 (wsm):打开一个包含三页(12KB)的现有文件并修改中间页。此工作负载类似于许多修改现有文件内容的应用程序:LMDB 总是修改其数据库文件的前两个元数据页;PostgreSQL 将表存储为磁盘上的文件并就地修改它们。具体来说,wsm 按以下顺序发出系统调用:open, lseek (4K), write (4K), fsync, fsync, sleep (40), close。第一个 fsync 强制脏页写入磁盘。虽然在没有失败的情况下一个 fsync 就足够了,但我们感兴趣的是失败后 fsync 重试的影响;因此,wsm 包含了第二个 fsync。最后,由于 ext4、XFS 和 Btrfs 会定期写出元数据并为日志设置检查点,wsm 包含了 40 秒的睡眠。

多块追加 (wma):以追加模式打开一个文件,写入一页后跟一个 fsync;在睡眠后重复写入和 fsyncing。此工作负载类似于许多定期写入日志文件的应用程序:Redis 将每个修改其内存数据结构的操作写入仅追加文件(append only file);LevelDB、PostgreSQL 和 SQLite 写入预写日志并在写入后 fsync 该文件。wma 在延迟后重复这些操作以允许检查点发生;这是现实的,因为客户端并不总是连续写入,检查点可能在这些间隙中发生。具体来说,wma 按以下顺序发出系统调用:open (追加模式), write (4K), fsync, sleep (40), write (4K), fsync, sleep (40), close。

3.2.2 实验概述

我们在三个不同的文件系统上运行工作负载:ext4、XFS 和 Btrfs,使用默认的 mkfs 和挂载选项。我们评估了带有元数据有序日志(data=ordered)的 ext4 和全数据日志(data=journal)的 ext4。我们使用带有 Linux 内核版本 5.2.11 的 Ubuntu 操作系统。

对于每个文件系统和工作负载,我们首先追踪块写入访问模式。然后我们多次重复工作负载,每次配置故障注入器使对给定扇区或块的第 i 次写入访问失败。在每次迭代中,我们仅使块内的单个块或扇区失败。我们使用离线工具(debugfs 和 xfs_db)和文档的组合将每个块映射到其相应的文件系统数据结构。我们使用 SystemTap [22] 来检查与文件系统中数据或元数据相关的相关 buffer heads 和页面的状态。

3.2.3 行为推断

我们为每个文件系统回答以下问题: fsync 故障基础 (Basics of fsync Failures):

Q1 哪些块(数据、元数据、日志)故障导致 fsync 失败?
Q2 如果数据块失败,元数据是否持久化?
Q3 文件系统是否重试失败的块写入?
Q4 失败的数据块在内存中是被标记为干净还是脏?
Q5 内存中的页面内容是否与磁盘上的内容匹配?

故障报告 (Failure Reporting):

Q6 哪个未来的 fsync 将报告写入失败?
Q7 写入失败是否记录在系统日志(syslog)中?

fsync 故障的后续影响 (After Effects of fsync Failure):

Q8 哪些块故障导致文件系统不可用?
Q9 不可用如何表现?文件系统是关闭、崩溃还是以只读模式重新挂载?
Q10 文件是否遭受空洞或块覆盖失败?如果是,它们可能发生在文件的哪些部分?

恢复 (Recovery):

Q11 如果由于 fsync 失败引入了任何不一致,fsck 能否检测并修复它?

3.3 发现

我们现在描述我们对已表征的三个文件系统的发现:ext4、XFS 和 Btrfs。我们对所提问题的回答总结在表 1 中。

3.3.1 Ext4

Ext4 文件系统是 Linux 上常用的日志文件系统。挂载此文件系统时两个最常见的选项是 data=ordered 和 data=journal,它们分别启用 ext4 有序模式和 ext4 数据模式。Ext4 有序模式将元数据写入日志,而 ext4 数据模式将数据和元数据都写入日志。

Ext4 有序模式 (Ext4 ordered mode): 我们通过描述在没有故障发生时,它对我们两个代表性工作负载的行为来概述 ext4 有序模式。

单块更新 (wsm)。当未注入故障且 fsync 成功时,ext4 有序模式的行为如下。在写入期间(步骤 1),ext4 用新内容更新页缓存中的页面并将其标记为脏。在 fsync 时,该页被写入一个数据块;在数据块写入成功完成后,元数据(即具有新修改时间的 inode)被写入日志,并且 fsync 返回 0 表示成功(步骤 2)。在 fsync 之后,脏页被标记为干净并包含新写入的数据。在第二次 fsync 时,由于没有脏页,不会发生块写入,并且由于没有错误,fsync 返回 0(步骤 3)。在睡眠期间,日志中的元数据被检查点设置到其最终的就地块位置(步骤 4)。在关闭期间(步骤 5),没有写入或页面状态更改发生。

如果 fsync 失败(即返回 -1 且 errno 设置为 EIO),则可能发生了各种写入问题。例如,数据块写入可能失败;如果发生这种情况,ext4 不会将元数据写入日志。然而,更新后的页面仍被标记为干净并包含来自步骤 1 的新写入数据,导致与磁盘内容不一致。此外,尽管在数据故障发生时 inode 表没有写入日志,但包含更新后修改时间的 inode 表在步骤 3 的第二次 fsync 中被写入日志。步骤 4 和 5 与上面相同,因此 inode 表被检查点设置。

因此,当页面保留在页缓存中时(即页面尚未被逐出且操作系统未重启),读取此数据块的应用程序将看到数据的新内容;但是,当页面不再在内存中并且必须从磁盘读取时,应用程序将看到旧内容。

或者,如果 fsync 失败,可能是因为对某个日志块的写入失败。在这种情况下,ext4 中止日志事务并以只读模式重新挂载文件系统,导致所有未来的写入失败。

多块追加 (wma)。下一个工作负载在 fsync 错误路径中测试了更多情况。如果没有错误且所有 fsync 都成功,ext4 上的多块追加工作负载行为如下。首先,在写入期间,ext4 创建一个包含新内容的新页面并将其标记为脏(步骤 1)。在 fsync 时,该页被写入一个新分配的磁盘数据块;在数据块写入成功完成后,相关元数据(即 inode 表和块位图)被写入日志,并且 fsync 返回成功(步骤 2)。与 wsm 一样,该页被标记为干净并包含新写入的数据。在睡眠期间,元数据被检查点设置到磁盘(步骤 3);具体来说,inode 包含新的修改时间和对新分配块的链接,块位图现在指示新分配的块正在使用中。第二次写入(步骤 4)、fsync(步骤 5)和睡眠(步骤 6)重复该模式。与 wsm 一样,在关闭期间(步骤 7)没有写请求或页面状态更改。

fsync 失败可能再次表明许多问题。首先,步骤 2 中对数据块的写入可能失败。如果是这种情况,fsync 失败并且页面被标记为干净;与 wsm 一样,页面包含新写入的数据,与包含原始块内容的磁盘块不同。inode 表和块位图在步骤 3 中被写入磁盘;因此,即使数据本身没有被写入,inode 也被修改为引用此块,并且相应的位在块位图中被设置。当工作负载在步骤 4 中写入另外 4KB 数据时,此写入会忽略之前的故障继续进行,并且步骤 5、6 和 7 照常进行。

因此,在数据块故障的情况下,磁盘上的文件在其本应包含来自步骤 1 的数据的位置包含一个未被覆盖的块。类似的可能性是步骤 5 中对包含在最后日志事务中的数据块的写入失败;在这种情况下,文件在末尾而不是中间某处有一个未被覆盖的块。同样,当这些失败的数据块保留在页缓存中时,读取它们的应用程序将看到新追加的内容;但是,当其中任何页面不再在内存中并且必须从磁盘读取时,应用程序将读取原始块内容。

fsync 失败也可能表明对日志块的写入失败。在这种情况下,与 wsm 一样,fsync 返回错误,并且后续写入失败,因为 ext4 已以只读模式重新挂载。

表 1: fsync 失败时各种文件系统的行为。该表总结了三种文件系统的行为:ext4、XFS 和 Btrfs 根据第 3.2.3 节提出的问题。问题分为顶部提到的四个类别。对于 需要识别块类型的问题,我们使用以下缩写:数据块 (data)、日志块 (jml)、元数据块 (meta)。在 Q9 中, Remount-ro 表示以只读模式重新挂载。在 Q10 中,“任意位置”和“内部”描述了空洞或未被覆盖块 (NOB) 的位置; “内部”不包括文件末尾。带有上标的条目表示存在问题。

由于此工作负载在步骤 3 中元数据被检查点设置后包含一个 fsync,它也说明了在检查点设置 inode 表和块位图时故障的影响。我们发现,尽管写入失败并且文件系统现在将处于不一致状态,后续的 fsync 不会返回错误。但是,元数据错误会被记录到系统日志。

我们注意到,对于所有这些 fsync 失败,ext4 有序模式都没有建议运行文件系统检查器;此外,运行检查器也无法识别或修复任何前述问题。最后,未来的 fsync 调用永远不会重试之前可能失败的数据写入。这些 ext4 有序模式的结果都总结在表 1 中。

ext4 文件系统还提供了在文件数据缓冲区发生错误时中止日志的功能(挂载选项 data_error=abort)和在发生错误时以只读模式重新挂载文件系统的功能(挂载选项 errors=remount-ro)。然而,我们观察到无论是否使用这些挂载选项,结果都是相同的。[2]

Ext4 数据模式 (Ext4 Data Mode): Ext4 数据模式与有序模式的不同之处在于数据块首先写入日志,然后在稍后检查点设置到其最终的就地块位置。 如表 1 所示,ext4 数据模式中 fsync 的行为在大多数情况下与 ext4 有序模式相似:例如,在写入错误时,即使页面没有写出到磁盘,页面也可能被标记为干净,在日志失败时文件系统以只读模式重新挂载,元数据故障不会被 fsync 报告,并且文件最终可能在中间或末尾出现未被覆盖的块。

然而,在一种重要场景下,ext4 数据模式的行为有所不同。因为数据块首先写入日志,然后在检查点设置期间写入其实际块位置,所以在写入后的第一次 fsync 可能成功,即使数据块未能成功写入其永久的就地位置。结果,数据块故障导致第二次 fsync 失败而不是第一次;换句话说,由于失败的意向(intention)[36],fsync 的错误报告被延迟。

3.3.2 XFS

XFS 是一个使用 B 树的日志文件系统。与像 ext4 那样执行物理日志记录不同,XFS 记录元数据更改的逻辑条目。

如表 1 所示,从错误报告和 fsync 行为的角度来看,XFS 与 ext4 有序模式相似。具体来说,写入数据块失败会导致 fsync 失败,并且故障的数据页被标记为干净,即使它们包含尚未传播到磁盘的新数据;因此,读取此故障数据的应用程序只有在页面被逐出页缓存之前才能看到新数据。 类似地,写入日志块失败会导致 fsync 失败,而写入元数据块失败则不会。在数据块故障后,XFS 仍可用于读取和写入。

XFS 处理 fsync 失败的方式在几个方面与 ext4 有序模式不同。首先,在日志块故障时,XFS 完全关闭文件系统,而不仅仅是重新挂载为只读模式;因此,所有后续的读写操作都会失败。其次,XFS 在检查点设置期间遇到故障时会重试元数据写入;重试次数限制由 /sys/fs/xfs//error/metadata//max_retries 中的值确定,但默认是无限的。如果超出重试限制,XFS 会再次关闭文件系统。

多块追加工作负载说明了当相关数据块写入失败时 XFS 如何处理元数据。如果对第一个数据块的写入失败,XFS 不会将任何元数据写入日志并立即使 fsync 失败。当稍后成功地将数据块追加到此文件时,元数据被更新,这会在文件中创建一个对应于第一次写入的未被覆盖块。相反,如果对包含在最后日志事务中的数据块的写入失败,磁盘上的元数据不会更新以反映这些最后的写入(即,如果在最后事务中有任何相关块失败,文件大小不会增加)。³ 因此,在 ext4 中失败的写入总是导致未被覆盖块,而在 XFS 中,未被覆盖块不可能出现在文件末尾。但是,对于任一文件系统,如果失败的块保留在页缓存中,应用程序可以读取这些块,无论它们是在文件中间还是末尾。

3.3.3 Btrfs

Btrfs 是一个写时复制文件系统,它避免对同一块写入两次,除了包含根节点信息的超级块。在高层次上,Btrfs 中的某些操作类似于日志文件系统中的操作:Btrfs 不是写入日志,而是在执行 fsync 时写入日志树(log tree)来记录更改;不是检查点设置到固定的就地位置,而是写入新位置并更新其超级块中的根。然而,由于 Btrfs 基于写时复制,它在处理 fsync 失败的方式上与 ext4 和 XFS 相比有一些有趣的差异,如表 1 所示。

与 ext4 有序模式和 XFS 一样,Btrfs 在遇到数据块故障时会使 fsync 失败。然而,与 ext4 和 XFS 不同,Btrfs 有效地将数据块(以及任何相关元数据)的内容恢复为其旧状态(并将页面标记为干净)。因此,如果应用程序在此失败后读取数据,它将永远不会看到失败的操作作为临时状态。与其他文件系统一样,在此数据块故障后 Btrfs 仍然可用。

类似于其他文件系统中日志的故障,在 Btrfs 中,日志树的故障会导致 fsync 失败并以只读模式重新挂载。与 ext4 和 XFS 不同,在检查点设置期间元数据块的故障会导致以只读模式重新挂载(但 fsync 仍然不返回错误)。

多块追加工作负载说明了 Btrfs 块分配中的有趣行为。如果第一次追加失败,文件系统的状态,包括跟踪所有空闲块的 B 树,会被恢复。然而,下一次追加将继续在文件描述符中存储的(错误)更新偏移量处写入,从而在文件中创建一个空洞。由于 B 树的状态被恢复,确定性块分配器将选择为下一次追加操作再次分配相同的块。因此,如果对该特定块的故障是瞬时的,下一次写入和 fsync 将成功,并且文件中将只有一个块的空洞。如果对该特定块的故障多次发生,未来的写入将继续失败;结果,Btrfs 可能比 ext4 和 XFS 在文件中造成更多的空洞。然而,与 ext4 和 XFS 不同,该文件没有块覆盖失败。

脚注³:准确地说,文件的 intime 和 ctime 被更新,但文件大小没有更新。为节省空间而省略的额外实验证实了这种行为。

3.3.4 文件系统总结

我们现在根据 §3.2.3 中的问题,为文件系统提出一组观察结果。

文件系统对 fsync 失败的行为 (File System Behavior to fsync Failures)。 在所有三个文件系统上,只有数据和日志块故障会导致 fsync 失败 (Q1)。元数据块故障不会导致 fsync 失败,因为元数据块在 fsync 期间被写入日志。然而,在检查点期间,XFS 和 Btrfs 上的任何元数据故障都会导致不可用 (Q8),而 ext4 记录错误并继续。⁴

在 ext4 的两种模式和 XFS 上,即使在文件系统遇到数据块失败后,元数据也会被持久化 (Q2);时间戳在这两个文件系统中总是被更新。此外,ext4 会向文件追加一个新块并更新文件大小,而 XFS 只有在后续有成功的 fsync 时才会这样做。结果,我们在 ext4 的文件中间和末尾都发现了未被覆盖块 (NOB),但在 XFS 中只在中间发现 (Q10)。Btrfs 在数据块失败后不会持久化元数据。然而,由于进程文件描述符的偏移量增加了,未来的写入和 fsync 会导致文件中间出现空洞 (Q10)。

在这三者中,XFS 是唯一重试元数据块写入的文件系统。然而,没有一个文件系统会重试数据或日志块写入 (Q3)。

所有文件系统即使在 fsync 失败后也将页面标记为干净 (Q4)。在 ext4 的两种模式和 XFS 中,页面包含最新的写入,而 Btrfs 将内存状态恢复为与磁盘一致 (Q5)。

我们注意到,尽管所有文件系统都将页面标记为干净,这并非继承自 VFS 层的任何行为。每个文件系统都注册自己的处理程序来将页面写入磁盘(ext4_writepages, xfs_vm_writepages, and btrfs_writepages)。然而,这些处理程序中的每一个在提交 bio 请求之前都调用 clear_page_dirty_for_io,并且在失败情况下不会设置脏位以避免内存泄漏⁵,独立地复制了该问题。

脚注⁵:Ext4 关注用户在使用中移除 USB 盘这一常见情况。永远无法写入已移除 USB 盘的脏页必须被标记为干净以卸载文件系统并回收内存 [23]。

故障报告 (Failure Reporting)。 虽然所有文件系统都通过使 fsync 失败来报告数据块故障,但 ext4 有序模式、XFS 和 Btrfs 使立即的 fsync 失败。由于 ext4 数据模式将数据放入日志中,第一次 fsync 成功而下一个 fsync 失败。(Q6)。所有块写入失败,无论块类型如何,都会记录在系统日志中 (Q7)。

后续影响 (After Effects)。 日志块故障总是导致文件系统不可用。在 XFS 和 Btrfs 上,元数据块故障也是如此 (Q8)。而 ext4 和 Btrfs 以只读模式重新挂载,XFS 则关闭文件系统 (Q9)。空洞和未被覆盖块 (Q10) 已作为 Q2 的一部分在前面介绍过。

恢复 (Recovery)。 没有文件系统提醒用户运行文件系统检查器。但是,Btrfs 检查器能够检测文件中的空洞 (Q11)。

4 应用程序研究

我们现在关注应用程序如何受到 fsync 故障的影响。在本节中,我们首先描述我们使用 CuttleFS 的故障模型,然后是工作负载描述、执行环境以及我们寻找的错误。接着,我们介绍五种广泛使用的应用程序的发现:Redis (v5.0.7)、LMDB (v0.9.24)、LevelDB (v1.22)、SQLite (v3.30.1) 和 PostgreSQL (v12.0)。

CuttleFS

我们将研究限制在应用程序如何受到数据块故障的影响,因为日志块故障会导致不可用,而元数据块故障不会导致 fsync 失败 (§3.3)。我们的故障模型很简单:当应用程序写入数据时,我们向一个数据块或其内的一个扇区注入单个故障。

我们构建了 CuttleFS⁶ - 一个 FUSE [39] 文件系统,用于模拟不同文件系统对我们故障模型定义的故障的反应。CuttleFS 不使用内核的页缓存,而是在用户空间内存中维护自己的页缓存。写操作修改用户空间页面并将其标记为脏,而读操作则从这些页面提供数据。当应用程序发出 fsync 系统调用时,CuttleFS 将数据与底层文件系统同步。

脚注⁶:墨鱼(Cuttlefish)有时被称为“海洋中的变色龙”,因为它们能够在一秒钟内迅速改变皮肤颜色。CuttleFS 可以更快地改变特性。

CuttleFS 有两种操作模式:追踪模式(trace mode)和故障模式(fault mode)。在追踪模式下,CuttleFS 跟踪写入并识别哪些块最终被写入磁盘。这与仅仅追踪 write 系统调用不同,因为应用程序可能在数据实际刷新到磁盘之前多次写入文件的特定部分。

在故障模式(fail mode)下,CuttleFS 可以被配置为使与特定文件关联的扇区或块的第 ithith 次写入失败。在 fsync 失败时,由于 CuttleFS 使用内存缓冲区,它可以被指示将页面标记为干净或脏,保留最新内容,或将文件恢复到之前的状态。错误报告行为可以配置为立即报告或在下次 fsync 调用时报告。简而言之,CuttleFS 可以以表 1 (Q4,5,6) 中提到的任何方式对 fsync 故障作出反应。此外,CuttleFS 接受命令以逐出所有或特定的干净页。

我们将 CuttleFS 配置为模拟第 3.3 节中研究的文件系统的故障反应。例如,为了模拟 ext4 有序模式和 XFS(因为它们具有相似的故障反应),我们配置 CuttleFS 将页面标记为干净,保留最新内容,并立即报告错误。此后,在展示我们的发现并引用由 CuttleFS 模拟的特性时,我们使用 CuttleFSext4o,xfs 表示上述配置。当页面被标记为干净,具有最新内容,但错误在下次 fsync 时报告时,我们使用 CuttleFSext4d。当页面被标记为干净,内容与磁盘匹配,并且错误立即报告时,我们将其称为 CuttleFSbtrfs。

工作负载和执行环境

我们在追踪模式下运行 CuttleFS,并识别应用程序写入哪些块。对于每个应用程序,我们选择一个简单的工作负载,即插入单个键值对,这是许多应用程序中常用的操作。我们对现有键(更新)和新键(插入)都执行实验。键的大小可以是 2B 或 1KB。⁷ 值的大小可以是 2B 或 12KB。我们运行所有四种组合的实验。大键允许在键内使单个扇区失败,大值允许在值内使页面失败。由于 SQLite 和 PostgreSQL 是关系数据库管理系统,我们创建一个包含两列的表:键(keys)和值(values)。

脚注⁷:由于 LMDB 将键大小限制为 511B,我们在 LMDB 实验中使用 2B 和 511B 的键大小。

使用追踪,我们为每个已识别的块及其内的扇区生成多个故障序列。然后,我们在故障模式下使用 CuttleFS 多次重复实验,每次使用不同的故障序列和文件系统反应。为了观察故障后的影响,我们在工作负载前后转储所有键值对。

在执行实验时,我们寻找以下类型的错误:

  • 旧值 (OldValue, OV): 系统暂时返回新值但随后恢复为旧值,或者系统传达成功响应但稍后返回旧值。

  • 错误失败 (FalseFailure, FF): 系统通知用户操作失败,但将来返回新值。

  • 键损坏 (KeyCorruptions, KC) 和值损坏 (ValueCorruptions, VC): 损坏的键或值被无意中返回。

  • 键未找到 (KeyNotFound, KNF): 系统通知用户已成功插入一个键,但后来无法找到它,或者系统未能将键更新为新值,但旧的键值对也消失了。

表 2: fsync 故障的应用程序发现。该表列出了当 fsync 由于数据块写入故障而失败时,应用程序表现出的不同类型的错误。错误 (OV, FF, KC, VC, KNF) 在 §4.2 中描述。我们根据 §3.3 中关于 Q4、Q5 和 Q6 的发现,按文件系统对 fsync 故障的反应方式对列进行分组。例如,ext4 有序和 XFS (ext4o,xfs) 都将页面标记为干净,页面在内存中和磁盘上的内容不同,并且 fsync 故障立即报告。对于每个应用程序,我们描述了错误在何时显现,依据是四个不同的执行环境因素(§4.2)的组合,其符号在左上角提供。例如,在 Redis 中,OldValue 在第一组 (ext4-ordered, XFS) 中仅在 (A)App=Restart,(BC)BufferCache=Evict 时显现。然而在最后一组 (Btrfs) 中,错误在 App=Restart,BufferCache=Evict 以及 App=Restart,BufferCache=Keep 时都显现,描述为两个符号的组合。

我们还识别导致所有这些错误显现的执行环境中的因素。如果应用程序维护自己的内存中数据结构,一些错误可能仅在应用程序重启并从文件系统重建内存状态时发生。或者,这些错误的显现可能取决于应用程序外部的状态变化,例如单页逐出或完整的页缓存刷新。我们将这些不同的场景编码为:

  • App=KeepGoing: 应用程序在不重启的情况下继续运行。

  • App=Restart: 应用程序在崩溃或正常关闭后重启。这会强制应用程序从磁盘重建内存状态。

  • BufferCache=Keep: 不发生逐出。

  • BufferCache=Evict: 一个或多个干净页被逐出。 注意 BufferCache=Evict 可以通过清除整个页缓存、重启文件系统或仅由于内存压力逐出干净页来显现。完整的系统重启将是 App=Restart 和 BufferCache=Evict 的组合,这会导致内存中干净页和脏页都丢失,同时也强制应用程序重启并从磁盘重建状态。 配置 CuttleFS 使某个块失败并根据其中一个文件系统反应作出反应,同时应用程序仅处理 App=KeepGoing 和 BufferCache=Keep 的场景。其余三个场景的处理方式如下。为了模拟 App=Restart 和 BufferCache=Keep,我们重启应用程序并转储所有键值对,确保 CuttleFS 中没有页面被逐出。为了解决剩余两个场景,我们指示 CuttleFS 在 App=KeepGoing 和 App=Restart 时都逐出干净页。

4.3 发现

我们将所有五个应用程序配置为以提供最强持久性的形式运行,并在它们各自的章节中讨论这些形式。表 2 总结了不同故障特性下每个应用程序的结果。

请注意,这些结果仅针对插入单个键值对的简单工作负载。复杂的工作负载可能会表现出更多错误或掩盖我们观察到的错误。

Redis: Redis 是一个内存中的数据结构存储,用作数据库、缓存和消息代理。默认情况下,它会定期将内存状态快照到磁盘。然而,为了提供更好的持久性保证,它提供了将每个修改存储的操作写入仅追加文件 (aof) [19] 的选项,以及多久对 aof 执行一次 fsync。在崩溃或重启时,Redis 通过读取 aof 的内容重建内存状态。

我们将 Redis 配置为对每个操作都对文件执行 fsync,以提供强持久性。因此,每当 Redis 收到一个修改状态的请求(如插入操作)时,它会将请求写入 aof 并调用 fsync。然而,Redis 信任文件系统能成功持久化数据,并不检查 fsync 的返回码。无论 fsync 是否失败,Redis 都会向客户端返回成功响应。

由于 Redis 无论 fsync 是否失败都向客户端返回成功响应,因此不会发生 FalseFailures。由于 Redis 仅在重建内存状态时才从磁盘读取,错误可能仅在 App=Restart 期间发生。

在 CuttleFSext4o,xfs 和 CuttleFSext4d 上,Redis 表现出 OldValue、KeyCorruption、ValueCorruption 和 KeyNotFound 错误。然而,如表 2 所示,这些错误仅在 BufferCache=Evict 和 App=Restart 时发生。在 BufferCache=Keep 时,页面包含最新的写入,这允许 Redis 重建最新状态。然而,当页面被逐出时,未来的读取将强制从磁盘读取,导致 Redis 读取该块上的任何内容。当故障损坏 aof 格式时,会显现 OldValue 和 KeyNotFound 错误。当 Redis 重启时,它在扫描 aof 时要么忽略这些条目,要么建议运行 aof 检查器,该检查器将文件截断到最后一个未损坏的条目。当故障发生在条目的键或值部分时,会显现 KeyCorruption 和 ValueCorruption。

在 CuttleFSbtrfs 上,Redis 表现出 OldValue 和 KeyNotFound 错误。这些错误在 App=Restart 时发生,与缓冲缓存状态无关。当 Redis 重启时,条目从 aof 中缺失,因为文件被恢复,因此插入或更新操作未被应用。

LMDB: 闪电内存映射数据库 (Lightning Memory-Mapped Database, LMDB) 是一个嵌入式键值存储,它使用 B+Tree 数据结构,其节点驻留在单个文件中。文件的前两页是元数据页,每页包含一个事务 ID 和根节点的位置。读取者总是使用具有最新事务 ID 的元数据页,而写入者进行更改并更新较旧的元数据页。

LMDB 使用自底向上的写时复制策略 [13] 来提交写事务。所有从叶子到根的新节点都被写入文件中未使用或新的页面,后跟一个 fsync。fsync 失败会终止操作而不更新元数据页,并通知用户。如果 fsync 成功,LMDB 继续用新的根位置和事务 ID 更新旧的元数据页,然后再跟另一个 fsync。⁸ 如果 fsync 失败,LMDB 在内存中的元数据页写入一个旧的事务 ID,防止未来的读取者读取它。

脚注⁸:准确地说,LMDB 在更新元数据页时并不执行写后跟 fsync。相反,它使用一个在 O_SYNC 模式下打开的文件描述符。在写入时,只有元数据页被刷新到磁盘。失败时,它使用普通的文件描述符。

在 CuttleFSext4o.xfs 上,LMDB 表现出 FalseFailures。当 LMDB 写入元数据页时,它只关心事务 ID 和新的根位置,两者都包含在单个扇区中。因此,即使该扇区已持久化到磁盘,元数据页其他七个扇区的失败也会导致 fsync 失败。如前所述,LMDB 在内存中的元数据页写入一个旧的事务 ID(例如 ID1)并向用户报告失败。然而,在 BufferCache=Evict 和 App=Restart(例如机器崩溃并重启)时,ID1 会丢失,因为它只被写入内存而未持久化。因此,读取者从最新的事务 ID(即之前失败的事务)读取。

LMDB 在 CuttleFSext4d 中不表现出 FalseFailures,因为立即成功的 fsync 导致客户端成功。相反,在 BufferCache=Evict 时发生 ValueCorruptions 和 OldValue 错误,无论应用程序是否重启。当包含值一部分的块发生故障时,会发生 ValueCorruptions。由于 LMDB mmap() 文件并直接从页缓存读取,BufferCache=Evict(例如页面逐出)导致从磁盘读取故障块的值。当元数据页发生故障时,会发生 OldVersion 错误。文件系统最初响应为 fsync 成功(因为数据成功存储在 ext4 日志中)。在短时间内,元数据页具有最新的事务 ID。然而,当页面被逐出时,磁盘上的元数据页恢复到旧的事务 ID,导致读取者读取旧值。KeyCorruptions 不会发生,因为最大允许键大小为 511B。

由于 CuttleFSbtrfs 立即报告错误,它不会面临在 CuttleFSext4d 中看到的问题。FalseFailures 不会发生,因为文件被恢复到其先前的一致状态。我们在许多应用程序中观察到相同的模式,除非相关,否则在其余讨论中省略它们。

LevelDB: LevelDB 是一个基于 LSM 树的广泛使用的键值存储。它在内部使用 MemTables 和 SSTables [33] 存储数据。此外,LevelDB 在更新 MemTable 之前将操作写入日志文件。当 MemTable 达到一定大小时,它变得不可变并被作为 SSTable 写入新文件。SSTable 总是被创建,从不就地修改。在重启时,如果存在日志文件,LevelDB 会从其内容创建一个 SSTable。

我们将 LevelDB 配置为在每次写入后都对日志执行 fsync,以获得更强的持久性保证。如果 fsync 失败,则不会更新 MemTable,并通知用户失败。如果在 SSTable 创建期间 fsync 失败,则操作被取消,SSTable 被留作未使用。

在 CuttleFSext4o.xfs 上,如表 2 所示,LevelDB 仅在 App=Restart 和 BufferCache=Keep 时表现出 FalseFailures。当 LevelDB 被告知日志文件的 fsync 失败时,会通知用户失败。然而,在重启时,由于日志条目在页缓存中,LevelDB 在从日志文件创建 SSTable 时将其包含在内。从此点开始的读操作返回新值,反映了 FalseFailures。在 BufferCache=Evict 时不会发生 FalseFailures,因为 LevelDB 能够通过 CRC 校验和 [33] 检测无效条目。SSTable 中的故障被立即检测到,并且不会导致任何错误,因为在失败情况下 LevelDB 不使用新生成的 SSTable。

在 CuttleFSext4d 上,当故障发生在日志文件中时,LevelDB 表现出 KeyNotFound 和 OldVersion 错误。当插入键值对时,fsync 成功返回,允许未来的读操作返回新值。然而,在 BufferCache=Evict 和 App=Restart 时,LevelDB 拒绝损坏的日志条目,并为未来的读操作返回旧值。根据我们是插入新键还是现有键,当日志条目被拒绝时,我们会观察到 KeyNotFound 或 OldVersion 错误。此外,对于发生在 SSTable 中的故障,LevelDB 表现出 KeyCorruption、ValueCorruption 和 KeyNotFound 错误。Ext4 数据模式可能仅将数据放入日志并返回成功的 fsync。稍后,在检查点设置期间,由于故障,SSTable 被损坏。这些错误仅在 BufferCache=Evict 时显现,无论应用程序在运行时还是重启时,取决于何时从磁盘读取 SSTable。

SQLite: SQLite 是一个嵌入式 RDBMS,它使用 BTree 数据结构。每个表和索引使用一个单独的 BTree,但所有 BTree 都存储在磁盘上的单个文件中,称为“主数据库文件”(maindb)。在事务期间,SQLite 在第二个文件中存储附加信息,称为“回滚日志”(rj)或“预写日志”(wal),具体取决于其运行的模式。在崩溃或重启时,SQLite 使用这些文件来确保已提交或已回滚的事务反映在 maindb 中。一旦事务完成,这些文件会被删除。我们对两种模式都进行了实验。

SQLite 回滚模式 (SQLite RollBack): 在回滚日志模式中,在 SQLite 修改其用户空间缓冲区之前,它将原始内容写入 rj。在提交时,rj 被 fsyncd。如果成功,SQLite 向 rj 写入一个头部并再次 fsync(对 rj 执行 2 次 fsync)。如果此时发生故障,只需要恢复用户空间缓冲区中的状态。如果没有,SQLite 继续写入 maindb,使其反映用户空间缓冲区的状态。然后对 maindb 执行 fsync。如果 fsync 失败,SQLite 需要从 rj 中将旧内容重写到 maindb 并恢复其用户空间缓冲区中的状态。恢复内容后,删除 rj。

在 CuttleFSext4o,xfs 上,SQLite 回滚模式在 BufferCache=Evict 时表现出 FalseFailures 和 ValueCorruptions,无论应用程序是否重启。当故障发生在 rj 时,SQLite 选择使用 rj 本身来恢复内存状态,因为它包含足够的信息来回滚用户空间缓冲区。只要最新内容在页缓存中,这种方法就有效。然而,在 BufferCache=Evict 时,当 SQLite 读取 rj 来回滚内存状态时,rj 不包含最新的写入。结果,SQLite 的用户空间缓冲区可能仍然具有新内容(FalseFailure)或损坏的值,具体取决于故障发生的位置。

SQLite 回滚模式在 CuttleFSext4d 中表现出 FalseFailures,原因与上述相同,因为 fsync 失败在第二次对 rj 的 fsync 时被捕获。此外,由于 CuttleFSext4d 中的延迟错误报告,当故障发生在 maindb 时,SQLite 回滚模式表现出 ValueCorruption 和 KeyNotFound 错误。SQLite 在将数据写入 maindb 后看到成功的 fsync 并继续删除 rj。然而,在 App=Restart 和 BufferCache=Evict 时,根据故障发生的位置,会显现上述错误。

在 CuttleFSbtrfs 上,SQLite 回滚模式出于与上述相同的原因表现出 FalseFailures。但是,无论缓冲缓存状态是否改变,它们都会发生,因为 rj 中的内容被恢复。由于没有 rj 中的数据可供恢复,SQLite 保持用户空间缓冲区不变。ValueCorruptions 不可能发生,因为没有尝试恢复内存内容。

SQLite WAL 模式 (SQLite WAL): 与 SQLite 回滚不同,在事务提交时,更改被写入预写日志(wal)。SQLite 在 wal 上调用 fsync 并继续更改内存状态。如果 fsync 失败,SQLite 立即向用户返回失败。如果 SQLite 必须重启,它首先从 maindb 重建状态,然后根据 wal 中的条目更改状态。为确保 wal 不会变得太大,SQLite 定期运行检查点操作(Checkpoint Operation)以使用 wal 中的内容修改 maindb。

在 CuttleFSext4o,xfs 上,如表 2 所示,SQLite WAL 仅在 App=Restart 和 BufferCache=Keep 时表现出 FalseFailures,原因类似于 LevelDB。它从页缓存中读取有效的日志条目,即使它们可能由于磁盘上的故障而无效。

在 CuttleFSext4d 上,在检查点操作期间,当 maindb 中存在故障时,SQLite WAL 表现出 ValueCorruption 和 KeyNotFound 错误,原因与 SQLite 回滚中提到的相同。

PostgreSQL: PostgreSQL 是一个对象关系数据库系统,它为每个数据库表维护一个文件。启动时,它读取磁盘上的表并填充用户空间缓冲区。类似于 SQLite WAL,PostgreSQL 从预写日志(wal)读取条目并相应地修改用户空间缓冲区。类似于 SQLite WAL,PostgreSQL 运行检查点操作,确保 wal 不会变得太大。我们评估 PostgreSQL 的两种配置:默认配置和 DirectIO 配置。

PostgreSQL 默认模式 (PostgreSQL Default): 在默认模式下,PostgreSQL 像对待其他文件一样对待 wal,使用页缓存进行读写。只有在 wal 上的 fsync 成功后,PostgreSQL 才会通知用户提交(commit)操作成功。在检查点期间,PostgreSQL 将其用户空间缓冲区中的数据写入表中并调用 fsync。如果 fsync 失败,PostgreSQL(意识到 fsync 的问题 [8])选择崩溃。这样做避免了截断 wal,并确保稍后可以重试检查点。

在 CuttleFSext4o,xfs 上,PostgreSQL 表现出 FalseFailures,原因类似于 LevelDB。App=Restart 是读取日志条目所必需的,而 BufferCache=Evict 则不是。此外,应用程序重启无法避免,因为 PostgreSQL 在 fsync 失败时有意崩溃。在 BufferCache=Keep 时,PostgreSQL 读取页缓存中的有效日志条目。在 BufferCache=Evict 时,根据哪个块发生故障,PostgreSQL 要么接受要么拒绝日志条目。当 PostgreSQL 接受日志条目时,FalseFailures 显现。但是,如果文件系统也崩溃并重启,页缓存将与磁盘状态匹配,导致 PostgreSQL 拒绝日志条目。不幸的是,ext4 目前在使用挂载选项 data_error=abort 和 errors=remount-ro 时未按预期行为($3.3.1)。

由于 CuttleFSext4d 中的延迟错误报告,如表 2 所示,当故障发生在数据库表文件中时,PostgreSQL 表现出 OldVersion 和 KeyNotFound 错误。由于 PostgreSQL 维护用户空间缓冲区,这些错误仅在 BufferCache=Evict 和 App=Restart 时显现。在检查点操作期间,PostgreSQL 将用户空间缓冲区写入表。由于故障尚未报告,操作成功并且 wal 被截断。如果对应于故障的页面被逐出并且 PostgreSQL 重启,它将使用不正确的磁盘表文件重建其用户空间缓冲区。根据故障发生的位置显现错误。当更新时,PostgreSQL 会丢失现有键,而其他应用程序在插入新键时发生 KeyNotFound 错误,因为它就地修改表文件。

PostgreSQL DIO 模式 (PostgreSQL DIO): 在 DirectIO 模式下,PostgreSQL 绕过页缓存,使用 DirectIO 写入 wal。事务提交和检查点期间的操作序列与默认模式完全相同。

由于绕过了页缓存,FalseFailures 不会发生。然而,在 CuttleFSext4d 中,由于对数据库表文件的写入不使用 DirectIO,OldVersion 和 KeyNotFound 错误仍然会出于上述相同原因发生。

5 讨论

我们现在提出一组关于跨文件系统和应用程序处理 fsync 故障的观察和建议。

#1: 现有文件系统对 fsync 故障的处理不一致。 为了隐藏跨平台差异,POSIX 有意模糊了故障处理方式。因此,不同的文件系统在 fsync 失败后表现不同(如表 1 所示),导致对一视同仁地对待所有文件系统的应用程序产生不确定的结果。我们相信_POSIX 规范需要澄清 fsync,并更详细地描述预期的故障行为。_

#2: 写时复制文件系统(如 Btrfs)比现有日志文件系统(如 ext4 和 XFS)更好地处理 fsync 故障。 Btrfs 在将数据写入磁盘时使用新的或未使用的块;文件系统在成功时从一个状态整体转移到另一个状态,不允许中间状态。当只有某些块包含新写入的数据时,这种策略可以防御损坏。使用写时复制的文件系统通常可能比日志文件系统更能抵御 fsync 故障。

#3: Ext4 数据模式提供了一种虚假的持久感。 尽管性能较低,应用程序开发人员有时仍选择使用数据日志文件系统,因为他们相信数据模式更持久 [11]。Ext4 数据模式确实确保数据和元数据处于“一致状态”,但这仅是从文件系统的角度。如表 2 所示,应用程序级的不一致性仍然可能发生。此外,应用程序无法确定从 fsync 收到的错误是与最近的操作有关,还是与过去的某个操作有关。当失败的意向(failed intentions)可能发生时,应用程序需要与文件系统建立更强的契约,通知它们相关上下文,例如日志中的数据以及哪些块未能成功写入。

#4: 现有的文件系统故障注入测试缺乏在故障后继续运行的工作负载。 虽然所有文件系统都执行故障注入测试,但它们主要是为了确保文件系统在遇到故障后保持一致。这类测试涉及在故障后很快关闭文件系统,并检查文件系统在重启时是否正确恢复。_我们相信文件系统开发人员也应该测试在故障后继续运行的工作负载,看看效果是否如预期。然后应记录这些效果。_文件系统开发人员也可以在更改实际文件系统之前,通过运行那些工作负载在 CuttleFS 上快速测试对某些特性的影响。

#5: 应用程序开发人员编写特定于操作系统的代码,但并不了解所有的操作系统差异。 FreeBSD 的 VFS 层选择在失败时重新弄脏页面(除非设备被移除)[6],而 Linux 将故障处理责任交给 VFS 层以下的各个文件系统($3.3.4)。我们希望 Linux 文件系统维护者采用类似的方法,以努力跨文件系统统一处理 fsync 故障。 需要注意的是,对设备何时被移除进行分类也很重要。例如,通过网络连接的存储设备并不像本地硬盘那样永久,但它们比可移动 USB 盘更持久。网络上的临时断开不应被视为设备移除和重新连接;与此类设备相关的页面可以在写入失败时重新弄脏。 #6: 应用程序开发人员不针对特定的文件系统。 我们观察到数据密集型应用程序根据它们运行的操作系统配置其持久性和错误处理策略,但将特定操作系统上的所有文件系统视为等同。因此,如表 2 所示,单个应用程序根据文件系统可能表现出不同的错误。如果 POSIX 标准不完善,应用程序可能希望针对不同文件系统以不同方式处理 fsync 故障。 或者,应用程序可以选择针对故障处理特性进行编码,而不是针对特定的文件系统,但这要求文件系统暴露一些接口来查询特性,例如“故障后页面状态/内容”和“立即/延迟错误报告”。

#7: 应用程序在 fsync 失败时采用多种策略,但没有一种是充分的。 如第 4.3 节所见,Redis 选择信任文件系统,甚至不检查 fsync 返回码;LMDB、LevelDB 和 SQLite 恢复内存状态并向应用程序报告错误;而 PostgreSQL 选择崩溃。我们看到没有应用程序在失败时重试 fsync;应用程序开发人员似乎意识到页面在 fsync 失败时被标记为干净,再次调用 fsync 不会将额外数据刷新到磁盘。尽管应用程序非常小心地处理来自存储堆栈的各种错误(例如 LevelDB 写入 CRC 校验和以检测无效日志条目,SQLite 仅在数据持久化到日志后才更新回滚日志的头部),但只要 fsync 错误未得到正确处理,就无法保证数据持久性。虽然没有一种策略总是有效,但 PostgreSQL 当前采用的直接 IO 方法可能最能处理 fsync 故障。 如果文件系统确实选择以标准格式报告故障处理特性,应用程序或许能够采用更好的策略。例如,如果应用程序知道页面内容在失败时不会恢复(ext4、XFS),它们可以选择跟踪脏页并通过读取并写回单个字节来重新弄脏它们。在 Btrfs 上,必须跟踪页面及其内容。对于访问多个文件的应用程序,重要的是要注意文件可以存在于不同的文件系统上。

#8: 应用程序运行的恢复逻辑访问了页缓存中错误的数据。 依赖页缓存进行更快恢复的应用程序容易受到 FalseFailures 的影响。正如在 LevelDB、SQLite 和 PostgreSQL 中看到的,当 wal 发生 fsync 失败时,应用程序使操作失败并通知用户;在这些情况下,虽然磁盘状态可能已损坏,但页缓存中的条目是有效的;因此,从 wal 恢复状态的应用程序可能会从页缓存中读取部分有效的条目,并错误地更新磁盘状态。应用程序在执行恢复时应读取文件的磁盘内容。

#9: 应用程序恢复逻辑未使用低级块故障进行测试。 应用程序通过模拟系统调用返回码或模拟崩溃重启场景来测试恢复逻辑和数据丢失的可能性,限制了与底层文件系统的交互。因此,文件系统的故障处理逻辑没有得到验证。应用程序应使用强制底层文件系统错误处理的低级块注入器来测试恢复逻辑。 或者,他们可以使用像 CuttleFS 这样的故障注入器来模拟不同的文件系统错误处理特性。

6 相关工作

在本节中,我们讨论我们的工作如何以关键方式建立并区别于过去的研究。我们包含通过故障注入研究文件系统的著作、文件系统中的错误处理以及文件系统故障对应用程序的影响。

我们关于文件系统如何对故障做出反应的研究与 Prabhakaran 等人关于 IRON 文件系统 [49] 的工作以及 Jaffer 等人最近进行的一项研究 [40] 相关。其他著作研究了特定的文件系统,如 NTFS [28] 和 ZFS [58]。所有这些研究都在文件系统之下注入故障,并分析文件系统是否以及如何检测故障并从中恢复。这些研究使用系统调用工作负载(例如写和读)使文件系统与底层设备交互。

虽然先前的研究确实通过单一系统调用操作执行了 fsync 路径的某些部分,但它们没有执行检查点路径。更重要的是,与这些过去的努力相比,我们的工作特别关注文件系统的内存中状态以及未来操作对遭遇写入故障的文件系统的影响。具体来说,在我们的工作中,我们选择在引入故障后继续运行的工作负载。这类工作负载有助于理解 fsync 期间故障的后续影响,例如未来操作掩盖错误、修复故障或加剧故障。

Mohan 等人 [45] 使用有界黑盒崩溃测试来详尽地生成工作负载,并通过在不同的持久化点模拟电源故障来发现许多崩溃一致性错误。我们的工作专注于不一定导致文件系统崩溃的瞬时故障,以及即使文件系统可能一致但对应用程序的影响。此外,我们在 fsync 中间注入故障,而不是在成功的 fsync(持久化点)之后。

Gunawi 等人描述了日志文件系统中失败的意向(failed intentions)问题 [36],并建议使用链式事务来处理检查点期间的此类故障。另一项工作开发了一种名为错误检测与传播(Error Detection and Propagation)的静态分析技术 [37],并得出结论文件系统忽略了许多写入错误。尽管 Linux 内核改进了其块层错误处理 [10],文件系统可能仍然忽略写入错误。我们的结果完全基于在文件系统可以检测到的 bio 请求中注入错误。

Vondra 描述了关于 fsync 行为的某些假设如何导致 PostgreSQL 中的数据丢失 [55]。使用带有 dm-error 目标的设备映射器重现了数据丢失行为,这启发我们在设备映射器之上构建我们自己的故障注入器(dm-loki [4]),类似于 dm-inject [40]。此外,FSQA 套件(xfs 测试)[7] 使用 dm-flakey 目标 [5] 模拟写入错误。虽然 dm-flakey 对故障注入测试很有用,但故障是基于当前时间注入的;设备在 x 秒内可用,然后在 y 秒内表现出不可靠行为(x 和 y 可配置)。此外,任何配置更改都需要挂起设备。为了提高确定性并避免依赖时间,dm-loki 基于访问模式注入故障(例如,使对块 20 的第 2nd2nd 和 4th4th 次写入失败),并且能够在无需挂起设备的情况下接受配置更改。

最近的工作已将重点转移到研究分布式存储系统 [34] 和高性能并行系统 [29] 中文件系统故障的影响。类似地,我们的工作专注于理解在存在故障的情况下,文件系统及其上运行的应用程序的行为。

7 结论

我们展示了文件系统在 fsync 失败时表现不同。应用程序开发人员只能假设底层文件系统遇到了故障,并且数据可能已被部分、完全或完全没有持久化。我们表明,假设超出上述情况的应用程序容易受到数据丢失和损坏的影响。面对 fsync 故障时广泛认为的崩溃-重启修复方法并非总是有效;由于磁盘上和内存中的不匹配,应用程序会错误地恢复。

然而,我们相信,如果文件系统在故障处理和错误报告策略上更加统一,应用程序可以提供更强的保证。关心持久性的应用程序应包含扇区或块级故障注入测试,以有效测试恢复代码路径。或者,此类应用程序可以选择使用 CuttleFS 来注入故障并模拟文件系统的故障反应。

我们已在 https://github.com/WiscADSL/cuttlefs 开源了 CuttleFS,以及用于重现本文结果的设备映射器内核模块和实验。