磁盘 I/O 打满导致服务雪崩:一次 IO 瓶颈排查实录

一、现象:请求大量超时,CPU 却很闲

凌晨两点,报警群炸了:核心接口 P99 从 80ms 飙到 8s,但 CPU 使用率只有 15%、内存也很充裕。真相是磁盘 I/O 被打满——一次典型的 IO 瓶颈引发的连锁雪崩。本文复盘这次故障:从「看似 CPU 问题」的误判,到用 iostat 锁定罪魁,再到日志风暴的止血与根治。

这类故障最迷惑人的地方在于:监控大盘上 CPU、内存都正常,唯独响应时间雪崩。很多同学第一反应是「加机器、扩线程池」,结果毫无改善,因为瓶颈根本不在算力,而在磁盘吞吐。这也是为什么我们此前排查 慢 SQL 拖垮生产库 时反复强调:先量化瓶颈类型,再动手。

磁盘 I/O 之所以被称作「隐形杀手」,是因为它在绝大多数监控模板里都是二等公民。大家习惯盯 CPU、内存、QPS,却很少有人为 await、%util 设定阈值。等到 P99 爆红才回头看,往往已经雪崩半小时。这次故障如果早半小时被磁盘告警捕获,影响面能缩小一个数量级——监控覆盖的缺口,最后都会变成故障的代价。

二、定位:用 iostat 找到罪魁祸首

遇到「响应慢但 CPU 闲」的现场,第一动作不是重启,而是看磁盘。iostat 是最快的诊断入口,重点盯着 %util 和 await 两个指标。

# -x 扩展统计,-d 只看磁盘,1 每秒刷新
iostat -x -d 1

# 关键列解读
# %util  设备繁忙百分比,持续 > 90% 即 IO 饱和
# await  单次 IO 平均等待(ms),正常个位数,雪崩时常 > 100
# r/s w/s 读写 IOPS;rkB/s wkB/s 读写吞吐
Device   r/s   w/s   rkB/s  wkB/s  await  %util
sda      2.1  890  120.0  48000  142.6   99.8

上面这个输出就是典型症状:写吞吐 48MB/s 并不夸张,但 %util 卡在 99.8%、await 高达 142ms——磁盘已经饱和,所有 IO 请求都在排队。此时任何需要落盘的操作(写日志、刷数据库、GC 落地)都会被拖死。

这里要区分两个指标的含义:%util 高只代表设备「很忙」,不一定拥堵;真正说明用户痛苦的是 await——它衡量单次 IO 从发起到完成的等待时长,包含队列排队时间。机械盘的 await 正常在几毫秒,SSD 应低于 1ms;一旦 await 突破几十毫秒,说明请求已经在排队,应用感知到的延迟会成倍放大。所以诊断时二者要结合看:%util 接近 100% 且 await 同步抬高,才是确凿的 IO 饱和。

三、根因:日志风暴与备份抢占

知道「磁盘满了」只是第一步,还得知道「是谁在写」。用 iotop 按进程排序,再用 lsof 反查文件,基本能锁定元凶。

# 按 IO 占用排序看进程(需要 root)
iotop -oP

# 反查某个进程正在写哪些文件
lsof -p <pid> | grep REG

# 再看具体文件大小增长
lsof -p <pid> | awk '$5=="REG"{print $7, $9}' | sort -n

本次根因是两点叠加:① 一个边界 Bug 让单条异常请求打印了整条超长报文,日志量瞬间放大 200 倍,形成「日志风暴」;② 恰好赶上每日全量备份脚本抢占云盘吞吐。两者撞在一起,磁盘直接被打满。

日志风暴的触发点很不起眼:某个下游返回了一个未预期的超长 JSON,异常处理里又把它整包塞进了 ERROR 日志。平时单条几十字节的日志,瞬间变成单条几 MB,QPS 上千时写盘量直接从每秒几 MB 飙到上 GB。这类问题用 iotop 一眼就能看出——某个 logger 进程长期霸占写榜首,而它本不该是 IO 大户。教训是:日志里永远不要打印未经裁剪的请求体,异常字段必须脱敏截断。

3.1 云盘突发吞吐被限流

如果是云服务器,还要怀疑云盘本身的突发(Burst)额度。很多云盘平时有积分缓冲,持续写入会耗尽突发余额后被限速到几十 MB/s。这和前面 文件描述符耗尽 一样,属于「资源隐性上限」类故障,靠监控很难提前发现,只能靠 IO 指标兜底。

四、止血:三板斧先恢复

线上故障第一原则是止血优先于查因。我们用了三板斧,5 分钟内把 P99 拉回正常。

# 1. 立即停掉抢占 IO 的备份任务
systemctl stop nightly-backup.service

# 2. 用 ionice 把日志进程降级为 idle,避免饿死核心服务
ionice -c 3 -p $(pgrep -f "app-logger")

# 3. 临时关掉 DEBUG 级日志(改配置后无需重启的热加载)
curl -X POST http://localhost:8080/actuator/loggers/com.demo \
  -H 'Content-Type: application/json' \
  -d '{"configuredLevel":"WARN"}'

止血顺序很关键:先停外部抢占(备份),再降内部噪音(日志级别),最后才是降级。切记不要上来就重启——重启会触发大量冷加载 IO,反而让磁盘更堵。这与 Nginx 502/504 排查 中「先确认超时假说再动」的思路一脉相承。

五、根治:从架构上解耦 IO

止血只是救火,根治要消除「业务 IO 与运维 IO 互相踩踏」的结构性隐患。我们按 IO 来源做了分层治理。

IO 来源风险点对策
应用日志异常放大、级别过细异步写 + 采样 + 关键路径才 DEBUG
数据库落盘大事务、全表更新分批 + 低峰期 + 连接池限流(参考 HikariCP 连接泄漏
运维任务备份/索引抢占限速 ionice + 错峰调度 + 独立云盘

核心是把「可丢失的 IO」(日志、监控)和「不可丢失的 IO」(数据库 WAL)分到不同磁盘,再对运维任务做 ionice 限速,保证核心链路永远拿得到吞吐。

具体的工程落地有三件事值得做:第一,日志改为异步追加写,並在客户端做采样与级别控制,杜绝「一条异常打满盘」;第二,数据库的数据目录与 binlog/WAL 挂到独立云盘,运维备份只扫数据盘,不碰 WAL 盘;第三,对备份、重建索引等重 IO 任务统一加 ionice -c 2 限速,并在低峰期错峰执行。成本上,多挂一块普通云盘远比故障期间的业务损失便宜,这笔账很好算。

六、监控:把磁盘指标纳入告警

这次能快速定位,也得益于我们早已把磁盘指标接入了 Prometheus + Grafana 监控。关键是不要在 %util 爆了才报警,而要在 await 异常抬升时就预警。

# Prometheus 告警规则:磁盘 await 持续偏高即预警(不等 %util 爆)
  - alert: DiskIOHighLatency
    expr: rate(node_disk_read_time_seconds_total[5m]) / rate(node_disk_reads_completed_total[5m]) > 50
    for: 10m
    labels:
      severity: warning
    annotations:
      summary: "磁盘 IO 平均等待超过 50ms(磁盘可能即将饱和)"

配合 node_exporter 的 node_disk_* 系列指标,还能画出「吞吐 vs 等待」的趋势曲线,让容量规划从「拍脑袋」变成「看数据」。

更进一步,可以把磁盘 await 的 P95、P99 都纳入 SLO 看板,和历史基线做同比。一旦某块盘的等待时长连续偏离基线,就提前扩容或迁移,而不是等雪崩。和 OpenTelemetry 链路追踪 配合时,还能把「某次慢请求」直接归因到「当时磁盘 await 偏高」,闭环到根因,大幅提升复盘效率。

七、复盘清单

把这次故障沉淀成一张排查清单,下次遇到「响应慢但 CPU 闲」直接照着走。

步骤动作判断依据
1 看 CPUtop 看 us/sy若都低,排除算力瓶颈
2 看磁盘iostat -x 1%util>90% 且 await 高 = IO 饱和
3 找进程iotop + lsof锁定写盘最多的 PID 与文件
4 止血停备份/降日志/ionice先恢复再查因
5 查云盘看突发额度是否触发云盘限速
6 根治IO 分层 + 限速业务与运维 IO 解耦

磁盘 I/O 打满这类故障,表面上千奇百怪,骨架却高度一致:现象迷惑 → iostat 量化 → iotop 定性 → 止血优先 → 架构解耦。把它变成团队肌肉记忆,下次雪崩就能从「慌乱两小时」压缩到「五分钟止血」。

八、常见误判:别把 IO 瓶颈当成网络问题

最后提醒一个高频误判:磁盘 IO 饱和时,上游看到的往往是「连接超时」「接口 504」,于是团队一头扎进网络排查,调 Nginx 超时、查防火墙、甚至怀疑 DNS,折腾半天毫无效果。本质是磁盘卡住后,处理线程被 IO 阻塞,连接池被占满,对外就表现为超时。分辨方法很简单:抓一条慢请求,看它卡在「建连」还是「处理」——如果建连飞快、处理极慢,九成是后端资源(CPU/IO/锁)瓶颈,而非网络。先量后断,少走弯路。

上一篇 大模型偏好对齐实战:RLHF 与 DPO 选型落地
下一篇 任务编排神器:Makefile 与 just 让命令可复用