Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.4 日志结构化文件系统——只追加,不覆盖

你有一个永远不会改错字的打字机

想象一个场景:你有一台老式打字机。不是你爷爷那种——这是一台“只追加、不倒退“的打字机。你只能往前打字。你不能退格。你不能涂改液。你不能把纸抽出来反过来写背面。

你打错了一个字怎么办?

你按下回车,在新的一行写上更正。旧的那行就留在那里——成为一段历史的见证,一段永远不需要被删除的程序记录。

这就是日志结构化文件系统(Log-Structured File System, LFS)的核心思想:你永远只追加,不覆盖。旧的数据不删除——它们自然死亡(被后续的垃圾回收清理)。

这个思想,诞生于1992年Mendel Rosenblum和John Ousterhout的那篇著名论文《The Design and Implementation of a Log-Structured File System》。这篇论文最初是为了解决磁盘的“小写放大“问题——磁盘上大量的小同步写造成的寻道开销。但他们发现,这种设计天然地和Flash的物理特性完美契合。


1992年的洞察:为什么磁盘讨厌小写入

在1992年,磁盘的性能瓶颈是寻道时间。一次随机寻道大约需要10毫秒。如果你每秒要写100次日志——每次都更新一个inode(索引节点)和一个数据块——那就是每秒200次随机寻道,大约2秒的纯寻道延迟。磁盘的吞吐量崩溃。

Rosenblum和Ousterhout的观察是:“如果我们不是把inode和数据块分散在磁盘的各个位置上,而是把所有写入都追加到一个连续的日志流的末尾——那磁盘的写头就永远不需要移动。它只需要待在一个地方,持续写。”

磁盘上的原地更新 vs 日志追加
=======================

原地更新(传统FS,如Unix FFS):
  磁盘布局:
  ┌──────┬──────┬──────┬──────┬──────┬──────┐
  │inode1│inode2│块A   │块B   │块C   │块D   │
  └──────┴──────┴──────┴──────┴──────┴──────┘
    ↑       ↑       ↑       ↑       ↑       ↑
  每次修改inode或数据块,磁头都要移动到对应位置
  寻道延迟 ≈ 写延迟的 100 倍

日志追加(LFS):
  磁盘布局:
  ┌──────────────────────────────────────────┐
  │ inode1 │块A│inode2│块B│inode1│块A'│inode3│块C│ ... → 日志流 → │
  └──────────────────────────────────────────┘
                    ↑ 磁头一直待在这里,不断追加写
                    不需要寻道!写入速度 = 磁盘顺序写入速度

但这里有一个核心问题:如果数据块A被追加了新的版本(块A’),旧版本(块A)怎么办?

答案:旧的块A变成“垃圾“,等待回收。

LFS 的版本演进
=======================

首次写入:
  日志:  ...[inode1: version=1, 指向块A]...[块A: 内容="Hello"]...

修改文件:
  日志:  ...[inode1: version=2, 指向块A']...[块A': 内容="World"]...

旧块A的状态: [块A: 内容="Hello"] ← 仍然在磁盘上,但被标记为垃圾
                                  没有任何inode指向它了

GC 之后:
  旧块A被回收(擦除或重新分配),空间还给系统。

这个设计在磁盘上的性能提升是惊人的。在当时的测试中,LFS的写吞吐量达到了Unix FFS的10倍以上(对于小文件写入负载)。

但Rosenblum和Ousterhout没有想到的是:他们的设计,在30年后,成了Flash文件系统的天然范本。


为什么日志结构化天然适合Flash

把LFS的每一条设计选择和Flash的物理约束逐条对齐:

LFS 设计选择 → Flash 物理契合
=======================

1. 只追加,不覆盖
   LFS: 所有写入都追加到日志末尾
   Flash: 编程只能1→0,原地覆盖是不可能的
   契合度: ★★★★★ 完全吻合

2. 旧版本自然保留
   LFS: 旧的数据块没有被删除,它们变成垃圾等待GC
   Flash: 旧数据块在擦除之前保留着,可以提供掉电恢复
   契合度: ★★★★★ 完美利用Flash的Copy-on-Write特性

3. 垃圾回收(GC)
   LFS: 定期扫描日志,将有效数据(最新版本)移到日志尾部
   Flash: 必须GC来回收无效块,为写入腾出空间
   契合度: ★★★★★ 都是空间回收的必需机制

4. 顺序写入
   LFS: 日志流=顺序写入流
   Flash: NAND的顺序写入比随机写入快(页面编程流水线)
   契合度: ★★★★ 顺序写入有性能优势

5. 无原地修改
   LFS: 不存在"修改一个inode"的概念,每次都创建新的
   Flash: 不存在"原地修改",每次都是Copy-on-Write
   契合度: ★★★★★ 概念完全一致

6. 版本快照
   LFS: 每次checkpoint都是一次快照
   Flash: 旧数据天然保留,掉电后可以回滚到上一个checkpoint
   契合度: ★★★★★ 天然支持掉电恢复

LFS的设计哲学和Flash的物理约束之间,不是“适配“,是“命中注定“。

这不是巧合。这是物理约束决定了数据结构,而数据结构反过来验证了物理约束的合理性。Flash的三原罪(写前擦除、单向编程、有限寿命)把文件系统设计推向了日志结构化,而日志结构化恰好是应对这些原罪的最优解。


LFS的核心数据结构

具体看看LFS的数据结构。在KnotFS里,你会在很多地方看到这些结构的影子。

1. 超级块(Superblock)

LFS Superblock = KnotFS Superblock 的直系祖先
=======================

LFS:
  两个超级块副本,分别位于磁盘的两端
  ┌──────────┐                         ┌──────────┐
  │ SB 副本 1 │                         │ SB 副本 2 │
  │ (磁盘头部) │  ... 日志段 ...          │ (磁盘尾部) │
  └──────────┘                         └──────────┘

  每个超级块包含:
    - 日志尾部的起始位置(tail pointer)
    - 段使用表(segment usage table)的起始位置
    - inode映射表的起始位置(imap)
    - 时间戳和序列号

KnotFS:
  两个超级块副本,位于块0和块1
  ┌──────────┐ ┌──────────┐
  │ SB0 (块0) │ │ SB1 (块1) │
  └──────────┘ └──────────┘

  每个超级块包含:
    - sequence_number(决定哪个是有效的)
    - 文件表的位置信息
    - 空闲位图的起点
    - magic number 和 CRC

2. Inode映射表(Imap)

LFS Imap → KnotFS File Table
=======================

LFS:
  imap是一个紧凑的数组:imap[inode_number] → 日志中该inode的最新位置
  每次inode被更新(放到日志末尾),imap也要更新
  imap自身也以日志追加的方式存储

KnotFS:
  文件表嵌入在Superblock中(nodes[8]),双副本由SB双槽位保证
  nodes[x] → {文件名, 起始块号, 大小, 属性}
  每次文件修改,通过Compact重写整个SB来持久化文件表

3. 段(Segment)和段清洁器(Segment Cleaner)

LFS Segment → KnotFS GC Block
=======================

LFS:
  磁盘被划分为固定大小的段(segment),通常512KB-1MB
  segment cleaner = 垃圾回收器
  
  ┌────── Segment 1 ────────┐  ┌────── Segment 2 ────────┐
  │[有效│垃圾│有效│垃圾│有效]│  │[有效│有效│有效│有效│有效]│
  │ 50%  50%               │  │ 90%  10%                │
  └─────────────────────────┘  └─────────────────────────┘
  
  选择Segment 1进行清理(垃圾最多)
  读出有效数据 → 追加到日志尾部 → Segment 1被清空 → 可重用

KnotFS:
  每个块(Block)就是一个"段"
  gc_recycle() 选择垃圾最多的块
  搬迁有效数据到新块 → 擦除旧块 → 旧块回到空闲池

日志结构化的“元数据洪水“

但LFS有一个著名的工程代价:元数据洪泛(Metadata Flood)

因为一切都是日志追加,任何修改——哪怕只是改动一个inode的时间戳——都需要在日志末尾追加一条记录。也就是说:

修改一个inode的时间戳:

Unix FFS:
  1. 读inode块
  2. 修改其中的时间戳字段(8字节)
  3. 原地写回同一个扇区
  物理I/O: 1次读 + 1次写

LFS:
  1. 读入旧inode
  2. 在内存中修改时间戳
  3. 将修改后的inode(整个128字节左右)追加到日志尾部
  4. 更新imap → 追加一条imap记录到日志尾部
  5. 标记旧inode为垃圾
  物理I/O: 1次读 + 2次追加写 + 增加2条垃圾记录

LFS的写放大在元数据层面比传统FS更高。这个“元数据洪泛“在磁盘上被顺序写入的性能优势摊平了——但在Flash上,你同时有了磨损的问题。

这就是为什么KnotFS在LFS的基础上做了裁剪:SB Log的每一条记录只有8字节(tag+blk+val+crc32)。 它不记录“inode被修改成了什么“——它只记录“哪个块被标记为used/free“。文件表的修改不走日志——文件表嵌入在SB Header中,通过Compact一次性持久化。这种设计把“追加一整个inode“变成了“追加几个字节+定期Compact“。

KnotFS 的元数据 compact log
=======================

LFS 做法:
  修改inode字段 → 追加整个inode(128B) + imap条目(16B) → 144B

KnotFS 做法:
  修改free_map → 追加一条 SB Log 记录(8B)
  修改文件表 → 累积在内存中,Compact时一次性写入新SB

Compact log 的优势:
  - 一个4KB块可以塞下 ~400条 SB Log 记录
  - 日志记录极小,减少了磨损

这是KnotFS对LFS的一个关键优化——但不是对LFS的否定,而是对LFS的充实。


LFS的Checkpoint机制:一条线的两头

LFS的另一个重要贡献是Checkpoint(检查点)机制。

因为所有写入是追加的,你总可以找到“上一次完整写入的状态“。如果在写入过程中掉电,你只需要回滚到上一个checkpoint即可。

LFS Checkpoint = KnotFS 的双槽位Compact 的祖先
=======================

Checkpoint N (有效):
  ┌─────────────────────────────────┐
  │ Superblock → imap → inodes → data│
  └─────────────────────────────────┘
          ↓ 开始写入新数据
  ┌─────────────────────────────────┐
  │ 新inode │ 新data │ 新imap │ ... │   ← 追加到日志尾部
  └─────────────────────────────────┘
          ↓ 掉电!!
  日志尾部不完整 —— 但没关系
  重新mount时:读到Checkpoint N的信息
  从Checkpoint N的位置开始扫描日志
  发现不完整的记录 → 丢弃 → Checkpoint N成为恢复点

Checkpoint N+1 永远只在"所有数据都安全落地"时才被写出来。

KnotFS里的双槽位Compact——把内存中的完整状态(文件表+free_map+wear[])写入备用SB槽位,写入成功后切换活跃指针——本质上就是LFS Checkpoint的缩减版:Checkpoint写的是完整的inode映射表,Compact写的是完整的SB Header(内嵌文件表)。


一篇论文和一百二十八个Flash块

1992年,Rosenblum和Ousterhout在斯坦福的SUN工作站上写LFS时,他们面对的是300MB SCSI硬盘、16MB内存、每秒几百次随机I/O的性能瓶颈。

2026年,你在Cortex-R5核心上设计KnotFS,面对的是512KB NOR Flash、64KB SRAM、实时调度器的确定性要求。

硬件差了三个数量级。但核心问题完全一样:

1992 LFS → 2026 KnotFS
=======================

LFS 面对的:               KnotFS 面对的:
磁盘寻道延迟(10ms)         Flash擦除延迟(200ms)
小写放大(随机寻道代价)      小写放大(擦除-重写代价)
需要顺序写入提升吞吐        必须异地更新才能活
GC浪费磁盘带宽              GC浪费Flash P/E寿命
Checkpoint防崩溃            双槽位Compact防掉电
分段管理(512KB-1MB)        按块管理(4KB)

同构的问题,在不同的尺度上,产生了同构的解决思路。

这就是为什么你需要读LFS论文、理解LFS思想——不是因为它“先进“,而是因为它揭示了一个更本质的工程规律:当一个存储介质不允许原地修改时,日志结构化是唯一正确的数据结构选择。


为什么KnotFS是“日志结构化“而不只是“Copy-on-Write“

有一个微妙的区分需要说清:Copy-on-Write和日志结构化不是同一件事。

Copy-on-Write是一块一块的。你有一棵数据树,你修改一个叶子节点,你把修改过程一路向上传播到根节点——每一层都分配新块(CoW),旧的保留为垃圾。

日志结构化是一条流。你在日志末尾不断追加新的记录——不管它是一个数据块、一个inode、还是一个目录项。所有东西都是日志流上的一个条目。

CoW vs Log-Structured
=======================

CoW(如 Btrfs, ZFS):
  块0: [A]    → 修改A →    块0: [A 旧]
                             块N: [A'新]
  修改只影响"被修改的块所在的树路径"
  引用计数管理:当旧的树路径上所有节点都失去引用时,它们被回收

Log-Structured(如 LFS, KnotFS):
  日志: [条目1│条目2│条目3│条目4│...]  → 修改条目2 →    追加条目2'到末尾
  所有修改都追加到日志尾部
  有效版本永远在"日志尾部"方向
  垃圾是日志里被后面的记录覆盖了的前面记录

KnotFS采用了混合策略

  • 元数据(Superblock):日志结构化 + Compact。 SB Log 是 LFS 日志流的微缩版——free_map 的增量变化以 8 字节 log 记录追加。文件表嵌入在 SB Header 中,通过 Compact 持久化。
  • 数据块:Copy-on-Write。 文件数据存在独立的数据块中,修改时分配新块、复制旧数据+修改、标记旧块为垃圾。这更接近Btrfs的CoW而非LFS的纯日志。
KnotFS 的混合架构
======================

元数据层(Log-Structured + Compact):
  ┌──────────────┐  ┌──────────────┐
  │ SB0     SB Log│  │ SB1     SB Log│
  │ nodes[8]     │  │ nodes[8]     │
  │ [rec1][rec2] │  │ [空日志区]    │
  │ [...         │  │              │
  └──────────────┘  └──────────────┘
    追加写入           追加写入

数据层(Copy-on-Write):
  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
  │ 块3     │ │ 块5     │ │ 块8     │ │ 块12    │
  │ 文件1   │ │ 文件1   │ │ 文件2   │ │ 文件1   │  ← 旧的
  │ (旧)    │ │ (旧)    │ │ (旧)    │ │ (新CoW) │
  └────────┘ └────────┘ └────────┘ └────────┘
     垃圾       垃圾       垃圾       有效

为什么要这样分层?因为元数据很“小“(几字节一条记录),适合紧凑日志;数据块很“大“(4KB一块),做CoW更自然。两者用同样的垃圾回收机制(遍历有效块、搬迁到新块、擦除旧块)统一管理。


下集预告

LFS给Flash文件系统提供了理论基础。但在真实世界——在2026年的嵌入式开发中——你不是从零开始的。已经有一批Flash文件系统在不同领域服役:LittleFS在ARM Mbed上运行、SPIFFS在ESP32上运行、YAFFS和JFFS2在Linux上跑了二十年、F2FS在Android手机上服务了几亿设备。

下一节,我们铺开一张地图——给你看一遍这个“嵌入式Flash文件系统“的全家福。看看它们各自的设计哲学、数据结构和生死抉择。也看看为什么KnotFS不在这张地图上跟它们竞争,而是走一条完全不同的路。

悬念留给:你猜LittleFS为了节省RAM,怎么做了一个整个文件系统在一棵“链式元数据对“上的设计?两棵四层级的元数据树,在4KB RAM的微控制器上,怎么装下32MB Flash的全套文件系统?