磁盘性能测试工具 FIO 安装与使用,这个话题在内网排查磁盘问题、测试新硬件、验证存储方案、评估硬件采购的时候都绕不开。FIO 是 Flexible I/O Tester 的缩写,一个由 IO 压力生成器驱动的用户态测试工具,它能按你指定的读写比例、块大小、队列深度、并发数,对着指定文件甚至裸设备发起持续稳定的 IO 请求,然后把 IOPS、带宽、延迟的分布情况一股脑输出给你。它解决的核心问题是:以前我们只能靠“感觉卡”或者dd跑一遍来判断一块盘快不快,现在可以把一块盘放到特定压力模型下,得到可复现、可对比、可量化的数字。这篇内容适合三类人看:一是刚接手存储、需要做硬件验收的朋友;二是做数据库、消息队列这类 IO 敏感型服务,想知道自己的盘到底能扛多少并发的后端同学;三是纯粹想搞明白“为什么我这块盘标称那么快,跑起来却慢得像蜗牛”的普通使用者。下面我按自己平时实际干活的方式,从原理、安装、写参数、设计场景一直讲到读结果和踩坑,参数取值和计算过程都会给出来。
1. 先把 FIO 到底在测什么说透
1.1 从一次“背锅”经历说起
早些年我遇到过一个很典型的情况:一台机器的磁盘被反馈“卡到不能用”,运维同事用iostat看了一圈,%util显示 100%,于是判定磁盘已经跑满,要求换硬件。但真正换一块更高规格的盘上去,问题照旧。后来仔细看才发现,那块盘的%util100% 根本不是带宽或 IOPS 见顶,而是大量小块随机 IO 叠加在很低的队列深度上,导致每次请求都要停下来等磁头或者等闪存块回收,盘一直“有活干”,却始终没喂饱。这个例子说明一件事:iostat、vmstat、sar这类工具只能告诉你“现在发生了什么”,不能告诉你“在有压力的情况下它会怎么表现”,它们是被动观测,而 FIO 是主动施加负载。
FIO 的价值就在这个“主动”上。它可以直接告诉内核的块层“给我这么多并发请求,每个请求这么大,按这个比例读写,持续跑十分钟”,然后统计这十分钟里每一次 IO 的完成时间分布。你拿到的不只是平均速率,还能看到 P50、P95、P99、P99.9 这些百分位延迟,这一点在做容量规划时极其关键——平均值好看、长尾难看的盘,在真实业务里照样会把服务拖死。
1.2 FIO 的模型:队列深度驱动的 IO 发生器
理解 FIO 输出之前,必须先理解它的模型。FIO 内部是“job + 线程/进程 + 队列”的结构:一个 job 就是一段配置,它自己可以带多个并发执行的单元(numjobs),每个单元内部还可以同时挂着多个未完成的 IO 请求(iodepth)。所以你在默认配置下真正压到盘上的并发深度,大致等于numjobs × iodepth。
这个乘积是理解 FIO 的第一把钥匙。只调numjobs不调iodepth,在很多同步 ioengine 下每个线程仍然是一个请求一个请求地串行提交,并发根本上不去;反过来只调iodepth不调numjobs,在异步引擎下虽然也能把队列堆深,但单线程的 CPU 上下文切换有可能先成为瓶颈。我一般习惯是让numjobs控制到 CPU 核数量级,用iodepth在这个基础上精细调整。
第二把钥匙是利特尔法则(Little's Law),公式是并发数 = IOPS × 平均延迟(秒)。这个公式在磁盘测试里非常好用,它让你可以在测之前就做出合理预估。比如某块企业级固态盘标称 4K 随机读 100K IOPS、平均延迟 0.1ms,那么理论上 10 个并发就足以把它喂满(100000 × 0.0001 = 10)。如果你一上来就开 64 个并发,多出来的请求只是排队,测出来的延迟会明显变差,但 IOPS 并不会上涨。这是新手最常犯的参数设定错误。
1.3 它和 dd、iostat、其它压测工具的分工
顺便把几个常见工具的分工厘清,避免你在错误的地方较劲。dd够简单,但它的问题是:一来默认会走系统缓存,你测出来的往往是内存速度;二来它只有一个线程、一个串行的请求流,队列深度永远逼近 1,对于现代固态盘来说,这个压力根本喂不饱它;三来它只给你一个总耗时算出来的平均值,看不到延迟分布。FIO 恰好在这三点上都做了补强:可以精确控制缓存策略、可以堆队列深度、可以输出百分位延迟。
至于iostat,它的正确用法是配合 FIO 一起看。跑 FIO 的同时在另一个窗口开iostat -x 1,观察r/s、w/s、rkB/s、wkB/s、aqu-sz(平均队列长度)、%util的变化,可以交叉验证 FIO 报的数是否合理,也能看出压力是不是全都打到了你以为的那块设备上——多路径、软 RAID、云盘的虚拟设备名经常和你想的不一样。
2. 安装:三条路,按场景挑
2.1 包管理器直接装,覆盖绝大多数场景
如果不是要做很特殊的测试,直接用发行版仓库里的版本就行,省事而且依赖都帮你处理好了。按发行版家族分:
# apt 系 sudo apt update sudo apt install -y fio # rpm 系 sudo dnf install -y fio装完fio --version看一下版本号。仓库版本通常比最新版落后一两个大版本,但对日常的磁盘验收、性能对比完全够用。只有两种情况我建议你去折腾源码:一是需要只有新版本才有的 ioengine 或者输出字段;二是仓库版本太老,跑某些组合参数是会崩的。
提示:有些精简安装的镜像里,仓库版本可能缺少异步 IO 支持,跑
--ioengine=libaio会直接报引擎不存在。装之前先执行fio --enghelp看一眼可用引擎列表,比装完了再回来查要省时间。
2.2 源码编译:需要新版本特性时再走这条路
编译 FIO 本身不复杂,麻烦的是依赖。核心依赖是异步 IO 库的开发包,没有它编译出来的二进制就不带libaio引擎,这是最坑的一点——编译能过,跑起来报错。
# 先补齐开发依赖(包名按发行版微调) sudo dnf install -y gcc make libaio-devel zlib-devel tar -xzf fio-<version>.tar.gz cd fio-<version> ./configure --prefix=/usr/local make -j"$(nproc)" sudo make install./configure那一步的输出要仔细看,它会逐项打印检测到的特性,凡是标no的都要确认是不是你需要的。我习惯在 configure 之后先把那几十行输出里带aio、engines的行抓出来看一眼,确认目标引擎被启用了再make。
2.3 装完先做三件事
第一,确认版本和引擎列表:fio --version、fio --enghelp。第二,确认你准备测试的目标设备路径对得上:lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,MODEL,看清楚这块盘是挂在文件系统上的,还是裸设备,有没有做 RAID、LVM、多路径。第三,如果是要测延迟敏感的场景,先确认机器本身是安静的——后台有没有正在跑的备份、日志压缩、监控采集,CPU 是不是被其他任务占着,这些都会污染结果。
注意:FIO 的写测试会真的写数据,直接对裸设备跑写测试等于原地覆盖。上生产机之前一定用
size参数限定范围,或者老老实实建一个专用的测试文件,别拿业务数据盘练手。
3. job file 怎么写:把业务负载翻译成参数
3.1 job file 的骨架
FIO 支持命令行直接传参,但超过五六个参数就该改用 job file 了,因为可读、可存档、可复用、可以在文件里写注释。最小骨架长这样:
[global] ioengine=libaio direct=1 size=4G runtime=60 time_based=1 ramp_time=10 group_reporting=1 [randread-4k] rw=randread bs=4k iodepth=32 numjobs=4[global]段里的配置对所有 job 生效,下面每个方括号是一个独立 job。group_reporting=1会把同一组 job 的结果合并输出,不然你会看到一堆重复的统计块,眼花。写 job file 的好处是,你测完可以把文件连同结果一起存档,三个月后有人问“当初这块盘是什么条件下测的”,你直接把文件甩过去就行。
3.2 四个决定测试语义的核心参数
rw、bs、iodepth、numjobs,这四个参数基本决定了你测的是什么负载。其余参数都是修饰。
rw决定读写模式和访问模式。read/write是纯顺序,randread/randwrite是纯随机,rw/randrw是混合,readwrite是顺序往返。混合模式还要配合rwmixread指定读的比例。这里有个容易忽略的点:随机模式默认会自己维护一个偏移量映射表来保证不重复打同一个块,如果测试数据集远大于实际块数、或者你想模拟真实的不重复访问,可以加norandommap,但要注意它会让某些块被反复访问,结果会有偏差。
bs是块大小。顺序大块看带宽,随机小块看 IOPS,这个常识要有。但更关键的是要和你的真实业务对齐。数据库的在线事务大多是 8K 或 16K 随机读写,日志写入是追加式的大块顺序写,对象存储往往是几百 K 到几 M 的顺序读写。你用 4K 随机去测一个只做大文件顺序写的场景,测出来的结论毫无意义。想一次覆盖多种块大小,可以用bsrange=4k-64k,但要明白它是随机取值,结果没法直接和固定块大小的数字对比。
iodepth是每个 job 内部的队列深度,numjobs是 job 的并发实例数。前面说过,两者相乘才是真正压到设备上的并发。设置原则还是那句:先按利特尔法则估一个量级,再在这个量级附近上下扫几档,看 IOPS 什么时候不再上涨。当增加并发而 IOPS 不再增长、延迟却显著上升时,说明设备已经饱和,再往上加只是增加排队,这个拐点比任何标称参数都有参考价值。
3.3 保证数据真实的几个开关
direct=1是最重要的一个。它绕过操作系统的页缓存,让读写直达设备。不设这个的话,读测试很可能全打在内存里,你会得到几个 G 每秒的夸张数字,然后沾沾自喜。写测试同理,不加direct的时候数据可能停在缓存里就被算作完成了。需要注意的是direct=1要求偏移量和块大小按扇区对齐,FIO 的随机模式一般会自己处理,但如果用offset手工指定位置,要对齐到 4K。
size限定每个 job 的数据量。这个参数直接决定了你的测试会不会退化成“在小范围里反复摩擦”。测试数据集至少要大于设备缓存几个数量级,否则测出来的只是缓存命中率。我一般让总数据量(size × numjobs)至少是设备总容量的两倍,或者至少几十个 G 起步。
time_based=1配合runtime让测试跑固定时长而不是跑完固定数据量就停。这样不同参数的测试可以横向比较,不会因为速度快慢导致样本量差异。再加上ramp_time预热——前若干秒的 IO 往往受冷缓存、闪存块尚未进入稳态影响,把这部分数据剔除掉,结果会稳定得多。
3.4 常用参数速查表
| 参数 | 作用 | 典型取值与说明 |
|---|---|---|
ioengine | 提交 IO 的方式 | 异步用libaio,同步用psync/sync,内存对比用null |
direct | 绕过页缓存 | 几乎总是设为 1,否则测的是内存 |
rw | 读写模式 | read/write/randread/randwrite/randrw |
bs | 块大小 | 随机 4k/8k/16k,顺序 128k/1M |
iodepth | 单 job 队列深度 | 从 1 开始逐档加到 32/64/128,观察拐点 |
numjobs | 并发数 | 一般对齐 CPU 核数或按利特尔法则估算 |
size | 每 job 数据量 | 总量应远大于设备缓存 |
runtime | 运行时长 | 60~300 秒,配合time_based |
ramp_time | 预热时长 | 10~30 秒 |
rwmixread | 混合读比例 | 70 表示读占 70% |
iodepth_batch | 批量提交数 | 大批量提交可降低提交开销 |
fsync | 每次写完同步 | 模拟 fsync 密集的持久化场景,会大幅拉低速率 |
4. 四类典型场景的参数设计与计算
4.1 顺序大块读写:看的是持续带宽
这类场景对应的是大文件拷贝、备份归档、视频素材读写、日志顺序追加。参数特征就一个字:大。块大小 128K 到 1M,并发不用太高,队列深度 8 到 16 就够,因为大块顺序 IO 本身就是靠单次传输大、连续性好来吃带宽的。
fio --name=seq-write --filename=/data/fio-test \ --rw=write --bs=1M --direct=1 --size=8G \ --iodepth=16 --ioengine=libaio --numjobs=1 \ --time_based=1 --runtime=120 --ramp_time=15参数怎么定的:1M 块是机械盘和固态盘都常见的持续写单元,iodepth=16是按经验值取的,因为顺序写在多数设备上并不需要很深的队列就能吃满带宽。跑完你看到的BW字段就是持续写带宽,和厂商标称值对比时要打个折——标称值通常是理想条件下的峰值,实际文件系统、RAID、总线都会吃掉一部分。如果实测只有标称的一半,先别急着怀疑盘,按第 6 节的方法排查。
心得:顺序测试务必先确认
size足够大。如果只写 512M,可能整个都落在设备的写缓存里,测出来的带宽虚高。我一般至少让单次测试写满几个 G。
4.2 随机小块:IOPS 和队列深度的关系最要紧
数据库、消息队列、小文件服务,这些场景的特征是随机小块,考核指标是 IOPS 和延迟。这类测试的重点不是在某个固定参数下跑一个数,而是扫一遍队列深度,找出饱和点。
for depth in 1 4 8 16 32 64 128; do fio --name=rand-read-${depth} --filename=/data/fio-test \ --rw=randread --bs=4k --direct=1 --size=4G \ --iodepth=${depth} --ioengine=libaio --numjobs=1 \ --time_based=1 --runtime=30 --ramp_time=5 \ --output-format=json --output=rand-read-${depth}.json done这样跑出来是一组曲线关系。理想情况下 IOPS 随队列深度上升然后趋于平台,延迟则近似线性上升。平台出现的位置就是这块盘的饱和点,而平台之后每增加一倍深度、延迟涨多少,就是它在过载情况下的“抗压能力”。很多消费级固态盘在队列深处会突然出现延迟陡增,这个拐点对业务来说比峰值 IOPS 更有意义,因为真实业务不会永远在理想深度下运行。
选libaio而不是默认的同步引擎,是因为只有异步引擎才能真正把队列堆深。同步引擎下iodepth设多大都没用,实际永远是一个请求做完再发下一个。
4.3 混合读写:还原真实业务比例
真实系统里很少只有纯读或纯写,数据库有读有写,日志有追加也有回收。混合模式用rw=randrw配合rwmixread:
[global] ioengine=libaio direct=1 filename=/data/fio-test size=8G time_based=1 runtime=120 ramp_time=15 group_reporting=1 [mix-7-3] rw=randrw rwmixread=70 bs=8k iodepth=32 numjobs=4读 70%、写 30%,8K 块,总并发 128。这个配比接近不少在线交易类业务的形态。混合测试有个特点:写入会触发垃圾回收和写放大,尤其在固态盘上,混合负载的实测数字往往比纯读纯写单项加权平均的结果差不少。所以千万不要用“纯读 IOPS 乘 70% 加纯写 IOPS 乘 30%”去估算,必须实测。固态盘在长时间混合写之后进入稳态,性能可能只有刚上电那几分钟的一半甚至更低,这也是为什么要设ramp_time并把runtime拉长。
如果要做稳态测试,可以在测试前用--discard=1对数据区域做一次 TRIM 预处理,再正式开始。这一步很多人会忘,导致前后两次测试结果差异巨大。
4.4 延迟专项:抠 P99 而不是看平均
有一类业务对延迟特别敏感,比如实时风控、在线交易撮合,它们不在乎峰值 IOPS 多高,但在乎最慢的那 1% 请求会不会拖死整条链路。这时候要专门设计低并发、看长尾的测试。
fio --name=lat-4k --filename=/data/fio-test \ --rw=randread --bs=4k --direct=1 --size=4G \ --iodepth=1 --numjobs=1 --ioengine=libaio \ --time_based=1 --runtime=120 --ramp_time=10 \ --lat_percentiles=1关键点是把队列深度压到 1,测的是盘的无干扰单请求延迟,这个数字才是设备本身的响应能力。然后逐步加深度,观察 P99、P99.9 的变化曲线。如果深度到 4 的时候 P99 已经涨了五倍,说明这块盘在轻微并发下就不够稳,放到生产上会是隐患。
我个人的判断标准是这样的:如果 P99 延迟超过 P50 延迟一个数量级,就要警惕了;如果在目标并发下 P99.9 出现了毫秒级的尖峰,而业务本身要求亚毫秒响应,那这块盘基本就不合适。
5. 结果怎么读:把数字翻成人话
5.1 输出里的三个延迟字段别搞混
FIO 输出里最容易被误读的就是slat、clat、lat这三个。含义分别是:slat(submission latency)是从 FIO 产生一个 IO 到把它交给内核所花的时间,也就是提交开销;clat(completion latency)是从提交完成到收到完成通知的时间,也就是真正在设备上等待的时间;lat(total latency)是两者之和,从产生到完成的全部耗时。
判断设备能力强弱,主要看clat。如果slat很大,问题通常在用户态或内核提交路径上,比如 ioengine 选型不合适、单线程提交能力不够、或者 CPU 被抢占。我遇到过slat占了总延迟一半的情况,最后发现是测试机本身 CPU 负载太高,换成独立机器就正常了。所以看到延迟反常时,先分清是哪一段慢,再去找原因,这一步能省掉大量无效排查。
5.2 百分位延迟比平均值有用得多
clat percentiles那一段是我看结果时最先扫的地方。平均值会把极端情况抹平:如果 99% 的请求都是 0.1ms,1% 是 10ms,平均值算下来是 0.2ms,看着很漂亮,但业务感知到的是那 1% 造成的抖动。所以我看盘的第一眼永远是 P99 和 P99.9。
后面的lat (usec)和lat (msec)两个直方图块,是把所有请求按延迟区间做桶统计,可以看出延迟分布是集中在一个窄带(说明稳定)还是拖了一条很长很长的尾巴(说明有垃圾回收、有重传、有争抢)。稳定可控的盘,直方图会很窄。
还有一个字段经常被忽略:IO depths那一段,显示实际达成的队列深度分布。如果你设了iodepth=32,但输出显示绝大多数请求都在深度 1 到 4 之间,说明并发根本没起来,可能是引擎不对、可能是设备队列能力有限、也可能是测试数据太小导致全在缓存里。
5.3 判断瓶颈到底在哪一层
拿到一个不好的数字,别急着归咎于盘。按下面的顺序排查比较高效:
| 现象 | 大概率原因 | 验证方式 |
|---|---|---|
带宽远低于标称,%util很高 | 设备本身或链路上限 | 换一块已知良好的盘对比 |
IOPS 不随深度增长,slat很高 | CPU 提交瓶颈 | 增加numjobs,观察是否改善 |
| 延迟分布拖长尾,写测试尤其明显 | 写缓存耗尽、垃圾回收 | 加长ramp_time,做稳态测试 |
| 读测试数字异常高,动辄数 G | 缓存未绕过 | 确认direct=1且size足够大 |
| 实测只有理论值几分之一 | RAID/文件系统/虚拟化层损耗 | 对比裸设备测试结果 |
这个表我基本上是照着用,绝大多数问题能在前三行定位到。
6. 踩坑实录与排查清单
6.1 缓存没绕开,测出来的是内存速度
这是最高频的坑,没有之一。表现是读带宽动辄几个 G 每秒,明显超过设备物理上限。原因通常是三个:忘了加direct=1;size设得太小,整个数据集都在页缓存里;或者测试的是文件系统中的文件,而文件系统本身有预读和缓存。验证方法很简单,把size从 1G 改成 32G 再跑一遍,如果数字大幅下降,那就是缓存问题。彻底一点的办法是直接对裸设备跑,或者测试前用drop_caches清一下缓存(注意这会影响同机其他业务,生产机上慎用)。
6.2 参数叠加过头,压出来的是排队不是能力
第二个高频坑是并发设得离谱。见过有人直接把iodepth=1024、numjobs=16一起上,总并发一万六,然后抱怨盘不行。这种情况下测出来的延迟全是排队时间,IOPS 不会因为并发多就无限上涨。正确的做法是从小往大扫,找出饱和点,而不是一上来就开满。测试的目的是找到设备的能力边界,不是制造一个数值奇观。
还有一个小细节:numjobs和iodepth的乘积如果超过了内核块层的队列深度限制(nr_requests),多出来的请求会在上层排队,测出来的分布会失真。这时候去看IO depths直方图,会看到实际达成的深度远低于设定值。
6.3 文件系统层和裸盘的差距要心里有数
同一个设备,跑在文件系统上的一个文件和直接对裸设备跑,结果会差不少。文件系统会带来元数据开销、日志提交、块分配策略的影响;如果开了写屏障或者fsync语义,写的表现会更接近真实业务,但也更难看。这不是“测试不准”,而是测的东西不一样。验收硬件时用裸设备测硬件上限,评估业务表现时用文件系统测端到端能力,两者不能混用结论。我通常在报告里会把两组数字都列出来。
6.4 杂项坑速查
| 坑 | 表现 | 处理 |
|---|---|---|
| 测试目录选错 | 压到了系统盘 | 用lsblk确认设备,用独立测试盘 |
| 文件未预先创建 | 首次写测试包含分配开销 | 先跑一轮写把文件铺满,或加create_on_open |
| 后台任务干扰 | 结果波动大、不可复现 | 测试前关掉备份、日志采集 |
| 温度降频 | 长测试后段性能下降 | 拉长测试时长观察趋势 |
| 数据量小于设备缓存 | IOPS 虚高 | 加大size到缓存数倍以上 |
| 只测一次就下结论 | 结论偶然 | 每组参数至少跑三次取中位数 |
提示:所有测试跑之前,先跑一遍空载的
null引擎测试(--ioengine=null),确认 FIO 本身和测试机环境能支撑住你设定的并发。如果null引擎都跑不到目标 IOPS,说明瓶颈在 CPU 或 FIO 配置,跟磁盘一点关系都没有。
7. 工程化:把一次测试变成可复现的流程
7.1 用 JSON 输出做自动化
手工看输出适合探索,但要做多组参数对比、出报告、存档,就必须用结构化输出。加上--output-format=json --output=result.json,每个测试的结果会写进独立文件,之后用脚本把iops、bw_bytes、clat_ns.percentile这些字段抽出来汇总成表格,效率和可靠性都比人肉抄录高得多。
下面这段是我常用的提取脚本,跑完一轮测试后直接生成一张对比表:
import json, glob, os rows = [] for path in sorted(glob.glob("result-*.json")): with open(path) as f: data = json.load(f) for job in data["jobs"]: r = job["read"] if job["read"]["iops"] > 0 else job["write"] rows.append({ "case": job["jobname"], "iops": round(r["iops"], 1), "bw_MiB": round(r["bw_bytes"] / 1048576, 1), "clat_avg_us": round(r["clat_ns"]["mean"] / 1000, 1), "clat_p99_us": round(r["clat_ns"]["percentile"]["99.000000"] / 1000, 1), }) header = f"{'case':<16}{'IOPS':>10}{'BW(MiB/s)':>12}{'clat avg(us)':>15}{'clat p99(us)':>15}" print(header) for row in rows: print(f"{row['case']:<16}{row['iops']:>10}{row['bw_MiB']:>12}" f"{row['clat_avg_us']:>15}{row['clat_p99_us']:>15}")注意percentile的键名可能随版本变化,跑之前先 dump 一份 JSON 看看实际结构,别照抄了键名直接报 KeyError。
7.2 测试前后的环境处理
要让结果可复现,测试前后的动作要固化下来。测试前一分钟,确认没有其他 IO 在跑,确认目标文件已经存在且大小符合预期,确认页缓存状态是干净的。测试结束后,把 job file、原始 JSON、机器配置、设备型号一起归档——只留一个数字是没有意义的,三个月后没人知道那是在什么条件下测的。
还有一点,如果测试会写数据,跑完记得把测试文件删掉,或者用fallocate重新铺一遍,避免残留数据影响下一轮。对固态盘定期做一次 discard 预处理,可以让多轮测试的起点保持一致。
我个人的体会是,FIO 这个工具本身不难,难的是先想清楚自己要回答什么问题。是验收新硬件?那就测裸设备上限,参数往极限上压,看峰值和饱和点。是评估业务能不能上这套存储?那就要还原业务的块大小、读写比例、并发特征,用接近真实的参数组合去跑,看 P99 能压到多少。是排查线上卡顿?那得先抓一份线上 IoProfile,再回到 FIO 里复现它。同一个工具,三个问题对应三套完全不同的参数,搞混了就会得出“这盘看着挺快但用起来很卡”这种自相矛盾的结论。真跑起来,先跑null引擎验一遍环境底子,再跑单盘、再跑阵列,一层层往上比,问题出在哪一段基本就能锁住了。