磁盘IO打满怎么办?我用5个真实案例,总结了这套可复用的排查方法论

写在前面

昨天下午四点,监控系统突然推送了一条告警:数据库服务器磁盘IO使用率飙到95%。几乎同一时间,业务方在群里炸了------订单系统大量接口超时。

我花了将近一个小时,从系统层一路下钻到数据库执行计划,最终定位到根因:一个缺索引的批量UPDATE,加上任务调度的并发bug,把磁盘写入直接打满了。

事后复盘的时候我发现,磁盘IO类问题其实有一套非常清晰的排查路径。这篇文章我把那次完整的排查过程,加上后来遇到的另外4个典型案例,全部整理出来,希望能帮你在下次遇到磁盘IO告警时,少走弯路。

一、为什么磁盘IO问题容易被误判?

磁盘IO问题和CPU问题有一个本质区别:现象更隐蔽。

很多时候你上去一看,CPU使用率才20%,内存也还有富余,但load average居高不下,业务响应就是慢。这种"看起来资源没占满但业务就是卡"的场景,恰恰是磁盘IO问题的典型表现。

如果排查者没有相关经验,很容易被CPU和内存的正常指标误导,在错误的方向上浪费大量时间。

所以第一步,先建立几个核心概念。

二、先搞懂这几个核心指标

2.1 IOPS 和吞吐量是两回事

  • IOPS :每秒处理的读写请求次数
  • 吞吐量(Throughput) :每秒传输的数据量(MB/s)

这两个指标不是线性对应的:

  • 小块随机IO(数据库随机读写)→ IOPS容易打满,吞吐量可能还很低
  • 大文件顺序读写(日志归档、备份)→ 吞吐量容易打满,IOPS可能还有余量

排查时先判断是IOPS密集型 还是吞吐量密集型,优化方向完全不同。

2.2 %util 不等于"磁盘满了"

iostat里的%util表示设备处理IO请求的时间占比。

  • 机械硬盘:%util接近100%基本可以认为饱和
  • SSD/云盘/分布式存储:%util到100%不一定真的饱和,因为这类设备可以并行处理多个IO队列

所以不能只看单一指标,要结合await、队列深度交叉判断。

2.3 await:判断磁盘"真慢"的最直接指标

await是每个IO请求的平均处理时间(包含排队等待+实际处理),单位毫秒。

  • 机械盘正常:几毫秒到十几毫秒
  • SSD正常:1毫秒以内
  • 如果飙升到几十甚至上百毫秒 → 请求排队严重或设备本身响应变慢

重点关注r_await和w_await,可以帮你快速区分是读慢 还是写慢。

2.4 队列深度(aqu-sz)

平均有多少个IO请求在排队。持续远超1,说明磁盘处理能力跟不上请求速度。

三、整体排查思路(决策树)

我把排查路径整理成了一张决策树,遇到告警按这个顺序走:

bash 复制代码
磁盘IO告警触发
    │
    ├─ df -h 确认不是空间耗尽
    │
    ├─ iostat -x 1 看整体压力
    │       ├─ 读多写少 → 排查缓存未命中、全表扫描读
    │       ├─ 写多读少 → 排查批量写入、日志刷盘、备份任务
    │       └─ 读写都高 → 排查并发量上升或混合型批处理
    │
    ├─ iotop 定位具体进程
    │
    ├─ 深入进程类型排查
    │       ├─ 数据库 → 慢查询、执行计划、缺索引
    │       ├─ 日志/文件写入 → 日志级别、同步刷盘配置
    │       └─ 备份/归档 → 调度时间是否与业务高峰重合
    │
    ├─ 检查存储层(云盘限额 / 物理机硬件异常)
    │
    └─ 确认根因 → 紧急止血 + 根本修复

下面通过5个真实案例,把这条路径走一遍。


四、案例一:MySQL全表扫描把磁盘写爆了

现象

数据库服务器磁盘IO告警,订单系统大量接口超时。

排查过程

第一步:排除空间问题

bash 复制代码
df -h

输出显示根分区56%、数据分区63%,空间充足,确定是性能维度的IO问题。

第二步:iostat看整体压力

复制代码
iostat -x 1 5

第二次采样的关键数据:

bash 复制代码
Device    r/s    w/s    rkB/s   wkB/s   r_await w_await aqu-sz  %util
vdb       12.00  892.00 96.00   45620   2.10    68.40   15.32   98.50

判断:

  • %util 98.5% → 设备持续繁忙
  • w_await 68.4ms vs r_await 2.1ms → 写请求等待严重,问题在写入路径
  • w/s 892 远超 r/s 12 → 写密集型
  • aqu-sz 15.32 → 大量写请求排队

第三步:iotop定位进程

css 复制代码
iotop -o -b -n 5
bash 复制代码
TID   PRIO  USER   DISK READ  DISK WRITE  IO>    COMMAND
9201  be/4  mysql  0.00 B     43.80 M/s   92.30% mysqld

mysqld进程几乎独占了磁盘写入带宽,IO等待占比92.3%。范围从"整台机器"收窄到"MySQL进程"。

第四步:进入MySQL找具体SQL

sql 复制代码
SHOW FULL PROCESSLIST;
sql 复制代码
Id    User  Host          db     Command  Time  State     Info
8821  app   10.0.1.23:5566 orders Query   182   updating  UPDATE order_items SET status=2 WHERE batch_id=88231
8822  app   10.0.1.24:5566 orders Query   175   updating  UPDATE order_items SET status=2 WHERE batch_id=88232
8823  app   10.0.1.25:5566 orders Query   168   updating  UPDATE order_items SET status=2 WHERE batch_id=88233

三条结构相似的UPDATE,执行时间都超过160秒。查看表结构发现batch_id字段没有索引。

ini 复制代码
EXPLAIN UPDATE order_items SET status=2 WHERE batch_id=88231;
sql 复制代码
type: ALL  rows: 2843021  Extra: Using where

type=ALL全表扫描,预计扫描284万行,而实际目标可能只有几千行。全表扫描产生大量随机读IO,命中后还要写数据页+redo log+undo log,多个并发叠加直接把磁盘打满。

第五步:确认触发条件

查应用日志发现这是一个"批量标记订单状态"的定时任务,正常应该串行执行,但因为重试机制缺少互斥控制,导致多个批次并发执行,放大了索引缺失的问题。

修复方案

紧急止血:终止慢SQL

ini 复制代码
KILL 8821;
KILL 8822;
KILL 8823;

⚠️ 注意:KILL会触发事务回滚,大事务回滚本身也会产生IO压力,执行前要评估。同时确认业务有幂等重试机制。

根本修复:加索引 + 修复调度逻辑

sql 复制代码
ALTER TABLE order_items ADD INDEX idx_batch_id (batch_id);

同时修复批处理任务的调度逻辑,增加分布式锁避免并发执行。

验证效果

加索引后重新EXPLAIN:

rust 复制代码
type: ref  key: idx_batch_id  rows: 3200

扫描行数从284万降到3200,%util回落到30%以下,w_await回到个位数毫秒。


五、案例二:一行日志配置,磁盘IO长期偏高

现象

某网关服务的机器,%util长期维持在40%-60%,没到告警阈值,但明显是同类机器的3倍。业务没反馈问题,是做容量规划巡检时发现的。

排查过程

css 复制代码
iotop -o -b -n 3

发现是网关应用进程本身在持续写入。查看打开的文件句柄:

bash 复制代码
ls -la /proc/<PID>/fd | grep -i log

定位到一个访问日志文件,测算增长速度:

bash 复制代码
ls -la /app/logs/gateway-access.log
sleep 10
ls -la /app/logs/gateway-access.log

每秒新增接近2MB,对于每秒几百请求的网关来说明显偏大。

查看日志配置:

ini 复制代码
<root level="DEBUG">
    <appender-ref ref="FILE" />
</root>

根因找到了:日志级别被设为DEBUG,且每条日志包含完整请求头和响应体。查Git记录发现,这是上次紧急排查时临时改的,排查完忘记改回INFO了。

修复方案

xml 复制代码
<root level="INFO">
    <appender-ref ref="FILE" />
</root>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>/app/logs/gateway-access.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>/app/logs/gateway-access.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
        <maxFileSize>200MB</maxFileSize>
        <maxHistory>14</maxHistory>
        <totalSizeCap>10GB</totalSizeCap>
    </rollingPolicy>
</appender>

改回INFO级别,同时补充日志滚动策略(按大小+时间滚动、保留14天、总上限10GB、自动压缩)。

如果Logback配置了scan="true"支持热加载,修改后无需重启;否则需要走重新部署流程。

验证结果

日志增长速率从每秒2MB降到几十KB,%util从40%-60%回落到10%左右。

💡 教训:临时性的调试配置必须有明确的回退机制,不能依赖人工记忆。建议对临时调整建立工单跟踪,设置到期提醒。


六、案例三:备份任务与业务高峰撞车

现象

每天凌晨2点左右,多个业务系统短暂响应变慢(持续10-20分钟)。因为不在业务高峰期,长期没引起重视,直到某次大促延长了高峰时段,才真正造成影响。

排查过程

固定时间点出现的问题,第一反应查定时任务:

bash 复制代码
crontab -l -u root
cat /etc/cron.d/*

发现凌晨2点有一条数据库全量备份:

javascript 复制代码
0 2 * * * /app/scripts/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1

蹲点观察备份窗口的iostat:

javascript 复制代码
iostat -x 1 60 > /tmp/backup_window_iostat.log

2:00到2:18期间,%util从20%跳到90%以上,与备份执行时间高度重合。

mysqldump --single-transaction虽然不会阻塞写入(基于MVCC),但读取全表数据本身会产生大量顺序读IO,与业务读写争抢磁盘带宽。

修复方案

短期:调整备份时间,避开业务高峰。

根本:把备份迁移到从库执行,避免占用主库IO。

bash 复制代码
#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/data/backup/mysql"
DATE=$(date +%Y%m%d)
LOG_FILE="/var/log/mysql_backup.log"
REPLICA_HOST="10.0.2.15"

# 备份前检查从库复制延迟
SECONDS_BEHIND=$(mysql -h "${REPLICA_HOST}" -u backup_user -p"${MYSQL_BACKUP_PASSWORD}" \
    -e "SHOW REPLICA STATUS\G" | grep "Seconds_Behind_Source" | awk '{print $2}')

if [ -z "${SECONDS_BEHIND}" ] || [ "${SECONDS_BEHIND}" -gt 60 ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S') 复制延迟异常,跳过本次备份" >> "${LOG_FILE}"
    exit 1
fi

mysqldump -h "${REPLICA_HOST}" --single-transaction --quick \
    -u backup_user -p"${MYSQL_BACKUP_PASSWORD}" orders > "${BACKUP_DIR}/orders_${DATE}.sql"

# 清理旧备份,先print再delete
find "${BACKUP_DIR}" -name "*.sql" -mtime +7 -print
find "${BACKUP_DIR}" -name "*.sql" -mtime +7 -delete

几个要点:

  • 备份前检查复制延迟,避免备份到不一致的快照
  • 密码通过环境变量传入,不写死在脚本里
  • find -delete前先用-print确认范围

验证结果

连续观察一周,主库%util保持在20%左右正常波动,凌晨时段不再出现跳升。


七、案例四:Redis持久化引发的周期性IO尖刺

现象

缓存服务器每隔几分钟出现一次短暂IO尖刺(%util从5%跳到90%+,持续几秒到十几秒),业务反馈缓存偶尔出现超时毛刺。

排查过程

周期性、短时尖刺 + Redis服务器 → 怀疑RDB快照或AOF重写。

sql 复制代码
redis-cli CONFIG GET save
redis-cli INFO persistence
makefile 复制代码
save 900 1 300 10 60 10000
rdb_changes_since_last_save:15234
rdb_last_bgsave_time_sec:8

save 60 10000表示"60秒内有10000次写入变更就触发快照"。随着业务增长,写入量很容易在远小于60秒内达到这个阈值,导致快照触发频率远超预期。

持续观察rdb_last_save_time,发现大约每90-120秒更新一次,与业务反馈的现象吻合。同时段配合iostat -x 1观察,IO尖刺与rdb_bgsave_in_progress状态变化时间点完全重合。

修复方案

方案一:调整save规则,降低触发频率

sql 复制代码
redis-cli CONFIG SET save "3600 1 300 100"

⚠️ 注意:多条save规则是"任意一条满足即触发"的关系,调整时要完整重新设计规则组合,不是简单叠加。

方案二:如果是纯缓存场景且能接受数据丢失,可关闭自动快照

sql 复制代码
redis-cli CONFIG SET save ""

这个决策必须和业务方确认数据丢失的可接受范围,不能运维单方面决定。

持久化配置 :CONFIG SET只是运行时生效,需要同步写回配置文件:

复制代码
redis-cli CONFIG REWRITE

执行前建议先备份配置文件。

验证结果

调整后观察24小时,IO尖刺频率明显降低,业务超时毛刺消失。

💡 如果开启了AOF(appendonly yes),还要关注AOF重写的IO影响。aof_current_size相对aof_base_size的比例触发auto-aof-rewrite-percentage阈值时,同样会产生脉冲式IO。


八、案例五:挂载参数导致的性能差异

现象

新采购的一批服务器,同样的应用、同样的硬件,压测时磁盘写入性能只有老服务器的60%。

排查过程

对比两台机器的挂载参数:

bash 复制代码
mount | grep /data

老服务器:

bash 复制代码
/dev/vdb1 on /data type ext4 (rw,noatime,nodiratime)

新服务器:

bash 复制代码
/dev/vdb1 on /data type ext4 (rw,relatime)

差异在于atime相关参数。relatime(现代Linux默认值)在文件访问时仍会更新"最后访问时间"元数据,对于频繁读取大量小文件的场景,会产生额外的元数据写入开销。noatime完全禁用这类更新。

修复方案

修改/etc/fstab:

bash 复制代码
/dev/vdb1  /data  ext4  rw,noatime,nodiratime  0  2

无需重启,重新挂载即可生效:

bash 复制代码
sudo mount -o remount,noatime,nodiratime /data

⚠️ 注意:如果业务应用依赖atime语义(比如某些缓存淘汰策略基于"最近访问时间"判断),需要提前确认。多数常规业务场景不依赖这个语义。

验证结果

重新压测,写入吞吐提升到接近老服务器水平,差距从40%缩小到5%以内。

💡 教训:批量部署新服务器时,应该建立标准化的初始化配置检查清单(挂载参数、内核参数、文件系统类型等),通过Ansible等工具统一应用,避免个别机器配置遗漏导致性能差异。


九、常用命令速查表

目的 命令
确认磁盘空间 df -h / df -i(检查inode)
查看整体IO压力 iostat -x 1 5
定位具体进程 iotop -o -b -n 5
查看进程IO统计 cat /proc/<PID>/io
查看内存和缓存 free -h
查看挂载点对应设备 lsblk
查看磁盘硬件报错 `dmesg
查看SMART健康状态 smartctl -a /dev/sda
MySQL查看当前会话 SHOW FULL PROCESSLIST;
MySQL查看执行计划 EXPLAIN <SQL>;
MySQL查看InnoDB状态 SHOW ENGINE INNODB STATUS;
Redis查看持久化状态 redis-cli INFO persistence

十、这些坑我踩过,你别再踩

错误操作 风险 正确做法
发现慢查询直接KILL不核实 可能误杀重要长事务或数据同步任务 先用SHOW FULL PROCESSLIST确认来源
业务高峰期直接加索引 额外IO拖慢正常查询 低峰期执行,预发环境先评估耗时
改redis.conf只用CONFIG SET不持久化 重启后配置丢失,问题复发 同步执行CONFIG REWRITE或修改配置文件
用truncate处理正被写入的日志 可能导致应用日志写入异常 用logrotate或先确认无活跃写入
批量修改配置一次性全量执行 配置有误影响面覆盖全部机器 先少量节点验证,分批灰度,保留回滚
密码硬编码在脚本里 密码泄露风险 用环境变量、Vault等密钥管理方式
只看%util判断磁盘饱和 SSD/云盘场景下可能误判 结合await、队列深度、IOPS/吞吐量综合判断

十一、不同磁盘类型的正常基线参考

排查时需要判断"当前数值算不算异常",以下是经验参考,实际基线应以每台机器自身历史数据为准:

存储类型 典型顺序吞吐 典型随机IOPS 典型await(正常)
SATA HDD 100-200 MB/s 几十到一两百 5-20ms
SATA SSD 400-550 MB/s 数万 1ms左右
NVMe SSD 1000MB/s以上 数十万以上 远小于1ms
云盘(普通型) 依厂商规格 依厂商规格 数毫秒
云盘(高性能型) 依厂商规格 依厂商规格 亚毫秒到数毫秒

十二、最后说几句

这5个案例覆盖了磁盘IO问题在不同层次可能出现的典型根因:

  • 数据库层:缺索引的全表扫描
  • 应用层:日志级别配置不当
  • 运维层:备份任务与业务高峰重叠
  • 中间件层:Redis持久化配置不匹配
  • 操作系统层:文件系统挂载参数差异

一次完整的磁盘IO排查,本质上是在现象层 (告警、业务反馈)和根因层(具体的SQL、配置项、定时任务)之间建立一条可验证的证据链条。每一环------iostat的整体判断、iotop的进程定位、应用/数据库层面的深入排查、业务日志的交叉验证------缺了任何一环,得出的结论都可能是猜测,而不是站得住脚的根因。

工具命令是通用的,但每一次排查的具体路径都要基于实际观察到的指标数据来推进,而不是照搬上一次的结论。

希望这篇文章能帮你在面对下一次磁盘IO告警时,多一份底气。如果觉得有用,欢迎点赞收藏,有问题也可以在评论区交流。

相关推荐
金銀銅鐵9 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路10 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x10 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn12 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈13 小时前
AI 读你的提示词,其实很没耐心
后端
用户83562907805113 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我1234513 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒13 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记14 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk5987814 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端