磁盘 I/O 延迟突增:用 iostat、blktrace 与火焰图定位到具体调用栈

磁盘 I/O 延迟突增:用 iostat、blktrace 与火焰图定位到具体调用栈

1. 一个真实的线上场景

假设你负责一个订单服务,平时接口 P99 在 80ms 左右。某个下午开始,监控告警接连不断:接口 P99 涨到 1.2s,写入日志的线程大量卡住,部分请求超时。你登录机器,第一反应是 top,发现 CPU 并不高,load average 却从 2 涨到了 18。再看进程状态,好几个业务进程处于 D 状态。

这时候问题就来了:CPU 不忙、内存也不紧张,那系统到底在等什么?如果只是猜"磁盘慢",你依然不知道该扩容磁盘、改参数,还是改代码。真正需要回答的是三个问题:等待发生在哪一层、是哪类 I/O、最终落到哪段代码。

本文就沿着这条线走一遍。目标不是把命令手册抄一遍,而是让你在遇到"磁盘 I/O 延迟突增"时,有一套从现象到调用栈的固定推演路径。读者需要具备基本的命令行经验,读过 toppsdf 的输出即可。

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/sw/srMB/swMB/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_tagio_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]

看这段事件流:从 QI 只花了两微秒,说明进入队列很快;从 ID 花了约 18ms,说明请求在调度器队列里排了 18ms;从 DC 约 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: IOPSclat(完成延迟)和 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 事件,或者采集内核栈与用户栈的关联。

采集思路大致是:用 perfbcc 工具抓住进程进入和离开调度器的时间,把等待期间的栈展开并聚合,最后用 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),或用限流保护设备,再慢慢做根因分析。不要一边业务雪崩一边长时间抓包。

第二,采样要成对。iostatblktrace 尽量在同一时间窗口采集,否则你看到的设备指标和事件流可能对不上。建议在采集前先记录好开始时间与机器标识。

第三,参数调整要有对照。任何调度器或队列参数改动,都应有 fio 的前后对比数据,并明确记录版本、设备型号和负载模型。生产环境的"感觉变快了"不能作为结论。

第四,关注上层模式。大量排查最终都指向应用层:同步写日志、每次请求都刷盘、随机小块读写。这类问题改应用的收益,通常远大于调内核参数。

12. 排障清单

  • 先看 uptimeps,确认 load 高但不是 CPU 高,且有 D 状态进程。
  • iostat -x -m 1 5 记录 awaitaqu-sz%util、请求大小与 IOPS。
  • blktrace 配合 blkparse 采集同一时间窗,拆出排队时间与服务时间。
  • 检查 /sys/block/<dev>/queue/scheduler,确认调度器是否匹配设备类型。
  • biolatency 或 off-CPU 工具找出阻塞点,对齐应用调用栈。
  • 用 fio 在测试盘复现负载模型,验证参数调整是否真的有效。
  • 检查 dmesg,排除驱动、链路、云盘挂载等硬件或平台侧问题。
  • 最终结论要能回答:哪一层、哪类 I/O、哪个调用栈、如何验证修复。

13. 面试/复盘问题

  1. await%utilaqu-sz 三个指标分别反映什么,为什么不能只看其中一个?
  2. blktrace 的 QIDC 各代表什么阶段,如何用它们的差值定位排队瓶颈?
  3. 为什么 NVMe 上常用 none 调度器,而数据库场景常选 mq-deadline
  4. 进程处于 D 状态时,为什么 kill -9 无效?这对排障有什么影响?
  5. 如何用 fio 证明"把调度器从 none 换成 mq-deadline 改善了读延迟",需要固定哪些变量?
  6. 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 iostatman blktraceman blkparseman fio
  • Brendan Gregg:《Systems Performance: Enterprise and the Cloud》(第二版)
  • Brendan Gregg 的 FlameGraph 项目文档与 off-CPU 分析说明
  • bcc 项目文档中的 biolatencyoffcputime 工具说明
相关推荐
倔强的石头_1 小时前
数据迁移工具KDMS如何生成量化评估报告
数据库
程序员-Benothing1 小时前
Linux查找文件命令:find、locate、which、whereis实战
linux·运维·服务器
泡海椒1 小时前
jquick-pdf 核心原理解析:基于 HTML 模板动态渲染 PDF 的实现逻辑
java·开发语言·pdf
j7~1 小时前
【Linux网络加餐】(篇七)网络版计算器(中):协议落地、报文分隔与完整链路
linux·c++·网络编程·tcp·报文分割·网络计算器·协议落地
冰暮流星1 小时前
mysql多表练习2
数据库·sql·mysql
IT_陈寒1 小时前
Redis并发写入踩坑记录:别让超时设置坑了你
前端·人工智能·后端
晨枫阳1 小时前
Client authentication(客户端授权)开关与OAuth2 PKCE的深刻理解
前端·安全
福兮说1 小时前
在浏览器里写一个“够用就好“的 Excel 公式求值器
前端·javascript·excel·编译原理
天天喝旺仔1 小时前
Git 内部原理深度解析:从 blob/tree/commit 对象到 packfile 与垃圾回收
数据结构·数据库·git·算法·哈希