文件系统能正常挂载,并不说明崩溃前的两次业务写入同时保留下来。下面把块布局、结构恢复、数据校验和历史恢复 分别展开,避免用一个特性概括全部保障。
要解释的现象 阅读位置
文件偏移怎样找到设备块? 第 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 的写入会分离相应范围,另一文件仍引用旧内容。
{"id":"extent-states-and-cow","title":"同样读出零,占用关系却不同","summary":"空洞没有数据块,unwritten 已预留但读取为零。共享 extent 的写入会分离相应范围,另一文件仍引用旧内容。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><p class=\"caption\" data-i18n=\"logical\"></p><div class=\"choices\"><button data-block=\"0\">0</button><button data-block=\"1\">1</button><button data-block=\"2\">2</button><button data-block=\"3\">3</button><button data-block=\"4\">4</button><button data-block=\"5\">5</button></div><div id=\"state\" class=\"readout\"></div><div class=\"actions\"><button id=\"read\" data-i18n=\"read\"></button><button id=\"write\" data-i18n=\"write\"></button></div><div class=\"track-row\"><span data-i18n=\"mapping\"></span><div id=\"mapping\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"other\"></span><div id=\"other\" class=\"track\"></div></div><p class=\"caption\" data-i18n=\"result\"></p><div id=\"result\" class=\"value\">—</div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p></div><div class=\"actions\"><button id=\"reset\" data-i18n=\"reset\"></button></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n.choices{grid-template-columns:repeat(6,minmax(0,1fr))}.track{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.cell{min-height:65px}.readout{min-height:4.8em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let chosen=0,state,pfn,data,serial,result='—';function init(){state=['hole','unwritten','written','shared','shared','hole'];pfn=[null,100,101,102,103,null];data=['0','0','A','B','C','0'];serial=200;result='—';}function draw(){document.querySelectorAll('[data-block]').forEach(e=>e.setAttribute('aria-current',String(+e.dataset.block===chosen)));set('state','L'+chosen+' · '+t(state[chosen])+'\\n'+(pfn[chosen]===null?'—':'P'+pfn[chosen]));cells('mapping',pfn.map((p,i)=>({text:'L'+i+' → '+(p===null?'—':'P'+p)+' ['+data[i]+']',cls:i===chosen?'on':state[i]==='shared'?'gold':p===null?'empty':''})));cells('other',Array.from({length:6},(_,i)=>({text:'L'+i+' → '+(i===3?'P102 [B]':i===4?'P103 [C]':'—'),cls:i===3||i===4?'gold':'empty'})));set('result',result);set('explanation',t('note'));}document.querySelectorAll('[data-block]').forEach(e=>e.onclick=()=>{chosen=+e.dataset.block;draw();});$('#read').onclick=()=>{result='L'+chosen+' → '+data[chosen];draw();};$('#write').onclick=()=>{if(state[chosen]==='hole'||state[chosen]==='shared')pfn[chosen]=serial++;state[chosen]='written';data[chosen]='N';draw();};$('#reset').onclick=()=>{init();draw();};init();draw();","audio":false,"strings":{"heading":"同样读出零,占用关系却不同","hole":"空洞","intro":"选择一个逻辑块,读取或写入 N。重点比较空洞、预留未写块和共享块:写后哪些映射改变,另一份文件是否也改变?","logical":"当前文件:逻辑块编号","mapping":"当前文件 → 物理块","note":"观察箭头终点和旧内容。读取空洞不会在这个模型中分配数据块;写共享块不能直接覆盖另一文件仍引用的块。","other":"另一文件仍保留的共享内容","read":"读取所选块","reset":"重置","result":"最近的读取结果","scope":"六个 4 KiB 逻辑块;一次操作覆盖整块,不包含缓存、延迟分配、压缩和持久性。下行是各逻辑块的映射说明,不按物理距离画比例。共享块写入用一个新块,是范围 COW 的教学粒度。","scopeLabel":"模型范围与约定","shared":"共享有效块","unwritten":"预留,读零","write":"整块写入 N","written":"独占有效块"}}
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. 用能力边界比较,而不是给出固定冠军
机制 ext4 XFS Btrfs
典型元数据恢复方式 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
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 。