← 返回首页

海量小文件下的文件系统选型:ext4 / XFS / btrfs / f2fs 实测与分析

2026-06-15 · Linux文件系统性能测试ext4XFSbtrfsf2fs

环境: 单机,内核 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 个进程并行,每个进程负责一个目录。分四个阶段依次测量:

两个让数据可信的关键设计:

  1. loop 设备开 --direct-io=on,镜像文件的数据不进宿主页缓存,冷读真正落盘。
  2. 阶段之间用 umount / mount 重挂被测分区来清除它自己的页 / dentry / inode 缓存,而不动宿主机的其余部分。

这里有个值得一提的弯路:最初想用 echo 3 > /proc/sys/vm/drop_caches 清缓存,结果这条命令在内核里卡了 15 分钟才返回——因为这台机器有 1.9TB 页缓存,全局回收的代价极其昂贵。换成"只重挂测试分区"之后干净利落。在大内存机器上做存储测试,全局 drop_caches 是个陷阱。

每种文件系统跑 3 轮。第 1 轮是新格式化的干净盘,第 2、3 轮是经历过创建 / 删除循环的"老化"盘——这个区分后面会变得很重要。

四方对比

各项取三轮中位数(秒,越小越好):

阶段ext4XFSbtrfsf2fs
create(含 sync)8.552.370.515.5
stat 遍历(冷)0.451.051.923.17
read 冷读(新盘)5.211.04.88.2
read 冷读(老化后)35.311.05.28.2
delete(含 sync)32.828.523.55.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 倍。 这个退化是可复现的,值得搞清楚根因。

先排除最容易想到的"碎片化":

所以 e4defrag 这个最常被推荐的工具,在这里完全无效——因为压根没有它能整理的碎片。真正的原因在别处:采样某个目录里按文件名顺序排列的文件,看它们的 inode 号和物理块号,会发现大幅跳跃且非单调(块号 1607168 → 2157687 → 49817…)。

机理是这样的:ext4 的数据块跟随 inode 所在的块组分配。新盘上,创建顺序 = inode 顺序 = 盘上物理顺序,于是"按文件名遍历"恰好就是顺序读;而老化之后(inode 被回收复用、文件交错创建),文件名顺序与物理顺序脱钩,“按名字读"退化成了 4KB 随机读。这不是碎片化,是布局与访问顺序的脱钩。

理解了根因,解法就清楚了。实测三种:

手段成本冷读(按名字序)效果
不处理(基线)37.1s
e4defrag不适用无效:无文件内碎片可整理
读取时按 inode 排序零(只改读取顺序)4.4s8.5×,立即生效,不动盘上数据
cp -a 整树重写一次性 18.6s5.1s恢复到新盘水平

两种有效手段对应两种场景:

换个角度,这也解释了 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,2991
体积/天111.7 MB39.8 MB(小 2.8 倍)
全市场扫一天(16 线程,NFS)4.85s0.30s(快 16 倍)
查单票一天2ms16ms(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 卡死、备份迁移成本一起消失。

选型小结

就"海量小文件"这一个场景:

绝对数值受单盘 loop + 共享机器噪声影响,仅供参考;但趋势性结论(ext4 的元数据优势、XFS / btrfs / f2fs 的读取稳定性、ext4 的布局老化)在三轮里方向一致,可信度较高。