MIT 6.1810 · 第 18 课 · rtm

崩溃恢复

断电后文件系统还能用吗?本节课探讨崩溃恢复问题及其解决方案——预写日志(Write-Ahead Logging)。

授课教师:rtm (Robert T. Morris)

年份:2025 秋季

难度:中等

视频:Lecture 18 Notes

问题:崩溃恢复

什么是崩溃恢复?

  1. 进程创建文件、目录等
  2. 然后断电
  3. 重启后,文件系统还能用吗?

核心问题

文件系统操作需要多次磁盘写入,崩溃可能发生在部分写入之后,导致文件系统不变量被破坏:

具体例子

操作崩溃风险
创建文件 inode 已分配但目录项未写入 → inode 泄漏
写入数据 inode 指向未分配的块 → 读取垃圾数据
删除文件 (unlink) 目录项指向已释放的 inode → 灾难性后果

我们希望达到什么目标?

  1. 文件系统内部不变量保持——没有块既在空闲列表中又在文件中
  2. 除最后几个操作外,所有操作都被保留——昨天的数据保留,正在写的可能丢失
  3. 没有顺序异常——如果程序先写 result 再写 status,不能出现 status 存在但 result 不存在

正确性与性能的冲突

安全:立即写入磁盘(但磁盘很慢!)

性能:批量写入、写回缓存(但有崩溃风险!)

解决方案:预写日志 (Write-Ahead Logging)

基本思想

  1. 将要做的所有写入记录到日志中
  2. 在磁盘上写入"完成"标记(提交点)
  3. 然后执行实际的 FS 写入(安装)
  4. 崩溃+恢复时:如果日志有"完成",重放日志;否则忽略

预写日志规则

核心规则:在日志中所有写入完成且标记为已提交之前,不能将任何事务的写入安装到磁盘上的文件系统。

为什么这个规则有效?

日志是"魔法"

崩溃恢复对于复杂可变数据结构通常很难。日志可以:

xv6 日志实现

日志表示

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  缓冲缓存    │     │  磁盘 FS 树   │     │  磁盘日志    │
│  (buffer    │     │  (实际文件    │     │  (事务记录   │
│   cache)    │     │   系统)       │     │  + 提交点)   │
└─────────────┘     └──────────────┘     └─────────────┘

提交流程

  1. 写入时:将块号添加到内存数组,数据保留在缓冲缓存中(固定)
  2. 提交时:
  3. 提交后:将块安装到 FS 中的实际位置,解除固定,将 "n" 写零

日志头中的 "n" 值

示例:创建文件

$ echo > x
log_write 34   // ialloc
log_write 34   // iupdate
log_write 47   // writei (目录项)
log_write 33   // iupdate (目录 inode)
log_write 34   // iupdate (itrunc)
// 提交前没有磁盘写入!
bwrite 3       // 写入日志块 (x 的 inode)
bwrite 4       // 写入日志块 (目录块)
bwrite 5       // 写入日志块 (目录 inode)
bwrite 2       // 提交(写入日志头)
bwrite 34      // 安装 inode
bwrite 47      // 安装目录内容
bwrite 33      // 更新目录 inode
bwrite 2       // 标记日志为空
// 同一块的多次写入合并为一次
// 每个块写两次:日志 + 安装

崩溃场景分析

崩溃时机后果
提交前日志头 n=0,恢复时忽略日志,操作未发生
提交期间提交点是单个扇区写入,原子的;要么提交要么不提交
安装期间恢复时重放日志,完成安装
恢复期间再次重放日志(幂等)——只要没有其他活动介入就没问题

关键洞察:多次重放日志是安全的(幂等性),只要没有其他活动介入。

代码结构

sys_open()                     // sysfile.c
  → begin_op() / end_op()      // 标记事务边界
  → create()
      → ialloc()               // 分配 inode
          → log_write()        // 记录到日志
      → iupdate()              // 更新 inode
      → dirlink()              // 创建目录项
          → writei()
              → iupdate()
  → itrunc()
      → iupdate()

begin_op()                      // log.c
  → 检查日志是否正在提交
  → 检查写入是否能容纳在日志中

log_write()                     // log.c
  → 添加扇区号到内存数组
  → bpin() 固定缓冲区

end_op()                        // log.c
  → 如果没有未完成操作,提交

commit()                        // log.c
  → 将更新的块从缓存复制到磁盘日志
  → 在磁盘日志头中记录扇区号和 "done"
  → 安装写入(从磁盘日志复制到磁盘 FS)
  → bunpin() 解除固定
  → 擦除日志中的 "done"

挑战与 xv6 解决方案

挑战 1:防止写回缓存

问题:系统调用可以安全地更新缓存块,但在事务提交之前不能写入 FS。

xv6 方案:

挑战 2:系统调用数据必须能容纳在日志中

挑战 3:允许并发系统调用

挑战 4:块可能在事务中被多次写入

解决方案:日志只影响内存中的缓存块,安装只在没有进行中的事务时发生 → 写入吸收 (write absorption)优化

挑战 5:孤儿文件/目录

解决方案:ireclaim 在重启时检查孤儿文件

挑战 6:进程何时知道其写入在磁盘上?

典型方案:sync() 系统调用——仅在所有脏块写入磁盘后返回

xv6 日志:优缺点

优点缺点
预写日志保证正确性 每个块写两次(日志 + 安装)
日志自然批量化写入,磁盘吞吐量好 即使只修改几个字节也记录整个块
支持并发 同步写入日志块(可以批量化)
不适合操作超出日志容量的情况

要点总结

崩溃恢复是存储系统的核心问题

任何涉及多次磁盘写入的操作都可能因崩溃而不一致。

预写日志提供原子性

先记录到日志,再安装到实际位置,崩溃后重放日志。

提交点是关键

单个扇区的原子写入(日志头)决定了事务是否提交。

正确性与性能的权衡

xv6 的日志牺牲了一些性能换取简单性和正确性。

测验

Q1:预写日志的核心规则是什么?

Q2:为什么可以多次重放日志?

Q3:xv6 日志的提交点是什么?

延伸阅读