# 第 42 章实验:在磁盘镜像上"拔电源" `journal.c` 把原书 42.1 节的追加例子搬到一个 16 块(每块 512 字节)的磁盘镜像文件 `disk.img` 上: ``` 块 0: 日志超级块 JSB 块 1..5: 日志区 块 6: 数据位图 B 块 7: inode I 块 8..15: 数据块 D0..D7 ``` 初始状态是 I[v1](size=1,ptr=4)、B[v1](00001000)、D4=Da;空闲块里填的是 `GARBAGE(...)`,代表"磁盘上原来的旧内容"。 追加一个块需要写 I[v2]、B[v2]、Db 三个块,程序用四种协议来写: | 模式 | 写序列 | |---|---| | `none` | I[v2] → B[v2] → Db(直接原地写,没有任何保护) | | `data` | 数据日志:TxB、I[v2]、B[v2]、Db 写入日志 → 屏障 → TxE → 屏障 → checkpoint → 屏障 → 在 JSB 里标记释放 | | `nobarrier` | 同上,但五个日志块一起发出、不等屏障,磁盘把 TxE 排在 Db 前面落盘(原书"TxB I[v2] B[v2] ?? TxE"的情形) | | `ordered` | 有序元数据日志:Db 先写到最终位置 → TxB、I[v2]、B[v2] → TxE → checkpoint 元数据 → 释放 | 崩溃注入:每次块写都经过 `bwrite()`;在子进程里设置 `crash_after = n`,写满 n 个块后直接 `_exit()`, 之后的写永远不会发生——这正好模拟"断电时前 n 个写已经落盘"。然后父进程运行恢复(扫描日志、重放已提交事务), 最后做一次类似 fsck 的检查(位图与 inode 是否一致,inode 指向的块是不是垃圾)。 ## 怎么跑 ```bash cd labs/42-crash-consistency make ./journal crashtest # 4 种模式 × 每个崩溃点,打印恢复后的状态 ./journal show nobarrier 4 # 看一个具体崩溃点的磁盘镜像:崩溃后、恢复后 ./journal fsyncbench # 测 write / fdatasync / fsync / 原子 rename 的真实开销 ``` ## 该观察到什么 1. `none` 模式:崩溃在第 1、2 个写之后分别得到"不一致 + 指向垃圾"和"元数据一致但指向垃圾"——对应原书六种崩溃场景中的两种。 2. `data` 模式:TxE 落盘之前崩溃,恢复时丢弃事务,回到旧状态;TxE 落盘之后崩溃,恢复时重放,得到新状态。 **没有任何一个崩溃点会留下不一致的文件系统**。代价是 9 次块写(其中数据 Db 被写了两次)。 3. `nobarrier` 模式:崩溃在 TxE 之后、J:Db 之前时,事务"看起来已提交",重放把日志槽里的旧垃圾拷到了块 5—— 这就是为什么 TxE 必须等前面的写完成(或者像 ext4 那样在 TxB/TxE 里放事务校验和)。 4. `ordered` 模式:Db 先写,最坏只是"Db 写了但没人指向",不会出现指向垃圾的 inode;日志只写元数据,一共 8 次块写。 5. `fsyncbench`:`write()` 只是拷进页缓存,几微秒;加上 `fsync()` 后每次要等设备真正落盘,慢两三个数量级。 这就是文件系统要把更新攒成一个大事务(batching)、应用不能随手 fsync 的原因。 最后的"tmp + fsync + rename + fsync 目录"是应用层实现原子更新文件的标准做法,本质上也是"先写被指向的对象,再写指针"。 说明:本实验的崩溃点是按协议顺序"切一刀",真实磁盘在没有屏障时还可能以任意顺序完成同一批写; `nobarrier` 模式挑的是原书给出的那个最坏的重排。`fsync` 的数值取决于你的磁盘和文件系统(WSL2 的 ext4 虚拟盘上约为几毫秒)。