这是 Deconstruct 2019 上一场演讲的伪转录稿。为了让网速慢的用户以及使用屏幕阅读器的人都能访问,幻灯片已替换为内联文本(该演讲约有 120 张幻灯片;按每张平均 20 kB 计算,总计 2.4 MB。如果你觉得这微不足道,请考虑一下仍有半数美国人未使用宽带,而发展中国家的情况更糟。

我们来聊聊文件吧!大多数开发者似乎认为文件很简单。举个例子,看看 Dropbox 宣布仅在 Linux 上支持 ext4(最广泛使用的 Linux 文件系统)时,Reddit 的 r/programming 板块中点赞最高的评论。对于不熟悉 Reddit 的 r/programming 板块的人,我猜它是全球阅读量最大的英语编程论坛。

点赞最高的评论写道:

我有点困惑,为什么这些应用程序必须直接支持这些文件系统?内核本身难道不是已经抽象掉了底层文件存储细节吗?

我能想到的不同文件系统之间的唯一区别是文件大小限制和权限,但大多数现代文件系统不都差不多吗?

排名第二的评论(以及往下两层的最热门回复)是:

#2:应用程序为什么要在乎文件系统是什么?

#2:对于“普通应用”来说,这不应该是操作系统抽象掉的问题吗?

回复:这是一种有漏洞的抽象。我敢打赌,每个不同的文件系统都有自己的 bug,Dropbox 代码库中也有针对特定文件系统的修复。更多的文件系统意味着需要更多测试来确保一切正常……

第二层回复:你在说什么?这是 Dropbox,它到底需要文件系统提供什么?有几十种文件同步工具、数据传输工具、分布式存储软件,它们都能与 inotify 正常配合。Dropbox 到底哪里不行?

另一个第二层回复:当然,但由此产生的任何 bug 都应该在相应的抽象层修复,而不是自己重新实现整个栈。除非你无法从抽象层获得所需数据,否则不应该重新实现……DropBox 实现针对特定文件系统的变通方法和怪癖完全是小题大做。这就像 vim 提供针对特定键盘的变通方法来避免按键错误。所有抽象都有漏洞——但如果没人使用这些抽象,什么也做不成(而且我们会有数十亿个“操作系统”)。

在这次演讲中,我们将探讨文件系统之间的差异,以及写入文件时可能遇到的其他问题。我们将从顶层开始审视文件“栈”:首先是文件API,我们会发现它几乎不可能被正确使用,并且支持多个文件系统而不损坏数据比仅支持单个文件系统要困难得多;接着向下到文件系统,我们会看到其中存在严重漏洞,可能导致数据丢失和损坏;最后我们将研究磁盘,发现磁盘损坏数据的概率比厂商数据表中声称的高出五百万倍。

文件API

写入单个文件

假设我们想安全地写入一个文件,以避免数据损坏。就本次演讲而言,这意味着我们希望写入操作是“原子性”的——要么完全完成写入,要么能够撤销写入并回到初始状态。让我们看一个来自Pillai等人(OSDI’14)的例子。

我们有一个包含文本a foo的文件,想要将foo覆盖为bar,最终得到a bar。我们将进行大量简化。例如,你应该把每个正在写入的字符视为磁盘上的一个扇区(或者,如果你愿意,可以想象我们使用的是某种假设的高级NVM驱动器)。如果你不明白这意味着什么,不必担心,我只是指出本次演讲将包含许多简化,我不会逐一说明,因为我们只有二十五分钟,而未经简化的版本大概需要三个小时。

要执行写入,我们可能会使用pwrite系统调用。这是操作系统提供的一个函数,让我们能够与文件系统交互。调用这个系统调用的方式如下:

pwrite(
  [文件],
  "bar", // 要写入的数据
  3,     // 写入3个字节
  2)     // 从偏移量2开始

pwrite接受我们要写入的文件、要写入的数据bar、要写入的字节数3,以及开始写入的偏移量2。如果你习惯使用高级语言(如Python),可能会觉得接口看起来不同,但在底层,当你写入文件时,最终都会导致类似这样的系统调用,这才是真正将数据写入文件的操作。

如果我们就这样调用pwrite,可能会成功并得到输出a bar,也可能什么都没做而得到a foo,或者得到介于两者之间的结果,比如a booa bor等。

这里的情况是,我们在写入时可能会崩溃或断电。由于 pwrite 不保证原子性,如果崩溃,写入可能只完成一部分,导致数据损坏。避免这个问题的一种方法是存储一个“撤销日志”,用于恢复损坏的数据。在修改文件之前,我们会将要修改的数据复制一份(到撤销日志中),然后正常修改文件,如果一切顺利,就删除撤销日志。

如果在写入撤销日志时崩溃,那没问题——我们会发现撤销日志不完整,并且知道无需恢复,因为我们还没有开始修改文件。如果在修改文件时崩溃,那也没问题。当我们尝试从崩溃中恢复时,会看到撤销日志是完整的,可以用它来从数据损坏中恢复:

creat(/d/log) // 创建撤销日志
write(/d/log, "2,3,foo", 7) // 撤销操作:在偏移量2处写入3个字节"foo"
pwrite(/d/orig, "bar", 3, 2) // 像之前一样修改原始文件
unlink(/d/log) // 删除日志文件

如果我们使用的是广泛使用的 Linux 文件系统 ext3ext4,并且采用 data=journal 模式(稍后会讨论这些模式的含义),可能会得到以下一些结果:

d/log: "2,3,f"
d/orig: "a foo"

d/log: ""
d/orig: "a foo"

可能在日志文件写入过程中崩溃,导致日志文件不完整。在第一种情况下,我们知道日志文件不完整,因为文件指示应从偏移量 2 开始写入 3 个字节,但只指定了一个字节 f,所以日志文件肯定不完整。在第二种情况下,我们可以判断日志文件不完整,因为撤销日志格式应以偏移量和长度开头,但我们两者都没有。无论哪种情况,由于知道日志文件不完整,我们就知道无需恢复。

另一种可能的结果是:

d/log: "2,3,foo"
d/orig: "a boo"

d/log: "2,3,foo"
d/orig: "a bar"
"

在第一种情况下,日志文件是完整的,但我们在写入文件时崩溃。这没问题,因为日志文件告诉我们如何恢复到已知的正确状态。在第二种情况下,写入已完成,但由于日志文件尚未删除,我们会从日志文件恢复。

如果我们使用 `ext3` 或 `ext4` 的 `data=ordered` 模式,可能会看到类似这样的结果:

d/log: “2,3,fo” d/orig: “a boo”

d/log: “” d/orig: “a bor” “

data=ordered 模式下,无法保证对日志文件的 write 和修改原始文件的 pwrite 会按程序顺序执行。相反,我们可能会得到

creat(/d/log) // 创建撤销日志
pwrite(/d/orig, “bar", 3, 2) // 在写入撤销日志前修改文件!
write(/d/log, "2,3,foo", 7) // 写入撤销日志
unlink(/d/log) // 删除日志文件

为了防止这种重排序,我们可以使用另一个系统调用 fsyncfsync 是一个屏障(防止重排序),并且它会刷新缓存(我们稍后会讨论)。

creat(/d/log)
write(/d/log, “2,3,foo”, 7)
fsync(/d/log) // 添加 fsync 以防止重排序
pwrite(/d/orig, “bar”, 3, 2)
fsync(/d/orig) // 添加 fsync 以防止重排序
unlink(/d/log)

这在 ext3ext4data=ordered 模式下有效,但如果我们使用 data=writeback,可能会看到类似这样的情况:

d/log: "2,3,WAT"
d/orig: "a boo"

不幸的是,在 data=writeback 模式下,对日志文件的 write 操作不保证是原子的,并且跟踪文件长度的文件系统元数据可能会在我们完成写入日志文件之前被更新,这会导致日志文件看起来包含创建日志文件时磁盘上恰好存在的任意数据。由于日志文件存在,当我们尝试在崩溃后恢复时,最终可能会将随机垃圾“恢复”到原始文件中。为了防止这种情况,我们可以向日志文件添加校验和(一种确保文件实际有效的方法)。

creat(/d/log)
write(/d/log,“…[✓∑],foo”,7) // 向日志文件添加校验和以检测不完整的日志文件
fsync(/d/log)
pwrite(/d/orig, “bar”, 3, 2)
fsync(/d/orig)
unlink(/d/log)

这应该能在 data=writeback 模式下工作,但我们仍然可能看到以下情况:

d/orig: "a boo"

没有日志文件!尽管我们创建了一个文件,向其中写入数据,然后对其执行了 fsync。不幸的是,如果系统崩溃,无法保证目录会实际存储文件的位置。为了确保在崩溃后恢复时能轻松找到该文件,我们需要对新创建的日志文件的父目录执行 fsync

creat(/d/log)
write(/d/log,“…[✓∑],foo”,7)
fsync(/d/log)
fsync(/d) /// 对父目录执行 fsync
pwrite(/d/orig, “bar”, 3, 2)
fsync(/d/orig)
unlink(/d/log)

我们还需要做几件事。完成后也应该执行 fsync(未显示),并且还需要检查错误。这些系统调用可能返回错误,这些错误需要妥善处理。至少有一个文件系统问题使得这变得非常困难,但由于这本身不是 API 使用问题,我们将在文件系统部分再次讨论。

我们现在已经了解了安全写入文件所需采取的措施。这可能比我们想象的要复杂,但似乎是可行的——如果有人要求你以自包含的方式编写一个文件,比如面试题,并且你知道相应的规则,你很可能能正确完成。但如果这成为我们日常工作中的一部分,比如在一个大型代码库中每次写入文件时都需要安全地写入,那该怎么办呢?

实践中的 API

Pillai 等人(OSDI’14)研究了一系列写入文件的软件,包括我们期望能安全写入文件的数据库和版本控制系统:Leveldb、LMDB、GDBM、HSQLDB、Sqlite、PostgreSQL、Git、Mercurial、HDFS、Zookeeper。他们随后编写了一个静态分析工具,用于发现文件API的错误使用,例如错误地认为非原子操作是原子操作,错误地认为可重排序的操作会按程序顺序执行等。

通过分析,他们发现除了一种特定模式下的SQLite之外,所有被测试的软件都存在至少一个缺陷。这并非贬低这些软件的开发者或软件本身——像Leveldb、LMDB等软件的开发者对文件系统的了解远超绝大多数程序员,且这些软件的测试也比大多数软件更严格。但即便如此,他们仍无法每次都安全地使用文件!一个自然的后续问题是:为什么文件API如此难以使用,以至于连专家都会犯错?

并发编程很难

造成这种情况的原因有很多。如果你问人们“编程中的难题是什么?”,你会得到诸如分布式系统、并发编程、安全性、用CSS对齐元素、日期处理等答案。

而当我们审视并发编程中导致缺陷的错误类型时,会发现错误源于“错误地认为操作是原子的”和“错误地认为操作会按程序顺序执行”。这些让并发编程变得困难的因素同样让安全写入文件变得困难——我们在第一个例子中已经看到了这两种错误的实例。更广泛地说,许多导致并发编程困难的因素,也正是安全写入文件困难的原因,因此我们自然应该预料到写入文件是困难的!

安全写入文件与并发编程的另一个共同点是:容易编写出具有低概率、非确定性故障的代码。在文件方面,人们有时会说这反而让事情更简单(“我从未注意到数据损坏”、“大多数时候你的数据基本还在”等),但如果你因为要开发不应损坏数据的软件而需要安全写入文件,这种特性反而会让事情更困难——因为它让你更难判断代码是否真正正确。

API不一致

正如我们在第一个例子中看到的,即使使用同一个文件系统,不同的模式也可能表现出截然不同的行为。文件API的很大一部分都是如此——行为会因文件系统或同一文件系统的不同模式而异。例如,观察主流文件系统时,追加操作是原子的,除非使用data=writeback模式的ext3ext4,或任何模式下的ext2;而目录操作不能相对于其他操作重新排序,除非在btrfs上。理论上,我们都应该仔细阅读POSIX规范,确保所有代码符合POSIX标准,但如果人们真的检查文件系统行为,他们往往倾向于根据自己使用的文件系统来编写代码,而不是遵循某个抽象规范。

如果只看某个文件系统的一种特定模式(如data=journalext4),似乎还相对容易安全处理;但当为多种文件系统编写代码时,尤其是处理与ext3ext4差异很大的文件系统(如btrfs),人们就很难写出正确的代码了。

文档不清晰

在第一个例子中,我们看到使用不同的data=模式会导致不同的行为。查看ext3ext4中这些模式含义的手册页,会得到以下说明:

journal:所有数据在写入主文件系统之前,先提交到日志中。

ordered:这是默认模式。所有数据在元数据提交到日志之前,被强制直接写入主文件系统。

writeback:数据顺序不被保留——数据可能在元数据提交到日志之后才写入主文件系统。据称这是吞吐量最高的选项。它能保证文件系统内部完整性,但可能导致崩溃和日志恢复后文件中出现旧数据。

如果你想知道如何安全使用文件系统,而又不了解日志型文件系统是什么,这段说明肯定帮不上忙。如果你了解日志型文件系统,它会给你一些提示,但仍然不够。理论上可以通过阅读源代码弄清一切,但对大多数不熟悉文件系统工作原理的人来说,这相当不切实际。

至于英文文档,有lwn.net和Linux内核邮件列表(LKML)。LWN很棒,但无法覆盖所有内容,因此如果想获取全面信息,LKML是首选。以下是LKML上关于文件系统讨论的一个例子:

开发者1:就我个人而言,我很在意元数据一致性,而 ext3 的文档说明日志机制能保护其完整性。但问题在于,在损坏的存储设备上它并不能做到这一点,你仍然需要在那里运行 fsck。 开发者2:正如 ext3 的作者多年来反复强调的那样,你无论如何还是需要定期运行 fsck。 开发者1:这在哪里有文档说明? 开发者2:linux-kernel 邮件列表存档里。 文件系统开发者:大概来自 6 到 8 年前,我发的一些邮件。

虽然文件系统开发者通常乐于助人,会写出信息量丰富的回复,但大多数人大概不会去关注过去 6 到 8 年的 LKML。

性能与正确性的冲突

另一个问题是,文件 API 在性能与正确性之间存在着固有的冲突。我们之前提到过,fsync 是一个屏障(我们可以用它来强制排序),并且它会刷新缓存。如果你曾经设计过高性能缓存(比如微处理器缓存),你可能会觉得将这两件事捆绑到单个原语中很不寻常。之所以不寻常,是因为刷新缓存会带来显著的性能开销,而在很多情况下,我们只想强制排序,却不想承担这个性能开销。将这两件事捆绑到单个原语中,迫使我们在只关心排序时也要付出缓存刷新的代价。

Chidambaram 等人在 SOSP’13 上通过修改 ext4 添加了一个不刷新缓存的屏障机制来研究这种性能开销,他们发现,如果适当修改软件,并在不需要完整 fsync 的地方使用他们的屏障操作,就能在性能上大致相当于完全禁用缓存刷新的 ext4(这样做不安全,可能导致数据损坏),同时又不会牺牲安全性。然而,对于大多数编写用户级软件的人来说,自己制作文件系统并让它被采用是不切实际的。有些数据库会完全或几乎完全绕过文件系统,但这对于大多数软件来说同样不切实际。

以上就是文件 API 的情况。既然我们已经看到它极其难以使用,那么接下来看看文件系统。

文件系统

如果我们想确保文件系统正常工作,能做的最基本的测试之一就是在文件系统之下的层注入错误,看看文件系统是否能正确处理它们。例如,在写入时,我们可以让磁盘无法写入数据并返回相应的错误。如果文件系统忽略了此错误或未能正确处理,那就意味着数据丢失或数据损坏。这与 Kyle Kingsbury 昨天在分布式系统测试演讲中提到的分布式系统故障类型类似(尽管这类错误测试起来要直接得多)。

Prabhakaran 等人在 SOSP’05 上的研究发现,在测试的大多数文件系统中,几乎所有的写入错误都被忽略了。唯一的例外是 ReiserFS,它在所有类型的错误测试中表现良好,但由于超出本次演讲范围的原因,ReiserFS 如今已不再被广泛使用。

我们(Wesley Aptekar-Cassels 和我)在 2017 年重新审视了这个问题,发现情况已有显著改善。大多数文件系统(除了 JFS)都能通过这些非常基础的错误处理测试。

另一种查找错误的方法是检查文件系统代码,看其是否正确处理了内部错误。Gunawai 等人在 FAST’08 上的研究采用了这种方法,发现内部错误在相当高比例的情况下被忽略。他们使用的技术难以判断那些可能返回多种不同错误的函数是否正确处理了每种错误,因此他们还专门研究了只能返回单一错误的函数调用。在这些情况下,根据函数的不同,错误被忽略的比例大约在 2/3 到 3/4 之间。

Wesley 和我在 2017 年也重新审视了这一点,发现有了显著改进——Gunawi 等人研究的相同函数中,错误被忽略的比例“仅”为 1/3 到 2/3,具体取决于函数。

Gunawai 等人还检查了这些被忽略错误附近的注释,发现了诸如“此时忽略错误。除了继续运行,我们别无他法。”(XFS)和“出错,跳过块并祈求好运。”(ext3)之类的注释。

现在我们已经看到,虽然文件系统过去会忽略甚至最基本的错误,但它们现在能正确处理了,但在某些代码路径中,错误仍可能被忽略。举一个具体的例子,让我们回顾一下第一个例子。如果在 fsync 上遇到错误,除非使用的是相当新的 Linux 内核(大约 2018 年第二季度),否则错误很可能会被忽略,甚至可能被报告给错误的进程!

在较新的Linux内核上,错误有很大概率会被报告(甚至能报告给正确的进程)。Wilcox在PGCon’18上指出,fsync出错基本上是无法恢复的。具体行为取决于文件系统——在XFSbtrfs上,文件系统中已修改的数据会被丢弃且无法恢复。在ext4上,数据不会被丢弃,但会被标记为未修改状态,因此文件系统不会再尝试将其写回磁盘;一旦出现内存压力,这些数据随时可能被丢弃。如果你愿意冒险,可以尝试在数据被丢弃前通过各种技巧恢复(例如强制文件系统重新标记为已修改,或将其写入其他设备——即使标记为未修改,写入操作也会强制文件系统写出数据),但无法保证能在数据被丢弃前成功恢复。在Linux的ZFS上,似乎存在一条旨在正确处理错误的代码路径,但会导致CPU使用率飙升,系统可能挂起或变得不可用。

总的来说,Linux上并没有很好的方法从这种错误中恢复。Postgres、MySQL和MongoDB(广泛使用的数据库)会自行崩溃,用户需要从上一个检查点恢复。大多数软件可能只会静默丢失或损坏数据。而fsync还算相对较好的情况——例如,syncfs在Linux上根本不会返回错误,导致静默数据丢失和损坏。

顺便一提,当Craig Ringer最初提议Postgres应在fsync错误时崩溃时,Postgres开发邮件列表的第一条回复是:

你肯定在开玩笑……如果[当前fsync的行为]真是这样,我们必须抵制这种内核脑残设计

但在详细讨论后,所有人都同意崩溃是唯一可行的选择。诸多不幸之一在于,大多数磁盘错误是瞬态的。由于文件系统丢弃了在出现任何错误时避免数据损坏所需的关键信息,本可重试的瞬态错误反而迫使软件采取极端措施。

虽然我们讨论的是Linux,但这并非Linux独有问题。许多不同操作系统上的Fsync错误处理(以及一般的错误处理)都存在缺陷。在Postgres“发现”Linux上fsync行为时,FreeBSD的行为可以说是正确的,但OpenBSD和NetBSD与Linux行为一致(真实错误状态被丢弃,重试返回成功,数据丢失)。OpenBSD和可能其他一些BSD已修复此问题,但Linux基本仍保持相同行为,且无法保证在任意类UNIX系统上都能正常工作。

既然我们已经看到,多年来文件系统在最直接简单的场景中都无法正确处理错误,且至今仍有未正确处理的案例,那么接下来让我们看看磁盘。

磁盘

刷新操作

我们已经看到,在需要调用 fsync 时很容易忘记调用它,而且即使正确调用了 fsync,bug 也可能导致 fsync 无法真正生效。Rajimwale 等人(DSN’11)研究了磁盘在收到刷新请求时是否真的执行刷新(假设磁盘以上所有组件正常工作;他们的论文实际上主要讨论其他内容,仅在开头简要提及这一点)。一位来自微软的匿名人士告诉他们:“[某些磁盘] 无法让文件系统正确强制写入数据”,而来自硬盘制造商希捷的人士则表示:“[某些磁盘(虽然不是我们的产品)] 无法让文件系统正确强制写入数据”。Bairavasundaram 等人(FAST’07)在研究磁盘可靠性时也发现了同样的问题。

错误率

我们已经看到,文件系统有时无法正确处理磁盘错误。要了解这个问题的严重程度,我们需要考察磁盘产生错误的频率。磁盘数据手册通常会给出以下不可纠正比特错误率:消费级 HDD(常称为“旋转金属”或“旋转锈蚀”磁盘)为 1e-14,企业级 HDD 为 1e-15,消费级 SSD 为 1e-15,企业级 SSD 为 1e-16。这意味着,平均而言,每读取 1e14 比特数据,我们预计会遇到一次不可恢复的数据错误。

为了直观理解这一数字的实际意义:1TB 目前是相当常见的磁盘容量。如果完整读取一次整个磁盘,那就是 1e12 字节,约等于 1e13 比特(严格来说是 8e12 比特)。这意味着,如果我们购买一块 1TB 的 HDD 并完整读取约十次,预计会遇到一次不可恢复错误。如今,我们可以购买 10TB 的 HDD,那么每次完整读取一块消费级 HDD 时,预计会遇到 0.8 次错误(严格来说)。

实际上,观测到的错误率显著更高。Narayanan 等人(SYSTOR’16,微软)观察到 SSD 的错误率在 1e-11 到 6e-14 之间,具体取决于硬盘型号。Meza 等人(SIGMETRICS’15,Facebook)观察到的 SSD 错误率甚至更差,在 2e-9 到 6e-11 之间,同样取决于硬盘型号。以 2e-9 为例,这意味着每 2 吉比特(即 250 MB)数据就会出现一次错误,比数据手册声称的值高出 50 万到 500 万倍,具体取决于硬盘等级。

比特错误率对于磁盘驱动器来说可能并非最佳指标,但这是磁盘厂商宣称的指标,因此要进行同类比较,我们只能以此作为基准。其他类型的错误率可参考 Bairavasundaram 等人(SIGMETRICS’07)、Schroeder 等人(FAST’16)等文献。

需要注意的一点是,常有人说固态硬盘(SSD)不会出现数据损坏问题,因为它们使用纠错码(ECC),可以修复数据损坏。“闪存消除了不可恢复数据错误的幽灵”等等。这种说法忽略的是,现代高密度闪存设备非常不可靠,必须依赖ECC才能正常使用。Grupp等人在FAST’12上研究了SSD底层闪存的错误率,发现错误率在1e-1到1e-8之间。1e-1意味着每十位出现一次错误,1e-8则是每1亿位出现一次错误。

断电

你还会听到另一种说法:SSD能安全应对断电和某些类型的崩溃,因为它们现在具备“断电保护”——SSD内部有一种机制,能在断电期间维持足够长时间的电力,以便安全写入内部缓存。

Luke Leighton对此进行了测试,他购买了6款声称有断电保护的SSD,发现其中四款(非英特尔品牌的所有型号)在测试中失败。查看测试细节时发现,当硬盘失败时,似乎是因为它们的使用方式超出了断电保护实现者的预期(写入“过快”,尽管远低于硬盘的写入能力,或者并行写入“过多”文件)。当硬盘宣传具备断电保护时,这似乎意味着有人投入了一定精力实现了某种机制,能在某些情况下防止断电时的数据丢失或损坏。但正如Kyle昨天关于分布式系统的演讲中所说,如果你想确保这种机制确实有效,就不能依赖供应商进行严格甚至半认真的测试,而必须自己进行测试。

数据保留

查看SSD的数据手册,一块较新的硬盘(剩余90%写入寿命)通常被标称在写入后能保存数据约十年。而一块接近寿命终点的磨损硬盘,根据硬盘等级不同,被标称能保留数据一年到三个月。我认为人们常常惊讶地发现,硬盘在数据写入三个月后丢失数据竟然符合规格。

这些数字都来自数据手册和规格表,但正如我们所见,数据手册可能有些乐观。在许多早期SSD上,用尽大部分或全部写入寿命会导致硬盘变砖,你甚至无法获得标称的三个月的保留时间。

推论

既然我们已经看到文件栈的每一层都存在显著问题,接下来看看由此得出的几点结论。

该怎么办?

关于这个问题该如何处理,是个大话题。在剩余的时间里,与其写入文件,不如使用数据库。如果你想要一个轻量级、简单且能在大多数使用文件的场景中替代的方案,SQLite 是个不错的选择。我并不是说永远不该用文件——这需要权衡。但如果你希望降低数据损坏的概率,可以考虑用数据库代替文件来存储数据。

文件系统支持

在演讲开始时,我们提到了 Dropbox 的例子:多数人认为没必要移除对大多数 Linux 文件系统的支持,因为文件系统都一样。我认为他们是被数据存储/使用的方式所迫——按照他们的做法(这本身可能是个设计缺陷),只能依赖 ext 文件系统。但即使不是这样,你也能理解:一款试图可靠且高效地将数据同步到磁盘的软件,可能并不想支持某个相对小众操作系统上的所有文件系统。出于公关原因,或许值得支持所有文件系统,然后针对每个文件系统费尽周折避免数据损坏(你可以尝试直接按照 POSIX 规范编码,但正如我们所见,这在 Linux 上行不通)。然而,公关问题的根源在于误解。

我们在 Reddit 上看到的另一个评论(也是一种普遍观点)是:程序不应该为库或操作系统的缺陷负责。但无论 bug 的责任在谁,用户数据都会损坏。正如我们所见,文件系统层的 bug 可能持续多年。在 Linux 上,除了 ZFS,大多数文件系统似乎都认为在 fsync 错误时丢弃数据、且不报告数据无法写入是正确的行为(而 FreeBSD 或 OpenBSD 上,大多数文件系统至少会在后续 fsync 中报告错误,如果错误未解决的话)。这究竟是 bug 还是正确行为尚有争议,但无论如何,如果你的软件不考虑这一点,数据就会丢失或损坏。如果你坚持认为文件系统损坏数据不是你的错,那么最终付出代价的将是你的用户。

在准备这次演讲的过程中,我阅读了大量关于如何安全写入文件的网络讨论。在专业社区(如LKML、Postgres邮件列表等)之外的讨论中,许多人会留言说:“为什么大家都把这事搞这么复杂?用这个奇招就能轻松又完全安全地实现。”让我们看看两千条网络评论中关于如何安全写入磁盘最常见的“奇招”。

重命名

最常被提及的技巧是用重命名替代覆盖写入。如果你还记得我们单文件写入的例子,我们在修改文件前先复制了要覆盖的数据。这个技巧正好相反:

  1. 复制整个文件
  2. 修改副本
  3. 用副本重命名覆盖原文件

这个技巧行不通。人们似乎认为这很安全,因为POSIX规范说rename是原子操作,但这仅指在正常操作下rename是原子的,并不意味着它在崩溃时也是原子的。这并非纯粹的理论问题;看看主流的Linux文件系统,大多数至少有一种模式下重命名在崩溃时不是原子的。重命名也不能保证按程序顺序执行,正如人们有时期望的那样。

最主流的例外可能是btrfs,但即使在那里,情况也有些微妙——正如Bornholt等人在ASPLOS’16中指出的,rename仅在替换现有文件时在崩溃时是原子的,而不是在创建新文件时。此外,Mohan等人在OSDI’18中发现了btrfs上许多重命名原子性错误,有些非常古老,有些与论文同年引入,所以即使你编写的是btrfs专用代码,未经广泛测试也不应依赖这一点。

即使这能行,这种技术的性能也相当差。

追加

第二常见的技巧是只追加(而不是有时覆盖)。这同样行不通。正如Pillai等人在OSDI’14和Bornholt等人在ASPLOS’16中指出的,追加不保证顺序或原子性,认为追加安全正是某些错误的根源。

奇招

我们已经看到,最常被引用的简单技巧并不奏效。有趣的是,在这些讨论中,人们会闯入一个已经详细解释过为什么写入文件比人们天真以为的要难得多的话题,无视所有警告和解释,仍然继续阐述为什么这其实很简单。即使被警告文件操作比人们想象的要复杂,人们仍然觉得它很容易!

结论

总而言之,计算机并不靠谱(不过既然你来了Gary-conf,大概早就知道这点)。这次演讲恰好聚焦文件系统,但其他领域也能发现类似现象。

结束前我想强调一点:在我看来,根本问题并非技术层面。看看那些科技巨头(比如脸书、亚马逊、微软、谷歌等),它们处理磁盘写入时通常相当安全。它们会确保磁盘的断电保护功能切实有效,会在操作系统或其它监测工具中打补丁以保证错误能被正确报告,还会建立庞大的分布式存储集群来确保数据安全复制。我们其实知道如何让这些系统足够可靠。这很难,需要投入大量时间和精力——也就是巨额资金——但确实能做到。

如果你问从事这类工作的工程师,为什么愿意花天价来确保(或者说提升)数据正确性,通常会得到这样的回答:“我们有上千万台机器,算算数据损坏率,如果不做这些防护,每分钟都会发生数据损坏。这完全不可接受。“一家科技巨头大概有多少机器?千万量级?有趣的是,如果算算全球消费级设备的数量,以及消费软件在不可靠磁盘上运行的普遍性,数学结果其实很相似。消费级设备数量更多,虽然通常负载较轻,但总量足够大——如果你拥有一款广泛使用的桌面/笔记本/工作站软件,数据损坏的概率计算结果是差不多的。没有”极端“防护措施,我们理应随时看到数据损坏。

但看看消费软件的实际运作方式,它们在数据处理上通常非常不安全。我认为关键区别在于:当科技巨头丢失数据时——无论是用户点击广告的偏好数据还是电子邮件——公司会直接或间接承担损失,且损失大到足以让投入大量精力避免数据丢失成为明智之举。而当消费者在自己设备上遭遇数据损坏时,他们大多不够专业,无法判断责任方,因此公司可以避免承担主要责任。如果存在全局优化函数,数学结果是一样的——我们当然应该投入更多精力保护消费级设备的数据。但若公司只从自身利益出发进行局部优化,数学结果就不同了:也许不值得花大力气避免数据损坏。

昨天,拉姆齐·纳赛尔在一次演讲中非常有说服力地指出某个问题相当严重,随后有人评论说,他提出的解决方案很难获得广泛采纳。我同意这两点——他讨论了一个重要的问题,但如何解决这个问题能让人赚大钱并不明确,因此这个问题很可能得不到解决。

从《通用数据保护条例》中我们看到,监管可以迫使科技公司以它们本不情愿的方式保护用户隐私,但监管是一把非常重的锤子,其意外后果往往可能抵消甚至超过监管带来的好处。回顾那些旨在迫使企业做正确事情的历史法规,我们常常需要多年,有时甚至几十年,才能完全理解其影响。设计良好的监管非常困难,比我们今天讨论的任何技术问题都要难得多。

致谢

感谢莉亚·汉森、加里·伯恩哈特、卡迈勒·马尔胡比、丽贝卡·艾萨克斯、杰西·卢尔斯、汤姆·克雷福德、韦斯利·阿普特卡-卡塞尔斯、罗斯·艾姆斯、chozu@fedi.absturztau.be 和本杰明·吉尔伯特对本次演讲的帮助!

抱歉我们讲得太快了。如果你错过了什么,可以在 danluu.com/deconstruct-files 的伪转录稿中找到。

这份“转录稿”相当粗糙,因为我在演讲前今天早上匆忙写就。我会在几周内尽量整理完善,包括补充遗漏的材料、插入链接、修正拼写错误、添加遗漏的参考文献等。

感谢阿纳托尔·肖、耶尔内·西蒙契奇、@junh1024、尤里·维什涅夫斯基和乔什·达夫对本转录稿的评论/更正/讨论。