Skip to main content

第 7 章 - 日志 (Journaling)

Journaling(日志记录),也称为logging,是一种确保磁盘上数据结构正确性的机制。本章的目标是解释什么是journaling,文件系统如何实现它,以及提高journaling性能的技术。

为了理解journaling,我们首先需要理解它试图解决的问题。如果系统在更新磁盘上的数据结构时崩溃,该数据结构可能会损坏。如果在多次更新之间发生崩溃,需要更新多个磁盘块的操作就会面临风险。在对数据结构进行两次修改之间发生的崩溃将导致操作只完成一部分。部分更新的数据结构本质上是一个损坏的结构,因此文件系统必须特别小心以避免这种情况。

磁盘只能保证对单个磁盘块的写入能够成功。也就是说,对单个磁盘块的更新要么成功,要么失败。对磁盘上单个块的写入是一个不可分割(即原子性)的事件;不可能只部分写入一个磁盘块。如果文件系统的任何操作都不需要更新超过一个磁盘块,那么崩溃造成的损害是有限的:要么磁盘块被写入了,要么没有。不幸的是,磁盘上的数据结构通常需要修改几个不同的磁盘块,所有这些块都必须正确写入才能认为更新完成。如果数据结构的部分块被修改,可能会导致操纵该数据结构的软件损坏用户数据或崩溃。

如果在修改数据结构时发生灾难性情况,系统下次访问数据结构时,必须仔细验证数据结构。这包括遍历整个数据结构以修复之前系统停止造成的任何损坏——这是一个乏味且耗时的过程。

Journaling是数据库领域发明的一种技术,它通过确保对结构的每次更新要么完全发生,要么完全不发生,即使更新跨越多个磁盘块,从而保证了磁盘上数据结构的正确性。如果文件系统使用journaling,它可以假定,除非有bug或磁盘故障,其磁盘上的数据结构将保持一致,无论发生崩溃、断电或其他灾难性情况。此外,journaled文件系统的恢复与其大小无关。journaled卷的崩溃恢复大约只需几秒钟,而不是像大型非journaled文件系统那样需要数十分钟。保证一致性和快速恢复是journaling提供的两个主要特性。

在不了解细节的情况下,journaling可能看起来像魔术。正如我们将看到的,事实并非如此。此外,journaling并不能防止所有类型的故障。例如,如果一个磁盘块变坏且无法再从中读取或写入,journaling不会(也无法)提供任何保证或保护。更高级别的软件必须始终准备好处理物理磁盘故障。Journaling在它提供的保护方面有几个实际限制。

7.1 基础知识 (The Basics)

在日志文件系统中,一个事务是在一次操作期间对文件系统磁盘上结构所做的完整修改集合。例如,创建一个文件是一个单一的事务,它包括在文件创建期间修改的所有磁盘块。一个事务对于故障而言被认为是原子性的。一个事务要么完全发生(例如,文件被创建),要么根本不发生。当最后一次修改完成时,一个事务就结束了。即使事务结束了,在所有修改过的磁盘块都在磁盘上更新之前,它仍然没有完成。这种事务结束和事务完成之间的区别很重要,稍后会讨论。事务是journaling最基本的单位。

思考事务内容的另一种方式是从高层次来看待它们。在高层次上,我们可以将一个事务视为一个单一的操作,例如“创建文件X”或“删除文件Y”。这是一种比将事务视为一系列修改过的块更紧凑的表示方式。低层次视图不重视块的内容;它只是记录了哪些块被修改了。更紧凑、更高层次的视图需要对底层数据结构有深入的了解才能解释日志的内容,这使得journaling的实现更加复杂。事务的低层次视图实现起来要简单得多,并且具有独立于文件系统数据结构的优点。

当一个事务的最后一次修改完成时(即它结束了),事务的内容被写入日志。日志是磁盘上一个固定大小的连续区域,journaling代码将其用作一个循环缓冲区。用来指代日志的另一个术语是journal。journaling系统将所有事务记录在日志区域中。出于性能原因,可以将日志放在与文件系统其余部分不同的设备上。日志只在正常操作期间写入,并且当旧事务完成时,它们在日志中的空间会被回收。日志是journaling操作的核心。

当一个事务被写入日志后,有时也被称为一个日志条目(journal entry)。一个日志条目包含修改过的磁盘块的地址以及属于每个块的数据。日志条目通常存储为一个单独的内存块,并写入卷的日志区域。当一个journaled系统重新启动时,如果存在任何未标记为完成的日志条目,系统必须重放这些条目以使系统保持最新状态。重放日志可以防止部分更新,因为每个日志条目都是一个完整、自包含的事务。

Write-ahead logging(预写式日志)是指journaling系统在修改磁盘之前将更改写入日志。我们所知的所有journaling系统都使用write-ahead logging。我们假设journaling意味着write-ahead logging,并提及它只是为了完整性。

支持事务和日志的基本概念的是几个内存中的数据结构。这些结构在修改进行时将事务保存在内存中,并跟踪哪些事务已成功完成以及哪些事务正在 pending。这些结构当然会因不同的journaling实现而异。

7.2 Journaling 如何工作?(How Does Journaling Work?)

journaling 的基本前提是,一个事务中使用的所有修改过的块都锁定在内存中,直到事务完成。一旦事务完成,事务的内容就会写入日志,并且修改过的块被解锁。当所有缓存的块最终被刷新到磁盘上各自的位置时,该事务才被认为是完成的。在内存中缓冲事务并首先将数据写入日志可以防止部分更新的发生。

journaling 的关键在于它在允许将数据写入磁盘上的正常位置之前,先将事务的内容写入磁盘上的日志区域。也就是说,一旦一个事务成功写入日志,组成该事务的块就会从缓存中解锁。然后,缓存的块允许在未来的某个时间点(即缓存方便时将它们刷新到磁盘)写入其常规位置。当缓存将事务的最后一个块刷新到磁盘时,日志会被更新以反映该事务已完成。

journaling 背后的“魔法”在于,事务期间修改的磁盘块直到整个事务成功写入日志后才会被写入。通过在事务完成之前将其缓冲在内存中,journaling 避免了部分写入的事务。如果系统在成功写入日志条目之前崩溃,则该条目不被视为有效,并且该事务从未发生。如果系统在写入日志条目后崩溃,当它重新启动时,它会检查日志并重放未完成的事务。这种重放事务的概念是 journaling 一致性保证的关键。

当一个 journaling 系统重放一个事务时,它会有效地重新执行该事务。如果日志存储了构成事务一部分的修改过的磁盘块,那么重放一个事务就仅仅是将其这些磁盘块写入磁盘上正确的位置。如果日志存储了事务的高层表示,那么重放日志涉及重新执行这些操作(例如,创建文件)。当系统完成重放日志后,journaling 系统会更新日志,以便将其标记为干净。如果在重放日志期间系统崩溃,也不会造成损害,系统下次启动时日志将再次被重放。重放事务使系统恢复到已知的一致状态,并且必须在执行对文件系统的任何其他访问之前完成。

如果我们按照创建文件所涉及事件的时间线(如图 7-1 所示),我们可以看到 journaling 如何保证一致性。对于这个例子,我们将假设创建文件只需要修改两个块:一个用于分配 i-node,一个用于将新文件名添加到目录中。

figure7-1

如果系统在时间 A 崩溃,系统仍然是一致的,因为文件系统尚未被修改(日志中没有任何内容写入,也没有块被修改)。如果在时间 C 之前的任何时候系统崩溃,事务都没有完成,因此日志认为该事务没有发生。尽管在时间 C 之前的任何时候发生崩溃,文件系统仍然一致,因为原始块没有被修改。如果系统在时间 C 和 D 之间崩溃(在写入日志条目时),日志条目只完成了一部分。这不影响系统的一致性,因为日志在检查日志时总是忽略部分完成的事务。此外,没有其他块被修改,所以就像事务从未发生过一样。

如果系统在时间 D 崩溃,日志条目是完整的。在时间 D 或之后崩溃的情况下,当系统重新启动时,它将重放日志,更新磁盘上相应的块,并且文件将被成功创建。在时间 E 或 F 崩溃与在时间 D 崩溃类似。就像之前一样,文件系统将重放日志并将日志中的块写入磁盘上正确的位置。即使在时间 D 和 E 之间可能已经更新了一些实际的磁盘块,也不会造成损害,因为日志包含的值与这些块相同。

在时间 F 之后崩溃与我们的事务无关,因为所有磁盘块都已更新,并且日志条目标记为完成。在时间 F 之后崩溃甚至不会知道文件被创建了,因为日志已经被更新以反映事务已完成。

7.3 Journaling 的类型 (Types of Journaling)

在文件系统中,主要有两种形式的 journaling。第一种风格称为旧值/新值日志记录(old-value/new-value logging),它记录事务一部分的旧值和新值。例如,如果一个文件被重命名,旧名称和新名称都会被记录到日志中。记录这两个值使得文件系统能够中止一个更改并恢复数据结构的旧状态。旧值/新值日志记录的缺点是必须写入日志的数据量增加了一倍。能够回滚一个事务非常有用,但旧值/新值日志记录的实现要困难得多,而且由于写入日志的数据更多而速度较慢。

为了实现旧值/新值日志记录,文件系统必须在修改磁盘块之前记录任何磁盘块的状态。这可能会使 B+tree 等算法复杂化,因为它们在修改其中一个节点之前可能需要检查许多节点。旧值/新值日志记录要求对代码的最低层进行修改,以确保它们正确地存储它们修改的任何块的未修改状态。

仅新值日志记录(new-value-only logging)是另一种 journaling 风格。仅新值日志记录只记录对磁盘块所做的修改,而不记录原始值。在文件系统中支持仅新值日志记录相对简单,因为代码执行正常块写入的任何地方都简单地变成了对日志的写入。仅新值日志记录的一个缺点是它不允许中止事务。无法中止事务使得错误恢复变得复杂,但这种权衡是值得的。仅新值日志记录写入的数据量是旧值/新值日志记录的一半,因此速度更快,并且需要更少的内存来缓冲更改。

7.4 什么会被日志记录?(What Is Journaled?)

关于 journaling 的主要困惑之一是日志中到底包含什么。日志只包含对文件系统元数据(metadata)的修改。也就是说,日志包含对目录、位图(bitmap)、i-node 的更改,以及在 BFS 中,包含对索引的更改。日志不包含对存储在文件(或 BFS 中的属性)中的用户数据的修改。这意味着,如果文本编辑器保存一个新文件,新文件的内容不会在日志中,但新的目录条目、i-node 和修改过的位图块会存储在日志条目中。这是关于 journaling 的一个重要点。journaling 不仅不在日志中存储用户数据,而且也不能这样做。如果日志也要记录用户数据,可以写入日志的数据量将是无界的。由于日志是固定大小的,事务永远不能大于日志的大小。如果用户写入的数据量大于日志的大小,文件系统就会卡住,并且没有地方存放所有的用户数据。用户程序可以写入比固定大小日志所能存储的数据量更多的数据,因此用户数据不会写入日志。

Journaling 只保证文件系统数据结构的完整性。Journaling 不保证用户数据始终完全是最新的,也不保证文件系统数据结构相对于崩溃时的时间点是最新的。如果一个日志文件系统在向一个新文件写入数据时崩溃,当系统重新启动时,文件数据可能不正确,而且文件甚至可能不存在。文件系统有多新取决于文件系统和日志缓冲区的数据量。

journaling 的一个重要方面是,虽然文件系统可能是一致的,但系统不必也是最新的。在日志系统中,一个事务要么完全发生,要么完全不发生。这可能意味着即使在崩溃前(从程序的角度来看)成功创建的文件,在重新启动后也可能不存在。

很自然会问,为什么 journaling 不能同时保证文件系统是最新的?如果 journaling 最多只缓冲一个事务,它可以提供这个保证。通过一次只缓冲一个事务,如果发生崩溃,只有崩溃时正在进行的最后一个事务会被撤销。但只缓冲一个事务会增加写入日志的磁盘次数,这会显著降低文件系统的速度。只缓冲一个事务引入的速度下降是如此显著,以至于大多数文件系统宁愿提供更高的吞吐量,而不是更好的时间点一致性保证。文件系统所属的其余系统的对一致性的需求决定了 journaling 代码应该进行多少缓冲或进行多少缓冲。

7.5 超越 Journaling (Beyond Journaling)

Berkeley 日志结构文件系统(Log Structured File System, LFS)通过将整个磁盘视为日志区域并将所有内容(包括用户数据)写入日志来扩展 journaling 的概念。在 LFS 中,文件从未被删除,它们只是被重写。LFS 通过查找已被后续事务取代的事务来回收日志空间。

LFS 将其日志事务以大的连续块写入,这是写入磁盘最快的方式。不幸的是,当磁盘几乎满时(磁盘的稳定状态),LFS 可能不得不搜索大量的日志条目才能找到一个空闲区域。这种搜索的开销可能会抵消大块写入带来的好处。回收日志空间的任务可能非常耗时,并且需要锁定文件系统。LFS 假设回收日志空间是那种可以在深夜运行的任务。这个假设对于持续运行的 Unix 式系统来说很好,但对于桌面环境来说效果不佳,因为桌面环境可能不总是运行。

有趣的是,由于 LFS 从不覆盖文件,它有潜力隐含地对所有文件进行版本控制。由于 LFS 不会就地重写文件,因此可以提供钩子来定位文件的先前版本并检索它。这种功能也适用于恢复已删除的文件甚至撤销文件保存。然而,当前版本的 LFS 尚未实现此功能。日志结构文件系统仍然是一个研究领域。尽管 LFS 随 BSD 4.4 发布,但由于磁盘满时回收空间相关的缺点,它通常不用于商业系统。LFS 的详细信息超出了本书的范围(有关日志结构文件系统的更多信息,请参阅 Mendel Rosenblum 撰写的论文)。

7.6 代价是什么?(What’s the Cost?)

Journaling 为文件系统提供了两个显著的优点:保证元数据的一致性(除非发生硬件故障)以及在发生故障时的快速恢复。journaling 最明显的代价是元数据必须写入两次(一次到日志,一次到其常规位置)。令人惊讶的是,写入两次数据并不会影响性能——在某些情况下甚至可以提高性能!

写入两倍的文件系统元数据怎么可能提高性能呢?答案很简单:数据的第一次写入是到日志区域,并且与其他元数据一起进行批量处理,形成一次大的连续写入(即速度很快)。当数据随后从缓存刷新时,缓存管理器可以按磁盘地址对块列表进行排序,这最大限度地减少了写入块时的寻道时间。对块进行排序带来的差异是显著的。最终的证明体现在性能数据上。对于各种文件系统元数据密集型基准测试(例如,创建和删除文件),日志文件系统可能比传统的同步写入文件系统(例如 Solaris 中使用的 Berkeley Fast File System)快几倍。我们将在第 9 章中介绍更多关于性能的细节。

日志文件系统面临的最大瓶颈是所有事务都写入一个单一的日志。使用单一日志,所有事务在进行修改之前都必须锁定对日志的访问。单一日志有效地将文件系统 Updates 强制进入单线程模型。如果需要支持大量的并发文件系统修改,这是一个严重的缺点。

解决这个问题的显而易见的方案是支持多个日志文件。具有多个日志文件的系统将允许独立写入每个日志,从而允许事务并行发生。多个日志需要对事务进行时间戳标记,以便日志回放可以正确地按顺序处理不同日志中的事务。多个日志还需要重新审视文件系统中使用的锁定方案。

允许更多并发访问日志的另一种技术是让每个事务预留固定数量的块,然后独立于其他事务管理该空间。这也带来了许多锁定和排序问题。例如,后一个事务可能比前一个事务花费更少的时间完成,因此刷新该事务可能需要等待前一个事务完成。SGI 的 XFS 使用了这种技术的一种变体,尽管他们在论文中没有详细描述。

当前版本的 BFS 没有实现这两种提高日志并发访问的技术。BFS 的主要用途可能不是在面向事务的环境中,而且到目前为止,现有的性能已被证明是足够的。

7.7 BFS Journaling 实现 (The BFS Journaling Implementation)

BFS 的 journaling 实现相当简单。文件系统其余部分使用的 journaling API 由三个函数组成。实现 journaling 和日志回放(即崩溃恢复)的代码不足 1000 行。journaling 的价值远远超过其实现的成本。

用于写入日志条目的日志区域是在文件系统初始化时分配的一个固定区域。超级块维护对日志区域的引用,以及指向日志活动区域开始和结束的两个移动索引。日志区域以循环方式使用,开始和结束索引仅标记包含活动事务的日志边界。在图 7-2 中,我们看到有三个事务已结束但尚未完成。当缓存将日志条目 1 的最后一个块刷新到磁盘时,日志开始索引将向前移动,指向日志条目 2 的开头。如果一个新的事务完成,它将被添加到日志条目 3 后面的区域(如果需要,会回绕到日志区域的开头),并且当事务结束时,日志结束索引将增加,指向事务结束之后的位置。如果系统在图 7-2 所示的状态下崩溃,则会重放三个日志条目,这将使文件系统进入一致状态。

figure7-2

BFS journaling API 包含三个函数。第一个函数用于创建一个表示事务的结构:

struct log_handle *start_transaction(bfs_info *bfs);

该函数的输入仅是一个指向表示文件系统的内部结构的指针。这个指针总是传递给所有文件系统例程,因此总是可用。返回的句柄名义上是一种不透明的数据类型,调用代码无需检查。该句柄表示当前事务并包含状态信息。

start_transaction() 的第一个任务是获取对日志的独占访问权限。一旦 start_transaction() 获取了日志信号量,它将一直持有,直到事务完成。start_transaction() 执行的最重要的任务是确保日志中有足够的空间来容纳此事务。事务大小可变,但必须小于最大大小。固定事务的最大大小是必要的,以保证任何新事务都有足够的空间完成。也可以在调用 start_transaction() 时传入代码所需的空间量。

检查日志以查看是否有足够的空间很容易。对超级块中维护的开始和结束索引(可从 bfs_info 结构访问)进行简单的算术运算即可揭示可用空间量。如果日志中有足够的空间,则会分配必要的事务结构和一个缓冲区来容纳事务,并将句柄返回给调用代码。

如果日志中没有足够的空间,调用者就无法继续,直到有足够的空间来容纳新事务。释放日志空间的第一个技巧是强制将块从缓存中刷新出去,最好是属于先前事务的块。通过强制将块刷新到磁盘,先前的日志事务可以完成,从而释放日志空间(稍后我们将更详细地了解其工作原理)。这可能仍然不足以释放日志中的空间。正如我们稍后也将讨论的,BFS 将多个事务分组并批量处理成一个事务。出于这个原因,可能需要释放日志信号量,强制进行日志刷新,然后重新获取日志信号量。这是一种非常罕见的情况,并且只有在当前缓冲的日志事务的大小几乎与整个日志区域一样大时才会发生。

写入日志 (Writing to the Log)

一旦 start_transaction() 完成,调用代码就可以开始对文件系统进行修改。每次代码修改磁盘上的数据结构时,它都必须调用以下函数:

ssize_t log_write_blocks(bfs_info *bfs, struct log_handle *lh,
off_t block_number, const void *data,
int number_of_blocks);

log_write_blocks() 例程将修改后的数据提交到日志,并将数据锁定在缓存中。log_write_blocks() 进行的一项优化是,如果同一块在同一个事务中被修改多次,则只缓冲一份数据。这很有效,因为事务是全有或全无的——整个事务要么成功,要么不成功。

在事务期间,任何修改文件系统元数据块的代码都必须对修改后的数据调用 log_write_blocks()。如果未能严格遵守这一点,则在发生崩溃时文件系统将无法保持一致。

log_write_blocks() 维护了几个数据结构。这些数据结构维护与当前事务相关的所有状态信息。由 log_write_blocks() 管理的三个结构是:log handle,它指向 entry list;entry list 又有一个指向 log entry 的指针;log entry 存储事务的数据。

它们之间的关系如图 7-3 所示。

figure7-3

log handle 结构管理事务的整体信息。该结构包含:

  • 事务中的总块数
  • entry list 结构的数量
  • 一个描述该事务使用日志区域哪一部分的块运行(block run)
  • 已刷新块的计数

描述日志区域的块运行和已刷新块的计数仅在事务完成后才维护。

在内存中,一个事务仅仅是包含修改过的块的缓冲区列表。BFS 通过 entry list 和 log entry 结构来管理这一点。entry list 记录了日志条目中使用的块数,指向 log entry 的指针,以及指向下一个 entry list 的指针。每个 log entry 实际上只是一个内存块,可以容纳一定数量的磁盘块(在 BFS 中是 128 个)。log entry 预留第一个块来跟踪属于事务的数据块的块号。包含其余块块号的第一个块被作为事务的一部分写入。块列表对于在发生故障时能够回放日志至关重要。没有块列表,文件系统将不知道每个块在磁盘上属于哪里。

在磁盘上,事务的结构如图 7-4 所示。事务的磁盘布局反映了其在内存中的表示形式。

figure7-4

一个事务使用多个 entry list 结构的情况很少见,但也有可能发生,特别是在批量事务(本节稍后讨论)中。事务的最大大小是一个难以计算的量,因为它不仅取决于具体的操作,还取决于被操作的对象。BFS 中事务的最大大小等于日志区域的大小(默认为 2048 块)。单个操作有可能需要比日志区域中更多的块,但幸运的是,这种情况足够病态,我们可以预期它们只会发生在测试中,而不会在现实世界中出现。在测试期间出现的一个案例是删除一个拥有略多于三百万个属性的文件。在这种情况下,删除所有相关属性导致文件系统修改的块数超过了日志区域中块的最大数量(2048)。这种极端情况非常罕见,BFS 不会对此太过担心。

可以设想,BFS 可以改进对这种情况的处理。

事务的结束 (The End of a Transaction)

当文件系统操作完成修改并且更新完成时,它会调用:

int end_transaction(bfs_info *bfs, struct log_handle *lh);

此函数完成一个事务。在调用 end_transaction() 后,文件系统操作除非开始一个新的事务,否则不能再对磁盘进行修改。

刷新日志事务的第一步是将内存中的事务缓冲区写入磁盘的日志区域。必须小心处理,因为日志区域是一个循环缓冲区。如果当前开始索引靠近日志区域的末尾而结束索引靠近开头,则写入日志条目到磁盘必须处理回绕(wraparound)情况。

为了跟踪日志区域哪些部分正在使用中,文件系统跟踪日志的开始和结束索引。在一个新的文件系统上,开始和结束索引都指向日志区域的开始,并且整个日志都是空的。当一个事务被刷新到磁盘时,结束索引会按事务的大小递增。

刷新日志缓冲区后,end_transaction() 遍历日志缓冲区中的每个块,并为缓存中的每个块设置一个回调函数。缓存将在块刷新到磁盘上的常规位置后立即调用回调函数。回调函数是日志用来了解事务的所有块何时已写入磁盘的连接。回调例程使用 log handle 结构来跟踪已刷新块的数量。当最后一个块被刷新时,事务被认为是完成的。

当一个事务被认为是完成的,其占用的日志空间可能会被回收。如果在此事务之前日志中没有其他未完成的事务,则只需将日志开始索引按事务的大小向上移动即可。出现的一个困难是日志事务可能乱序完成。如果后一个事务在先一个事务之前完成,日志代码就不能简单地将日志开始索引向上移动。在这种情况下,日志完成代码必须跟踪哪些日志事务已完成,哪些仍在未完成状态。当跨越当前开始索引范围的所有事务都完成后,开始索引才能跨越该范围递增。

如前所述,BFS 并不是每次事务完成时都写入日志条目。为了提高性能,BFS 将多个事务分组,并一次性刷新整个组。出于这个原因,end_transaction() 不一定会将事务刷新到磁盘。在大多数情况下,end_transaction() 记录事务缓冲区已使用的空间量,释放日志信号量,然后返回。如果日志缓冲区几乎满了,则 end_transaction() 会将日志刷新到磁盘。

批量处理事务 (Batching Transactions)

让我们后退一步,思考一下在同一个缓冲区中缓冲多个事务的含义。事实证明,这能显著提高性能。为了更好地理解这一点,查看一个例子很有用,例如从存档中提取文件。提取文件会在一个目录中创建许多文件。如果我们把每次文件创建都作为一个单独的事务,构成目录的数据块就会多次写入磁盘。多次写入同一位置会损害性能,但不如不可避免的磁盘寻道造成的损害大。将多个文件创建批量处理成一个事务可以最大限度地减少目录数据的写入次数。此外,如果可能的话,i-node 可能会顺序分配,这反过来意味着当它们从缓存中刷新时,它们将被强制以一次写入的方式输出(因为它们是连续的)。将多个事务批量处理成一个事务的技术通常被称为“组提交”(group commit)。组提交可以为日志文件系统提供显著的速度优势,因为它将写入磁盘的开销分摊到多个事务上。由于系统是日志化的,这实际上允许一些事务完全在内存中完成(类似于 Linux ext2 文件系统),同时仍然保持文件系统的一致性保证。

调整日志缓冲区的大小和磁盘上日志区域的大小直接影响可以在内存中保留多少事务以及在发生崩溃时会丢失多少事务。在退化情况下,日志缓冲区只能容纳一个事务,并且日志区域只足够容纳一个事务。另一方面,日志缓冲区可以容纳内存中的所有事务,并且没有任何内容会写入磁盘。现实介于两者之间:日志缓冲区的大小取决于系统的内存限制,而日志的大小取决于可以专用于日志的磁盘空间量。

7.8 什么是事务?——更深入的探讨 (What Are Transactions?—A Deeper Look)

BFS 视为单一原子事务的操作包括:

  • 创建文件/目录
  • 删除文件/目录
  • 重命名文件(包括删除现有同名文件)
  • 更改文件大小(增长或缩小)
  • 向属性写入数据
  • 删除属性
  • 创建索引
  • 删除索引
  • 更新文件的属性

这些操作中的每一个通常都对应于用户级的系统调用。例如,write() 系统调用向文件写入数据。这其中隐含着文件将增长以容纳新数据。将文件增长到特定大小是一个原子操作——即一个事务。其他操作都必须定义事务的开始和结束边界——什么包含在事务中,什么不包含。

创建文件/目录 (Create File/Directory)

在 BFS 中,创建文件或目录涉及修改位图(以分配 i-node),将文件名添加到目录,并将名称插入到名称索引中。创建目录时,文件系统还必须写入目录的初始内容。所有这些子操作修改的块都将被视为创建文件或创建目录事务的一部分。

删除 (Delete)

删除文件比创建文件复杂得多。文件名首先从目录和主要文件系统索引(名称、大小、最后修改时间)中移除。这被视为一个事务。当对文件的所有访问都完成后,文件数据和属性会在另一个单独的事务中被移除。移除属于文件的数据涉及遍历分配给文件的所有块并在位图中释放它们。移除附加到文件的属性类似于删除目录中的所有文件——每个属性都必须像常规文件一样被删除。

一个删除事务潜在地会触及许多块。

重命名 (Rename)

重命名操作是文件系统支持的最复杂的操作。重命名操作的语义是,如果新名称已存在文件,则先删除它,然后重命名旧文件。因此,重命名可能触及的块数与删除操作一样多,此外还需要触及从目录(和索引)中删除旧文件名以及在目录(和索引)中重新插入新名称所需的所有块。

更改文件大小 (Change a File Size)

与重命名相比,更改文件大小是一个微不足道的操作。调整文件大小涉及修改文件的 i-node、写入新数据块地址的任何间接块以及分配发生的位图块。涉及双重间接块的大量分配可能作为事务的一部分触及许多块。通过了解 BFS 的分配策略,很容易计算出在文件创建中可能触及的块数。首先,间接块和双重间接块运行的默认分配大小是 4K。也就是说,间接块是 4K,双重间接块也是 4K,并指向 512 个间接块运行(每个 4K)。知道这些数字后,通过增长文件可能触及的最大块数如下:

  • i-node 1 个块
  • 间接块 4 个块
  • 第一级双重间接块 4 个块
  • 第二级双重间接块 512 × 4 个块

总计 1 + 4 + 4 + 512 × 4 = 2057 个块

这种情况可能发生在程序创建一个文件,寻找到 9 GB 的文件位置,然后写入一个字节。或者,在完全碎片化的文件系统上(即每隔一个磁盘块被分配),这会在一个 1 GB 的文件上发生。这两种情况都极不可能发生。

其余操作 (The Rest)

其余操作都可以分解为上述操作之一。例如,创建索引相当于在索引目录中创建一个目录。向文件添加属性相当于在附加到文件的属性目录中创建一个文件。由于其他操作在性质上与前面的基本操作等同,我们将不再进一步考虑它们。

7.9 总结 (Summary)

Journaling 是一种借鉴数据库社区的技术,并应用于文件系统。日志文件系统通过收集操作期间进行的修改,并将这些修改批量处理成一个单一的事务,然后文件系统将其记录在日志中,从而防止其数据结构损坏。Journaling 可以防止文件系统数据结构的损坏,但不能保护写入常规文件的数据。journaling 技术还可以提高文件系统的性能,允许它向磁盘写入大块连续数据,而不是同步写入许多单独的块。