这是文件系统系列的第 6 篇。前面几篇我们把 VFS、挂载、块设备层这些外围机制大致过了一遍,今天终于要正面硬刚 Linux 上最常见、也最能打的文件系统:ext4。很多人每天都在mkfs.ext4、mount、df -h,但真被问到底层原理时,能讲清楚 extent 和 ext3 间接块区别、延迟分配为什么能提速、journal 到底在保护什么的人其实不多。这篇我用“运维 + 内核源码视角”的方式把 ext4 的工作原理完整捋一遍。不管你是写应用、搞嵌入式、调内核,还是准备面试,这篇都能给你一套能落地的认知框架。
1. 先认清文件系统的位置:VFS 与挂载机制
1.1 一次 write 调用,数据到底经过了谁
很多初学者有个误区,觉得write()就是把数据“写进硬盘”。严格说,write()只是把数据从用户态缓冲区拷贝到内核的 page cache 里,真正的落盘动作发生在后台的 writeback 机制中。流程大致是:
ssize_t write(int fd, const void *buf, size_t count);这个系统调用进来后,VFS 会根据文件描述符找到对应的struct file,再通过file->f_op->write_iter进入具体文件系统。对 ext4 来说,调用链会走ext4_file_write_iter,最终把数据写到页缓存中并标记为脏页。脏页什么时候回写,由内核的 writeback 线程根据水位和超时机制决定,不是应用写一次就立刻落盘。
这套异步模型是理解 ext4 一切行为的地基。比如你问“为什么我cp大文件然后马上断电,文件大小是对的但内容却是旧数据?”答案就在这里:大小变更属于元数据,可能已经被 journal 保护;数据内容是否落盘,取决于 writeback 的时间点。这也是为什么数据库这类应用必须自己fsync,否则操作系统不会替你保证持久性。
1.2 挂载流程:ext4 是怎么“接管”一个分区的
执行mount -t ext4 /dev/sdb1 /mnt时,发生的事情远比想象中多。VFS 首先分配一个空的super_block对象,然后调 ext4 的ext4_fill_super去读取磁盘上的超级块,校验魔数、特性标志、UUID 等关键信息,再把块大小、inode 数量、日志信息载入内存。整个过程在 dmesg 里可以看到类似:
EXT4-fs (sdb1): mounted filesystem with ordered data mode. Opts: (null)这条日志里的ordered data mode是日志模式,后面专门讲。挂载成功后,VFS 通过 root inode 找到/目录,后续所有路径查询都从它开始。
启动阶段也一样,只不过顺序更拧巴:内核先挂载一个内存中的 rootfs,加载 initramfs,再由 initramfs 里的脚本把真正的根文件系统挂载到/root,最后switch_root切换过去。嵌入式开发里,很多人喜欢在调试阶段用 NFS 挂载根文件系统,方便改动后立即生效;生产环境再换回 ext4。这种开发模式本身没问题,但你要清楚,NFS 挂载的语义和本地 ext4 不一样,比如sync的作用范围、锁的语义、掉电恢复能力,都不能直接套用本地文件系统的经验。
2. 物理布局:块组、位图与 flex_bg
2.1 整个磁盘的“街区规划”
ext4 会把分区划分成很多个“块组”,每个块组负责管理一部分连续的数据块。这样做的好处很朴素:元数据离数据近,文件在组内创建时,inode 和数据块往往都在同一个组,磁盘寻道时间短。每个块组内部大致是这样的结构:
- 块 0:引导扇区,可用于存放引导程序,普通数据分区通常为空
- 超级块和组描述符:记录整个文件系统的全局信息,以及每组的基础元数据
- 块位图:用一位(bit)表示一个块是否被占用
- inode 位图:用一位表示一个 inode 是否被占用
- inode 表:存放该组所有 inode 的实际结构
- 数据块区:真正保存文件内容、目录项、扩展属性等数据的地方
超级块不是只存一份,而是在部分块组里有备份。原因很直白:如果主超级块损坏,文件系统就直接废了。备份超级块的存放位置有固定规律,后续排查章节我们会用到,这里先知道有这个设计。
块位图和 inode 位图相当于“房产登记册”,创建文件时先在 inode 位图里找空闲 inode,再在块位图里找空闲数据块。两本册子都必须在修改数据前更新,否则断电后会出现“文件占用了别人数据块”的严重问题。所以大家在日志里常看到的“bitmap mismatch”等报错,本质就是这两本册子和实际占用对不上了。
2.2 flex_bg:把块组“捆”起来管理
ext3 时代每个块组单独存放位图和 inode 表,创建大量文件时,要来回读取不同组里的元数据,磁头到处乱跑。ext4 引入 flex_bg 特性,把相邻若干个块组(通常是 16 个)合并成一个“弹性块组”,这些组的块位图、inode 位图、inode 表被集中放在这个弹性块组起始附近的一块连续区域里。
这样做的好处非常大:inode 表连续,元数据读取有更好的局部性;后续分配时也能更从容地找到连续数据块。实践里,用 ext4 格式化一个分区后,tune2fs -l里如果看到flex_bg特性,说明已经启用了。这也是 ext4 和 ext3 在面对“上百万小文件”这类场景时,性能差别明显的重要来源之一。
2.3 块大小选择:4K 不是唯一答案
mkfs.ext4默认块大小是 4096 字节,但可以用-b 1024、-b 2048调整。块越小,小文件浪费的空间越少,但单 block 能管理的位图范围有限,大分区会需要更多块组,元数据总量变大。块越大,大文件连续读写的吞吐越高,但小文件最后一截按块对齐浪费也越明显。
举个例子,一个文件只有 100 字节,用 4K 块意味着还是要占一个完整的 4096 字节块,剩余空间直接浪费。如果是几百万个小文件,浪费加起来非常可观。反过来,一个 2GB 的视频文件,用 1K 块会让数据块数量翻倍,日志和 extent 数量都会增加,读写效率反而不如 4K。我的建议是:没有特殊需求就保持 4K,它和 CPU 页缓存、磁盘扇区之间配合得最好;嵌入式场景如果 flash 空间极紧并且文件普遍很小,可以考虑 1K 或 2K,但要接受元数据复杂度上升。
3. 核心寻址:inode、extent 与内联数据
3.1 inode 到底存了什么
inode 是文件系统的核心结构,你可以把它理解成“档案袋”。里面不存文件名,而是存权限、属主、大小、时间戳、数据块地址等信息。执行stat可以看到这些信息:
$ stat app.log File: app.log Size: 1024 Blocks: 8 IO Block: 4096 regular file Device: fd01h/64769d Inode: 265842 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 1000/ user) Gid: ( 1000/ user) Access: 2024-05-20 10:00:00.000000000 +0800 Modify: 2024-05-20 10:05:00.000000000 +0800 Change: 2024-05-20 10:05:00.000000000 +0800 Birth: 2024-05-20 09:59:00.000000000 +0800注意这里的Links,它表示硬链接数。每次创建一个硬链接,Links就加一;删除一个硬链接,就减一。只有当链接数降到 0,inode 才会被真正释放。所以mv文件名时 inode 号不变,因为只改了目录项;cp是创建新 inode,所以 inode 号一定会变。这个细节在排查“为什么磁盘空间满了但找不到大文件”时特别重要,后面会再提。
老读者可能会问:那符号链接呢?符号链接是一种特殊文件,它保存的是目标路径字符串。如果目标路径很短,比如 60 字节以内,这个字符串可以直接存在 inode 的数据块指针区域里,不需要额外分配一个数据块。这是 ext4 里一个容易忽略的省空间设计。
3.2 extent 树:从“指针数组”到“区间描述”
ext3 时代用间接块寻址:inode 里放 12 个直接块指针,不够就指向一个间接块,间接块里再存更多指针,还不够就是双重间接、三重间接。这种方案有几个缺点:大文件寻址层级多,读完一个间接块才能拿到下一批指针;文件碎片化时,指针数量爆炸;写日志时,修改这些间接块本身的频率也高。
ext4 改用 extent 树。所谓 extent,就是“一段连续的物理块区间”。一个 extent 用三个关键字段描述:起始逻辑块、长度、起始物理块。比如一个文件从逻辑块 1000 开始,连续占据 32768 个块,在 inode 里只需要一条 extent 记录,而不是 32768 个指针。
extent 树的结构可以简单理解成这样:
inode 的 i_block 区域保存 extent 树根 ├── 如果只有少量 extent,树根直接就是叶子 └── 如果 extent 很多,树根变成索引节点 ├── 指向下一级索引 └── 最终指向叶子 extent每个叶子 extents 能描述的最大范围是 32768 个块,乘以 4K 块大小就是 128MB。所以一个 2GB 文件理论上至少需要 16 条 extent 记录。实际上文件连续程度好的话,extent 数量极少,这也是 ext4 顺序读写性能出色的原因之一。
用一个实际命令看最直观:
$ filefrag -v large.bin Filesystem type is: ef53 File size of large.bin is 1073741824 (262144 blocks of 4096 bytes) ext: logical_offset: physical_offset: length: expected: 0: 0.. 32767: 345678.. 378445: 32768: 1: 32768.. 65535: 378446.. 411213: 32768:如果输出的 extent 数量很少且长度都接近 32768,说明文件非常连续;如果出现一堆长度为 8、16 的小 extent,说明文件碎片化严重。这个命令我在所有排查性能问题的场合都会先跑一遍。
3.3 小文件藏哪里:内联数据
ext4 还有一个被很多人忽略的功能:inline_data。这个特性允许小文件内容直接放进 inode 结构里,最多能存大约一百多字节,硬件上不额外分配数据块。对小文件极多的目录,比如/etc下面一堆几十字节的配置文件,能省下大量块。嵌入式系统上这个特性很受欢迎,因为 flash 的块本来就珍贵。
要确认有没有开启,可以看 mount options 或tune2fs -l里的 features。老系统上如果没开启,用tune2fs -O inline_data可以加,但要注意:部分旧内核和旧 e2fsprogs 不识别该特性,存在兼容风险。生产环境别乱加,先在测试机上验证。
4. 日志子系统:崩溃恢复的看门人
4.1 为什么不能只靠“写完再插拔”
磁盘写入不是原子的。一个 4K 的块,在硬件层面可能被拆成多次扇区写入;即使一个扇区,也可能因为掉电出现“半个扇区”的情况。文件系统要执行一个涉及多个块修改的操作,比如创建文件,必须同时更新 inode 表、块位图、目录项。如果这些更新只做了一半就断电,盘上就会留下不一致状态:inode 占用了某块,但块位图说那块是空的。
解决思路就是把“要做的多步修改”先记到一个日志区域。真正修改磁盘之前,先把“如何修改”写进日志,等日志安全落盘,再去真正改数据。崩溃后重新挂载时,文件系统根据日志重放或丢弃这些修改,保证元数据一致。这就是 JBD2 日志系统干的事。
4.2 三种日志模式与安全性分级
ext4 沿用了 ext3 的三种日志模式,区别在于“数据块”是否也进日志:
| 模式 | 行为 | 安全性 | 典型场景 |
|---|---|---|---|
data=journal | 数据先写进日志,再写正式位置,双写开销大 | 最安全,即使数据块损坏也能从日志恢复 | 对数据安全极其苛刻的环境 |
data=ordered | 日志只记元数据,但强制数据块先于元数据落盘 | 高,既保证一致性,性能也可接受 | ext4 默认模式 |
data=writeback | 日志只记元数据,数据落盘顺序不定 | 较低,可能崩出“内容错乱但结构正常”的文件 | 追求性能且能接受数据风险 |
默认是ordered,这也是绝大多数发行版的初始值。需要提醒的是,不要把ordered理解为“文件内容一定最新”。它保证的是:如果一个文件的元数据被持久化,那么它对应的数据块至少在元数据落盘之前已经开始写了。但用户进程在write()返回后、fsync()之前崩溃,文件内容仍可能丢失或部分丢失。
4.3 故障恢复:重放与孤儿清理
挂载时如果发现日志非空,ext4 会进入 recovery 流程,JBD2 会把已提交但未完全应用的事务重新执行一遍。dmesg 里会看到类似输出:
EXT4-fs (sdb1): recovery complete EXT4-fs (sdb1): mounted filesystem with ordered data mode还有一种情况是“孤儿 inode 清理”。文件被删除时,如果删除操作没有完成,inode 会先被挂到一个孤儿链上,等崩溃恢复后统一清理。这也是为什么异常断电后,磁盘上偶尔会看到lost+found里多出一些文件——那就是恢复时找不到合法目录项的孤立文件。
我干运维时最怕看到有人为了“提升性能”把barrier=0加上。barrier 是保证 IO 请求顺序的手段,关掉后日志可能先于数据落盘,出现“日志说改完了但实际数据没改”的反转问题。实验室环境无所谓,生产库千万别这么干。
4.4 fast commit:下一代优化点
近几年内核合入了 fast commit 特性,目标是显著降低fsync的延迟。原理很简单:完整事务要覆盖大量元数据,太慢;fast commit 只记录极小的提交信息,配合主日志使用,让大部分fsync只落盘一小段快速日志。这个特性对数据库这类频繁fsync的应用很有价值。
实际用不用得看特点和内核版本,新内核配合新 e2fsprogs 才有意义。我记得这个特性在 5.10 之后逐步完善,如果你在做高并发写入的测试,可以关注一下。
5. 块分配策略:延迟分配、mballoc 与预分配
5.1 延迟分配:先攒着,再一次性给够
ext4 性能提升最关键的一点,我认为是延迟分配(delayed allocation)。它的核心思想是:写文件时不急着分配物理块,先把数据放在 page cache 里,等 writeback 真正要落盘时,一次性把所有脏页的所有块分配出来。
这个方法的好处是,文件系统在分配时能看清“这次要写多少块”,于是可以一次性申请一大段连续空间,而不是 application 每写 4K 就去找一个块。后者正是 ext3 碎片化的主要原因之一。从语义上讲,用户看到文件大小在增长,但底层块还没分配;直到sync或后台回写,物理块才真正被占用。这也是为什么断电前后df显示的空间使用情况往往和文件名、大小对不上。
5.2 mballoc:多块分配器是怎么找空闲区的
mballoc(multi-block allocator)是 ext4 的块分配核心。它会尽量在同一个块组里凑出连续空间,如果当前组不行就换下一组。它还维护了一些预分配组,专门给顺序写场景使用,减少碎片。
工具层面有个小技巧:如果知道文件将来会很大,可以先用fallocate预分配空间,比如虚拟机磁盘镜像:
fallocate -l 20G vm.img这样 ext4 会提前把 20G 范围内的块分配好并记录成“未初始化” extent。读取该区域时文件系统保证返回全零,所以是安全的。对数据库和超大日志文件,预分配能显著降低运行期间空间分配和碎片整理的负担。
5.3 碎片怎么量化,怎么拆
判断文件是否碎片化,除了前面说的filefrag -v,还可以看文件大小是不是和实际占用的 logical block 数对得上。如果filefrag输出的 extent 数量远超预期,考虑整理或重写。简单粗暴的办法是:
cp file file.tmp && mv file.tmp filecp 会产生新的连续分配,mv 后新旧 inode 交换,碎片就顺了一遍。不过 VFS 的 page cache 可能让这个操作的热数据仍然在缓存里,如果想强制看到磁盘上的真实布局,先sync再执行。
6. 目录是怎么组织的:线性目录与 HTree
6.1 目录在磁盘上也是一种文件
很多人以为目录就是“一层一层的文件夹”,但磁盘上目录就是一个特殊文件,里面存的是目录项。每个目录项记录了三样关键东西:inode 号、文件名、文件类型。VFS 里的dentry缓存是内存概念,磁盘上并没有所谓的“目录树节点”。
当你访问/etc/hosts时,内核先读根目录 inode,找到etc这个目录项,拿到etc目录的 inode,再读etc目录文件,找到hosts目录项,最后定位到hosts文件的 inode。每个斜杠就是一次目录文件查找。
6.2 小目录线性扫,大目录用 HTree
如果目录项很少,直接在目录文件里从头到尾线性扫描就够快。但一个目录里放了几万、几十万个文件,线性扫描就是灾难。ext4 在目录达到一定规模(通常几块之后)会启用 HTree,也就是对文件名做哈希,建成一棵树形索引。这样查找一个文件只需要走哈希树,而不是翻遍整个目录。
我用一个在嵌入式设备上踩过的例子说明问题。当时设备上有个缓存目录,运行几个月后积累了近 30 万个文件。旧内核和文件系统下ls那个目录要等几十秒,换成 ext4 并启用目录索引后基本是秒开。日常使用中,如果你发现大目录操作明显变慢,可以用e2fsck -f重新校验和重建目录索引。不过要注意,HTree 索引和哈希值相关,cp、find这些命令遍历目录时得到的顺序不保证和创建时间一致,别在脚本里依赖顺序。
7. 日常运维:常用命令、挂载选项与避坑经验
7.1 mkfs 和 tune2fs 的关键参数
格式化命令里,我常用的几个参数是:
mkfs.ext4 -L mydata -m 1 -E lazy_itable_init=1 /dev/sdb1-L指定卷标;-m 1把保留块比例从默认的 5% 降成 1%,大容量分区上能省出不少空间;-E lazy_itable_init=1跳过初始化 inode 表的耗时操作,格式化能快很多,但在第一次大量写入时会略微变慢。另外,-O可以明确开启或关闭特性,比如:
mkfs.ext4 -O ^has_journal /dev/sdb1这样可以关闭日志,但要明确这是为了什么。日志一致性是 ext4 掉电安全的核心,非必要不要关。
格式化完成后,tune2fs -l是查看文件系统状态的瑞士军刀:
$ tune2fs -l /dev/sdb1 Filesystem features: has_journal ext_attr resize_inode dir_index filetype extent 64bit flex_bg ... Block count: 26214400 Reserved block count: 262144 Free blocks: 25067520 Inode count: 6553600 Free inodes: 6523100这里面每个字段都有实际价值:Reserved block count是保留块;Inode count和Free inodes用来排查“空间还有但 inode 用完了”的问题;Filesystem features一眼看出 ext4 开了哪些能力。
扩容也是老生常谈的话题。LVM 环境下lvextend之后直接resize2fs就能在线扩大文件系统,不需要卸载分区。我遇到过一次在线扩容失败,原因是临时分区没开flex_bg,resize 时找不到足够连续空间来移动元数据。所以早期分区规划时别乱关flex_bg。
7.2 挂载选项:是时候做个取舍表了
挂载选项对性能和安全性的影响很大,下面是我实践中整理过的一张速查表:
| 挂载选项 | 作用 | 注意点 |
|---|---|---|
defaults | 读写、suid、dev 等基础选项 | 总得有个起点 |
noatime/relatime | 减少 atime 更新频率 | 默认就是 relatime,没必要再激进关掉 atime |
commit=NN | 日志提交周期,默认 5 秒 | 调大能减少小写放大,但掉电丢数据窗口变大 |
data=ordered | 元数据日志 + 数据有序落盘 | 默认,安全性和性能平衡 |
barrier=1 | 保证关键 IO 顺序 | 默认开启;不要为了数字好看而关 |
discard/nodiscard | 块释放时要不要发 TRIM | SSD 上建议nodiscard+ 定期fstrim,避免每次删除都触发 TRIM |
inline_data | 允许小文件内容塞进 inode | 看特性支持情况 |
嵌入式场景里,我经常给 eMMC 用noatime,commit=60,减少不必要的元数据写入,延长 flash 寿命。但同样要考虑崩溃后丢数据的窗口,如果设备会随机断电,commit=5还是更稳。
7.3 嵌入式与特殊场景:ext4 不是唯一解
热搜词里有人提到 PlatformIO 使用 littlefs 之类的问题,这让我想多说一句选型。ext4 设计目标是通用块设备,适合 eMMC、SD 卡、SSD 和机械硬盘。但在小容量 NOR Flash、以及掉电频繁的 IoT 设备上,littlefs 这类专为 flash 设计的文件系统往往更合适,因为它有磨损均衡、掉电保护、O(1) 的目录操作,元数据占用也很小。开发时用 NFS 挂载根文件系统方便调试,正式量产则老老实实用 ext4 或 littlefs,别混着来。
8. 常见故障与排查实录:从报错到根因
8.1 “空间满了”但 df 看起来没事
这是运维最经典的坑:应用报“No space left on device”,但df -h显示还有几个 G。原因大概率是 inode 用完了。文件系统里能创建文件的数量由Inode count决定,不是由可写字节数决定。执行下面两条命令对照:
df -h df -i如果df -i的IUse%是 100%,那就去删点小文件,或者用find把海量小文件归类处理。还有一种更隐蔽的情况:某个进程删了文件但还持有打开的文件句柄,空间其实已经被释放,但df一直显示占用。用lsof +L1能列出这类已删除但未被释放的文件,定位后重启进程即可。
8.2 目录索引损坏:ext4_find_entry 报错
如果 dmesg 里出现类似EXT4-fs error: ext4_find_entry: 目录索引损坏的信息,通常是目录的 HTree 索引和实际目录项对不上了。这种情况先别慌,也别强行继续写入。正确做法是卸载分区或重启后执行:
e2fsck -f /dev/sdb1它会扫描目录结构,重建索引并修复不一致。我见过有人为图省事直接加-y一路 yes,结果把一些可恢复的目录项当成垃圾删掉。第一次修复别急着-y,先不加参数跑一遍看它准备干什么,心里有数再加。还有,修复前如果分区很重要,先dd做镜像,风险控制永远是第一步。
8.3 超级块损坏:备用超快的救命用法
主超级块损坏时,直接mount会失败,报错信息会说“wrong fs type, bad superblock”。ext4 的备份超级块有固定位置,比如在块组 1、3、5、7 以及 3 的幂次组里,常见备份位置是 32768、98304、163840 等。不知道具体位置时,可以用:
mke2fs -n /dev/sdb1-n只打印参数不真正格式化,也能输出备份超级块位置。然后用指定的备份块修复:
fsck.ext4 -b 32768 -y /dev/sdb1修复完成后立刻备份新的超级块信息。这套操作我救回过一次客户分区,所以特别有印象。顺带一提,看到 “wrong fs type” 先确认是不是真的 ext4,有时候是分区表类型弄错,或者把加密卷当普通设备挂载,没必要一上来就 fsck。
8.4 sync 和 fsync 的取舍
热搜词里有人刷sync,这里我多说一句。sync是把整个系统的脏页、元数据都刷一遍,代价很大;fsync(fd)只针对某个文件,代价小得多。高频写场景下,正确姿势是只在关键节点fsync,比如数据库提交、配置文件替换后的 rename、嵌入式固件升级时的关键步骤。rename 之后还要fsync目录本身,否则目录项没有持久化,重启后可能找不到新文件名的文件。这个细节很多人踩过。
另外,ext4 的ordered模式只保证“元数据日志有序”,并不是“数据零丢失”。如果业务对持久性有硬要求,光靠文件系统默认选项不够,还得靠应用层 fsync 策略、写放大控制和电源掉电检测方案配合。
最后分享一个我自己的体会:ext4 越透明,你越感觉不到它的存在,说明这套设计确实成熟。但真到排查问题那天,上面这些知识点没有一个是多余的。尤其是“延迟分配”和“日志模式”,这两个概念能帮你解释掉一半以上的 weird 现象。记住一个总原则:一切性能优化,都必须先想清楚掉电后的后果,再决定要不要做。