在看文章前可以先看下这个,
吴海波:专栏的序
先有个大概的认识会对阅读有所帮助。
到目前为止我们所看到的文件系统就是管理一组数据结构,以实现预期的抽象:文件、目录和所有其他元数据。与大多数数据不同(例如内存中的数据),文件系统的数据必须能长时间保存,也就是说,这些存储设备断电也可以保留数据。因此文件系统面临的一个主要挑战是在系统崩溃的情况下,应该怎么保证数据正确的存储。具体来说,如果在更新磁盘的过程中,有人把电源线绊掉,会发生什么情况呢?或者操作系统遇到错误并崩溃呢?由于电源损耗和崩溃,更新持久数据结构可能非常棘手,这些问题,统称为崩溃一致性问题。
因此,我们有一个所有文件系统都需要解决的问题:系统在任何两次写入之间都可能崩溃或失去电源,因此磁盘上的数据可能只得到部分保存。考虑到崩溃可能发生在任意时间点,我们如何确保文件系统将磁盘映像保持在合理的状态?在本章中,我们将介绍一些文件系统用来克服这个问题的方法。我们将首先介绍旧文件系统(称为fsck或文件系统检查器)所采取的方法。然后,我们将注意力转向另一种方法,称为日志记录,这种技术为每一次写入增加了一些开销,但从崩溃或断电中恢复会更快。
文件系统不一致的问题
让我们先看一个例子。我们使用某种工作负载(workload)的方式更新磁盘。这里假设工作负载很简单:将单个数据块附加到现有文件中。完成附加操作的方法是打开文件,调用lseek()将文件偏移到文件末尾,然后在关闭文件之前对文件发出一个4KB的写入。我们还假设在磁盘上使用的是标准的简单文件系统结构,类似于我们前面章节的文件系统。这个小小的例子包括一个inode位图(只有8位,每个inode一个),一个数据位图(也是8位,每个数据块一个),inode(总共8个,编号为0到7,分布在四个块)和数据块(总共8个,编号为0到7)。下面是这个文件系统的图示:
从图中可以看到已经分配了一个inode(编号2),它在inode位图中也被标记,而一个已分配的数据块(数据块4)也被分配到了数据位图中。inode被表示为I[v1],因为它是该inode的第一个版本。
owner : remzi
permissions : read-write
size : 1
pointer : 4
pointer : null
pointer : null
pointer : null
在这个简化的inode中,文件的大小是1(它分配了一个块),第一个直接指针指向块4(文件的第一个数据块Da),所有其他三个直接指针都被设置为NULL(它们没有被使用)。当然,真正的inode还有更多的字段。因为要写入新数据,我们需要一个新的数据块,因此必须更新三种磁盘上的结构:inode(它必须指向新块并记录扩大了的文件大小)、新的数据块DB,和一个新版本的数据位图(称为B[v2])。因此,在系统的内存中,我们有三个必须写入磁盘的块。更新后的inode(inode版本2,简称I[v2])现在看起来如下所示:
permissions : read-write
size : 2
pointer : 4
pointer : 5
pointer : null
pointer : null
更新后的数据位图(B[v2])现在看起来如下:00001100。最后,还有数据块(DB)。文件系统的最终磁盘映像如下所示:
要实现上面的操作,文件系统必须对磁盘执行三个单独的写操作,一个是inode(i[v2]),一个是位图(B[v2]),另一个是数据块(DB)。注意,当用户发出write()系统调用时,这些写入通常不会立即发生;相反,脏的inode、位图和新数据将首先在主内存中停留一段时间;然后,当文件系统最终决定将它们写入磁盘时(例如5秒或30秒后),文件系统将向磁盘发出必要的写入请求。不幸的是在这个时间段内可能会发生崩溃。特别是,如果崩溃发生在这几个写操作之间,那么文件系统可能会处于一种奇怪的状态。
为了更好地理解这个问题,让我们看看一些崩溃的具体场景。假设只有一次写入成功;因此有三种可能的结果,我们在这里列出:
1.只将数据块(DB)写入磁盘。在这种情况下,数据在磁盘上,但是没有inode指向它,也没有显示块被分配的位图信息。因此,看起来像是从未发生过写入一样。
2.只将inode(i[v2])写入磁盘。在这种情况下,inode指向即将写入DB的磁盘地址(5),但DB尚未写入其中。因此,如果我们信任该指针,我们将从磁盘读取到的是垃圾数据。此外,还有一个新的问题,称之为文件系统的一致性问题.磁盘上的位图告诉我们数据块5没有被分配,但是inode说它已经分配了。位图和inode之间的分歧导致文件系统的数据结构不一致;因此我们必须以某种方式解决这个问题(下面将详细介绍)。
3.只将更新后的位图(B[v2])写入磁盘。在这种情况下,位图指示块5已经被分配,但没有指向它的inode。因此,文件系统再次不一致;如果未解决,第5块永远不会被文件系统使用。
在试图将三个块写入磁盘的尝试中,还有另外三种崩溃可能。在这些情况下,会有2个写入成功:
1.inode(i[v2])和位图(B[v2])被写入磁盘,数据并没有写入。在这种情况下,文件系统元数据是完全一致的:inode有一个指向块5的指针,位图表示5在使用中,因此从文件系统元数据的角度来看,一切看起来都正常。但是有一个问题:5里面又是垃圾数据。
2.inode(i[v2])和数据块(DB)写入了,但没有写入位图(B[v2])。在这种情况下,我们有指向磁盘上正确数据的inode,但是inode和位图(B1)的旧版本之间有不一致之处。因此,在使用文件系统之前,我们需要解决这个问题。
3.位图(B[v2])和数据块(DB)写入,inode(i[v2])没有写入。在这种情况下,我们再次在inode和数据位图之间出现了不一致。所以即使块是写入的,并且位图显示了它的使用情况,我们也不知道它属于哪个文件,因为没有inode指向该文件。
可以看到由于崩溃导致文件系统出现了许多可能发生的问题。理想情况下,我们要做的是将文件系统从一个一致的状态原子地移动到另一个状态。不幸的是,我们无法轻松地做到这一点,因为磁盘同一时间只能完成一次写操作,并且在这些更新之间随时可能会发生崩溃或断电。
解决方案一:fsck
早期文件系统采取了简单的方法来解决一致性问题。基本的策略是让不一致的事情发生然后再修正他们(当重新启动时)。这种方法的一个典型例子是执行FSCK2这个工具:。fsck是一个用于查找这种不一致并修复它们的UNIX工具。请注意,这种方法无法修复所有问题;例如,文件系统看起来一致,但inode指向垃圾数据。以下是fsck的基本概要:
1.超级块(Superblock):fsck首先检查超级块是否合理,主要进行健全性检查,例如确保文件系统大小大于已分配的块的数量。通常这些健全性检查的目标是寻找有问题的超级块;在这种情况下,系统(或管理员)可以决定使用超级块的替代副本。
2.自由块(Free blocks):下一步,fsck扫描inode,间接块,双间接块等。知道哪些块已经被分配。使用这个数据生成正确版本的位图;因此,如果位图和inode之间存在任何不一致,将信任inodes内的信息。
3.inode状态:检查每个inode是否存在损坏或其他问题。例如,fsck确保每个已分配的inode有效类型字段有分配(例如,常规文件、目录、符号链接等).如果存在不容易确定的inode字段的问题,inode被认为是可疑的,并被fsck清除;inode位图相应地更新。
4.inode链接:fsck还验证每个已分配inode的链接计数。链路计数表示指向该特定文件的引用数量。要验证链路计数,fsck扫描整个目录树,从根目录开始,并建立文件系统中每个文件和目录的链接计数。如果新计算的计数和找到的计数之间不匹配,必须采取纠正措施,通常是修改inode中的引用计数值。如果发现了已分配的inode,但是没有目录引用它,它将被移动到lost+found的目录。
5.重复:在扫描所有指针时,还会执行对错误块指针的检查。如果指针明显地指向它的有效范围之外的地方,那么指针就被认为是“坏”的,例如,指针指向的地址大于分区大小的块。在这种情况下,fsck不做任何太智能的事情;它只是从inode或间接块中移除(清除)指针。
6.目录检查:fsck不了解用户的文件内容;但是,目录保存着由文件系统本身创建的特定格式的信息。因此,fsck对每个目录的内容执行额外的完整性检查,确保"."还有“..”是第一个条目,在目录条目中引用的每个inode都被分配,并确保在整个层次结构中没有任何目录被链接到不止一次。
正如你所看到的,构建一个工作的fsck需要了解文件系统的复杂知识;确保这样的代码在所有情况下都能正确工作是很有挑战性的。然而,fsck(和类似的方法)有一个更大、也许更根本的问题:它们太慢了。扫描整个磁盘以找到所有分配的块,并读取整个目录树可能需要许多分钟甚至小时。fsck的性能随着磁盘容量的增长和RAID的普及而变得令人望而却步。在更高的层面上,fsck的基本前提似乎有点不合理。考虑上面的示例,其中只向磁盘写入了三个块;但是却需要扫描整个磁盘来修复。这种情况类似于把你的钥匙丢在你卧室的地板上,然后用一种搜索整栋房子的恢复算法。因此,随着磁盘(RAID)的增加,人们开始寻找其他解决方案。
解决方案二:日志记录型文件系统
对于一致性的问题,最流行的解决方案是从数据库管理系统中学习的一个想法。在文件系统中,由于历史原因,我们通常称为journaling。第一个这样做的文件系统是Cedar,许多现代文件系统都使用这种思想,包括Linux ext3和ext4、ReiserFS、IBM的JFS、SGI的XFS和WindowsNTFS。基本思想如下。
当更新磁盘时,在覆盖数据结构之前,首先写日志(在磁盘上的某个地方),先将日志写入磁盘,可以确保如果正在更新数据的期间发生崩溃,可以回过头来查看所做的日志,然后再试一次;因此,你将知道在崩溃后修复什么(以及如何修复),而不必扫描整个磁盘。我们现在以Linux ext3为例子,来看看如何将日志合并到文件系统中。ext3大多数磁盘上的结构与Linux ext2相同,例如,磁盘被划分为组,每个组包含一个inode位图、数据位图、inode和数据块。新的核心结构是日志本身。ext 2文件系统(没有日志记录)如下所示:
假设日志被放置在同一个文件系统映像中(虽然有时被放置在单独的设备上,或者作为文件系统中的文件),带有日志的ext3文件系统如下所示:
真正的区别只是日志记录部分的存在,当然还包括它是如何使用的。
让我们看一个简单的例子来了解日志是如何工作的。假设我们希望再次将inode(i[v2])、位图(B[v2])和数据块(DB)写入磁盘。在将它们写入最终磁盘位置之前,我们首先将它们写入日志。这将是日志中的内容:
你可以看到我们在这里写了五个块。事务开始(TxB),需要更新到文件系统的内容(例如,块I[v2]、B[v2]和DB),以及事务标识符(transaction identifier,TID)。中间三个块包含了本次更新的实际内容;这种日志被称为物理日志,因为我们将更新的确切物理内容放在日志中。(另一种方式是逻辑日志,是在日志中放置更紧凑的逻辑表示,例如,“此更新希望将数据块DB附加到文件X”,这有点复杂,但可以节省日志中的空间,并可能提高性能)。最终块(TxE)是此事务结束的标记,并且还将包含TID。一旦事务安全地更新在磁盘上,我们就可以覆盖文件系统中的旧数据,这个过程称为检查点(checkpointing)。因此,我们操作的顺序是:
1.日志写入:将事务写入日志,包括一个transaction-BEGIN块、所有待写入的数据和元数据以及一个Transaction-end块;然后等待这些写入完成。
2.检查点: 将挂起的元数据和数据写入文件系统中它们的最终位置。
在我们的示例中,我们首先将TxB、I[v2]、B[v2]、DB和TxE写入日志。当这些写入完成时,我们将通过检查点来完成对I[v2]、B[v2]和DB的写入。但是当崩溃发生在写日记的时候,事情又会变得复杂。比如为了加快写这些日志的速度,会将这5块的信息作为一个i/o请求去进行操作,至于写的顺序那就要看具体的调度实现了,有可能出现先写TxB、I[v2]、B[v2]和TxE,然后写DB的情况,如果在这2个步骤之间断电了,那么就会出现下面这种情况的日志。当重启机器恢复日志的时候,读到的数据就会是垃圾数据了。
这种问题解决方法是分两步发出事务写入的请求。首先,将除TxE块之外的所有块写入日志,并且同时发出这些写入。 当这些写入完成后,日志将类似于这样:
当这些写入完成后,文件系统发出TxE块的写入,从而使日志处于最后的安全状态:
此过程的一个重要支撑是磁盘提供的原子性保证。磁盘保证任何512字节的写入都是原子性的。因此,为了确保TxE的写入是原子的,应该将它变成一个512字节的块。到这里,当前更新文件系统的协议分为如下三个阶段:
1.日志写入:将事务的内容(包括TxB、元数据和数据)写入日志;等待这些写入完成。
2.日志提交:将事务提交块(包含TxE)写入日志;等待写入完成
3.检查点: 将更新的内容(元数据和数据)写入它们对应的最终磁盘位置。
崩溃恢复
现在让我们了解文件系统如何使用日志的内容从崩溃中恢复。如果崩溃发生在事务安全地写入日志之前(即在上面的步骤2完成之前),那么我们的工作很简单:不对数据进行更新。
如果崩溃发生在事务提交到日志之后,但在检查点完成之前,文件系统可以恢复更新。当系统启动时,文件系统恢复程序将扫描日志并查找已提交到磁盘的事务;文件系统再次尝试将事务中的块写入其最终的磁盘位置。我们现在这种形式的日志记录是最简单的形式之一,称为redo logging。通过恢复日志中提交的事务,文件系统确保了磁盘上的结构是一致的。
你可能已经注意到,基本协议可能会增加许多额外的磁盘通信量。例如,假设我们在同一目录中创建了两个文件,名为file 1和file 2。要创建一个文件,必须更新多个磁盘上的结构,最少包括:inode位图,新创建的inode,包含新目录条目的父目录的数据块,以及父目录inode(需要更新修改时间)。这2个文件的创建动作,我们会写2块日志信息。因为这些文件位于同一个目录中,并且假设它们的inode信息甚至在同一个inode块中,所以我们最终可能会多次访问相同的块。
为了解决这个问题,一些文件系统不是有了更新就向磁盘提交的(例如Linux ext3);相反,可以将所有更新缓冲到一个全局事务中。在上面的示例中,当创建两个文件时,文件系统只是将需要更新的数据添加到当前事务的块列表中。当最终将这些块写入磁盘时(例如,5秒后),将提交包含上述所有更新的单个全局事务。因此,在许多情况下,通过缓冲,文件系统可以避免过多地与磁盘通信。
上面的协议已经可以满足我们最基本的要求了。但是,日志只有有限的大小。如果我们不断把事务添加进去(如本图所示),日志空间将很快填满。
当日志满时会出现两个问题。第一个比较简单,但不太关键:日志越大,恢复所用的时间就越长,因为恢复过程必须重新执行日志中的所有事务(为了恢复)。第二个问题则严重得多:当日志已满(或接近满)时,无法将更多事务提交到磁盘,会导致文件系统“不可用”。
为了解决这些问题,日志文件系统将日志空间视为循环的数据结构,反复使用它;这就是为什么日志有时被称为循环日志的原因。要做到这一点,文件系统必须在检查点之后的某个时间采取特定的操作。具体来说,一旦事务完成了检查点,文件系统应该释放它在日志中占用的空间,从而允许重用日志空间。实现的方法有很多种;例如,可以在日志超级块中标记日志中最古老和最新的非检查点事务;这样其他空间都是空闲的。下面是对应的描述:
在日志超级块中(不要与主文件系统超级块混淆),有足够的信息知道哪些事务尚未被检查,从而缩短了恢复时间,并允许循环地重用日志。因此,我们的基本协议中又增加了一个步骤:
1.日志写入:将事务的内容(包含TxB和更新的内容)写入日志;等待这些写入完成。
2.日志提交:将事务提交块(包含TxE)写入日志;等待写入完成;事务现在已提交。
3.检查点:将更新的内容写入文件系统中的最终位置。
4.释放:一段时间后,通过更新日志超级块来标记日志中的事务。
我们现在有了最终的日志记录协议。但是仍然存在一个问题:我们需要将每个数据块写两次到磁盘,这是一个巨大的消耗,特别是对于像系统崩溃这样罕见的场景而这么做。你能想出一种更高效的方法吗?
元数据日志
虽然现在恢复很快(扫描日志然后重新执行几个事务,而不是扫描整个磁盘),但文件系统的常规操作将比我们希望的要慢。特别是,对于每一次磁盘写入,我们要将所有的数据都先写入日志,从而使写入流量加倍。此外,在写入日志和写入主文件系统之间,存在一个代价高昂的查找,这也会带来明显的开销。
由于将每个数据块两次写入磁盘的成本高,人们尝试了一些不同的事情,以加快性能。例如,在linux ex3中,日志中不会写入用户数据,写入的日志信息如下:
那么我们什么时候写入数据块呢?
答案就是将数据块先写入。对于这种方式,我们现在的整体步骤变为了如下几步:
1.数据写入:将数据写入最终位置;等待完成(等待是可选的;详见下文)。
2.日志元数据写入:将BEGIN块和元数据写入日志;等待写入完成。
3.日志提交:将事务提交块(包含TxE)写入日志;等待写入完成;完后后事务(包括数据)就表示已经提交了。
4.检查点元数据:将元数据的内容写入文件系统中的最终位置。
5.释放:稍后,在日志超级块中标记事务。
对于步骤一,其实可以和步骤2一起写入,因为我们最终是根据步骤三的完成情况来判断这个事务的日志数据是否完整,所以1,2同时进行是可以的。
在结束日志记录的讨论之前,我们总结下讨论过的协议,并给出了每个协议的执行时间表。图42.1显示了同时记录数据和元数据时的协议,而图42.2则显示了只记录元数据时的协议。在每个图形中,时间向下增长,图中的每一行显示发出或可能完成写入的逻辑时间。例如,图42.1中,事务开始块(TxB)的写入和事务的内容可以在逻辑上同时发出,因此可以按任何顺序完成;但是,在写完之前,不能发出对事务结束块(TxE)的写入。水平虚线显示必须遵守的顺序。
元数据日志记录协议显示了类似的时间线。请注意,数据和事务内容可以同时发起;但是,必须在事务结束TXE之前完成数据写入。在实际系统中,完成时间由I/O系统决定,系统也可以重新排序写入的顺序以提高性能。