崩溃一致性:FSCK和日志
至此我们看到,文件系统管理一组数据结构以实现预期的抽象:文件、目录,以及所有其他元数据,它们支持我们期望从文件系统获得的基本抽象。与大多数数据结构不同(例如,正在运行的程序在内存中的数据结构),文件系统数据结构必须持久(persist),即它们必须长期存在,存储在断电也能保留数据的设备上(例如硬盘或基于闪存的SSD)。
文件系统面临的一个主要挑战在于,如何在出现断电(power loss)或系统崩溃(system crash)的情况下,更新持久数据结构。具体来说,如果在更新磁盘结构的过程中,有人绊到电源线并且机器断电,会发生什么?或者操作系统遇到错误并崩溃?由于断电和崩溃,更新持久性数据结构可能非常棘手,并导致了文件系统实现中一个有趣的新问题,称为崩溃一致性问题(crash-consistency problem)。
这个问题很容易理解。想象一下,为了完成特定操作,你必须更新两个磁盘上的结构A和B。由于磁盘一次只为一个请求提供服务,因此其中一个请求将首先到达磁盘(A或B)。如果在一次写入完成后系统崩溃或断电,则磁盘上的结构将处于不一致(inconsistent)的状态。因此,我们遇到了所有文件系统需要解决的问题:
关键问题:考虑到崩溃,如何更新磁盘
系统可能在任何两次写入之间崩溃或断电,因此磁盘上状态可能仅部分地更新。崩溃后,系统启动并希望再次挂载文件系统(以便访问文件等)。鉴于崩溃可能发生在任意时间点,如何确保文件系统将磁盘上的映像保持在合理的状态?
在本章中,我们将更详细地探讨这个问题,看看文件系统克服它的一些方法。我们将首先检查较老的文件系统采用的方法,即fsck,文件系统检查程序(file system checker)。然后,我们将注意力转向另一种方法,称为日志记录(journaling,也称为预写日志,write-ahead logging),这种技术为每次写入增加一点开销,但可以更快地从崩溃或断电中恢复。我们将讨论日志的基本机制,包括Linux ext3 [T98,PAA05](一个相对现代的日志文件系统)实现的几种不同的日志。
1 一个详细的例子
为了开始对日志的调查,先看一个例子。我们需要一种工作负载(workload),它以某种方式更新磁盘结构。这里假设工作负载很简单:将单个数据块附加到原有文件。通过打开文件,调用lseek()将文件偏移量移动到文件末尾,然后在关闭文件之前,向文件发出单个4KB写入来完成追加。
我们还假定磁盘上使用标准的简单文件系统结构,类似于之前看到的文件系统。这个小例子包括一个inode位图(inode bitmap,只有8位,每个inode一个),一个数据位图(data bitmap,也是8位,每个数据块一个),inode(总共8个,编号为0到7,分布在4个块上),以及数据块(总共8个,编号为0~7)。以下是该文件系统的示意图:
查看图中的结构,可以看到分配了一个inode(inode号为2),它在inode位图中标记,单个分配的数据块(数据块4)也在数据中标记位图。inode表示为I [v1],因为它是此inode的第一个版本。它将很快更新(由于上述工作负载)。
再来看看这个简化的inode。在I[v1]中,我们看到:
owner : remzi
permissions : read-write
size :
1
pointer :
4
pointer :
null
pointer :
null
pointer :
null
在这个简化的inode中,文件的大小为1(它有一个块位于其中),第一个直接指针指向块4(文件的第一个数据块,Da),并且所有其他3个直接指针都被设置为null(表示它们未被使用)。当然,真正的inode有更多的字段。更多相关信息,请参阅前面的章节。
向文件追加内容时,要向它添加一个新数据块,因此必须更新3个磁盘上的结构:inode(必须指向新块,并且由于追加而具有更大的大小),新数据块Db和新版本的数据位图(称之为B[v2])表示新数据块已被分配。
因此,在系统的内存中,有3个块必须写入磁盘。更新的inode(inode版本2,或简称为I [v2])现在看起来像这样:
owner : remzi
permissions : read-write
size :
2
pointer :
4
pointer :
5
pointer :
null
pointer :
null
更新的数据位图(B[v2])现在看起来像这样:00001100。最后,有数据块(Db),它只是用户放入文件的内容。
我们希望文件系统的最终磁盘映像如下所示:
要实现这种转变,文件系统必须对磁盘执行3次单独写入,分别针对inode(I[v2]),位图(B[v2])和数据块(Db)。请注意,当用户发出write()系统调用时,这些写操作通常不会立即发生。脏的inode、位图和新数据先在内存(页面缓存,page cache,或缓冲区缓存,buffer cache)中存在一段时间。然后,当文件系统最终决定将它们写入磁盘时(比如说5s或30s),文件系统将向磁盘发出必要的写入请求。遗憾的是,可能会发生崩溃,从而干扰磁盘的这些更新。特别是,如果这些写入中的一个或两个完成后发生崩溃,而不是全部 3个,则文件系统可能处于有趣的状态。
崩溃场景
为了更好地理解这个问题,让我们看一些崩溃情景示例。想象一下,只有一次写入成功。因此有以下3种可能的结果。
此外,遇到了一个新问题,我们将它称为文件系统不一致(file-system inconsistency)。磁盘上的位图告诉我们数据块5尚未分配,但是inode说它已经分配了。文件系统数据结构中的这种不同意见,是文件系统的数据结构不一致。要使用文件系统,我们必须以某种方式解决这个问题。
在这个向磁盘写入3次的尝试中,还有3种崩溃场景。在这些情况下,两次写入成功,最后一次失败。
崩溃一致性问题
希望从这些崩溃场景中,你可以看到由于崩溃而导致磁盘文件系统映像可能出现的许多问题:在文件系统数据结构中可能存在不一致性。可能有空间泄露,可能将垃圾数据返回给用户,等等。理想的做法是将文件系统从一个一致状态(在文件被追加之前),原子地(atomically)移动到另一个状态(在inode、位图和新数据块被写入磁盘之后)。遗憾的是,做到这一点不容易,因为磁盘一次只提交一次写入,而这些更新之间可能会发生崩溃或断电。我们将这个一般问题称为崩溃一致性问题(crash-consistency problem,也可以称为一致性更新问题,consistent-update problem)。
2 解决方案1:文件系统检查程序
早期的文件系统采用了一种简单的方法来处理崩溃一致性。基本上,它们决定让不一致的事情发生,然后再修复它们(重启时)。这种偷懒方法的典型例子可以在一个工具中找到:fsck[2]。fsck是一个UNIX工具,用于查找这些不一致并修复它们[M86]。在不同的系统上,存在检查和修复磁盘分区的类似工具。请注意,这种方法无法解决所有问题。例如,考虑上面的情况,文件系统看起来是一致的,但是inode指向垃圾数据。唯一真正的目标,是确保文件系统元数据内部一致。
工具fsck在许多阶段运行,如McKusick和Kowalski的论文[MK96]所述。它在文件系统挂载并可用之前运行(fsck假定在运行时没有其他文件系统活动正在进行)。一旦完成,磁盘上的文件系统应该是一致的,因此可以让用户访问。
以下是fsck的基本总结。
如你所见,构建有效工作的fsck需要复杂的文件系统知识。确保这样的代码在所有情况下都能正常工作可能具有挑战性[G+08]。然而,fsck(和类似的方法)有一个更大的、也许更根本的问题:它们太慢了。对于非常大的磁盘卷,扫描整个磁盘,以查找所有已分配的块并读取整个目录树,可能需要几分钟或几小时。随着磁盘容量的增长和RAID的普及,fsck的性能变得令人望而却步(尽管最近取得了进展[M+13])。
在更高的层面上,fsck的基本前提似乎有点不合理。考虑上面的示例,其中只有3个块写入磁盘。扫描整个磁盘,仅修复更新 3 个块期间出现的问题,这是非常昂贵的。这种情况类似于将你的钥匙放在卧室的地板上,然后从地下室开始,搜遍每个房间,执行“搜索整个房子找钥匙”的恢复算法。它有效,但很浪费。因此,随着磁盘(和RAID)的增长,研究人员和从业者开始寻找其他解决方案。
3 解决方案2:日志(或预写日志)
对于一致更新问题,最流行的解决方案可能是从数据库管理系统的世界中借鉴的一个想法。这种名为预写日志(write-ahead logging)的想法,是为了解决这类问题而发明的。在文件系统中,出于历史原因,我们通常将预写日志称为日志(journaling)。第一个实现它的文件系统是Cedar [H87],但许多现代文件系统都使用这个想法,包括Linux ext3和ext4、reiserfs、IBM的JFS、SGI的XFS和Windows NTFS。
基本思路如下。更新磁盘时,在覆写结构之前,首先写下一点小注记(在磁盘上的其他地方,在一个众所周知的位置),描述你将要做的事情。写下这个注记就是“预写”部分,我们把它写入一个结构,并组织成“日志”。因此,就有了预写日志。
通过将注释写入磁盘,可以保证在更新(覆写)正在更新的结构期间发生崩溃时,能够返回并查看你所做的注记,然后重试。因此,你会在崩溃后准确知道要修复的内容(以及如何修复它),而不必扫描整个磁盘。因此,通过设计,日志功能在更新期间增加了一些工作量,从而大大减少了恢复期间所需的工作量。
我们现在将描述Linux ext3(一种流行的日志文件系统)如何将日志记录到文件系统中。大多数磁盘上的结构与Linux ext2相同,例如,磁盘被分成块组,每个块组都有一个inode和数据位图以及inode和数据块。新的关键结构是日志本身,它占用分区内或其他设备上的少量空间。因此,ext2文件系统(没有日志)看起来像这样:
假设日志放在同一个文件系统映像中(虽然有时将它放在单独的设备上,或作为文件系统中的文件),带有日志的ext3文件系统如下所示:
真正的区别只是日志的存在,当然,还有它的使用方式。
数据日志
看一个简单的例子,来理解数据日志(data journaling)的工作原理。数据日志作为Linux ext3文件系统的一种模式提供,本讨论的大部分内容都来自于此。
假设再次进行标准的更新,我们再次希望将inode(I[v2])、位图(B[v2])和数据块(Db)写入磁盘。在将它们写入最终磁盘位置之前,现在先将它们写入日志。这就是日志中的样子:
你可以看到,这里写了5个块。事务开始(TxB)告诉我们有关此更新的信息,包括对文件系统即将进行的更新的相关信息(例如,块I[v2]、B[v2]和Db的最终地址),以及某种事务标识符(transaction identifier,TID)。中间的3个块只包含块本身的确切内容,这被称为物理日志(physical logging),因为我们将更新的确切物理内容放在日志中(另一种想法,逻辑日志(logical logging),在日志中放置更紧凑的更新逻辑表示,例如,“这次更新希望将数据块Db追加到文件X”,这有点复杂,但可以节省日志中的空间,并可能提高性能)。最后一个块(TxE)是该事务结束的标记,也会包含TID。
一旦这个事务安全地存在于磁盘上,我们就可以覆写文件系统中的旧结构了。这个过程称为加检查点(checkpointing)。因此,为了对文件系统加检查点(checkpoint,即让它与日志中即将进行的更新一致),我们将I[v2]、B[v2]和Db写入其磁盘位置,如上所示。如果这些写入成功完成,我们已成功地为文件系统加上了检查点,基本上完成了。因此,我们的初始操作顺序如下。
1.日志写入:将事务(包括事务开始块,所有即将写入的数据和元数据更新以及事务结束块)写入日志,等待这些写入完成。
2.加检查点:将待处理的元数据和数据更新写入文件系统中的最终位置。
在我们的例子中,先将TxB、I[v2]、B[v2]、Db和TxE写入日志。这些写入完成后,我们将加检查点,将I[v2]、B[v2]和Db写入磁盘上的最终位置,完成更新。
在写入日志期间发生崩溃时,事情变得有点棘手。在这里,我们试图将事务中的这些块(即TxB、I[v2]、B[v2]、Db、TxE)写入磁盘。一种简单的方法是一次发出一个,等待每个完成,然后发出下一个。但是,这很慢。理想情况下,我们希望一次发出所有 5 个块写入,因为这会将 5 个写入转换为单个顺序写入,因此更快。然而,由于以下原因,这是不安全的:给定如此大的写入,磁盘内部可以执行调度并以任何顺序完成大批写入的小块。因此,磁盘内部可以(1)写入TxB、I[v2]、B[v2]和TxE,然后才写入Db。遗憾的是,如果磁盘在(1)和(2)之间断电,那么磁盘上会变成:
补充:强制写入磁盘
为了在两次磁盘写入之间强制执行顺序,现代文件系统必须采取一些额外的预防措施。在过去,强制在两个写入A和B之间进行顺序很简单:只需向磁盘发出A写入,等待磁盘在写入完成时中断OS,然后发出写入B。
由于磁盘中写入缓存的使用增加,事情变得有点复杂了。启用写入缓冲后(有时称为立即报告,immediate reporting),如果磁盘已经放入磁盘的内存缓存中、但尚未到达磁盘,磁盘就会通知操作系统写入完成。如果操作系统随后发出后续写入,则无法保证它在先前写入之后到达磁盘。因此,不再保证写入之间的顺序。一种解决方案是禁用写缓冲。然而,更现代的系统采取额外的预防措施,发出明确的写入屏障(write barrier)。这样的屏障,当它完成时,能确保在屏障之前发出的所有写入,先于在屏障之后发出的所有写入到达磁盘。
所有这些机制都需要对磁盘的正确操作有很大的信任。遗憾的是,最近的研究表明,为了提供“性能更高”的磁盘,一些磁盘制造商显然忽略了写屏障请求,从而使磁盘看起来运行速度更快,但存在操作错误的风险[C+13, R+11]。正如Kahan所说,快速几乎总是打败慢速,即使快速是错的。
为什么这是个问题?好吧,事务看起来像一个有效的事务(它有一个匹配序列号的开头和结尾)。此外,文件系统无法查看第四个块并知道它是错误的。毕竟,它是任意的用户数据。因此,如果系统现在重新启动并运行恢复,它将重放此事务,并无知地将垃圾块“??”的内容复制到Db应该存在的位置。这对文件中的任意用户数据不利。如果它发生在文件系统的关键部分上,例如超级块,可能会导致文件系统无法挂装,那就更糟了。
补充:优化日志写入
你可能已经注意到,写入日志的效率特别低。也就是说,文件系统首先必须写出事务开始块和事务的内容。只有在这些写入完成后,文件系统才能将事务结束块发送到磁盘。如果你考虑磁盘的工作方式,性能影响很明显:通常会产生额外的旋转(请考虑原因)。
我们以前的一个研究生Vijayan Prabhakaran,用一个简单的想法解决了这个问题[P+05]。将事务写入日志时,在开始和结束块中包含日志内容的校验和。这样做可以使文件系统立即写入整个事务,而不会产生等待。如果在恢复期间,文件系统发现计算的校验和与事务中存储的校验和不匹配,则可以断定在写入事务期间发生了崩溃,从而丢弃了文件系统更新。因此,通过写入协议和恢复系统中的小调整,文件系统可以实现更快的通用情况性能。最重要的是,系统更可靠了,因为来自日志的任何读取现在都受到校验和的保护。
这个简单的修复很吸引人,足以引起Linux文件系统开发人员的注意。他们后来将它合并到下一代Linux文件系统中,称为Linux ext4(你猜对了!)。它现在可以在全球数百万台机器上运行,包括Android手持平台。因此,每次在许多基于Linux的系统上写入磁盘时,威斯康星大学开发的一些代码都会使你的系统更快、更可靠。
为避免该问题,文件系统分两步发出事务写入。首先,它将除TxE块之外的所有块写入日志,同时发出这些写入。当这些写入完成时,日志将看起来像这样(假设又是文件追加的工作负载):
当这些写入完成时,文件系统会发出TxE块的写入,从而使日志处于最终的安全状态:
此过程的一个重要方面是磁盘提供的原子性保证。事实证明,磁盘保证任何512字节写入都会发生或不发生(永远不会半写)。因此,为了确保TxE的写入是原子的,应该使它成为一个512字节的块。因此,我们当前更新文件系统的协议如下,3个阶段中的每一个都标上了名称。
1.日志写入:将事务的内容(包括TxB、元数据和数据)写入日志,等待这些写入完成。
2.日志提交:将事务提交块(包括TxE)写入日志,等待写完成,事务被认为已提交(committed)。
3.加检查点:将更新内容(元数据和数据)写入其最终的磁盘位置。
恢复
现在来了解文件系统如何利用日志内容从崩溃中恢复(recover)。在这个更新序列期间,任何时候都可能发生崩溃。如果崩溃发生在事务被安全地写入日志之前(在上面的步骤2完成之前),那么我们的工作很简单:简单地跳过待执行的更新。如果在事务已提交到日志之后但在加检查点完成之前发生崩溃,则文件系统可以按如下方式恢复(recover)更新。系统引导时,文件系统恢复过程将扫描日志,并查找已提交到磁盘的事务。然后,这些事务被重放(replayed,按顺序),文件系统再次尝试将事务中的块写入它们最终的磁盘位置。这种形式的日志是最简单的形式之一,称为重做日志(redo logging)。通过在日志中恢复已提交的事务,文件系统确保磁盘上的结构是一致的,因此可以继续工作,挂载文件系统并为新请求做好准备。
请注意,即使在某些更新写入块的最终位置之后,在加检查点期间的任何时刻发生崩溃,都没问题。在最坏的情况下,其中一些更新只是在恢复期间再次执行。因为恢复是一种罕见的操作(仅在系统意外崩溃之后发生),所以几次冗余写入无须担心[3]。
批处理日志更新
你可能已经注意到,基本协议可能会增加大量额外的磁盘流量。例如,假设我们在同一目录中连续创建两个文件,称为file1和file2。要创建一个文件,必须更新许多磁盘上的结构,至少包括:inode位图(分配新的inode),新创建的文件inode,包含新文件目录条目的父目录的数据块,以及父目录的inode(现在有一个新的修改时间)。通过日志,我们将所有这些信息逻辑地提交给我们的两个文件创建的日志。因为文件在同一个目录中,我们假设在同一个inode块中都有inode,这意味着如果不小心,我们最终会一遍又一遍地写入这些相同的块。
为了解决这个问题,一些文件系统不会一次一个地向磁盘提交每个更新(例如,Linux ext3)。与此不同,可以将所有更新缓冲到全局事务中。在上面的示例中,当创建两个文件时,文件系统只将内存中的inode位图、文件的inode、目录数据和目录inode标记为脏,并将它们添加到块列表中,形成当前的事务。当最后应该将这些块写入磁盘时(例如,在超时5s之后),会提交包含上述所有更新的单个全局事务。因此,通过缓冲更新,文件系统在许多情况下可以避免对磁盘的过多的写入流量。
本文截选自《操作系统导论》
操作系统导论
作者:[美] 雷姆兹·H.阿帕希杜塞尔( Remzi H. Arpaci-Dusseau), [美]安德莉亚·C.阿帕希杜塞尔(Andrea C. Arpaci-Dusseau)
译者:王海鹏
本书围绕虚拟化、并发和持久性这三个主要概念展开,介绍了所有现代系统的主要组件(包括调度、虚拟内存管理、磁盘和I/O子系统、文件系统)。全书共50章,分为3个部分,分别讲述虚拟化、并发和持久性的相关内容。作者以对话形式引入所介绍的主题概念,行文诙谐幽默却又鞭辟入里,力求帮助读者理解操作系统中虚拟化、并发和持久性的原理。
本书内容全面,并给出了真实可运行的代码(而非伪代码),还提供了相应的练习,很适合高等院校相关专业的教师开展教学和高校学生进行自学。
本书具有以下特色:
● 主题突出,紧紧围绕操作系统的三大主题元素——虚拟化、并发和持久性。
● 以对话的方式引入背景,提出问题,进而阐释原理,启发动手实践。
● 包含众多“补充”和“提示”,拓展读者知识面,增加趣味性。
● 使用真实代码而不是伪代码,让读者更加深入透彻地了解操作系统。
● 提供作业、模拟和项目等众多学习方式,鼓励读者动手实践。
● 为教师提供教学辅助资源