高级后端 · 磁盘 IO 分析

util 100% 不等于饱和, await 才是体感 — IO 分析三仪表: 延迟 await / 排队 aqu-sz / 参考 %util, 再用 pidstat -d 抓凶手

一次 write 的旅程: 延迟藏在哪几站 应用 write 系统调用返回 Page Cache 写合并 · 脏页异步刷 块层队列 aqu-sz 在这里 盘 await 读路径: 先查 cache, miss 才到盘 (冷读 = majflt/await) 写路径: 进 cache 就返回, 回写异步 — 平时飞快, 刷盘时抖 事故形态: 回写风暴 + 块层排队 (aqu-sz↑) + await 1ms→200ms fsync 会强制把这条路径走完 — 同步刷盘的代价就在这里 iostat -xz 1: 五个必看字段 r/s w/s IOPS — 注意大小 IO 不等价 rkB/s wkB/s 吞吐 — IOPS 高 ≠ 吞吐高 await 单请求等待 — 1ms→200ms 即事故 aqu-sz 平均队列 — 持续 >1 就是排队 %util 仅参考 — NVMe 多队列时代会骗人 磁盘出事的收网链: 四步锁定凶手进程 ① 有没有在等 IO vmstat 1 → wa / b wa=65% b=20 → 转 IO 线 ② 哪块盘什么程度 iostat -xz 1 await 500ms aqu-sz 80 ③ 哪个进程在读写 pidstat -d 1 kB_wr/s 409600 → 凶手 ④ 抓现行 + 治根因 iotop -o 实时确认 限速 / 加缓存 / 挪盘 事故现场 A: du 与 df 对不上 df 90% 但 du 只找到 50G → 文件被 rm, 进程还握着 fd lsof +L1 → application.log (deleted) 占 40G 正解: : > /path/app.log 截断 or 重启进程, 再配 logrotate 教训: 清理大日志用 truncate, 不是 rm 事故现场 B: 空间没满却写不进 df -h 显示 40%, 写文件报 No space left on device df -i → IUse% 100% ← inode 耗尽 (海量小文件) 正解: 清理小文件 / 会话缓存目录 / 邮件队列残留 教训: 容量有两个维度 — block 和 inode 巡检口径: await 突变 > 5×基线 或 aqu-sz 持续 > 1 → 告警; %util 只做参考

读写两条路

  • • 读: cache 命中飞快, miss 才付磁盘延迟
  • • 写: 进 page cache 就返回, 回写异步
  • • 抖动常来自"刷盘时刻", 不是平均速度
  • • fsync 把异步变同步, 每条都 fsync 是灾难

三仪表定饱和

  • • await: 单请求等待, 体感的直接来源
  • • aqu-sz: 队列深度, 持续>1 即排队
  • • %util: 传统盘参考, NVMe 时代会骗人
  • • IOPS 与吞吐要一起看 (大小 IO 不等价)

两个文件系统暗坑

  • • deleted 文件仍占空间: lsof +L1
  • • inode 耗尽: df -i, 容量没满也写不进
  • • 清大日志用 truncate 不用 rm
  • • logrotate 要配 copytruncate 才闭环

💡 一句话理解

磁盘 IO 像餐厅出餐: 应用点单 (write) 通常只要递进page cache这个传菜口就算完, 后厨 (磁盘) 异步慢慢做 — 所以平时飞快; 一旦后厨积压 (队列 aqu-sz 涨), 传菜口也开始堵, 顾客等待时间 (await) 才暴露真相。分析 IO 就是看三个仪表: 等了多久 (await)、排了多长 (aqu-sz)、设备忙不忙 (%util 仅参考), 然后顺着 vmstat → iostat → pidstat -d → iotop 四步把"哪口锅糊了"落实到"哪个厨师"。

🧠 必知必会 必考 & 必会

iostat -xz 总览
-x 扩展统计 -z 隐藏空闲设备。第一行是开机以来均值, 判断要看后续逐行。
$ iostat -xz 1
# Device  r/s  w/s  await  aqu-sz  %util
# vda     2    840  320.5  82.3   100.0  ← 平均值无意义, 看下一行
await
IO 请求从提交到完成的平均毫秒数, 是"磁盘体感"的直接来源。突变 (1ms→200ms) 比绝对值重要。
# await 基线 1ms 的 SSD:
# 突变到 200ms → 磁盘明显出事, 立刻查谁在读写
aqu-sz 队列深度
平均在飞行的 IO 数。和应用线程池一个道理: 持续大于 1 就是排队, 持续上涨是饱和前兆。
# aqu-sz 偶发 2 → 抖动正常
# aqu-sz 持续 80 → IO 在深度排队, 延迟必然爆炸
%util 陷阱
旋转盘时代 util≈100% 基本等于饱和; NVMe/多队列设备可并行处理, util 100% 只说明"一直有请求在飞", 不等于饱和。
# NVMe: %util=100 但 await=0.3ms aqu-sz=0.5
# → 没饱和! 判饱和: await 突变 + aqu-sz 持续高
IOPS vs 吞吐
4K 随机 IO 拼的是 IOPS 天花板, 大文件顺序读写拼的是 MB/s。两个指标不换算, 压测要分开。
# 随机 4K:  10000 IOPS = 40MB/s
# 顺序 1M:  500 IOPS   = 500MB/s  ← 同一块盘两种命
iowait 真相
wa 是"CPU 等 IO 的时间占比", 只说明有 IO 在等, 不度量磁盘本身, 多核下解释更复杂 — 它是线索不是结论。
# wa=50% → 只能说"去查 IO"
# 结论要到 iostat await/aqu-sz + pidstat -d 才能下
pidstat -d
进程级读写定位: kB_rd/s、kB_wr/s、iodelay。它回答"哪口锅糊了→哪个厨师干的"。
$ pidstat -d 1
# PID   kB_rd/s  kB_wr/s  iodelay  Command
# 5678      0    409600   95       log-agent
iotop -o
实时版凶手照片, -o 只显示有 IO 的进程, 磁盘突然忙时抓现行最快。
$ iotop -o -b -n 3    # 批模式打 3 轮, 可丢进日志
# 5678 be/4 root  380.5M/s  0.0B/s  log-agent
page cache 与回写
写进 cache 就返回; 内核 flusher 按 dirty_ratio/dirty_expire 异步刷盘。突发写大文件时"积攒-集中刷盘"正是延迟毛刺来源。
$ grep -E "Dirty|Writeback" /proc/meminfo
# Dirty: 3.2GB  ← 大量脏页待刷, 接下来会有回写风暴
fsync 的代价
fsync 强制走完 cache→盘全程再返回。每条日志/每笔交易都 fsync, 吞吐直接掉一个数量级; 要用组提交 (group commit) 或批量。
# 每条 fsync:  ~1000 条/s (机械盘受限明显)
# 攒 100 条一次 fsync: ~5 万条/s  ← WAL 的原理
deleted-open 文件
rm 后进程仍握 fd, 空间不释放: du 看不到但 df 记账。lsof +L1 直接点名, 截断或重启释放。
$ lsof +L1 | grep deleted
# app 1234  40G  REG  /var/log/app.log (deleted)
$ : > /var/log/app.log    # 截断释放, 不用重启
inode 耗尽
每个文件占一个 inode, 海量小文件能把 inode 烧光: 容量 40% 也报 No space left on device。
$ df -i /data
# Filesystem Inodes IUsed IUse% Mounted
# /dev/vdb   6.5M   6.5M  100% /data  ← inode 满, block 才 40%
随机 vs 顺序
数据库重随机 IOPS, 日志/备份重顺序吞吐。同盘两种负载混跑会互相毁掉预读与合并。
# 备份 (顺序大读) 撞上 OLTP (随机小 IO)
# → 数据库 await 起飞; 正解: 错峰 + ionice/限速

🏭 生产实战 real world

场景 1 · API P99=10s 而 CPU 20%: 从 vmstat 收网到日志 agent

tmp 系列经典案例的完整走法: CPU 正常先想"在等", 顺着 IO 链四步收网。

$ vmstat 1
# wa=65%  b=20        ← 不是 CPU 空闲, 是在等 IO
$ iostat -xz 1
# await=500ms  aqu-sz=80   ← 设备排队爆炸
$ pidstat -d 1 | sort -k5 -rn | head -3
# 12:00  PID  kB_wr/s  Command
#        5678  409600  log-agent   ← 每秒 400MB 在写

# 结论: 日志 agent 本地缓冲配置错误, 全量直写
# 修复: 改批量+压缩, 写入限速 50MB/s → await 回落 2ms

问题从"接口怎么慢了"缩小成"磁盘被这个进程拖死", 这就是性能分析。

场景 2 · iotop -o 抓现行: 每小时整点磁盘爆满

毛刺整点出现, iostat 历史能对齐时间, iotop 批模式守株待兔拿到进程名。

# 守在整点前 (cron 排查)
$ echo "iotop -o -b -n 30 -d 2 | grep -v '^Total'" > /tmp/watch.sh
$ at 09:58 < /tmp/watch.sh   # 整点前后各抓 1 分钟

# 抓到: 09:59:58  backup.sh  R  820M/s  tar
# 根因: 全量 tar 打到数据盘; 修复:
#   ionice -c3 nice -n 19 tar ...   + 限速 rsync --bwlimit=50000

批处理与线上 IO 隔离后, 整点 P99 毛刺消失。

场景 3 · rm 掉 40G 日志磁盘仍满: lsof +L1 定位

运维 rm 大日志"清空间", df 纹丝不动 — 文件还被进程握着。

$ df -h /var | tail -1
# /dev/vda1  100G  90G  88%  /var
$ du -sh /var/log
# 50G   /var/log     ← 少了 40G, 差值在 deleted

$ lsof +L1 | sort -k7 -rn | head -3
# app  1234  40G  REG  /var/log/app.log (deleted)

# 不重启的解法: 截断 fd 对应文件
$ : > /proc/1234/fd/15      # 直接清空该 fd 指向的已删文件
# 根治: logrotate 配 copytruncate

从此大文件清理 SOP: 先 lsof 看引用, 再决定 truncate 还是重启。

场景 4 · 写图片报 No space left 但 df 只有 40%: inode 用尽

缩略图服务把每张图拆成小分片, 两个月后 inode 先于容量阵亡。

$ df -h /data | tail -1
# /dev/vdb  500G  190G  40%  /data     ← 明明没满
$ df -i /data | tail -1
# Inodes  6.5M  6.5M  100%           ← inode 烧光

# 找小文件大户:
$ find /data -type f | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head
# 5900000 /data/cache/thumb
# 修复: 分片合并存储 + 过期清理任务 → IUse% 回落到 12%

巡检加一条: block 使用率与 inode 使用率都进面板。

场景 5 · await 1ms→200ms 却没有大写入: 云盘 burst credit 耗尽

凌晨延迟起飞但自家流量平稳 — 云盘的突发额度用完了, 这是云环境特有病灶。

$ iostat -xz 1 | awk '$NF==100.0'
# vdb  await 200  util 100%  但 w/s 只有 300

$ 云监控: EBS/云盘 IO Credit Balance → 0
# 根因: 每天集中备份把 burst 额度烧光, 之后基线性能暴跌
# 修复: 升配到 io 型盘 / 备份限速到基线以内 / 错峰

云盘性能 = 基线 + burst, 容量规划要按"基线"算, 不按"峰值"。

场景 6 · 每条审计日志一次 fsync: 吞吐 50k→3k 的修复

"必须落盘才返回"被理解成"每条 fsync", 组提交才是正解。

// 错: 每条刷盘
for e := range ch {
    f.Write(e)
    f.Sync()          // 每次 ~1ms, 吞吐锁死 1000/s
}

// 对: 攒批 + 单次 fsync (组提交)
ticker := time.NewTicker(10 * time.Millisecond)
for {
    select {
    case e := <-ch:  buf = append(buf, e)
    case <-ticker.C:
        f.Write(bytes.Join(buf, nil)); f.Sync(); buf = buf[:0]
    }
}
// 吞吐: 1k/s → 48k/s, 最坏丢失窗口 = 10ms

取舍写进注释: 每秒最多丢 10ms 内的日志, 换 48 倍吞吐。

场景 7 · 深分页导出拖垮 DB 磁盘: 顺序扫描挤占随机 IO

运营导出全表触发大范围顺序扫描, 预读把 buffer 池挤空, 在线随机读全部 miss 到盘。

$ pidstat -d 1 | grep mysqld
# kB_rd/s = 80000   ← mysqld 在疯狂读盘

$ iostat -xz 1
# await 2ms → 45ms, aqu-sz 30  ← 数据盘排队

# 慢查询日志: SELECT ... LIMIT 100 OFFSET 8000000
# 修复: 导出走只读副本 + keyset 分页 (WHERE id > ? LIMIT 100)
# 效果: await 回落 2ms, 主库 P99 恢复

离线大查询永远不进主库 — 这是 IO 层面的隔离, 比加索引更根本。

场景 8 · aqu-sz 持续大于 1: 把排队深度做成告警

容量水位不该只看磁盘空间, IO 排队是更早的领先指标。

# node_exporter + Prometheus 规则
- alert: DiskQueueDepthHigh
  expr: rate(node_disk_io_now[5m]) > 1
  for: 10m
  annotations:
    summary: "{{ $labels.device }} IO 持续排队"

# 配套: await 突变告警
# expr: (await_now / await_baseline_7d) > 5
# 处置顺序: 找凶手进程 → 限速/挪盘 → 还没救回再扩容

两项规则上线后, 三起磁盘事故都在用户感知前处理掉了。

场景 9 · %util 100% 但业务不慢: NVMe 判饱和的正确姿势

新同事看到 util 100% 要求扩盘 — 数据打脸: await 0.3ms, aqu-sz 0.4。

$ iostat -xz 1
# nvme0n1  r/s=8000 w/s=12000  await=0.3  aqu-sz=0.4  %util=100

# 解读: NVMe 多队列并行处理, "一直忙"≠"排长队"
# 判饱和三板斧:
#   1) await 是否相对基线突变
#   2) aqu-sz 是否持续 > 1
#   3) biolatency 看延迟分布 (P99 而非平均)
# 结论: 不扩容, 关单

把"判饱和三板斧"写进容量评审 checklist, 杜绝 util 单指标决策。

场景 10 · sar -d 历史回放: 三天前的 IO 尖峰是谁

事故已过去, sysstat 历史是唯一证人 — 前提是采集开着。

$ sar -d -f /var/log/sa/sa24 | awk '$1 ~ /^14:/'
# 14:00  await  aqu-sz  %util  dev
# 14:10   180    65     100    vdb  ← 尖峰窗口

$ sar -d -f /var/log/sa/sa24 | awk '$1 ~ /^14:1/ {print $6}'
# 结合当时进程日志: 14:10 数据迁移脚本启动
# 修复: 迁移脚本加 --throttle, 排到 02:00 低峰

所有生产机开 sysstat (ENABLED="true"), 保留 30 天 — 成本几乎为零, 救命率极高。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · %util 100% 就判磁盘饱和 — NVMe/多队列并行处理, util 100% 只表示"一直有请求". 原因: 旋转盘经验过时. 正解: await 突变 + aqu-sz 判饱和。
# 错: util 100% → 扩容工单
# 对: await=0.3ms aqu-sz=0.4 → 不饱和, 关单
坑 2 · 拿 await 平均值下结论 — 大部分 1ms + 少量 1s, 平均 50ms 看不出双峰. 原因: 分布被平均. 正解: biolatency 看延迟分布。
# 错: await 50ms → "还行"
# 对: biolatency → P50=1ms, P99=1s → 有问题
坑 3 · 只看 IOPS 不看延迟 — IOPS 没降但 await 翻 100 倍, 体感早崩了. 原因: 吞吐思维. 正解: IOPS/吞吐/延迟三件套同看。
# 错: "w/s 3000 很正常"
# 对: 同图叠加 await 曲线
坑 4 · 忽略 aqu-sz — 队列深度是饱和最直观的领先指标, 很多面板没有它. 原因: 字段陌生. 正解: aqu-sz 进监控与告警。
# 错: 面板只有 %util
# 对: 加 aqu-sz, 持续 >1 告警
坑 5 · wa 高就换盘 — wa 只说明"CPU 在等 IO", 凶手可能是别的进程. 原因: 跳过定位链. 正解: pidstat -d 找到人再说。
# 错: wa 60% → 采购 SSD
# 对: pidstat -d → log-agent 直写 → 配置修复
坑 6 · du/df 对不上不追查 — 差值就是 deleted 文件, 放着会一直吃空间. 原因: 以为统计误差. 正解: lsof +L1 点名。
# 错: "du 少 40G, 大概是工具 bug"
# 对: lsof +L1 → app.log (deleted) → 截断
坑 7 · 用 rm 清理在写的大日志 — 空间不释放, 白 rm. 原因: fd 还握着. 正解: : > file 截断或 logrotate copytruncate。
# 错: rm -f app.log (进程还在写)
# 对: truncate -s 0 app.log
坑 8 · logrotate 不配 copytruncate — 默认 create 模式下应用仍写旧 fd, 新文件 0 字节旧文件无限涨. 原因: 轮转模式不懂. 正解: 持 fd 的应用配 copytruncate。
# 错: 只配 daily rotate 30
# 对: copytruncate + compress + missingok
坑 9 · 只盯容量不盯 inode — 海量小文件先烧光 inode. 原因: 单维度巡检. 正解: df -i 与 df -h 同巡检。
# 错: df -h 40% → 高枕无忧
# 对: df -i 100% 才是写不进的原因
坑 10 · 小 IO 直打 SSD — 4K 随机小写造成写放大, SSD 寿命与性能双输. 原因: 无合并层. 正解: 批量/合并写入, 日志缓冲。
# 错: 每条 200B 记录一次 write()
# 对: 攒 64KB 或 10ms 批量落盘
坑 11 · 滥用 O_DIRECT — 绕过 page cache 后每次读都真实到盘, 应用自建缓存反而更差. 原因: 听闻"直 IO 快". 正解: 数据库引擎才用, 一般服务用默认 buffered。
# 错: open(..., O_DIRECT) 写普通日志
# 对: 走 page cache + 批量回写
坑 12 · 每条记录 fsync — 同步刷盘把吞吐锁死在单次延迟倒数. 原因: 把"可靠"翻译成"每条刷". 正解: 组提交/批量 fsync。
# 错: for { write(); fsync() }  → 1k/s
# 对: 攒批 10ms 一次 fsync → 48k/s
坑 13 · 瞎调块层队列参数 — nr_requests/scheduler 调优不结合负载类型. 原因: 玄学调参. 正解: 先明确随机/顺序特征, 基准对比再动。
# 错: echo none > scheduler 后性能更差
# 对: fio 基准 (randrw 4k / seq 1m) 对比后再定
坑 14 · 忽视云盘 burst credit — 平时飞快的云盘在额度耗尽后暴跌到基线, 误判"磁盘坏了". 原因: 不懂云盘计费模型. 正解: 按基线做容量规划, 监控 credit 余额。
# 错: "昨天还好好的, 今天磁盘坏了"
# 对: 云监控 credit → 0 → 升配或限流备份
坑 15 · dd 测速不丢缓存 — 读测试全走 page cache, 测出的是内存速度. 原因: 忘了绕缓存. 正解: oflag=direct / 测前 drop cache。
# 错: dd if=test of=/dev/null bs=1M  → 8GB/s 假象
# 对: dd if=test of=/dev/null bs=1M iflag=direct
坑 16 · 忽略 dmesg 的 IO 报错 — blk_update_request I/O error 意味着盘在坏, 别只调性能. 原因: 日志不进巡检. 正解: dmesg 关键字巡检 + 提前换盘。
# 错: 只看 iostat 数字
# 对: grep -i "i/o error" dmesg → 报修换盘
坑 17 · 备份与 OLTP 同盘同时跑 — 顺序大读挤掉随机 IO 的预读与合并. 原因: 负载混布. 正解: 错峰 + ionice + 限速, 或独立盘。
# 错: 白天 tar 全库到同盘
# 对: 02:00 + ionice -c3 + --bwlimit
坑 18 · 忽略读预读的作用 — 随机化访问顺序 (如 hash join 打乱) 会让预读失效, IO 翻倍. 原因: 不懂 readahead. 正解: 保持扫描顺序 / 调 blockdev --setra。
# 错: 导出按 hash 乱序读主键
# 对: ORDER BY id 扫描, 让预读连续命中
坑 19 · NFS 卡住误判 CPU 问题 — 大量 D 状态把 load 顶高, 看起来像负载爆炸. 原因: 不查 b 列. 正解: vmstat b + ps stat D 定位到挂载。
# 错: load 30 → 扩 CPU
# 对: b=28, wchan=rpc_wait → NFS 挂了
坑 20 · iostat 看第一行就下结论 — 第一行是开机以来平均值, 稀释了事故窗口. 原因: 输出顺序误会. 正解: 丢弃首行, 看逐秒行。
# 错: iostat -xz 1 只看第一行 (均值)
# 对: 看 2 行以后: await=500 在当下