1本章要解决什么

如何在崩溃的情况下更新磁盘?系统可能在任意两次写之间崩溃或断电,磁盘上的状态因此只被更新了一部分。 崩溃后系统重启、想再次挂载文件系统。既然崩溃可以发生在任意时刻,我们怎样保证磁盘上的文件系统镜像始终处于合理的状态?

THE CRUX: HOW TO UPDATE THE DISK DESPITE CRASHES — The system may crash or lose power between any two writes, and thus the on-disk state may only partially get updated. … how do we ensure the file system keeps the on-disk image in a reasonable state?

前两章(第 40、41 章)里,文件系统的一次操作(比如给文件追加一块)要修改好几个磁盘结构:inode、位图、数据块…… 可磁盘一次只处理一个请求,这些写必然有先有后。如果恰好在两次写之间断电,磁盘上就会留下"改了一半"的结构——这就是崩溃一致性问题(crash-consistency problem),也叫一致性更新问题(consistent-update problem)。

本章介绍三条路:老派的文件系统检查程序 fsck(先让它乱,重启时再全盘修)、现代主流的日志(journaling,即预写日志 write-ahead logging)(先在别处记下"我要做什么",再动手), 以及软更新、写时复制、反向指针等其他方案。下一章的日志结构文件系统(LFS)把"写时复制"推到极致;第 45 章的校验和则会再次出现在日志里。

2核心概念速览

崩溃一致性问题

一次逻辑更新要写多个块,崩溃可能发生在任意两次写之间,留下部分更新的磁盘镜像。

fsck(file system checker)

崩溃后、挂载前扫描整个磁盘,让元数据重新自洽。能用但太慢,且修不了"指向垃圾"。

日志 / 预写日志(journaling / WAL)

覆盖原位置之前,先把要写的内容作为一个事务写进日志;崩溃后只需看日志。

事务:TxB … TxE

TxB 记录事务号和各块的最终地址,TxE 是提交标记。TxE 落盘 = 事务提交(committed)。

检查点(checkpoint)

事务提交后,把日志里的块写到它们在文件系统中的最终位置。

重做日志(redo logging)

恢复时按顺序重放已提交但可能没检查点完的事务;没提交的直接丢弃。

有序(元数据)日志

只把元数据写进日志,数据块先写到最终位置——"先写被指向的,再写指向它的"。

写屏障(write barrier)

保证屏障前发出的写全部落盘后,屏障后的写才会落盘;日志协议靠它保证顺序。

3一个具体例子:给文件追加一块

工作负载很简单:打开一个已有文件,lseek() 到末尾,写入一个 4KB 块,关闭。文件系统也很小: inode 位图 8 位、数据位图 8 位、8 个 inode(编号 0–7,占 4 个块)、8 个数据块(编号 0–7)。现在只有 2 号 inode 被分配,它的唯一一个数据块是 4 号块 Da。

上:追加之前,I[v1] 只指向块 4(Da),数据位图 00001000。下:我们希望到达的状态,I[v2] 同时指向块 4、5,数据位图 00001100,块 5 里是新数据 Db。

追加前的 inode I[v1]

owner : remzi permissions : read-write size : 1 pointer : 4 pointer : null pointer : null pointer : null

追加后的 inode I[v2]

owner : remzi permissions : read-write size : 2 pointer : 4 pointer : 5 ← 新增 pointer : null pointer : null

于是内存里有三个脏块要写回磁盘:新 inode I[v2]、新数据位图 B[v2](00001100)、新数据块 Db。 它们通常不会在 write() 时立即写盘,而是先在页缓存(page cache / buffer cache)里待上 5 秒或 30 秒,之后文件系统才发出三个写请求。 问题是:三个写之间的任何时刻都可能断电。

4六种崩溃场景

三个写里"只成功了一个"有 3 种情况,"成功了两个"又有 3 种。原书逐一分析了它们:

落盘的写后果元数据一致?
只有 Db数据在盘上,但没有 inode 指向它、位图也没标记——就像这次写从没发生。对文件系统来说不是问题(用户丢了数据)。一致
只有 I[v2]inode 指向块 5,但 Db 还没写到那里:相信这个指针就会读到垃圾(块 5 的旧内容)。而且位图说块 5 空闲、inode 说它在用——不一致。不一致
只有 B[v2]位图说块 5 已分配,却没有 inode 指向它——不一致;不修的话块 5 永远不会再被使用,即空间泄漏(space leak)。不一致
I[v2] + B[v2]从元数据角度看完全一致:inode 指向 5,位图也说 5 在用。但块 5 里又是垃圾。一致(但指向垃圾)
I[v2] + Dbinode 指向正确的数据,但旧位图 B[v1] 说块 5 空闲——不一致,必须先解决才能使用文件系统。不一致
B[v2] + Db位图说块 5 在用、块 5 也写好了,但没有 inode 指向它——不一致,而且我们根本不知道这个块属于哪个文件。不一致

我们真正想要的是原子地(atomically)把文件系统从一个一致状态(追加前)移动到另一个一致状态(追加后)。但磁盘一次只提交一个写,崩溃可能发生在任意两次写之间,所以做不到"天然原子"。下面的模拟器让你亲手制造每一种崩溃。

模拟器

三个写,在哪儿断电?

选择三个写的发出顺序,用单步按钮决定"断电前落盘了几个写"。观察磁盘镜像、后果和 fsck 能修成什么样。上面一排按钮直接载入原书的 6 种场景。

把写入顺序换成"Db 先写"的两种顺序(Db → I → B、Db → B → I),逐个崩溃点看一遍:你会发现任何崩溃点都不会让 inode 指向垃圾。 "先写被指向的对象,再写指向它的对象"这条规则,就是后面有序日志和软更新(Soft Updates)的核心。

5方案一:文件系统检查程序 fsck

早期文件系统的做法很"懒":允许不一致发生,重启时再修。典型代表是 UNIX 工具 fsck(读作 "eff-ess-see-kay"、"eff-ess-check"……)。 它在文件系统挂载之前运行(假设此时没有其他文件系统活动),完成后磁盘上的元数据应当重新自洽。注意它的目标只是元数据内部一致: 像"inode 和位图都更新了、但数据块是垃圾"这种情况,它根本看不出来。

按 McKusick 和 Kowalski 的论文,fsck 分几个阶段:

检查项做什么出了问题怎么办
超级块(Superblock)合理性检查,如文件系统大小要大于已分配的块数超级块可疑时改用备份副本
空闲块(Free blocks)扫描所有 inode、间接块、二级间接块……得出哪些块真正在用,重建分配位图;inode 位图同理以 inode 为准修正位图
inode 状态(Inode state)每个已分配 inode 的类型字段是否合法(普通文件、目录、符号链接……)修不好的可疑 inode 被清除,inode 位图同步更新
inode 链接(Inode links)从根目录遍历整棵目录树,自己数出每个文件/目录的链接数,与 inode 里的链接计数比较改正 inode 里的计数;已分配但没有任何目录引用的 inode 移入 lost+found
重复(Duplicates)两个不同 inode 指向同一个块明显坏的 inode 可以清除;或者复制该块,各给一份
坏块(Bad blocks)指针明显越界(比如地址大于分区大小)没什么聪明办法,直接清除该指针
目录检查(Directory checks)目录的格式由文件系统自己定义,可以检查:"." 和 ".." 是否是前两项;每个目录项引用的 inode 是否已分配;没有目录在整个层次中被链接超过一次修正或删除坏的目录项

fsck 的根本问题是太慢:磁盘越来越大、RAID 越来越普及,扫描整个卷找出所有已分配块、读完整棵目录树可能要几分钟甚至几小时。 而且前提本身就有点不讲理——只为了三个块的更新,就把整块盘扫一遍。原书的比喻:钥匙掉在卧室地板上,你却从地下室开始把整栋房子搜一遍。能找到,但很浪费。

模拟器

迷你 fsck(仿 fsck.py)

随机生成一个小文件系统(16 个 inode、16 个数据块),再注入一种损坏。先自己找出不一致在哪、该怎么修,再点"运行 fsck"对答案。格式同 fsck.py:[d a:0 r:4] 表示目录、数据块 0、链接数 4;目录块里是 (名字, inode 号),u 表示用户数据。
估算

fsck 与日志恢复要多久?

粗略估算(参数可调):fsck 的工作量正比于整个卷的元数据量,日志恢复正比于日志大小。原书的结论:日志把恢复时间从 O(卷大小) 降到 O(日志大小)。

6方案二:日志(预写日志)

最流行的解法借自数据库领域的预写日志(write-ahead logging),文件系统里出于历史原因叫日志(journaling)。 第一个这么做的文件系统是 Cedar;今天的 Linux ext3/ext4、reiserfs、IBM JFS、SGI XFS、Windows NTFS 都用它。

基本想法:在原地覆盖之前,先在磁盘上一个众所周知的位置写一条小"笔记",描述你将要做什么。写笔记是"预写(write ahead)",笔记组织成"日志(log)"。 崩溃发生在覆盖过程中也不怕——回头看笔记,照着再做一遍即可;不必扫描整个磁盘。日志在更新时多做一点事,换来恢复时少做很多事。

ext2 Super Group 0 Group 1 … Group N ext3 Super Journal Group 0 Group 1 … Group N
ext3 的大部分磁盘结构与 ext2 相同(块组里仍是 inode 位图、数据位图、inode、数据块),唯一新增的是日志(这里放在同一分区里;也可放在另一个设备上,或作为文件系统中的一个文件)。

数据日志(data journaling)

还是那个追加:要写 I[v2]、B[v2]、Db。数据日志(ext3 的一种模式)先把它们写进日志,形成一个事务(transaction):

Journal TxBid=1, 地址 I[v2] B[v2] Db TxEid=1 物理日志(physical logging):原样记录块的内容
TxB(transaction begin)记录这次更新的信息:各块的最终地址和事务号 TID;中间三块是块内容本身;TxE(transaction end)是结束标记,也含 TID。

把块的原样内容写进日志叫物理日志(physical logging);另一种逻辑日志(logical logging)只记"本次要把数据块 Db 追加到文件 X",更紧凑、可能更快,但更复杂。 事务安全落盘后,再把 I[v2]、B[v2]、Db 写到它们的最终位置,这叫加检查点(checkpointing)。于是最初的协议是两步:① 日志写入,② 检查点。

顺序问题:能不能五块一起写?

一块一块地写、每写一块等它完成,太慢。理想情况是五个写一次性发出,变成一次顺序写。但这不安全:面对一个大写请求,磁盘内部可能调度,以任意顺序完成其中的小块。 比如磁盘先写完 TxB、I[v2]、B[v2]、TxE,之后才写 Db——如果恰好在这中间断电:

Journal TxBid=1 I[v2] B[v2] ?? TxEid=1
事务看起来完全合法:有开始、有结束,事务号匹配。文件系统没法判断第四块是不是垃圾——它本来就是任意的用户数据。恢复时会照单全收,把 ?? 拷到 Db 应在的位置。要是这块是超级块之类的关键结构,文件系统甚至可能无法挂载。

解决办法:分两步发出。先一次性发出除 TxE 以外的所有块;等它们全部完成后,再发出 TxE。 磁盘保证任何 512 字节的写要么完整发生、要么完全没发生(不会写一半),所以把 TxE 做成一个 512 字节的块,它的写入就是原子的。协议变成三步:

  1. 日志写入(Journal write):把事务内容(TxB、元数据、数据)写入日志,等待完成。
  2. 日志提交(Journal commit):写入提交块 TxE,等待完成;此时事务称为已提交(committed)。
  3. 检查点(Checkpoint):把更新的内容(元数据和数据)写到它们在磁盘上的最终位置。

过去,要保证 A 在 B 之前落盘很简单:发出 A,等磁盘中断告诉 OS 完成了,再发 B。后来磁盘有了写缓存(write buffering,也叫立即报告 immediate reporting): 写进磁盘自己的内存缓存就报告"完成",这时再发 B,就不能保证 B 落在 A 之后。办法之一是关掉写缓存;更现代的办法是发出显式的写屏障(write barrier)——屏障完成时,保证屏障前发出的写都先于屏障后的写到达磁盘。 遗憾的是,研究发现有些厂商为了"更快"而故意忽略写屏障请求。正如 Kahan 所说:快的几乎总能打败慢的,即使快的是错的。

先写 TxB 和内容、等完成、再写 TxE,通常会多付出一次磁盘旋转。Vijayan Prabhakaran 的主意:在 TxB 和 TxE 里放一个日志内容的校验和(checksum)。 这样整个事务可以一次写出、不用等;恢复时如果重新计算的校验和与记录的不符,就知道写事务时发生了崩溃,丢弃这次更新。 常见情况更快,而且从日志读出的内容都受校验和保护,还更可靠。这个改进被 Linux ext4 采用,随 Android 等平台跑在数以百万计的机器上。(校验和本身见第 45 章。)

恢复(Recovery)

崩溃可能发生在这串写的任意时刻:

批量更新日志(Batching log updates)

在同一目录里连续创建 file1、file2:每次创建都要改 inode 位图、新文件的 inode、父目录的数据块(多了一个目录项)、父目录的 inode(修改时间变了)。 如果每次创建各自提交一个事务,同样这几个块会被反复写进日志。ext3 等文件系统的做法是把所有更新缓冲进一个全局事务(global transaction): 在内存里把这些块标脏、加进当前事务的块列表,等到该写盘时(比如 5 秒超时)一次提交。这样就避免了大量多余的写流量。

让日志有限:循环日志

日志空间有限。一直往里追加事务会出两个问题:日志越长,恢复要重放的东西越多;更严重的是日志满了以后,再也提交不了新事务,文件系统就没法用了。 所以日志被当作循环(circular)数据结构反复使用:事务检查点完成后,释放它在日志里占的空间。一种办法是在日志超级块(journal superblock)(不要和文件系统超级块混淆)里记录最老和最新的尚未检查点的事务,其余空间都是空闲的。

模拟器

循环日志与日志超级块

日志区有 8 个槽(每个事务假设占 1 槽)。提交事务、加检查点、释放,观察日志超级块记录的范围;日志满时新事务只能等待。随时"崩溃并恢复",看恢复要重放哪些事务。
点"提交新事务"开始。

加上释放这一步,数据日志的最终协议是四步:

  1. Journal write:把事务内容(TxB 和更新内容)写进日志,等待完成。
  2. Journal commit:写提交块 TxE,等待完成;事务提交。
  3. Checkpoint:把更新内容写到文件系统中的最终位置。
  4. Free:之后某个时刻,更新日志超级块,把该事务标记为空闲。

7元数据日志(有序日志)

数据日志恢复很快,但平时太慢:每个数据块都要写两遍(日志一遍、最终位置一遍),顺序写负载只能跑到磁盘峰值带宽的一半;日志和主文件系统之间还要来回寻道。 更简单、更常见的是有序日志(ordered journaling),也叫元数据日志(metadata journaling):用户数据不进日志,日志里只有

Journal TxB I[v2] B[v2] TxE
Db 不再写进日志,而是只写一次、直接写到文件系统中的最终位置。磁盘流量大部分是数据,所以这大大减轻了日志的负担。

那 Db 什么时候写?顺序很关键。如果在事务(I[v2]、B[v2])提交之后才写 Db,而 Db 没来得及落盘就崩溃:恢复程序重放 I[v2] 和 B[v2], 元数据是一致的,但 I[v2] 指向垃圾——Db 不在日志里,谁也救不回来。所以 ext3 等系统先写数据块,再写相关元数据:

  1. Data write:把数据写到最终位置,等待完成(等待是可选的,见下)。
  2. Journal metadata write:把 TxB 和元数据写进日志,等待完成。
  3. Journal commit:写提交块 TxE,等待完成;事务(包括数据)提交。
  4. Checkpoint metadata:把元数据写到最终位置。
  5. Free:之后在日志超级块里标记事务空闲。

强制先写数据,就能保证指针永远不会指向垃圾。"先写被指向的对象,再写指向它的对象"是崩溃一致性的核心规则。 另外,第 1 步不必在第 2 步之前完成:数据、TxB、日志元数据可以同时发出,唯一的要求是第 1、2 步都在第 3 步(发出 TxE)之前完成。

实践中元数据日志比完整的数据日志更流行:Windows NTFS、SGI XFS 都用某种元数据日志;Linux ext3 可选 data、ordered、unordered(数据任何时候都可以写)三种模式。 这几种模式都保证元数据一致,区别只在用户数据的语义。

时间线(原书 Figure 42.1 / 42.2)

时间向下增长,每一行是某个写可以被发出(issue)或可能完成(complete)的逻辑时刻;虚线是必须遵守写顺序的地方。同一段里的写可以任意顺序完成。

TxB内容
(元数据)
内容
(数据)
TxEFS 元数据FS 数据
1issueissueissue
2complete
3complete
4complete
┄┄ 以上全部完成 ┄┄
5issue
6complete
┄┄ 已提交 ┄┄
7issueissue
8complete
9complete
Figure 42.1 数据日志时间线
TxB内容
(元数据)
TxEFS 元数据FS 数据
1issueissueissue
2complete
3complete
4complete
┄┄ 以上全部完成(包括数据)┄┄
5issue
6complete
┄┄ 已提交 ┄┄
7issue
8complete
Figure 42.2 元数据日志时间线:数据写可与 TxB、日志内容同时发出,但必须在 TxE 发出之前完成

注意:图中每个写的完成时刻是任意画的,真实系统里由 I/O 子系统决定,它可能为了性能重排写。我们唯一能依赖的顺序保证,就是协议正确性所要求的那些(虚线)。

8日志模拟器:随时拔电源

下面把两张时间线变成可以"拔电源"的模拟器。每一步是一次写的发出或完成(落盘);同一批发出的写,磁盘可以按任意顺序完成(改"完成顺序种子"就会重排,种子 0 是原书时间线上的顺序)。 按下"⚡ 拔电源"后,已发出但没完成的写全部丢失,恢复程序扫描日志、决定重放还是丢弃,然后判断文件系统落在什么状态。拔电源保持按下时继续单步,可以逐个检查每个崩溃点。

模拟器

日志协议时间线 · 断电 · 恢复(replay)

对比六种写法:无日志、数据日志、有序元数据日志、错误的"提交后再写数据"、不等屏障一次写出、带事务校验和一次写出。关注:哪个崩溃点会得到"不一致"或"指向垃圾"?

9棘手情况:块复用与撤销记录

ext3 的主要作者 Stephen Tweedie 说过:"整个系统里最可怕的部分是什么?……是删除文件。与删除有关的一切都很麻烦……你会做噩梦,梦见块被删除后又被重新分配。"

他举的例子(使用元数据日志,数据块不进日志):目录 foo 的数据在块 1000。用户在 foo 里创建了一个文件,由于目录内容算元数据,foo 的目录块被写进了日志。 接着用户删除 foo 里的一切和 foo 本身,块 1000 被释放;然后创建新文件 bar,恰好复用了块 1000。bar 的 inode 进了日志,但 bar 的数据是普通用户数据,不进日志,直接写到块 1000。 此时崩溃,日志里这些事务都还在:重放时会把旧的 foo 目录内容写到块 1000——bar 的用户数据被旧目录内容覆盖了!

模拟器

块复用 + 撤销记录(revoke record)

单步走一遍 Tweedie 的例子;打开"撤销记录"再走一遍,对比恢复结果。

解决办法有好几种。比如:在块的删除被检查点移出日志之前,干脆不复用这些块。Linux ext3 的做法是在日志里加一种新记录——撤销记录(revoke record): 删除目录时往日志写一条撤销记录;重放时先扫描所有撤销记录,被撤销的数据永远不会被重放。

10方案三:其他方法

软更新(Soft Updates)

Ganger 和 Patt 提出:仔细排序所有写,使磁盘结构永远不会处于不一致状态(例如先写数据块,再写指向它的 inode)。难点是需要了解每一种文件系统数据结构,复杂度很高;而日志层几乎不需要知道这些。

写时复制(Copy-on-Write, COW)

如 Sun ZFS:从不原地覆盖文件或目录,而是把新版本写到未使用的位置;一批更新完成后,翻转文件系统的根结构去指向新结构。下一章的 LFS 是 COW 的早期例子。

基于反向指针的一致性(BBC)

写之间不强制任何顺序。每个块都带一个反向指针(如数据块记录它属于哪个 inode)。访问文件时检查正向指针指向的块是否指回来;是则一致,否则报错。一种新的"懒"一致性。

乐观崩溃一致性(Optimistic)

尽量减少等待磁盘写完成的次数:用推广的事务校验和等技术尽可能多地同时发出写,再检测可能出现的不一致。某些负载下性能提高一个数量级,但需要略微不同的磁盘接口。

根(旧) inode(旧) Da Db(新位置) inode'(新) 根'(最后写) ① 新内容写到空闲位置,旧版本原样保留 ② 最后一次原子写翻转根 崩溃在 ② 之前 → 看到的仍是完整的旧版本
写时复制本质上也是"先写被指向者、最后写指针":只有最后那次根指针的更新需要原子,之前任何时刻崩溃,旧版本都完好无损。

11先预测再验证

在"三个写"模拟器里选择顺序 I → B → Db。预测:断电前落盘 1 个、2 个写时,fsck 分别能把磁盘修成什么样?用户最后能读到什么?
看答案
落盘 1 个(只有 I[v2]):位图与 inode 不一致,fsck 信任 inode,把位图第 5 位置 1——元数据修好了,但块 5 里仍是垃圾,用户会读到垃圾。 落盘 2 个(I[v2]+B[v2]):元数据一致,fsck 什么也发现不了,用户同样读到垃圾。fsck 只能保证元数据自洽,不能保证数据正确。
在日志模拟器里选"数据日志",打开"⚡ 拔电源",从第 1 步单步走到最后。预测:哪些崩溃点恢复后是旧状态、哪些是新状态?有没有"不一致"?再点"枚举所有崩溃点"验证。
看答案
TxE 落盘之前的所有崩溃点:恢复程序找不到匹配的 TxE(或根本没有 TxB),丢弃事务 → 旧状态。TxE 落盘之后(检查点前、中、后、释放前):重放 → 新状态。释放之后:日志超级块说事务已释放,什么也不做,文件系统本来就是新状态。 全程没有任何不一致——代价是 9 次块写(Db 写了两次)、三次等待。
切换到"五块一起发出、不等屏障",种子 0(原书的重排:TxB、I[v2]、B[v2]、TxE 先完成,Db 最后)。预测哪一个崩溃点是危险的、会得到什么结果;再把种子改成 1、2、3……还能遇到更糟的情况吗?
看答案
危险点是"J:TxE 已完成、J:Db 还没完成":事务看上去已提交,重放把日志槽里的旧垃圾拷到块 5,得到"元数据一致但指向垃圾"。 换种子后,如果 TxE 抢在 J:I[v2] 或 J:B[v2] 之前完成,重放甚至会用垃圾覆盖 inode 块或位图块——元数据本身被毁,这就是原书说的"如果恰好是超级块,文件系统可能无法挂载"。
同样一次写出,切换到"数据日志 + 事务校验和"。预测:还会有危险崩溃点吗?和"等屏障"的数据日志比,写的次数和等待次数有什么区别?
看答案
没有危险点了:只要日志内容有任何一块没写完,恢复时重新计算的校验和就和 TxB/TxE 中记录的不符,事务被丢弃。写的次数一样(仍是 9 次),但日志写入阶段少了一次等待(TxE 不必等前四块),这正是 ext4 的优化。
选"有序元数据日志",数一数块写次数;再选"Db 在提交之后才写",找出危险的崩溃点。为什么有序模式里 Db 可以和 TxB 同时发出,却必须在 TxE 之前完成?
看答案
有序模式 8 次块写(Db 只写一次)。"提交后写数据"时,TxE 已落盘但 FS 数据块 Db 未落盘的崩溃点会得到"元数据一致但指向垃圾":重放 I[v2]、B[v2],但 Db 不在日志里。 TxE 是"这个事务成立"的承诺,一旦它落盘,重放就会让 inode 指向块 5;所以块 5 的内容必须在那之前到位。至于 Db 与 TxB/日志元数据之间谁先谁后无所谓,因为在 TxE 落盘之前它们都不会被任何人"相信"。
在迷你 fsck 里分别选"inode 位图:已用 inode 被标为空闲"和"数据位图:空闲块被标为已用"。预测 fsck 会以哪一方为准?再选"目录项指向未分配的 inode"和"孤儿 inode",fsck 该怎么修?(改编自 fsck.py 的作业题)
看答案
位图与 inode 不一致时,fsck 信任 inode(inode 里的信息更丰富,位图只是它的摘要),据此重建位图。 目录项指向未分配的 inode:这个目录项是悬空的,删除它(或者也可以考虑恢复 inode,但信息不足)。孤儿 inode:已分配却没有目录引用它,fsck 把它移进 lost+found,数据得以保留,只是名字丢了。

12动手实验:在磁盘镜像上拔电源

labs/42-crash-consistency/journal.c 把本章的追加例子搬到一个 16 块(每块 512 字节)的镜像文件上,实现了四种写协议(无日志、数据日志、不等屏障的数据日志、有序日志), 并在子进程里"写满 n 个块就 _exit()"来模拟断电,然后运行恢复(扫描 TxB/TxE、重放)和一个类似 fsck 的检查。它还测量了 fsync() 的真实开销,并演示应用层原子更新文件的标准做法。

cd labs/42-crash-consistency && make
./journal crashtest          # 4 种协议 × 每个崩溃点
./journal show nobarrier 4   # 看一个崩溃点:崩溃后/恢复后的整个镜像
./journal fsyncbench         # write / fdatasync / fsync / 原子 rename 的开销

示例输出(节选,你的机器上 fsync 的数字会不同):

== 无日志(原地写 I、B、Db):共 3 次块写 ==
  写序列:I[v2] → B[v2] → Db
  崩溃于第 1个写之后 I[v2]      | 恢复:日志中没有事务 → 什么都不做
                           结果:不一致 + inode 指向垃圾
  崩溃于第 2个写之后 B[v2]      | 恢复:日志中没有事务 → 什么都不做
                           结果:元数据一致,但 inode 指向垃圾!

== 数据日志(data journaling):共 9 次块写 ==
  写序列:J:TxB → J:I[v2] → J:B[v2] → J:Db → J:TxE → I[v2] → B[v2] → Db → JSB:free
  崩溃于第 4个写之后 J:Db       | 恢复:有 TxB 无匹配 TxE(未提交)→ 丢弃事务
                           结果:旧状态(一致;追加丢失)
  崩溃于第 5个写之后 J:TxE      | 恢复:发现已提交事务 → 重放(replay)
                           结果:新状态(一致,追加成功)

== 数据日志但不等屏障(磁盘把 TxE 排在 Db 前):共 9 次块写 ==
  写序列:J:TxB → J:I[v2] → J:B[v2] → J:TxE → J:Db → I[v2] → B[v2] → Db → JSB:free
  崩溃于第 4个写之后 J:TxE      | 恢复:发现已提交事务 → 重放(replay)
                           结果:元数据一致,但 inode 指向垃圾!

== 有序元数据日志(ordered):共 8 次块写 ==
  写序列:Db → J:TxB → J:I[v2] → J:B[v2] → J:TxE → I[v2] → B[v2] → JSB:free
  崩溃于第 1个写之后 Db         | 恢复:日志中没有事务 → 什么都不做
                           结果:旧状态(一致;Db 写了但没人指向,追加丢失)

$ ./journal show nobarrier 4     (恢复后的镜像,节选)
  [ 4] J3  GARBAGE(old block 4)        ← 日志里 Db 的槽还是旧垃圾
  [ 5] J4  TXE tid=1                   ← 但 TxE 已经落盘
  [ 7] I   INODE size=2 ptr=4,5,-1,-1
  [13] D5  GARBAGE(old block 4)        ← 重放把垃圾拷到了块 5
--- 检查:元数据一致,但 inode 指向垃圾!

$ ./journal fsyncbench
  write()                  200 次 4KB 追加:平均      6.8 us/次
  write() + fdatasync()    200 次 4KB 追加:平均   5021.0 us/次
  write() + fsync()        200 次 4KB 追加:平均   6078.9 us/次
  原子更新(tmp+fsync+rename+fsync目录)100 次:平均  13312.2 us/次
  config.txt 最终内容:version=99

该观察到什么:数据日志和有序日志在任何崩溃点都不会留下不一致或指向垃圾的文件系统;去掉屏障后,原书描述的"TxB I[v2] B[v2] ?? TxE"立刻出现。 write() 只把数据拷进页缓存(几微秒),加上 fsync() 要等设备真正落盘,慢了近千倍——这就是日志要批量提交、而不是每个操作都单独等待的原因。 最后的"写临时文件 → fsync → rename → fsync 目录"是应用程序原子替换文件的标准写法,本质上仍是"先写被指向的内容,再原子地切换指针"。

13自测题

追加一块时只有 I[v2] 和 B[v2] 落盘、Db 没落盘。下列说法正确的是?
inode 指向 5、位图也标记 5 已用,元数据完全自洽;问题在于块 5 里是旧内容。fsck 只检查元数据,看不出来。
位图与 inode 对某个块是否已分配意见不一时,fsck 以谁为准?
原书:fsck 扫描 inode、间接块等得出真正在用的块,据此生成正确的位图——"trusting the information within the inodes"。
数据日志协议里,事务在什么时刻算"已提交"?
Journal commit 步骤:写 TxE、等待完成,事务才算提交。在此之前崩溃,恢复会丢弃事务。
为什么不能把 TxB、I[v2]、B[v2]、Db、TxE 五个块作为一个大写请求一次发出(不带校验和)?
磁盘内部会调度大写请求的各部分。若 TxE 已落盘而 Db 没有,事务头尾匹配、看似合法,恢复就会把 ?? 拷到 Db 的最终位置。
关于有序(元数据)日志,下列哪项正确?
原书:强制 Step 1 在 Step 2 前完成并非正确性所必需,唯一要求是 Step 1、2 都在 Step 3(发出提交块)之前完成。
检查点进行到一半时崩溃(I[v2] 已写到最终位置,B[v2]、Db 还没有)。恢复后会怎样?
重做日志不关心检查点进行到哪里,已提交的事务整体重放。多写几块没关系,恢复本来就很少发生。
撤销记录(revoke record)解决的是什么问题?
Tweedie 的例子:foo 的目录块 1000 被记入日志,之后 foo 被删、块 1000 被 bar 复用写入用户数据;重放会用旧目录内容覆盖 bar。撤销记录让恢复跳过被撤销的块。
日志超级块(journal superblock)的主要作用是?
它标出最老和最新的未检查点事务,其余空间空闲;Free 步骤就是更新它。这既让日志成为循环日志,也减少了恢复时需要扫描重放的内容。
关于各种崩溃一致性方案,哪种说法错误?
说反了:日志把恢复时间从 O(size-of-the-disk-volume) 降到 O(size-of-the-log)。

14常见误区与小结