环境: 单机,内核 6.17,256 逻辑核 / 2TB 内存。被测文件系统各自建在
/lyra-share(NVMe)上的一个 10GB 镜像文件里,通过 loop 设备(--direct-io=on)挂载。mkfs / mount 全部用默认参数。
起因是一个具体问题:存放海量小文件,ext4 和 XFS 谁更合适?既然环境都搭好了,索引扩到四种主流本地文件系统一起测:ext4、XFS、btrfs、f2fs。这篇记录测试方法、四方对比数据,以及测试过程中发现的两个比单纯跑分更值得说的结论。
测试方法
负载固定为 16 个目录 × 20,000 个文件 = 320,000 个 4KB 文件(约 1.25GB),16 个进程并行,每个进程负责一个目录。分四个阶段依次测量:
- create — 创建全部文件并写入 4KB 内容,计时包含末尾一次
sync -f(把元数据日志真正落盘的代价算进去) - stat —
scandir遍历每个目录并对每个文件stat(冷缓存) - read — 按文件名顺序读取全部文件内容(冷缓存)
- delete — 删除全部文件与目录,计时包含
sync -f
两个让数据可信的关键设计:
- loop 设备开
--direct-io=on,镜像文件的数据不进宿主页缓存,冷读真正落盘。 - 阶段之间用 umount / mount 重挂被测分区来清除它自己的页 / dentry / inode 缓存,而不动宿主机的其余部分。
这里有个值得一提的弯路:最初想用 echo 3 > /proc/sys/vm/drop_caches 清缓存,结果这条命令在内核里卡了 15 分钟才返回——因为这台机器有 1.9TB 页缓存,全局回收的代价极其昂贵。换成"只重挂测试分区"之后干净利落。在大内存机器上做存储测试,全局 drop_caches 是个陷阱。
每种文件系统跑 3 轮。第 1 轮是新格式化的干净盘,第 2、3 轮是经历过创建 / 删除循环的"老化"盘——这个区分后面会变得很重要。
四方对比
各项取三轮中位数(秒,越小越好):
| 阶段 | ext4 | XFS | btrfs | f2fs |
|---|---|---|---|---|
| create(含 sync) | 8.5 | 52.3 | 70.5 | 15.5 |
| stat 遍历(冷) | 0.45 | 1.05 | 1.92 | 3.17 |
| read 冷读(新盘) | 5.2 | 11.0 | 4.8 | 8.2 |
| read 冷读(老化后) | 35.3 | 11.0 | 5.2 | 8.2 |
| delete(含 sync) | 32.8 | 28.5 | 23.5 | 5.1 |
每种文件系统的画像截然不同:
ext4 — 元数据写入的王者。小文件创建吞吐约 19 万文件/秒,是其余三者的数倍;目录遍历 + stat 也稳定最快。代价见下一节。
XFS — 不冒尖,但极其稳定、抗老化。冷读三轮恒定 11s 几乎零方差,删除表现中规中矩。它的 create 慢主要慢在日志 sync 上(纯写入 op 阶段并不慢)。适合"不想做任何维护、要可预测性"的场景。
btrfs — 冷读最快且完全不老化(三轮 4.8–5.2s),代价是创建最慢(CoW 元数据 b-tree 的开销都在 op 阶段,约 4.7 千文件/秒)。删除是四者中第二快。
f2fs — 最均衡的全能选手。create 第二、read 第二且不老化、delete 断层第一(log-structured 删除只是丢弃节点表项,比其他三者快 4–6 倍)。隐患在第三轮:create 的 sync 从 6–7s 飙到 36.6s,反复增删后 GC / checkpoint 债务开始显现——这是 log-structured 设计的已知软肋。
ext4 的冷读为什么会"老化",怎么救
最值得展开的发现在 ext4 的 read 那一行:新盘冷读 5.2s,经历一轮创建 / 删除循环后退化到 35s+,慢了将近 7 倍。 这个退化是可复现的,值得搞清楚根因。
先排除最容易想到的"碎片化":
e2freefrag:空闲空间只有 14 个 extent、平均 729MB——空闲空间根本不碎。e4defrag -c:碎片评分 0,“无需整理”——4KB 文件本就是单 extent,没有文件内碎片。
所以 e4defrag 这个最常被推荐的工具,在这里完全无效——因为压根没有它能整理的碎片。真正的原因在别处:采样某个目录里按文件名顺序排列的文件,看它们的 inode 号和物理块号,会发现大幅跳跃且非单调(块号 1607168 → 2157687 → 49817…)。
机理是这样的:ext4 的数据块跟随 inode 所在的块组分配。新盘上,创建顺序 = inode 顺序 = 盘上物理顺序,于是"按文件名遍历"恰好就是顺序读;而老化之后(inode 被回收复用、文件交错创建),文件名顺序与物理顺序脱钩,“按名字读"退化成了 4KB 随机读。这不是碎片化,是布局与访问顺序的脱钩。
理解了根因,解法就清楚了。实测三种:
| 手段 | 成本 | 冷读(按名字序) | 效果 |
|---|---|---|---|
| 不处理(基线) | — | 37.1s | — |
| e4defrag | — | 不适用 | 无效:无文件内碎片可整理 |
| 读取时按 inode 排序 | 零(只改读取顺序) | 4.4s | 8.5×,立即生效,不动盘上数据 |
cp -a 整树重写 | 一次性 18.6s | 5.1s | 恢复到新盘水平 |
两种有效手段对应两种场景:
- 读取侧代码可控:
readdir拿到目录项后按d_ino排序再处理。这是 tar、rsync、mutt 等工具内置多年的经典优化,一行排序的事,8.5 倍提速且完全不碰磁盘。 - 读取侧不可控(别的程序按文件名访问):维护窗口用
cp -a/ rsync 把目录树整体重写到新位置再改名,文件会按新的创建顺序重新连续分配,恢复到新盘水平。
换个角度,这也解释了 XFS 那个恒定的 11s:它的布局策略不依赖创建顺序,所以不需要这种维护——代价是峰值性能(4–5s)低于调理好的 ext4。长期反复增删小文件又没人做维护的目录,XFS / btrfs / f2fs 的可预测性是实打实的优势。
更进一步:很多时候正确答案是别让它们成为文件
横向跑完会发现一件事:无论选哪个文件系统,每个文件至少一个 inode + 一次目录项查找的固定开销是绕不开的。当小文件规模到千万级,与其在文件系统之间抠几个百分点,不如换个思路——把小文件打包,让它们不再是独立的文件。
对不透明的二进制小文件,经典做法是塞进 SQLite / RocksDB / LMDB。SQLite 官方那篇《35% Faster Than The Filesystem》给的就是这个结论:对 10KB 级别的 blob,从一张 (path TEXT PRIMARY KEY, data BLOB) 的表里读,比逐个 open / read / close 快约 35%,还更省空间。原因正是本文测出来的那些固定开销被摊掉了:没有逐文件系统调用、没有 VFS 层的 inode / dentry 查找、数据在 B-tree 叶子页里物理相邻(顺序 I/O 且不会像 ext4 那样老化)、写入按事务摊销 fsync。
而如果数据本身是结构化的表格(这正是我手头的真实场景:一批按股票代码切分的行情 parquet,每天每只股票一个文件),那答案更直接——parquet 本身就是打包格式,问题只是切得太碎了。
拿一天的 K 线数据实测,11,299 个小 parquet 文件:
| 指标 | 现状:每票一个文件 | 合并:每天一个文件 |
|---|---|---|
| 文件数/天 | 11,299 | 1 |
| 体积/天 | 111.7 MB | 39.8 MB(小 2.8 倍) |
| 全市场扫一天(16 线程,NFS) | 4.85s | 0.30s(快 16 倍) |
| 查单票一天 | 2ms | 16ms(filter 下推,可忽略) |
| 合并成本 | — | 每天约 2.4s,一次性 |
体积小 2.8 倍主要来自:12KB 的小文件里 footer 元数据就占 1–3KB,且跨文件无法共享压缩字典。合并时按 (code, time) 排序、配合 row group 统计信息,查单票靠谓词下推只读相关 row group(实测 16ms,完全可接受),无信息损失。查询层用 DuckDB / polars 直接 scan("*.parquet") 把整个历史当一张表查,按文件名做分区裁剪——这是现在数据工程里的标准玩法。
规模收益远不止速度:这一个数据集是 12,781 票 × 3,506 天 ≈ 4500 万个文件,合并后变 3,506 个。本文反复出现的"每文件固定开销"问题,在 NFS 上还要再乘一个网络往返;文件数砍掉四个数量级,服务器元数据压力、ls 卡死、备份迁移成本一起消失。
选型小结
就"海量小文件"这一个场景:
- 写多读少、追求创建吞吐 → ext4
- 读多写少、目录长期增删又不做维护 → btrfs(读最快且不老化)或 XFS(更保守)
- 通用负载、不想纠结 → f2fs 最均衡,但长期高重写要盯着 GC
- ext4 已经在用且冷读变慢 → 先确认是不是"布局脱钩”(
e4defrag评分若为 0 就是),读侧按 inode 排序,或维护窗口整树重写;别指望e4defrag - 规模到千万级文件 → 优先考虑根本不让它们成为文件:结构化数据合并成大 parquet,二进制 blob 塞进 SQLite / 嵌入式 KV
绝对数值受单盘 loop + 共享机器噪声影响,仅供参考;但趋势性结论(ext4 的元数据优势、XFS / btrfs / f2fs 的读取稳定性、ext4 的布局老化)在三轮里方向一致,可信度较高。