磁盘 I/O 延迟突增:用 iostat、blktrace 与火焰图定位到具体调用栈
1. 一个真实的线上场景
假设你负责一个订单服务,平时接口 P99 在 80ms 左右。某个下午开始,监控告警接连不断:接口 P99 涨到 1.2s,写入日志的线程大量卡住,部分请求超时。你登录机器,第一反应是 top,发现 CPU 并不高,load average 却从 2 涨到了 18。再看进程状态,好几个业务进程处于 D 状态。
这时候问题就来了:CPU 不忙、内存也不紧张,那系统到底在等什么?如果只是猜"磁盘慢",你依然不知道该扩容磁盘、改参数,还是改代码。真正需要回答的是三个问题:等待发生在哪一层、是哪类 I/O、最终落到哪段代码。
本文就沿着这条线走一遍。目标不是把命令手册抄一遍,而是让你在遇到"磁盘 I/O 延迟突增"时,有一套从现象到调用栈的固定推演路径。读者需要具备基本的命令行经验,读过 top、ps、df 的输出即可。
2. 先建立一个最小模型:一次读请求会经过谁
可以先把它理解成一条流水线:应用发起 read(),数据从磁盘经过块设备、页缓存,再回到应用。如果数据在页缓存里,这次调用几乎不产生磁盘 I/O;如果不在,进程就会被挂起,等磁盘把数据搬上来。
先记住一个最小模型,整条链路大致是:
text
应用 read()/write()
|
v
VFS / 文件系统(ext4、xfs)
|
v
页缓存 page cache
|(未命中)
v
通用块层 + I/O 调度器(mq-deadline、bfq、none)
|
v
块设备驱动(nvme、virtio-blk、sd)
|
v
物理设备(SSD / HDD / 云盘)
把整体拆成三部分更好记:上半层 是文件系统和页缓存,决定这次 I/O 是不是真的下发;中间层 是块层和调度器,决定多个请求怎么排队、合并、排序;下半层是设备驱动和硬件,决定一个请求最终耗时多久。
这里最容易误解的是"磁盘 await 高就是磁盘坏了"。await 是从请求进入块层到完成的总时间,包含排队时间。队列长、调度器不合适、甚至上层发了太多小 I/O,都会让它变高,设备本身可能一点问题都没有。
3. 用 iostat 判断等待发生在哪一层
iostat 的价值在于它把"设备视角"的统计摊开给你看。它读的是 /proc/diskstats 和内核块层计数器,属于低成本采样,可以在生产环境直接跑。第一个要看的指标是 %util,它表示设备有 I/O 请求在处理的忙碌时间比例。
第二个关键指标是 await,即平均每次 I/O 的等待加服务时间,单位毫秒。第三个是 aqu-sz(老版本叫 avgqu-sz),表示平均排队长度。还有一个容易忽略的是 r/s、w/s 与 rMB/s、wMB/s,它们告诉你请求是"小而多"还是"大而少"。
先看一个实际场景的输出:
bash
# 每 1 秒采样,共 5 次,-x 输出扩展指标,-m 以 MB 为单位
iostat -x -m 1 5
text
Device r/s rMB/s rrqm/s %rrqm r_await rareq-sz w/s wMB/s wrqm/s %wrqm w_await wareq-sz aqu-sz %util
nvme0n1 12.0 0.19 0.00 0.00 2.10 16.0 860.0 6.72 410.0 32.3 45.80 8.0 3.90 99.6
这段输出怎么读?写入请求 860 次/秒,平均每次只有 8KB,说明是大量小写入;aqu-sz 达到 3.9,说明写入在排队;w_await 45.8ms 远高于正常 SSD 的个位数毫秒;%util 接近 100%,设备基本没空闲。综合起来,更可能是上层持续发小写入把队列压满了,而不是设备硬件故障。
反过来说,如果 %util 不高但 await 很高,通常意味着请求数量少、单个请求本身慢,方向就要往设备健康、链路带宽或远程存储上去找。这里要注意一个边界:%util 对多队列 NVMe 的解释能力有限,一块盘可能被拆成多个硬件队列,单个队列忙不代表整盘饱和。
4. D 状态和 I/O 等待:先分清"谁在等"
进程处于 D 状态,意思是它正在做不可中断的睡眠,通常是在等 I/O 完成。它和 S(可中断睡眠)的区别在于:D 状态的进程不响应普通信号,kill -9 也杀不掉,必须等内核把这次 I/O 处理完。
排查时可以先看两个地方。第一是负载:
bash
uptime
cat /proc/loadavg
load average 统计的是运行中加不可中断睡眠的任务数,所以磁盘卡住时 load 会升高,而 CPU 使用率可能很低。第二是找出具体进程:
bash
# 按状态筛选 D 状态进程,并显示进程名与 wchan(内核等待点)
ps -eo pid,stat,wchan:32,comm | awk '$2 ~ /^D/'
wchan 会给出内核里正在等待的函数名,比如 blk_mq_get_tag、io_schedule 之类。它不是精确调用栈,但能快速告诉你"卡在块层"还是"卡在文件系统锁"。如果需要更完整的信息,可以看 /proc/<pid>/stack(需要 root 且内核开启相关配置)。
这里有一个常见误解:D 状态进程多,不一定等于磁盘有问题。NFS、FUSE、云盘挂载异常都可能造成 D 状态堆积。判断的关键是结合 iostat 看设备指标是否同步异常,以及 dmesg 里有没有驱动或链路报错。
5. blktrace:把块层的事件流摊开看
iostat 告诉你"慢了",但不会告诉你"慢在哪一段"。要看清楚一个 I/O 从进入到完成经过了哪些阶段,就要用 blktrace。它通过内核的块层 tracepoint 采集事件,能看到请求的排队、合并、下发、完成时刻。
块层的事件类型里,最常用的几个是:Q 表示请求进入块层队列;G 表示获取请求结构体;I 表示插入调度器队列;M 表示合并到已有请求;D 表示下发到驱动;C 表示完成。把它们按时间排开,就能算出一次 I/O 在各阶段停留了多久。
采集方式通常配合 blkparse 一起用:
bash
# 采集 nvme0n1 上 10 秒的块层事件,输出到文件
blktrace -d /dev/nvme0n1 -o trace -w 10
# 解析并只看写入方向,输出每笔 I/O 的详细事件流
blkparse -i trace.blktrace.0 -a write -O trace.out
text
8,0 3 1 0.000000000 1024 Q WS 1234567 + 8 [kworker/u8:2]
8,0 3 2 0.000001120 1024 G WS 1234567 + 8 [kworker/u8:2]
8,0 3 3 0.000002340 1024 I WS 1234567 + 8 [kworker/u8:2]
8,0 3 4 0.018220100 1024 D WS 1234567 + 8 [kworker/u8:2]
8,0 3 5 0.041550980 1024 C WS 1234567 + 8 [0]
看这段事件流:从 Q 到 I 只花了两微秒,说明进入队列很快;从 I 到 D 花了约 18ms,说明请求在调度器队列里排了 18ms;从 D 到 C 约 23ms,是驱动加设备的真实服务时间。结论就很清楚:延迟的大头在调度器排队和下发阶段,而不是应用代码。
这就是 blktrace 的核心价值:把"await 高"这个笼统数字,拆成排队时间与服务时间两部分。前者可以通过调整调度器或降低并发来缓解,后者往往要换设备或改 I/O 模式。
6. 块层调度器在做取舍
调度器夹在文件系统和设备驱动之间,作用是决定多个请求按什么顺序发给设备,并尽量合并相邻请求。多队列块层(blk-mq)下常见的几种调度器各有取舍,选择取决于设备类型。
| 调度器 | 主要策略 | 适合场景 | 主要代价 |
|---|---|---|---|
| none | 不排序,直接下发 | NVMe、高性能 SSD 多队列 | 不做合并,依赖设备自身 |
| mq-deadline | 按截止时间排序,读优先 | 通用服务器、数据库 | 高并发下排序开销 |
| kyber | 按读、写、丢弃分队列限流 | 延迟敏感型负载 | 配置不当会限流过度 |
| bfq | 按进程公平分配带宽 | 桌面、多租户公平性 | 吞吐下降、CPU 开销高 |
选择逻辑可以记成两句话:当设备是低延迟 NVMe 且应用自己会控制并发时,用 none 往往最好;当设备是较慢的 SSD/HDD 且读延迟敏感时,mq-deadline 更稳。bfq 更适合要保证单个进程不被其他进程饿死的场景,而不是追求极限吞吐。
查看和切换调度器:
bash
# 查看某块盘当前调度器,方括号里是生效项
cat /sys/block/nvme0n1/queue/scheduler
# 切换为 mq-deadline(重启后失效,需要写入 udev 规则固化)
echo mq-deadline > /sys/block/nvme0n1/queue/scheduler
# 查看队列深度,判断是否成为瓶颈
cat /sys/block/nvme0n1/queue/nr_requests
这里有一个设计取舍需要说明:更深的队列能提高吞吐,但会拉长单个请求的排队时间。如果你的目标是 P99 延迟而不是吞吐,盲目调大 nr_requests 可能让情况更糟。切换调度器后一定要用后面的 fio 方式做前后对比。
7. 用 fio 复现并验证假设
排查的核心原则是:没有复现就没有结论。fio 是常用的 I/O 压测工具,可以精确控制读写比例、块大小、队列深度和并发数,用来验证"小写入压满队列"这类假设。
第一个完整示例,目标是复现前面的现象:4KB 随机写、队列深度 32,观察 await 是否升高。
bash
# fio-write.fio:模拟大量小写入
[global]
ioengine=libaio
direct=1
rw=randwrite
bs=4k
iodepth=32
numjobs=4
size=2G
time_based
time=30s
filename=/data/fio_test.bin
[randwrite-4k]
bash
# 执行前先创建目录与文件位置,跑完后清理
mkdir -p /data
fio fio-write.fio
rm -f /data/fio_test.bin
预期输出里重点看 write: IOPS、clat(完成延迟)和 slat(提交延迟)的 percentile 分布。如果 clat 的 p99 明显远高于 p50,说明高并发下有排队。边界提醒:direct=1 绕过页缓存,更接近数据库行为;测试期间务必确认目标盘有足够空间,别在系统盘上乱写大文件。
第二个完整示例,目标是复现"顺序大块读不受影响,随机小写被拖慢"的混合场景,验证调度器切换是否有效。
bash
# fio-mixed.fio:读写混合,读为顺序大块,写为随机小块
[global]
ioengine=libaio
direct=1
size=1G
time_based
time=30s
filename=/data/fio_mixed.bin
[seq-read]
rw=read
bs=256k
iodepth=8
numjobs=2
[rand-write]
rw=randwrite
bs=4k
iodepth=32
numjobs=4
bash
# 分别在 none 与 mq-deadline 下各跑一次,对比 clat p99
for s in none mq-deadline; do
echo $s > /sys/block/nvme0n1/queue/scheduler
echo "--- scheduler: $s ---"
fio fio-mixed.fio --output-format=json | \
python3 -c "import sys,json;d=json.load(sys.stdin);print([ (j['jobname'], j['write']['clat_ns']['percentile']['99.000000']) for j in d['jobs']])"
done
rm -f /data/fio_mixed.bin
关键步骤是先固定一个变量(同一块盘、同一份配置),只改调度器,再比较读和写各自的 p99 完成延迟。可观察结果是:mq-deadline 下读的延迟通常更稳,但写吞吐可能略降。如果两组数据差异不明显,说明瓶颈不在调度器而在设备本身,别硬调参数。
第三个完整示例更贴近生产,目标是验证"应用写日志时 fsync 频率过高"是否会放大延迟。
bash
# fio-fsync.fio:每次写完都 fsync,模拟同步刷盘
[global]
ioengine=libaio
direct=1
rw=write
bs=4k
size=512M
filename=/data/fio_sync.bin
[fsync-per-io]
fsync_on_close=1
fsync=1
bash
fio fio-fsync.fio --output-format=normal
rm -f /data/fio_sync.bin
和上一个示例的随机写相比,fsync=1 会把每次小写入都变成同步等待,clat 的 p99 往往成倍上升。这正好对应日志框架里"每条日志都 fsync"的常见反模式。要注意运行边界:这类测试对设备寿命和线上盘有影响,应在专门的测试盘或低峰期进行。
8. 把延迟落到调用栈:off-CPU 火焰图
前面几步解决的是"块层发生了什么",火焰图解决的是"哪段代码在等"。CPU 火焰图只展示占用 CPU 的栈,而 I/O 等待属于 off-CPU 时间,进程在睡眠。想看真正的阻塞点,需要采集 off-CPU 事件,或者采集内核栈与用户栈的关联。
采集思路大致是:用 perf 或 bcc 工具抓住进程进入和离开调度器的时间,把等待期间的栈展开并聚合,最后用 FlameGraph 脚本生成 SVG。下面是一个用 bcc 自带工具观察块层延迟的思路示例:
bash
# 列出块层 I/O 的延迟分布(bcc 工具集,需要 root)
biolatency-bpfcc -D 10 1
# 按进程统计 off-CPU 等待时间(示意,具体工具名随发行版不同)
offcputime-bpfcc -p $(pgrep -f my-order-service) 30 > out.offcpu
text
Flame Graph 调用链示意(自上而下)
my-order-service
do_write_log
fsync
vfs_fsync_range
ext4_sync_file
file_write_and_wait_range
blkdev_issue_flush
blk_mq_get_tag <-- 等待队列标签,主要延迟来源
这段调用栈的价值在于,它把前面的"块层排队"和具体的业务函数连起来了:原来是业务代码在每次写日志时调用了 fsync,触发同步刷盘,进程卡在等待块层标签。到这里,"磁盘慢"就变成一个可以改代码解决的问题,比如批量刷盘或改成异步写。
需注意边界:off-CPU 采集有一定开销,采样时间长、事件多时会影响业务;栈信息可能被编译器内联或裁剪,需要带调试符号才能读得准。不要在生产高峰期长时间全量采集。
9. 完整推演:一次请求按时间走一遍
现在把前面的工具串成一条时间线,假设一个写请求从应用发起:
text
时间轴 应用/文件系统 块层/调度器 设备驱动/硬件
|
|--> write() 进入 VFS
| 页缓存命中?
| 否,创建 bio
| --> Q 进入块层队列
| 调度器排序 / 合并
| --> I 插入调度队列
| 等待硬件队列标签
| --> D 下发驱动 --> 设备执行写入
| |
| <-- C 完成事件 <-- 写完成
|<-- write() 返回
在这条链路上,应用看到的是"write 很慢",但慢点可能出现在三处:页缓存回写造成的抖动、调度器排队、设备服务时间。用 iostat 看总量与 await,用 blktrace 看事件间隔,用 off-CPU 火焰图看代码位置,三者互相印证。
常见误区是只用一个工具下结论。只有 iostat 会误判设备故障;只有 blktrace 会忽略上层到底在做什么;只有火焰图可能在低采样率下丢失偶发问题。三者的证据要能对上:设备排队时间长,火焰图里就应该出现大量块层等待栈。
10. 常见误区与它们错在哪
| 误区 | 错在哪 | 正确做法 |
|---|---|---|
| await 高就是磁盘坏 | await 含排队时间 | 用 blktrace 拆排队与服务时间 |
| %util 100% 必须换盘 | 多队列下含义有限 | 结合 aqu-sz 与 IOPS 判断 |
| 调大队列深度一定更快 | 排队时间会变长 | 延迟敏感场景反而要减小 |
| D 状态多就是磁盘问题 | NFS/FUSE 也会 D 状态 | 结合 dmesg 与挂载类型 |
| 直接改生产内核参数 | 影响面大且不可回滚 | 先在测试环境用 fio 验证 |
把这张表当成自查清单,比记住一堆参数更有用。真正的判断依据永远是"现象、指标、调用栈"三者一致。
11. 生产实践建议
第一,先止血再定位。如果业务受损严重,可以先把写入频率降下来(比如临时关闭高频 fsync),或用限流保护设备,再慢慢做根因分析。不要一边业务雪崩一边长时间抓包。
第二,采样要成对。iostat 和 blktrace 尽量在同一时间窗口采集,否则你看到的设备指标和事件流可能对不上。建议在采集前先记录好开始时间与机器标识。
第三,参数调整要有对照。任何调度器或队列参数改动,都应有 fio 的前后对比数据,并明确记录版本、设备型号和负载模型。生产环境的"感觉变快了"不能作为结论。
第四,关注上层模式。大量排查最终都指向应用层:同步写日志、每次请求都刷盘、随机小块读写。这类问题改应用的收益,通常远大于调内核参数。
12. 排障清单
- 先看
uptime与ps,确认 load 高但不是 CPU 高,且有 D 状态进程。 - 用
iostat -x -m 1 5记录await、aqu-sz、%util、请求大小与 IOPS。 - 用
blktrace配合blkparse采集同一时间窗,拆出排队时间与服务时间。 - 检查
/sys/block/<dev>/queue/scheduler,确认调度器是否匹配设备类型。 - 用
biolatency或 off-CPU 工具找出阻塞点,对齐应用调用栈。 - 用 fio 在测试盘复现负载模型,验证参数调整是否真的有效。
- 检查
dmesg,排除驱动、链路、云盘挂载等硬件或平台侧问题。 - 最终结论要能回答:哪一层、哪类 I/O、哪个调用栈、如何验证修复。
13. 面试/复盘问题
await、%util、aqu-sz三个指标分别反映什么,为什么不能只看其中一个?- blktrace 的
Q、I、D、C各代表什么阶段,如何用它们的差值定位排队瓶颈? - 为什么 NVMe 上常用
none调度器,而数据库场景常选mq-deadline? - 进程处于 D 状态时,为什么
kill -9无效?这对排障有什么影响? - 如何用 fio 证明"把调度器从 none 换成 mq-deadline 改善了读延迟",需要固定哪些变量?
- off-CPU 火焰图和 CPU 火焰图的适用场景有什么区别?
14. 总结
回到开头那个场景:CPU 不高、load 很高、进程 D 状态。现在你应该有一套固定流程:先用 iostat 确认设备侧确实在排队,再用 blktrace 把 await 拆成排队与服务时间,然后借助 off-CPU 火焰图把阻塞落到具体调用栈,最后用 fio 在受控环境复现并验证结论。
记住这条主线:应用 → 文件系统与页缓存 → 调度器与块层 → 驱动与设备。每一层都有对应的观测工具,也都有各自的边界。工具之间要互相印证,结论要能被复现。下次再看到"磁盘 I/O 延迟突增",你至少知道第一步该看什么、第二步该排除什么。
15. 参考资料
- Linux 内核文档:Block layer 文档与
Documentation/block/目录 - Linux 内核文档:
Documentation/admin-guide/iostats.rst man iostat、man blktrace、man blkparse、man fio- Brendan Gregg:《Systems Performance: Enterprise and the Cloud》(第二版)
- Brendan Gregg 的 FlameGraph 项目文档与 off-CPU 分析说明
- bcc 项目文档中的
biolatency、offcputime工具说明