文件系统实现:布局、日志、COW 与恢复

本页目录

文件系统能正常挂载,并不说明崩溃前的两次业务写入同时保留下来。下面把块布局、结构恢复、数据校验和历史恢复分别展开,避免用一个特性概括全部保障。

要解释的现象阅读位置
文件偏移怎样找到设备块?第 1 节:extent 手算
日志和 COW 各怎样恢复?第 2~4 节:ext4、XFS、Btrfs
设备分层还需要哪些协调?第 5 节:bcachefs
如何在相同条件下比较?第 6 节:能力边界与计算实验

1. Extent 是连续范围映射,不是常数时间魔法

设块大小为 4 KiB,extent 描述“逻辑块 100 起的 8 块,对应物理块 900 起的 8 块”。访问 102 × 4096 + 17 时:

步骤计算
定位 extent 内相对块102 − 100 = 2
映射到物理块900 + 2 = 902
保留块内偏移第 902 块内的第 17 字节

物理块号仍属于文件系统层;底下还可能经过卷管理或设备映射。

extent 节省的是连续区域的映射记录:8 个块可用一条记录描述,而不是每块一个指针。间接块设计并不是查找第 N 块就线性扫描 N 个指针;它可通过索引逐级定位。extent 的树查找代价也取决于树高与碎片数量,不能概括成“间接块 O(N),extent O(1)”。

ext4 的 inode 中有 60 字节区域容纳 extent 树根:12 字节头部之后可容纳 4 条 12 字节记录。树高为零时这些是叶子 extent;长大后根中放索引,不能继续说“前四段数据永远直接留在 inode”。ee_len 还有 initialized/unwritten 的编码规则,不只是未经解释的普通长度。见 ext4 inode 与块映射。

三种“还没写入”,占用关系并不一样

状态物理位置与读取语义
空洞通常尚无数据块
unwritten extent已保留块,读取呈现零,写入后转换有效状态
延迟分配缓存阶段推迟物理位置决定

延迟分配有利于聚合,但后续写回仍可能分配失败;预分配也不保证未来元数据更新或 COW 永不缺空间。

下面把“读出来都是零”和“已经分配物理块”分开画。对空洞、unwritten、普通与共享范围分别写入,观察位置和另一份文件内容是否一起改变。

正在呈现知识画面
同样读出零,占用关系却不同

空洞没有数据块,unwritten 已预留但读取为零。共享 extent 的写入会分离相应范围,另一文件仍引用旧内容。

2. ext4:日志保护什么,ordered 又增加了什么

JBD2 将一组元数据修改写入日志,建立有效提交记录后,可在稍后 checkpoint 到其最终位置。崩溃恢复重放有效事务,使目录、分配位图、inode 等相关修改不至于停在不一致的中间状态。commit 是日志事务可恢复的边界,checkpoint 是向最终位置推进,两者不是同一时刻。

数据模式用户数据与日志的关系应用仍需处理什么
data=ordered通常只记录元数据,对相关数据与元数据提交施加顺序约束原地改写及多次 write 不自动成为应用事务
data=writeback不提供 ordered 模式的那组数据先行约束恢复后内容可能不是应用期待的新数据
data=journal数据也经过日志仍需定义应用提交点并检查同步结果

例如应用连续覆盖同一文件的两个区域,即使文件系统元数据完全合法,崩溃后也不应凭 data=ordered 就认定两处必定同时为新版本。日志一致性关心文件系统可恢复结构,应用的一笔业务还需要自己的原子更新协议。见 ext4 日志说明。

延迟分配和后台写回可扩大内存中尚未持久化的数据集合,但“崩溃丢脏页”并非延迟分配独有。是否必须在此处保留修改,应由 fsync、同步打开等接口表达,而不是猜测后台几秒后会刷完。

3. XFS:把分配并发分散到 AG

AG 是 Allocation Group。各组管理本地空闲空间和 inode 分配信息,减少所有分配请求挤在一个全局结构上的压力。文件的数据可分布在多个 AG,AG 也不是每个 CPU 固定独占的分区;日志、同一 inode 的并发修改与设备吞吐仍可能成为共享瓶颈。

XFS 用不同索引回答不同问题:按地址与按大小组织的空闲空间索引用于寻找合适区域;inode 分配树描述 inode chunk 及使用情况;文件自己的 extent 映射描述逻辑偏移到物理区域。不能把 inode 分配树简单写成“每个叶子都存放一个完整 inode”。小规模记录还可能以内嵌形式存在,并非每次访问都必然遍历一棵外部 B+tree。

共享 extent:独立文件仍可共享内容

支持 reflink 的文件系统可让两个不同 inode 共享数据 extent,修改共享范围时执行 COW。复制并非硬链接:两者名字与元数据独立,之后改写一方不会按硬链接语义改动另一方。XFS reflink 和 Btrfs 都能按范围共享和分裂,不能以“extent 级与整个文件级 COW”区分二者。

现代 XFS 的自描述元数据可包含校验、所属对象与位置等信息,以发现损坏或写错位置;这不等于普通用户文件数据也有同样的端到端校验。见 XFS 元数据完整性设计。

4. Btrfs:更新一条引用路径

COW 与快照:改写路径,保留仍被引用的旧块 快照根 R₀ 保留旧版本 当前根 R₁ 提交后的新版本 旧元数据 M₀ 新元数据 M₁ 旧数据 A 未修改数据 B 新数据 A′ 修改 A 不要求复制整个文件;快照引用仍在,旧 A 就不能回收。

默认 COW 数据路径将修改写到新位置,再更新相关元数据引用。事务提交需要满足写入顺序和根的有效性要求;不是“每个指针单独原子写,所以任何中间状态都安全”。旧 extent 只有在当前树、快照及其他引用都不需要它时才可回收。

快照通常先共享已有结构和数据,创建不需复制整个子卷内容,但并非任何规模与负载下都严格 O(1)。之后保留的旧版本才会逐步消耗空间;删除文件后,快照仍可能保留它占用的块。子卷有自己的文件树,可以单独挂载,但不是独立物理卷,也不会自动形成 mount namespace 隔离。嵌套子卷的内容不会被父子卷快照递归收入。

COW 也不要求小修改永远复制原来完整的大 extent,实际会按实现及压缩单元等规则拆分/重写;压缩、碎片与共享程度影响写放大。启用 NOCOW 等配置又有不同的数据校验与共享写入限制。Btrfs 存在用于加速 fsync 的 tree log,故“COW 文件系统不需要任何日志”同样不成立。见 Btrfs 特性说明 与 子卷语义。

检测、修复、历史恢复是三个能力

需要的能力必须具备什么不能顺带保证什么
检测损坏内容与校验记录可比较检出不等于能重建
修复损坏另一份可验证的正确副本镜像不自动保留删除前历史
恢复历史快照或历史版本仍在同池快照仍共享故障域
应对整池损失独立故障域中的备份仍需验证恢复流程

因此“两块盘”“有校验”“有快照”是不同条件,不能用其中一个替代其他条件。

Btrfs 的数据校验算法可以配置,不应把所有文件都写成固定 CRC32C;数据与元数据 profile 也可不同。“有两块盘”不能替代检查实际 profile、设备错误与剩余可用副本。

5. bcachefs:分层放置与回收也是设计重点

bcachefs 结合 extent 映射、B-tree、日志、校验、压缩与多设备管理。快速设备承接写入或缓存、较慢设备存放后台迁移数据,涉及放置目标、副本数量、后台迁移和空间回收之间的协调。copygc 将仍有效的数据搬离回收区域,因此用户写入量与设备总写入量可能不同。

日志不是一个可随意去掉的装饰性选项,“COW 为主,可选 journal”不能说明其恢复协议。比较时应看持久提交条件、数据与元数据副本策略、设备故障后的恢复路径,而不是把项目宣传目标写成已经证明的性能结论。

版本与部署边界

版本核对:2026-09-08。 bcachefs 曾于 Linux 6.7 进入主线,但官方目前说明从 6.18 起不再随主线内核分发,转为树外 DKMS 模块。部署和升级时需要同时核对模块、工具、目标内核与磁盘格式的兼容性,不能再仅写“6.7+ 内核自带”。见 bcachefs 官方说明 与 官方源码仓库。

6. 用能力边界比较,而不是给出固定冠军

机制ext4XFSBtrfs
典型元数据恢复方式JBD2 日志元数据日志COW 事务与 tree log 等机制
文件数据共享不提供常规原生 reflink支持 reflink 的格式可用reflink 与子卷快照
普通数据校验不普遍提供内容校验不普遍提供内容校验通常提供,受配置影响
空间组织关注点块组、extent、延迟分配AG、extent、多种分配索引块组、profile、共享引用

容量上限取决于块大小、格式特性、内核实现和工具版本,不宜把格式理论位数直接写成用户可用上限。性能比较应固定数据集、并发、同步频率、剩余空间与快照状态;短时间缓冲写入只测到内存吸收速度时,无法说明设备持续能力。

下面的纯计算示例检查逻辑范围映射,不修改设备:

extents = [(100, 8, 900), (108, 4, 3000)]  # 逻辑起点、长度、物理起点

def locate(byte_offset, block_size=4096):
    if byte_offset < 0:
        raise ValueError("negative offset")
    logical, within = divmod(byte_offset, block_size)
    for start, count, physical in extents:
        if start <= logical < start + count:
            return physical + logical - start, within
    return None  # 本模型中表示空洞;未建模 unwritten/delalloc

assert locate(102 * 4096 + 17) == (902, 17)
assert locate(108 * 4096) == (3000, 0)
assert locate(112 * 4096) is None
print("extent 范围和边界:通过")

持久更新的应用协议接 VFS 进阶,存储池、TXG 与 ZIL 接 ZFS。