Appearance
第九章:文件系统
文件系统管理持久对象、命名与恢复。文件系统既要把路径解析成对象,也要把对象内容组织到存储设备上。运行期间又存在 FD、打开文件、缓存等内存对象,它们与磁盘结构相关,但寿命不同。
进程中的 FD 引用打开文件对象,其中保存偏移和打开方式;打开对象引用文件身份,通常由 inode 表示。目录把名字关联到 inode,inode 的数据映射再把文件偏移关联到数据块。这些内存和磁盘结构各有生命周期,修改还要通过日志等协议形成可恢复状态。
两条主线需要分别建立:对象关系解释打开、链接、删除与访问;更新协议解释可见性、原子性和掉电恢复。能找到一个文件,并不代表最近写入已持久化;磁盘结构可以恢复,也不等于应用的多文件事务已经完成。
目录、inode 与打开文件
先掌握名字、打开实例和数据布局之间的关系,才能解释链接、删除与独立偏移。
xv6 文件系统
xv6 磁盘布局由 fs.h 中的 superblock 规定:
text
boot | super | log | inode blocks | bitmap | data blocks磁盘 inode struct dinode 保存类型、link count、大小和 direct/indirect block addresses,不保存文件名。目录内容是一组 struct dirent { inum, name },所以名字到 inode number 的映射属于目录文件。namei() 从根目录或 cwd 开始逐段调用 dirlookup();open 后的 struct file 则直接引用内存 inode。
namei() 和 dirlookup() 查找路径与目录项,iget()/ilock() 获取并锁定 inode,readi()/writei() 据此访问文件数据。
文件布局
inode 不保存文件名;名字存在目录数据中。多个名字可以通过硬链接指向同一 inode。符号链接则是一个保存目标路径字符串的独立文件。
这是从 xv6 到 Linux 都成立的基本关系。差异在数据块映射与缓存策略。
Linux 扩展
现代文件系统常用 extent 描述一段连续逻辑块到连续物理块的映射,而不是为每个块单独记录指针。这节省元数据并利于大文件顺序 I/O。稀疏文件允许逻辑范围没有物理块,读取时返回零。
延迟分配先将脏页保留在内存,回写时再决定物理位置,有利于形成较大 extent;磁盘空间不足等错误也会因此延迟报告。
Linux VFS
VFS 为 open/read/write/mmap/rename 提供通用对象模型,把路径解析到具体挂载点和文件系统。常见对象可粗略理解为:
- superblock:一次已挂载文件系统的整体状态;
- inode:文件身份、类型、权限、大小与数据位置;
- dentry:目录中的名字到 inode 的关联及路径缓存;
- file:一次打开实例,保存偏移和打开标志。
统一接口使 ext4、XFS、Btrfs、tmpfs 或网络文件系统都能通过 FD 使用,但不同实现的持久化边界、错误模式、大小写规则、快照和远程一致性语义仍有差异。
路径用于命名和查找对象,不构成对象身份。文件打开后,即使原目录项被删除,FD 仍可引用 inode,直到最后一个引用释放。rename() 修改命名关系,不移动已打开对象的内容。
Linux 中 openat2() 的路径解析最终得到 dentry 和 vfsmount,struct file 中的 f_path 保存这对引用,f_inode 可取得 inode,f_op 决定 read/write/fsync 调到哪个文件系统实现。对象字段和回调可直接对照 VFS 文档。
文件写入与持久化
先明确调用者要求什么保证,再分析底层机制是否实现。
崩溃语义
- 原子性:观察者看到旧状态或新状态,不看到中间状态;
- 持久性:成功确认后,即使崩溃也保留;
- 顺序性:若 B 依赖 A,恢复后不能只看到 B;
- 一致性:恢复结果满足文件系统或应用定义的不变量。
rename() 通常在同一文件系统内提供命名原子性,但不自动提供断电持久性。fsync() 提供持久化屏障,但不会将多个文件更新自动组合成应用事务。完整持久化协议通常由多项操作组成。
write() 的返回语义
在 xv6 中,filewrite() 把大写入拆成能放入日志的多个 transaction,因此一次大 write() 本身也未必具有整体原子性。end_op() 若发现没有其他 outstanding operation,就完成 log commit;若仍有并发文件系统操作,本次调用可以先返回,由最后退出者提交共享 transaction。底层实际发出的 virtio 请求则以 sleep/wakeup 等待 completion。xv6 没有 fsync() 和后台 page-cache writeback,所以不能从 xv6 的返回点直接推断 Linux write() 语义。
Linux buffered I/O
对普通文件的 buffered write,成功返回 N 通常表示内核接受了 N 字节,并让同机后续正常读取看到相应内容。它一般不表示:
- 数据已离开 DRAM;
- 文件元数据已形成崩溃一致状态;
- 设备易失缓存已刷新;
- 远端存储已复制;
- 机器断电后内容仍存在。
短写是合法结果。磁盘满、信号、资源限制等都可能让返回值小于请求长度;应用必须循环处理,而不是把“调用没报 -1”当成全部完成。
close() 释放 FD 和内核引用,不是通用的 durability barrier。若业务需要持久化,必须显式使用适当同步协议并检查错误。
以 Linux buffered write 的常见路径看,write() 经过 VFS 后把用户数据复制进 page-cache folio、置 dirty,便可以返回;真正的块 I/O 由后续 writeback 提交。因此返回点位于“内核已接收并修改缓存”,而不是 NVMe completion。Linux iomap 对这一路径的说明见 Buffered I/O。
fsync() 的持久化范围
fsync(fd) 的目标是使文件数据和恢复这些数据所需的元数据到达持久存储边界。文件系统会提交脏页和必要日志,并向设备发出 flush/FUA 等命令;其成功返回提供比 write() 更强的持久化保证。
但新建文件的名字属于父目录。经典安全替换协议是:
text
write temporary file
fsync(temporary file)
rename temporary → final
fsync(parent directory)文件 fsync 保证内容,不一定保证新目录项在崩溃后存在;目录 fsync 用来持久化命名变更。实际语义还依赖文件系统、内核和存储栈,跨文件系统 rename 也不再是同一个原子操作。
fdatasync() 可少同步与数据取回无关的元数据。同步越强、调用越频繁,延迟越高;批量提交能摊销 barrier,却扩大一次崩溃可能丢失的最近时间窗口。
fsync() 也不是硬件指令。VFS 调用当前 struct file 的 f_op->fsync;文件系统提交相应 dirty folio 和元数据事务,块层再把 flush/FUA 语义传给设备。后台 writeback 的错误通过 mapping 的 wb_err 留存,并由后续 fsync 的 error cursor 取走。这个错误传播协议可见 VFS writeback error handling。
日志与崩溃恢复
日志与 COW 组织更新顺序,使中断后的存储结构仍可解释。
日志
若一次元数据更新散落在多块上,崩溃可能只写一部分。Journaling 把相关更新组成 transaction,先把日志记录持久化,再标记 commit,之后才把更新写到最终位置。恢复时只重放完整提交的事务。
text
写 journal records → 持久化 commit → checkpoint 到 home locations日志主要保证文件系统内部结构一致,不自动保证应用的多文件事务。不同模式可能只记录元数据,数据块仍存在新旧内容的顺序问题。文件系统结构一致性与应用不变量属于两个不同层次。
ext4 扩展
与 xv6 把整个修改后的 block 写进物理 redo log 相比,ext4/JBD2 还要处理并发 transaction、异步 checkpoint 和不同 data mode。默认 data=ordered 中,普通文件数据不写入 journal,但相关数据块要先于提交 metadata transaction 落盘;data=writeback 放松这一顺序,data=journal 才把数据也记入日志。具体格式与顺序见 ext4 journal 文档。
write-ahead logging 同样用于数据库:描述变更的日志必须先于依赖它的数据持久化。若该顺序只存在于 CPU 或页缓存中,设备重排仍可能破坏它,因此需要使用 flush 或 barrier 将顺序要求传递到底层设备。
xv6 日志
xv6 的 log.c 实现物理 redo log。文件系统系统调用先 begin_op() 预留日志空间,修改块时由 log_write() 把 block number 记入内存 log header,并 pin 相应 buffer;end_op() 发现自己是最后一个未完成操作后执行:
text
write_log 把修改后的块写入磁盘 log 区
write_head 写 log header,形成 commit point
install_trans 把 log 中的块复制到原位置
write_head 清空 log以下恢复推理使用 xv6 教学模型的存储假设:相关块写入满足所需原子性和先后顺序,已确认的日志内容在恢复时可读。实际设备的易失缓存与撕裂写需要额外协议,不能仅凭这段代码推断能抗任意掉电故障。
启动时 recover_from_log() 读取 header;非零 header 表示 transaction 已提交,重新执行 install_trans() 即可。崩溃发生在 commit point 前,旧文件系统仍有效;发生在其后,恢复会完成整个 transaction。
xv6 一次 commit 可以包含若干并发文件系统系统调用的块更新;它保证整个已提交 log transaction 可恢复,却不允许应用指定哪些系统调用组成一笔业务事务。
COW 文件系统
COW 文件系统不原地覆盖树节点,而是写出修改后的新块,自底向上产生新路径,最后原子切换根指针。旧根仍描述旧版本,因此快照和校验更自然。
text
old root → old metadata → old data
new root → new metadata ─┬→ old unchanged data
└→ new changed dataCOW 避免部分原地更新,却可能产生碎片、写放大和复杂垃圾回收。RAID、设备缓存和崩溃恢复仍会带来边界条件。Journaling 与 COW 是组织更新的不同策略,不是“一个可靠、一个不可靠”。
存储故障模型
恢复协议只能在约定的故障范围内成立,文件系统一致性与业务正确性仍需分别验证。
进程崩溃、内核 panic、整机掉电、设备谎报 flush、介质局部损坏、远程副本分区,是不同故障。一个协议可能能抗进程退出,却不能抗突然掉电;能抗单盘坏块,却不能抗应用把错误数据成功 fsync。
必须先声明故障模型,再讨论“可靠”:
- 是否假设存储设备遵守 flush?
- 是否需要应对 torn write?
- 只要求单机掉电恢复,还是机房故障?
- 恢复时允许丢最近多少数据?
- 检测到校验错误后,有没有另一份正确副本?
校验和能检测腐坏,不能凭空恢复数据;复制能提供副本,但若错误被正常复制,也会得到多份同样的错误。
实验
先给 xv6 的 namex()、dirlookup()、writei()、log_write() 和 commit() 设置断点,画出一次文件写入涉及的 inode、目录项、数据块和日志块。随后分别在 write_log()、写入 commit record 和 install_trans() 后模拟崩溃,检查恢复结果。下面的 Linux 工具用于比较生产文件系统的可见语义。
bash
# 查看文件系统与挂载选项
findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS
# 观察一次写入、同步与重命名系统调用
strace -e openat,write,fsync,fdatasync,rename,close your-program
# 查看稀疏文件逻辑大小与实际占用
truncate -s 1G sparse
ls -lh sparse
du -h sparse验证崩溃一致性不能只测试程序正常退出后的结果。需要在虚拟机或测试设备上于不同更新点中断执行,重启恢复后检查不变量;第十三章将把 fault injection 纳入全路径验证。
练习
先回答前两题,再用实现细节核对后面的题目。
删除文件名、关闭一个 FD、释放最后一个打开引用,是同一个操作吗?分别改变了什么关系?
文件内容已同步,目录项却未同步,掉电后可能缺少什么保证?这与文件系统结构是否一致有什么区别?
xv6 为什么把文件名放在目录项而不是 inode 中?删除名字后已打开文件为何仍可访问?
xv6 日志的 commit point 是哪次磁盘写入?恢复程序据此如何判断事务完整?
为什么一次 xv6
write()可能被拆成多个事务?并发系统调用又为什么可能进入同一次 commit?Linux buffered
write()、fsync()和目录fsync()的承诺分别覆盖什么?日志保证文件系统结构一致,为什么仍不等于业务事务正确?