Li Sheng | Backend / Distributed Storage Engineer Li Sheng | Backend / Distributed Storage Engineer
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
  • Go语言

  • C++

  • 算法题

  • 存储系统

    • 导航

    • 单机IO基础

    • IO语义与持久化

      • VFS层
      • Page Cache总览
      • fsync、写回与刷盘语义
      • 文件系统日志与崩溃恢复
    • 块层与高速路径

    • Ceph

    • DAOS

    • 归档

  • CephFS

  • 分布式系统

  • 计算机网络

  • Redis与缓存

  • Kubernetes

  • 技术笔记
  • 存储系统
  • IO语义与持久化
lisheng
2026-04-27

文件系统日志与崩溃恢复

# 文件系统日志与崩溃恢复

沿着前两篇的主线继续往下走,Page Cache 解释了为什么写入会先变成内核中的状态,fsync 解释了系统怎样在这条异步链路里人为插入一个更强的提交边界。接下来最自然的问题并不是“日志是什么”,而是“如果系统在这条链路的中间位置突然停电、panic 或重启,磁盘上最终会留下什么状态”。文件系统日志与崩溃恢复机制,正是围绕这个问题建立起来的。它们要面对的不是一个抽象的“数据会不会丢”,而是一组更具体也更棘手的现实:一次看似简单的写文件动作,在底层往往会拆成多个彼此关联但并不同步完成的修改;这些修改可能跨越数据块、inode、目录项、分配位图、超级块计数器以及文件系统自己的内部元数据结构;而系统崩溃恰好会把这些原本应该成组成立的修改截断在任意位置。恢复机制存在的原因,就是文件系统不能接受“做到一半也算一种结果”。

如果把一次文件更新拆开看,问题会立刻变得具体。应用追加一段内容,可能意味着先把数据页写到某些块,再更新 inode 中的大小和时间戳,再把新分配的块记录进分配信息里,如果是新文件或 rename,还会牵涉目录项的增删改。这里最关键的事实不是“有很多结构需要改”,而是“这些结构共同定义了一个逻辑结果”。用户想要的是“文件变成了一个新版本”,但底层真正执行的却是一串彼此分离的写操作。只要系统在中途停下,就可能出现非常尴尬的中间状态:inode 指向了还没真正写好的数据块,数据块写好了但 inode 大小还没更新,目录项已经可见但对应 inode 状态不完整,甚至元数据之间互相矛盾,导致文件系统结构本身失去自洽性。这说明崩溃后的首要问题不是“最后一次写是否完整”,而是“磁盘上是否可能残留一个无法解释的半成品状态”。

日志机制首先解决的,就是“如何让一组分散修改在崩溃后仍然可判定”。它的本质不是简单地把数据再写一遍,也不是为了给人一种“更安全”的模糊印象,而是为了在真正改动关键结构之前,先建立一份可用于恢复判断的中间记录。换句话说,日志是文件系统给自己留下的“施工记录”或“提交证据”。在没有日志时,系统崩溃后只能直接面对磁盘上的结果,而磁盘上的结果很可能是截断、交叉、部分覆盖且缺乏边界标记的。此时恢复程序很难可靠地区分哪些修改已经完整成立,哪些只是做了一半。引入日志之后,文件系统就多了一层恢复锚点:它不必仅凭主数据区当前长什么样来猜,而可以根据日志中的记录去判断某一组修改是否已经进入可承认的提交状态,以及未完成的修改应该丢弃还是补完。

这意味着,日志真正建立的是“恢复边界”,而不只是“写入顺序”。前一篇里我们说 fsync 关心的是“我希望系统在什么时候承认这次修改已经稳定提交”;到了这一篇,日志机制进一步回答的是“如果系统在通往那个提交点的路上崩了,恢复时凭什么认定哪些状态算数”。因此,fsync 与日志不是两个平行主题,而是同一条语义链上的上下游。fsync 是运行时对提交边界的显式推进,日志则是故障后对提交边界的重新识别。没有 fsync,应用可能根本没有要求系统及时推进状态;没有日志,即便系统曾经努力推进过,崩溃后也可能很难判断推进到了哪里。把这两者连起来看,才会明白文件系统恢复语义并不是“是否有 journal”这么简单,而是“系统用什么记录方式,来让提交边界在故障后仍然可以被找回来”。

从状态机的角度看,日志把原本隐含在多个元数据写之间的逻辑事务显式化了。没有日志时,一组更新只是分散地写向不同位置,它们在逻辑上相关,但在物理上未必有清晰的共同边界;有了日志之后,文件系统会先把“这组修改准备怎么做”或者“这组修改包含哪些结果”组织进一个可追踪的事务单元,然后再去改动真正的目标位置。于是崩溃恢复时,文件系统面对的就不再只是主数据区里一堆零散的结构,而是一批带着开始、进行中、已完成等语义痕迹的事务记录。恢复程序可以据此做判断:某个事务如果只有开头没有提交标记,就说明这组修改不能被视为生效;某个事务如果日志完整但主数据区尚未来得及更新完全,系统可以选择重放它;某些已经完全落到主结构里的事务,则可以在后续检查点完成后从日志空间中清理。这里最重要的不是具体实现细节,而是这种“先建立可判定的事务边界,再去动真实结构”的设计思想。

但只要继续深挖,就会发现“文件系统可恢复”与“应用数据安全”之间仍然隔着一层很重要的边界。文件系统日志最优先保证的,通常是文件系统自身结构不要坏,也就是 inode、目录项、块分配关系、引用计数和其他元数据之间保持自洽。只有结构自洽,挂载和后续访问才有意义。从文件系统视角看,只要磁盘上的结构仍然能被正确解释,恢复就已经达成了第一目标。可应用真正关心的往往不是这些结构本身,而是更高层的问题:我刚刚写进去的那条业务日志是否还在,文件内容和长度是否同时反映了同一次提交,rename 是否对崩溃前后的观察者都表现为原子切换,事务提交后是否还能读到旧数据。于是一个极其关键的区别就出现了:文件系统恢复成功,未必等于应用语义恢复成功。前者强调结构一致性,后者强调数据一致性和提交可见性,两者相关但绝不等价。

这也是为什么不同日志模式和不同文件系统设计在工程上会表现出差异。某些设计重点保护元数据一致性,让文件系统重启后至少不会烂掉,但对普通文件数据本身的时序与原子性只给出较弱保证;某些设计会让数据与元数据的提交关系更加紧密,但代价是更高的写放大和更重的同步路径;还有些文件系统依赖更复杂的延迟分配、顺序约束、检查点推进和后台整理机制,在平均性能与恢复确定性之间做更细的平衡。也就是说,日志并不是单一功能开关,而是一整组工程取舍的承载点。你选择怎样记录、何时提交、何时把日志中的结果真正转移到主结构、何时允许清理旧日志,本质上都在回答同一个问题:系统愿意花多大代价,来换取崩溃后多强的可判定性和多小的歧义空间。

异常路径之所以困难,还因为崩溃不只会发生在“日志写完之前”或“日志重放之后”这样干净的位置。它可能发生在事务只记录了一半的时候,也可能发生在事务已经提交但检查点尚未推进的时候;可能发生在日志块已经下发但设备缓存尚未稳定化的时候,也可能发生在主数据区已经部分覆盖、日志区仍保留旧提交记录的时候。恢复逻辑真正要做的,不是简单“把最后一次写回来”,而是在这些中间状态中重建一条可信的因果链。哪些日志记录足够完整,可以被视为已经提交;哪些只有部分痕迹,必须视为未完成;哪些主数据区修改已经做了一半,但因为缺乏对应的有效提交标志,恢复时仍需放弃;哪些日志即使主结构还未同步完成,也应该被重新应用。这些判断背后都依赖同一个原则:故障恢复必须优先恢复到一个可以被一致解释的状态,而不是优先追求“尽量多保留最后写过的字节”。

一旦这样理解恢复流程,就会明白为什么日志机制天然与顺序约束绑定在一起。日志只有在“某些记录必须先于某些真实修改被承认”时才有意义,否则恢复时根本无法利用它做判断。于是写路径上的很多顺序控制,本质上都不是为了提高运行时效率,而是为了保证崩溃后还能够进行确定性解释。先写日志还是先写主结构,提交标志什么时候能出现,哪些元数据可以延后,哪些数据必须在某个同步点之前完成,这些都不是孤立的小技巧,而是在定义故障后的世界里,恢复程序还能依赖什么证据。也正因如此,设备缓存、flush、barrier、FUA、断电保护之类底层机制会重新变得重要,因为文件系统即便在逻辑上定义了很清楚的提交顺序,也必须依赖更底层的执行现实来兑现这种顺序。否则日志里看似已经出现的“已提交”痕迹,在真实掉电后可能并没有到达稳定介质,恢复语义就会被底层打穿。

从性能角度看,日志与恢复机制从来不是“额外送的保险”,而是明确要付费的。首先,日志意味着额外写入,无论是记录元数据变更、记录事务边界,还是为后续重放留下证据,都会形成写放大。其次,越想让恢复边界清晰,往往就越需要更多顺序约束与同步点,而同步点直接拉高前台延迟并放大尾延迟敏感性。再次,后台检查点、日志空间回收、脏数据整理、元数据更新聚合等工作虽然看起来不在前台路径上,但最终都会与写回、内存压力和设备队列竞争资源。因此,日志系统的工程设计从来都是一组平衡动作:日志写得更保守,恢复判断通常更清楚,但吞吐和时延会受影响;日志写得更激进、更多依赖后台整理,运行时平均性能也许更好,但故障时的边界就需要更复杂的机制来解释。恢复语义越强,往往意味着运行时越愿意为了未来的可恢复性预付成本。

也正因为文件系统层面的恢复目标主要服务于“局部存储结构仍然可解释”,数据库、分布式存储和对象存储系统才经常在其上再叠加一层自己的日志或提交协议。它们关心的不只是单机磁盘重启后能否挂载成功,而是事务边界、复制确认、主从切换、幂等重放和跨对象的一致可见性。这些语义超出了通用文件系统日志的职责范围。文件系统可以保证目录和 inode 不坏,可以尽量给出某些 fsync 后的承诺,但它通常不会替应用定义“哪一批业务更新在集群层面算一次完整提交”。因此,应用层 WAL 并不是在重复造轮子,而是在更高层重新定义一条比文件系统恢复线更贴近业务的提交线。文件系统日志解决的是“本地结构如何在崩溃后恢复为可解释状态”,应用层日志解决的则是“业务世界如何在崩溃后恢复为语义上可接受的状态”。

如果要把这一篇压缩成一个统一模型,我会把文件系统日志看成“为崩溃后的判定建立证据链”的机制。应用修改先在 Page Cache 中形成内核状态,fsync 决定系统是否要主动把这些状态推进到更强的提交边界,而文件系统日志则保证在推进过程中即便中断,恢复程序仍然有办法区分已提交、未提交和部分执行的状态。于是我们在阅读后续主题时,就不必再把 journal、checkpoint、redo、事务提交、元数据一致性这些词看成彼此分散的术语,而是可以始终围绕同一个核心问题思考:故障发生后,系统凭什么认定某个状态应被保留,而另一个状态应被丢弃。

因此,这篇文章在这组笔记中的位置,是把“运行时提交边界”和“故障后恢复边界”正式接起来。前面的 fsync、写回与刷盘语义 负责说明系统运行时如何推进状态,后面的 块层与I-O调度总览 会继续把这些状态推进到底层请求路径和设备调度层去看,而如果继续往下拆,最自然长出来的专题就是 ext4 的日志模式差异、XFS 的恢复语义、元数据一致性与数据一致性的边界,以及为什么应用层最终仍然需要自己的 WAL。

Edit (opens new window)
Last Updated: 2026/04/27, 14:16:46
fsync、写回与刷盘语义
块层与I-O调度总览

← fsync、写回与刷盘语义 块层与I-O调度总览→

最近更新
01
待完成专题池
04-28
02
待完成专题池
04-28
03
为什么 Kubernetes CSI 插件架构要拆成 Controller、Node 与 Sidecar
04-28
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式