这一关回答:机器在写文件到一半时断电,ext4 如何保证元数据不损坏。答案是 journaling(jbd2):所有元数据修改先打包进 transaction,commit 时顺序写入 journal(一块连续的日志区域),写完 commit record 后再 checkpoint 回写原位置。崩溃后 recovery 扫描 journal:有完整 commit record 的事务重放(replay),没有的直接丢弃——事务要么全生效,要么全没生效,永远没有“一半”。
三个机制支点:jbd2 的 handle / transaction / commit 三级结构(handle_t 是单次原子更新,多个 handle 聚成 transaction,commit 是落盘单位)、ext4 的三种 data mode(data=journal / data=ordered / data=writeback——区别只在“数据进不进 journal、以及什么顺序落盘”)、ext4_sync_file(fsync = 等数据落盘 + 等 transaction commit + 必要时刷设备缓存;“fsync 只保证顺序不保证落盘”是对照源码就能证伪的流言)。
jbd2 里一次“原子更新”叫 handle_t——头文件注释原话:“represents a single atomic update being performed by some process”。ext4 每次修改元数据前调用 jbd2_journal_start(journal, nblocks) 拿 handle,nblocks 是这次操作预计要碰的块数(credits),jbd2 用它做日志空间的预算管理:空间不够就先 checkpoint 腾地方,绝不让 commit 中途发现“日志写不下”。
多个 handle 聚成一个 transaction(running 状态),commit 是落盘单位。commit 由专职内核线程 kjournald2 执行(线程名 "jbd2/%s"),触发条件有二:transaction 满了,或超时——JBD2_DEFAULT_MAX_COMMIT_AGE 5,注释写明“the default maximum commit age, in seconds”。journal 里的块有类型:descriptor(描述“接下来这些日志块要写回哪里”)、commit record(提交点)、revoke(作废记录)。
kjournald2 的源码注释把两个职责分得清清楚楚:COMMIT——每隔一段时间把当前状态提交到磁盘(transaction 内容顺序写入 journal);CHECKPOINT——日志空间是环形复用的,在复用一段日志之前,必须先把其中的元数据写回文件系统原位置,否则重放时无据可查。
这个顺序是崩溃一致性的全部秘密:commit 是顺序追加写(journal 区域连续,写放大友好),commit record 是原子提交点;checkpoint 是随机的原位置回写,可以慢慢来。崩溃发生在 commit 完成前 → 该事务无 commit record → 丢弃;崩溃发生在 checkpoint 完成前 → journal 里有完整的 commit record → 重放。“先写日志、再写原位置”把“写一半”变成了“可判定”的两种状态。
mount 时 recovery 扫描 journal(看 s_sequence / s_start 定位日志头):有 commit record 的事务按 descriptor 重放回原位置,没有的跳过。revoke 解决一个刁钻的竞态:块 A 在事务 T1 里被修改并记入日志,T1 还没 checkpoint 时块 A 被 truncate 释放、又分配给了新文件——如果此时崩溃重放 T1,会把旧内容写到已另作他用的块上。revoke record 就是“该块的旧日志作废”声明,重放时跳过被 revoke 的块。没有 revoke,日志重放本身会制造 corruption。
ext4 挂载时三选一(fs/ext4/super.c 的选项表与 _ext4_show_options 互为印证):
data=journal:数据也写进 journal。最安全也最慢——数据写两遍(journal + 原位置)。源码里挂着一串代价:禁用 delayed allocation、dioread_nolock、O_DIRECT 和 fast commit(ext4_check_journal_data_mode 明写警告)。data=ordered(默认,journal 支持 REVOKE 特性时):数据不进 journal,但保证“数据先于元数据落盘”。机制是 struct jbd2_inode——注释原话:“linking inodes in ordered mode present in a transaction so that we can sync them during commit”:transaction 记下哪些 inode 有脏数据,commit 前先把这些数据写回(writeback),再写 journal。崩溃后元数据指向的都是已落盘的数据。data=writeback:数据不进 journal,也不保证顺序。最快,但崩溃后文件可能看到旧数据(元数据已提交指向新块,数据还没写)。默认值的选择逻辑同样诚实:没指定 mode 时,有 REVOKE 能力的 journal 用 ordered,否则退回 journal——ordered 的“数据先行”依赖 revoke 机制兜底,没有 revoke 的老 journal 不敢用 ordered。
ext4 用 extent tree 做逻辑块到物理块的映射(ext4_ext_map_blocks(),fs/ext4/extents.c):树的根在 inode 的 i_data 里(小文件直接内联),index 节点指向下一层,leaf 记录“逻辑块区间 → 物理块区间”。相比 ext2 时代的间接块,extent 让大文件的映射项从 O(块数) 降到 O(区间数)——这是分配器层面的事,本关只取“映射长什么样”这一结论。
ext4_sync_file()(fs/ext4/fsync.c)是检验“fsync 语义”的试金石,三步走:(1) file_write_and_wait_range()——把脏数据页写下去并等待;(2) ext4_fsync_journal()——等待包含该 inode 变更的 transaction commit(目录等特殊文件走 ext4_force_commit 强制全量提交);(3) 若 journal 要求 barrier,blkdev_issue_flush()——给设备发缓存刷盘命令。
所以流言“fsync 不保证落盘、只保证顺序”对照源码是错的:fsync 返回成功意味着数据已写出、相关 transaction 已 commit、设备易失缓存已刷——它保证的是“返回时已持久”,代价是贵。但有两个诚实的边界:第一,目录项需要单独 fsync 目录——你 fsync 的是文件,文件名到 inode 的映射是父目录的元数据;内核只对“刚创建的文件”做了 ext4_sync_parent() 递归兜底(EXT4_STATE_NEWENTRY),rename 这类操作不在兜底范围内。第二,内核的保证止于“设备诚实执行 flush”——设备若虚报 flush,内核无能为力,这是硬件契约问题,不是内核语义问题。
为什么 journal 要“先顺序写日志、再回写原位置”,而不是直接写原位置?
因为“原子性”需要一个“全有或全无”的分界线。直接写原位置时,崩溃会留下“写了一半的元数据”——一半的 inode、一半的位图,fsck 都救不回来。而 journal 是顺序追加写,commit record 是天然的原子提交点:有它则全有,无它则全无,recovery 只需做二选一的判定。附带好处是顺序写对设备友好(连续区域、写放大低),随机回写的脏活 checkpoint 慢慢干——把“正确性关键路径”和“性能关键路径”拆成了两次写。
为什么默认是 ordered,而不是更安全的 journal 或更快的 writeback?
journal 把数据写两遍,吞吐直接减半——为“崩溃后数据一定最新”付全价,大多数场景不值;writeback 快,但崩溃后文件内容可能错乱(读到旧数据),对“元数据一致、内容可错乱”的承诺太弱。ordered 是中间点:数据只写一遍(原位置),但用“数据先于元数据”的顺序换来“元数据指向的总是有意义的数据”。代价是 commit 前必须等数据写回——用 commit 延迟换正确性,这是 ordered 唯一的税。
为什么 fsync 还要 blkdev_issue_flush,而不是等 journal commit 就够了?
因为 journal commit 只保证“数据到达了设备”——可能还在设备的易失写缓存里。断电时缓存里的东西照丢不误,recovery 扫描 journal 会发现 commit record 压根没落盘。flush(CACHE FLUSH 命令)是让设备把易失缓存刷到持久介质的唯一手段。内核的持久化保证是分层的:文件系统层保证顺序与原子性(journal),块层保证命令送达(M11),设备层保证介质落盘(flush)——每一层只承诺自己看得见的事。
往前看的连接:
kjournald2 是内核线程——journal 的 commit 与 checkpoint 有专职线程在跑,和 M01 的“内核线程借来的 mm”一样,是 task_struct 的又一用武之地。blkdev_issue_flush 在块层就是一条 REQ_OP_FLUSH 请求,走的正是 bio → request → hardware queue 这条路;ordered 模式“数据先落盘再提交元数据”的顺序契约,最终依赖块层按序交付。崩溃一致性的最后一公里,是块层发出去的 flush 命令。一句话总结 M12:journal 把“崩溃”从“不可推理的中间态”变成了“有 commit record 全有、无则全无”的二选一——一致性不是不丢,而是有明确的丢弃语义。
出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。
handle_t 是单次原子更新(“a single atomic update”),多个 handle 聚成一个 running 的 transaction,commit 是把 transaction 落盘的单位(由 kjournald2 执行)。nblocks 是这次操作预计要碰的块数(credits)——jbd2 的预算管理:日志空间不够就先 checkpoint 腾地方,绝不让 commit 写到一半发现日志满了。预算先行,是 commit 原子性的前提。
丢弃整个事务,当它没存在过。因为 recovery 的判定只看 commit record 是否完整:有则重放,无则跳过。commit record 能当原子提交点,是因为它是一次顺序追加写中的最后一个块——journal 写入是顺序的,要么这个块完整落盘(则前面的 descriptor 全在),要么没落盘(则整个事务不可见)。“写一半”的不确定性被压缩到了“一个块是否完整”这个可判定的问题上。
重放会把 T1 日志里块 A 的旧内容写回,而块 A 现在属于新文件——日志重放本身制造 corruption,把新文件的内容覆盖成旧数据。revoke record 是“该块的旧日志作废”声明:块被释放重用时记一笔 revoke,重放时跳过被 revoke 的块。记住这个因果链:块重用 + 日志重放 = 必须有作废机制,否则“恢复”比“崩溃”更危险。
data=journal:数据也进 journal,最安全最慢(写两遍,且禁用 delalloc / dioread_nolock / O_DIRECT / fast commit)。data=ordered:数据不进 journal,但保证数据先于元数据落盘(commit 前先写回脏数据)。data=writeback:数据不进 journal 也不保证顺序,最快,崩溃后可能读到旧数据。默认 ordered——源码逻辑:没指定 mode 时,journal 有 REVOKE 能力则 ordered,否则退回 journal(ordered 的“数据先行”需要 revoke 兜底)。
delayed allocation(delalloc)是“先不分配物理块、攒到写回时再分配”——它的前提是数据可以晚定去向。但 data=journal 要求数据当时就写进 journal,写 journal 必须知道物理块位置(descriptor 要记录“回写到哪里”),所以分配不能再延迟。“数据进日志”和“分配延迟”互斥:前者要求落笔时去向已定,后者要求落笔时去向未定。源码里 ext4_check_journal_data_mode 直接 clear_opt(sb, DELALLOC),不是优化选择,是机制冲突。
选 data=ordered。数据库的 WAL 已经在用户态实现了一次“先写日志再回写”(和 jbd2 同构),文件系统的 data=journal 是第二层 journal——数据写四遍(DB WAL + fs journal + 原位置×2 概念上),写放大翻倍,却没增加新的正确性(DB 崩溃恢复只认自己的 WAL)。ordered 只保证“元数据指向的数据有意义”,这正是 DB 需要的全部:WAL 恢复依赖的是“读到的数据是某次完整写的结果”,ordered 的顺序保证恰好给出这个。教训:分层系统中,每层只做自己层该做的保障,重复保障是纯成本。
错在把“保证”说反了。ext4_sync_file 三步:file_write_and_wait_range() 把脏数据写下去并等待;ext4_fsync_journal() 等待包含该 inode 变更的 transaction commit;若需 barrier 则 blkdev_issue_flush() 刷设备缓存。返回成功意味着数据已写出 + 相关事务已提交 + 设备易失缓存已刷——保证的是“返回时已持久”,不是“只保证顺序”。流言可能把“write 系统调用返回”(只进页缓存)错安在了 fsync 头上。fsync 的两个诚实边界:目录项要单独 fsync 目录;设备虚报 flush 时内核无能为力。
fsync(fd) 保证的是文件 a 的数据和 inode持久,但“目录里有 a 这个名字”这个映射是父目录的元数据,不在这次 fsync 的范围内。崩溃窗口里:a 的数据和 inode 已落盘,但目录项没落盘——重启后“文件存在但内容为空”是最幸运的情况(更糟的是文件名直接消失,inode 成孤儿)。修复:对父目录的 fd 也做一次 fsync(open(dir, O_DIRECTORY) 后 fsync)。内核只对“刚创建的文件”做了 ext4_sync_parent() 递归兜底,rename、link 这类目录操作不在兜底范围内——凡是改了目录结构的操作,fsync 目录。
影响是全局的:transaction 是整个文件系统共享的(一个 journal 一个 running transaction),commit 被大文件的慢写回拖住 → 同一 journal 上所有元数据提交和 fsync 都被推迟——一个大文件的 I/O 模式绑架了全文件系统的提交延迟。这是 ordered 唯一的税(“用 commit 延迟换正确性”)在高并发下的放大。缓解思路:(1) 应用层把大 IO 拆小、分散 fsync 节奏,避免单个事务携带巨量脏数据;(2) 大文件走 data=writeback 的独立挂载/子卷(如果业务容忍旧数据);(3) 批量写后一次性 fsync 而不是逐次 fsync,减少 commit 次数。核心洞察:journal 是共享资源,提交延迟是全局的——评估 fsync 延迟不能只看自己的 I/O,要看同 journal 上最慢的那个写者。
设计:N 个文件的修改全部用 jbd2_journal_start 拿 handle,但显式加入同一个 transaction(而不是让它们自然散落到不同事务)——jbd2 的原子单位本来就是 transaction,commit record 天然给出“N 个全有或全无”。关键点:(1) credits 预算要按 N 个文件的总和申请,避免中途空间不够;(2) 必须阻止中途的自动 commit 把 N 个文件拆到两个事务——需要持有某种“事务不提交”屏障直到 N 个全就绪(jbd2 有 jbd2_journal_lock_updates 这类屏障机制可用);(3) fsync 语义配合:单个文件的 fsync 只保证自己的事务提交,N 文件原子语义需要新的“组提交”原语——要么 N 个一起 fsync 触发同组事务的 commit,要么提供 syncfs 级的整文件系统提交。崩溃后:有 commit record 则 N 个全重放,无则全丢弃——把“应用层的多文件原子”翻译成“jbd2 的单事务原子”,这正是 journaling 抽象的复用。
kjournald2 线程,COMMIT 与 CHECKPOINT 的分工jbd2_journal_start、handle 与 credits 预算ext4_sync_file 三步:数据写回、等事务提交、刷设备缓存ext4_ext_map_blocks,extent tree 的逻辑→物理映射journal 的两阶段:commit(顺序写日志)→ checkpoint(回写原位置)
running transaction ──kjournald2──▶ ┌─────────────────┐
│ journal(环形) │
│ descriptor 块 │ “这些日志回写到哪里”
│ 日志数据块 │
│ commit record │◀── 原子提交点
└────────┬────────┘
│ checkpoint(可慢)
▼
文件系统原位置(inode/位图/目录块)
崩溃在 commit 前 → 无 commit record → 丢弃;
崩溃在 checkpoint 前 → 有 commit record → 重放。
“写一半”被变成了可判定的二选一。
三种 data mode:区别只在“数据进不进 journal、什么顺序落盘”
data=journal 数据 ──▶ journal ──▶ 原位置 写两遍,最慢最安全
(禁用 delalloc / O_DIRECT / fast commit)
data=ordered 数据 ──▶ 原位置(先) 默认。commit 前先写回
(默认) 元数据 ──▶ journal ──▶ 原位置 脏数据,保证顺序
data=writeback 数据 ──▶ 原位置(无序) 最快,崩溃后可能见旧数据
元数据 ──▶ journal ──▶ 原位置
记住:ordered 的“数据先行”靠 jbd2_inode 链表在 commit 前写回数据实现。
ext4_sync_file:fsync 返回成功到底意味着什么
fsync(fd)
├─ file_write_and_wait_range() 脏数据页写下去并等待
├─ ext4_fsync_journal() 等包含该 inode 的 transaction commit
│ (目录等特殊文件:ext4_force_commit)
└─ blkdev_issue_flush() 刷设备易失缓存(需 barrier 时)
│
▼ M11 的路:REQ_OP_FLUSH → bio → request → 硬件队列
“fsync 只保证顺序”是错的:它保证“返回时已持久”。
边界:目录项要单独 fsync 目录;设备虚报 flush 则保证作废。
在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。