文件系统管理一组数据结构以实现预期的抽象:文件,目录,以及所有其他的元数据,以满足我们期望的从文件系统获得的基本抽象。文件系统必须持久化,存储在断电也能保留数据的设备上。所以文件系统面临了一个主要的挑战在于,如何在断电或者系统崩溃的情况下,更新持久化数据。
- 如果在更新磁盘结构的过程中,机器断电,会发生什么?
- 操作系统遇到错误并崩溃,会发生什么?
本章中,我们详细探讨这个问题,看看文件系统克服它的一些方法
1 背景
我们假定磁盘上使用一个标准的简单的文件系统结构,类似于我们之前分析的文件系统,如下图所示
- 一个inode位图(inode bitmap只有8位,每个Inode一个)
- 一个数据位图(data bitmap,也是8位,每个数据块一个)
- inode(总共8个,编号从0到7,分布在4个block上)
- 数据块,总共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(表示它们未被使用)。
向文件追加内容时,要向它添加一个新数据块,因此必须更新 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个,则文件系统可能处于非一致性的状态。
2 崩溃场景
为了更好地理解这个问题,让我们看一些崩溃情景示例。想象一下,只有一次写入成功。因此有以下 3 种可能的结果:
-
只将数据块(Db)写入磁盘 。在这种情况下,数据在磁盘上,但是没有指向它的inode,也没有表示块已分配的位图。因此,就好像写入从未发生过一样。从文件系统崩溃一致性的角度来看,这种情况根本不是问题。但是对于用户来说,这是一个问题,丢失了一些数据。
-
只有更新的 inode(I[v2])写入了磁盘 。在这种情况下,inode 指向磁盘地址(5),其中 Db 即将写入,但 Db 尚未写入。因此,如果我们信任该指针,我们将从磁盘读取垃圾数据(磁盘地址 5 的旧内容)。
- 此外,遇到了一个新问题,我们将它称为文件系统不一致(file-system inconsistency)。磁盘上的位图告诉我们数据块 5 尚未分配,但是 inode 说它已经分配了。会导致文件系统的数据结构不一致。要使用文件系统,我们必须以某种方式解决这个问题。
-
**只有更新后的位图(B [v2])写入了磁盘:**在这种情况下,位图指示已分配块 5,但没有指向它的 inode。因此文件系统再次不一致。如果不解决,这种写入将导致空间泄露(space leak),因为文件系统永远不会使用块 5。
在这个向磁盘写入 3 次的尝试中,还有 3 种崩溃场景。在这些情况下,两次写入成功,最后一次失败。
- 写入了 inode(I[v2])和数据块(Db),但没有写入位图(B[v2]) 。在这种情况下,inode 指向了磁盘上的正确数据,但同样在 inode 和位图(B1)的旧版本之间
- inode(I[v2])和位图(B[v2])写入了磁盘,但没有写入数据(Db) 。在这种情况下,文件系统元数据是完全一致的:inode 有一个指向块 5 的指针,位图指示 5正在使用,因此从文件系统的元数据的角度来看,一切看起来都很正常。但是有一个问题:5 中又是垃圾。
- 写入了位图(B[v2])和数据块(Db),但没有写入 inode(I[v2]) 。在这种情况下,inode 和数据位图之间再次存在不一致。但是,即使写入块并且位图指示其使用,我们也不知道它属于哪个文件,因为没有 inode 指向该块
你可以看到由于崩溃而导致磁盘文件系统映像可能出现的许多问题,在文件系统数据结构中可能存在不一致性。可能有空间泄露,可能将垃圾数据返回给用户等等。
理想的做法是将文件系统从一个一致状态(在文件被追加之前),原子地(atomically)移动到另一个状态(在 inode、位图和新数据块被写入磁盘之后)。遗憾的是,做到这一点不容易,因为磁盘一次只提交一次写入,而这些更新之间可能会发生崩溃或断电。我们将这个一般问题称为崩溃一致性问题
3 解决方案 1:文件系统检查程序
早期的文件系统采用了一种简单的方法来处理崩溃一致性。基本上,它们决定让不一致的事情发生,然后再修复它们(重启时)。这种偷懒方法的典型例子可以在一个工具中找到:fsck。
fsck 是一个 UNIX 工具,用于查找这些不一致并修复它们[M86]。在不同的系统上,存在检查和修复磁盘分区的类似工具。请注意,这种方法无法解决所有问题。例如,考虑上面的情况,文件系统看起来是一致的,但是 inode 指向垃圾数据。唯一真正的目标,是确保文件系统元数据内部一致。以下是fsck的基本工作原理
- 超级块: fsck 手续爱你检查超级块是否何理,主要是进行健全型检查,例如确保文件系统大小大于分配的块数。
- 空闲块: 接下来,fsck 扫描 inode、间接块、双重间接块等,以了解当前在文件系统中分配的块。它利用这些生成正确版本的分配位图。因此,如果位图和 inode之间存在任何不一致,则通过信任 inode 内的信息来解决它。对所有 inode 执行相同类型的检查,确保所有看起来像在用的 inode,都在 inode 位图中有标记。
- inode 状态: 检查每个 inode 是否存在损坏或其他问题。例如,fsck 确保每个分配的 inode 具有有效的类型字段(即常规文件、目录、符号链接等)。如果 inode 字段存在问题,不易修复,则 inode 被认为是可疑的,并被 fsck 清除,inode 位图相应地更新。
- inode 链接: fsck 还会验证每个已分配的 inode 的链接数。你可能还记得,链接计数表示包含此特定文件的引用(即链接)的不同目录的数量。为了验证链接计数,fsck 从根目录开始扫描整个目录树,并为文件系统中的每个文件和目录构建自己的链接计数。如果新计算的计数与 inode 中找到的计数不匹配,则必须采取纠正措施,通常是修复 inode 中的计数。如果发现已分配的 inode 但没有目录引用它,则会将其移动到 lost + found 目录。
- 重复: fsck 还检查重复指针,即两个不同的 inode 引用同一个块的情况。如果一个inode 明显不好,可能会被清除。或者,可以复制指向的块,从而根据需要为每个inode 提供其自己的副本。
- 坏块: 在扫描所有指针列表时,还会检查坏块指针。如果指针显然指向超出其有效范围的某个指针,则该指针被认为是“坏的”,例如,它的地址指向大于分区大小的块。在这种情况下,fsck 不能做任何太聪明的事情。它只是从 inode 或间接块中删除(清除)该指针。
- 目录检查: fsck 不了解用户文件的内容。但是,目录包含由文件系统本身创建的特定格式的信息。因此,fsck 对每个目录的内容执行额外的完整性检查,确保“.”和“…”是前面的条目,目录条目中引用的每个 inode 都已分配,并确保整个层次结构中没有目录的引用超过一次。
对于非常大的磁盘卷,扫描整个磁盘,以查找所有已分配的块并读取整个目录树,可能需要几分钟或几小时。
4 解决方案 2:日志(或预写日志)
对于一致更新问题,最流行的解决方案可能是从数据库管理系统的世界中借鉴的一个想法。这种名为预写日志(write-ahead logging)的想法,是为了解决这类问题而发明的。在文件系统中,出于历史原因,我们通常将预写日志称为日志(journaling)。第一个实现它
的文件系统是 Cedar [H87],但许多现代文件系统都使用这个想法,包括 Linux ext3 和 ext4、reiserfs、IBM 的 JFS、SGI 的 XFS 和 Windows NTFS。
其基本的思路如下:
更新磁盘时,在覆写结构之前,首先写下一个小标记(在磁盘上的其他位置),描述你将要做的事情,写下这个标记就是预写部分,我们把它写入一个结构,并组织成日志。通过将注释写入磁盘,可以保证在更新(覆写)正在更新的结构期间发生崩溃时,能够返回并查看你所做的注记,然后重试。因此,你会在崩溃后准确知道要修复的内容(以及如何修复它),而不必扫描整个磁盘。因此,通过设计,日志功能在更新期间增加了一些工作量,从而大大减少了恢复期间所需的工作量。
我们现在将描述 Linux ext3(一种流行的日志文件系统)如何将日志记录到文件系统中。大多数磁盘上的结构与 Linux ext2 相同,例如,磁盘被分成块组,每个块组都有一个 inode和数据位图以及 inode 和数据块。新的关键结构是日志本身,它占用分区内或其他设备上的
少量空间。
假设再次进行标准的更新,我们再次希望将 inode(I[v2])、位图(B[v2])和数据块(Db)写入磁盘。在将它们写入最终磁盘位置之前,现在先将它们写入日志。这就是日志中的样子
你可以看到,这里写了 5 个块,分别表示如下:
- 事务开始(TxB)告诉我们有关此更新的信息,包括对文件系统即将进行的更新的相关信息(例如,块 I[v2]、B[v2]和 Db 的最终地址),以及某种事务标识符(transaction identifier,TID)。
- 中间的 3 个块只包含块本身的确切内容,这被称为物理日志(physical logging)
一旦这个事务安全地存在于磁盘上,我们就可以覆写文件系统中的旧结构了。这个过程称为加检查点(checkpointing)。因此,为了对文件系统加检查点(checkpoint,即让它与日志中即将进行的更新一致),我们将 I[v2]、B[v2]和 Db 写入其磁盘位置,如上所示。如果
这些写入成功完成,我们已成功地为文件系统加上了检查点,基本上完成了。因此,我们的初始操作顺序如下。
- **日志写入:**将事务(包括事务开始块,所有即将写入的数据和元数据更新以及事务结束块)写入日志,等待这些写入完成
- **加检查点:**将待处理的元数据和数据更新写入文件系统中的最终位置
在我们的例子中,先将 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)之间断电,那么磁盘上会变成:
为避免该问题,文件系统分两步发出事务写入。首先,它将除 TxE 块之外的所有块写入日志,同时发出这些写入。当这些写入完成时,日志将看起来像这样
当这些写入完成时,文件系统会发出 TxE 块的写入,从而使日志处于最终的安全状态:
此过程的一个重要方面是磁盘提供的原子性保证。事实证明,磁盘保证任何 512 字节写入都会发生或不发生(永远不会半写)。因此,为了确保 TxE 的写入是原子的,应该使它成为一个 512 字节的块。因此,我们当前更新文件系统的协议如下,3 个阶段中的每一个都标上了名称。
- 日志写入: 将事务的内容(包括 TxB、元数据和数据)写入日志,等待这些写入完成。
- 日志提交: 将事务提交块(包括 TxE)写入日志,等待写完成,事务被认为已提交(committed)。
- 加检查点: 将更新内容(元数据和数据)写入其最终的磁盘位置。