写在前面
昨天下午四点,监控系统突然推送了一条告警:数据库服务器磁盘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,说明磁盘处理能力跟不上请求速度。
三、整体排查思路(决策树)
我把排查路径整理成了一张决策树,遇到告警按这个顺序走:
磁盘IO告警触发
│
├─ df -h 确认不是空间耗尽
│
├─ iostat -x 1 看整体压力
│ ├─ 读多写少 → 排查缓存未命中、全表扫描读
│ ├─ 写多读少 → 排查批量写入、日志刷盘、备份任务
│ └─ 读写都高 → 排查并发量上升或混合型批处理
│
├─ iotop 定位具体进程
│
├─ 深入进程类型排查
│ ├─ 数据库 → 慢查询、执行计划、缺索引
│ ├─ 日志/文件写入 → 日志级别、同步刷盘配置
│ └─ 备份/归档 → 调度时间是否与业务高峰重合
│
├─ 检查存储层(云盘限额 / 物理机硬件异常)
│
└─ 确认根因 → 紧急止血 + 根本修复
下面通过5个真实案例,把这条路径走一遍。
四、案例一:MySQL全表扫描把磁盘写爆了
现象
数据库服务器磁盘IO告警,订单系统大量接口超时。
排查过程
第一步:排除空间问题
df -h
输出显示根分区56%、数据分区63%,空间充足,确定是性能维度的IO问题。
第二步:iostat看整体压力
iostat -x 1 5
第二次采样的关键数据:
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
判断:
%util98.5% → 设备持续繁忙w_await68.4ms vsr_await2.1ms → 写请求等待严重,问题在写入路径w/s892 远超r/s12 → 写密集型aqu-sz15.32 → 大量写请求排队
第三步:iotop定位进程
iotop -o -b -n 5
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
SHOW FULL PROCESSLIST;
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字段没有索引。
EXPLAIN UPDATE order_items SET status=2 WHERE batch_id=88231;
type: ALL rows: 2843021 Extra: Using where
type=ALL全表扫描,预计扫描284万行,而实际目标可能只有几千行。全表扫描产生大量随机读IO,命中后还要写数据页+redo log+undo log,多个并发叠加直接把磁盘打满。
第五步:确认触发条件
查应用日志发现这是一个"批量标记订单状态"的定时任务,正常应该串行执行,但因为重试机制缺少互斥控制,导致多个批次并发执行,放大了索引缺失的问题。
修复方案
紧急止血:终止慢SQL
KILL 8821;
KILL 8822;
KILL 8823;
⚠️ 注意:KILL会触发事务回滚,大事务回滚本身也会产生IO压力,执行前要评估。同时确认业务有幂等重试机制。
根本修复:加索引 + 修复调度逻辑
ALTER TABLE order_items ADD INDEX idx_batch_id (batch_id);
同时修复批处理任务的调度逻辑,增加分布式锁避免并发执行。
验证效果
加索引后重新EXPLAIN:
type: ref key: idx_batch_id rows: 3200
扫描行数从284万降到3200,%util回落到30%以下,w_await回到个位数毫秒。
五、案例二:一行日志配置,磁盘IO长期偏高
现象
某网关服务的机器,%util长期维持在40%-60%,没到告警阈值,但明显是同类机器的3倍。业务没反馈问题,是做容量规划巡检时发现的。
排查过程
iotop -o -b -n 3
发现是网关应用进程本身在持续写入。查看打开的文件句柄:
ls -la /proc/<PID>/fd | grep -i log
定位到一个访问日志文件,测算增长速度:
ls -la /app/logs/gateway-access.log
sleep 10
ls -la /app/logs/gateway-access.log
每秒新增接近2MB,对于每秒几百请求的网关来说明显偏大。
查看日志配置:
<root level="DEBUG">
<appender-ref ref="FILE" />
</root>
根因找到了:日志级别被设为DEBUG,且每条日志包含完整请求头和响应体。查Git记录发现,这是上次紧急排查时临时改的,排查完忘记改回INFO了。
修复方案
<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分钟)。因为不在业务高峰期,长期没引起重视,直到某次大促延长了高峰时段,才真正造成影响。
排查过程
固定时间点出现的问题,第一反应查定时任务:
crontab -l -u root
cat /etc/cron.d/*
发现凌晨2点有一条数据库全量备份:
0 2 * * * /app/scripts/mysql_backup.sh >> /var/log/mysql_backup.log 2>&1
蹲点观察备份窗口的iostat:
iostat -x 1 60 > /tmp/backup_window_iostat.log
2:00到2:18期间,%util从20%跳到90%以上,与备份执行时间高度重合。
mysqldump --single-transaction虽然不会阻塞写入(基于MVCC),但读取全表数据本身会产生大量顺序读IO,与业务读写争抢磁盘带宽。
修复方案
短期:调整备份时间,避开业务高峰。
根本:把备份迁移到从库执行,避免占用主库IO。
#!/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重写。
redis-cli CONFIG GET save
redis-cli INFO persistence
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规则,降低触发频率
redis-cli CONFIG SET save "3600 1 300 100"
⚠️ 注意:多条save规则是"任意一条满足即触发"的关系,调整时要完整重新设计规则组合,不是简单叠加。
方案二:如果是纯缓存场景且能接受数据丢失,可关闭自动快照
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%。
排查过程
对比两台机器的挂载参数:
mount | grep /data
老服务器:
/dev/vdb1 on /data type ext4 (rw,noatime,nodiratime)
新服务器:
/dev/vdb1 on /data type ext4 (rw,relatime)
差异在于atime相关参数。relatime(现代Linux默认值)在文件访问时仍会更新"最后访问时间"元数据,对于频繁读取大量小文件的场景,会产生额外的元数据写入开销。noatime完全禁用这类更新。
修复方案
修改/etc/fstab:
/dev/vdb1 /data ext4 rw,noatime,nodiratime 0 2
无需重启,重新挂载即可生效:
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 | grep -i "error|fail" | grep -i "sd|nvme|vd" |
| 查看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告警时,多一份底气。如果觉得有用,欢迎点赞收藏,有问题也可以在评论区交流。