磁盘IO高负载排查:从识别瓶颈到根治优化
发布日期: 2026/08/10 阅读总量: 2

一场凌晨2点的磁盘IO告警

凌晨2点11分,值班群弹出一条告警:MySQL服务器磁盘IO使用率超过95%,持续5分钟。我打开Grafana,P99响应时间从平时的90ms直接飙到3.2s,慢查询每分钟从1条涨到260条。业务表现很直观:商品详情接口大面积超时,用户在手机上刷不出商品图。

第一反应不是「加磁盘」,而是「谁在打我的磁盘」。磁盘IO不会自己高,背后一定是某个进程、某条SQL、某个配置在作怪。

排查三步走:定位谁在打IO

第1步:iostat确认磁盘状态

我习惯用iostat -x 1 3连续采3次看趋势,只看一次容易误判。

# 系统:CentOS 7.9,内核 3.10.0,磁盘:SATA SSD 960GB
# 工具版本:sysstat 10.1.5
$ iostat -x 1 3

Device:         rrqm/s   wrqm/s     r/s     w/s   rkB/s   wkB/s  avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
sdb               0.00  1024.00    0.00  1024.00    0.00 204800.00   400.00   512.00  287.31   12.50  654.10   0.80  99.30

关键指标解读:

  • %util 99.3%:设备已经饱和,请求排不上队
  • avgqu-sz 512:IO请求队列堆积512个,正常值应该是个位数
  • w_await 654ms:写请求平均等待654ms,正常SATA SSD应该小于10ms

第2步:iotop定位进程

iostat告诉我磁盘饱和了,但不知道是谁在写。iotop直接看进程维度的IO,参数含义:-b批量模式(不加会占用整个终端),-k输出KB,-o只显示有IO的进程,-L不合并线程,-d 1每秒刷新。

# 工具版本:iotop 0.6-2.el7
$ iotop -bkLo -d 1

Total DISK READ: 1.24 M/s | Total DISK WRITE: 345.67 M/s
    TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
  18234  be/4  mysql      1.24 M/s    345.60 M/s  0.00%  99.50%  mysqld

mysqld占用了几乎全部写带宽:345MB/s。进程状态是D(不可中断睡眠),说明它在等IO返回,CPU反而在空转。

第3步:慢日志揪出元凶SQL

MySQL 8.0.32默认开启慢日志,阈值1秒。用pt-query-digest(Percona Toolkit 3.0.4)把TOP 10慢SQL拉出来:

$ pt-query-digest /var/log/mysql/mysql-slow.log --limit 10 > /tmp/slow_report.txt

# Profile
# Rank Query ID           Response time    Calls  R/Call  V/M   Item
# ==== ================== ================ ====== ====== ===== =====
# 1    0x8FAD2B1A3C      8160.7231 63.8%   96    85.007  0.02  UPDATE orders SET status=1
# 2    0x7DE21BB9F4      1210.3201  9.4%   310     3.904  0.00  SELECT * FROM orders WHERE status=0

排名第一的SQL是个update,没有走索引,更新orders表2800万行,平均每次执行85秒。查一下processlist确认它在运行:

-- 连接MySQL执行
SELECT id, time, state, left(info, 80) AS sql_text
FROM information_schema.processlist
WHERE command = 'Query' AND time > 10\G

**************************** 1. row ****************************
     id: 18234
   time: 2440
  state: updating
sql_text: UPDATE orders SET status = 1 WHERE order_no = 'SO202501010001'

一条WHERE order_no = ...的update,跑了40分钟还在跑。order_no上没有索引,每更新一行都要全表扫描一次,配合InnoDB的redo log写入,直接把磁盘写爆。

方案对比:加硬件 vs 找根因

当时团队有人提了个最快的方案:把这块SATA SSD换成NVMe RAID0。理由很直接:硬件瓶颈,换硬件不就行了。

我算了一笔账,放桌上对比:

维度方案A:SATA SSD换NVMe方案B:定位根因+四层优化
成本一块企业级NVMe P5510 3.84TB约8000元,整机停机迁移人力半天,0硬件成本
生效时间采购2天 + 迁移停机2小时当天生效
根因影响治标不治本。SQL全表扫描和fsync风暴不解决,NVMe也会被打爆直接消除SQL和参数层的瓶颈
风险新硬件兼容性问题,数据迁移风险参数调整可回滚,风险可控
附加问题NVMe下InnoDB写放大更严重,4KB随机写实际搬动16KB页

NVMe只是把「打爆磁盘」的时间从2小时延长到6小时。根因还是那条SQL和MySQL的刷盘策略。方案B是唯一正确的路。

四层优化:一条一条落地

第一层:SQL层——加索引

先看执行计划,确认全表扫描:

EXPLAIN SELECT * FROM orders WHERE order_no = 'SO202501010001'\G

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 28340000
     filtered: 10.00
        Extra: Using where

type=ALL,rows=2834万。加索引,5分钟的事:

ALTER TABLE orders ADD INDEX idx_order_no (order_no);
-- 加完复查执行计划,type从ALL变成ref,rows从2834万降为1
EXPLAIN SELECT * FROM orders WHERE order_no = 'SO202501010001'\G
*************************** 1. row ***************************
         type: ref
          key: idx_order_no
         rows: 1

注意:2800万行的表加索引会锁表,必须安排在业务低峰期执行。如果表太大,用pt-osc在线加更安全。

第二层:MySQL InnoDB参数调整

加完索引,写量已经降了一个量级。但MySQL的刷盘策略还是有优化空间。我们这台机器配置是MySQL 8.0.32,通过动态参数调整,不用重启:

-- 原值查看
SELECT @@innodb_io_capacity, @@innodb_io_capacity_max, @@innodb_flush_neighbors, @@innodb_adaptive_flushing;

-- 调整:SATA SSD写能力有限,调大刷新容量让后台线程更积极刷脏页
SET GLOBAL innodb_io_capacity = 1500;
SET GLOBAL innodb_io_capacity_max = 3000;

-- 关闭相邻页合并,SSD随机写性能远优于顺序写,合并反而增加延迟
SET GLOBAL innodb_flush_neighbors = 0;

-- 自适应刷盘,让InnoDB根据redo log生成速率动态调整刷脏页速度
SET GLOBAL innodb_adaptive_flushing = ON;

-- 持久化配置(写到my.cnf)
# vim /etc/my.cnf
[mysqld]
innodb_io_capacity = 1500
innodb_io_capacity_max = 3000
innodb_flush_neighbors = 0
innodb_adaptive_flushing = ON

这几个参数的核心逻辑:InnoDB后台线程负责把脏页刷到磁盘。原配置按机械盘(IO capacity 200)设定,我们的SATA SSD实际能承受1500-3000的IOPS。不调大,脏页堆积,最终触发前台用户线程同步刷盘,造成写抖动。

第三层:内核IO调度器

Linux 3.10默认IO调度器是deadline。deadline是为机械盘设计的,核心是磁头寻道调度——先把请求按扇区排序,减少磁头移动。SSD没有寻道,deadline的排序逻辑成了纯开销。

# 查看当前调度器,sdb是我们的数据盘
$ cat /sys/block/sdb/queue/scheduler
noop [deadline] cfq

# 改成noop(内核4.x+中noop更名为none)
$ echo noop > /sys/block/sdb/queue/scheduler
$ cat /sys/block/sdb/queue/scheduler
[noop] deadline cfq

重启会失效,用udev规则持久化:

# /etc/udev/rules.d/60-io-scheduler.rules
ACTION=="add", KERNEL=="sdb", ATTR{queue/scheduler}="noop"

# 重载规则
udevadm control --reload-rules && udevadm trigger

第四层:文件系统挂载参数

数据目录默认开启了atime(访问时间)更新。每次读取文件,内核都要更新inode的atime,产生一次写IO。对数据库这种大量读文件的场景,纯属浪费。

# 查看当前挂载参数
$ mount | grep sdb
/dev/sdb on /data type xfs (rw,relatime,attr2,inode64,noquota)

# 改成noatime,取消读文件时的时间戳更新
$ mount -o remount,noatime /data

# 确认生效
$ mount | grep /data
/dev/sdb on /data type xfs (rw,noatime,attr2,inode64,noquota)

同时把脏页刷盘参数调低,避免内存里的脏页瞬间大量写入磁盘:

# 查看当前值
$ sysctl vm.dirty_ratio vm.dirty_background_ratio
vm.dirty_ratio = 30
vm.dirty_background_ratio = 10

# 调整到保守值:脏页达到内存4%时开始后台刷盘,达到20%时阻塞写操作
$ sysctl -w vm.dirty_ratio=20 vm.dirty_background_ratio=4

# 持久化
$ vim /etc/sysctl.conf
vm.dirty_ratio = 20
vm.dirty_background_ratio = 4

验证:fio压测 + 线上数据

fio压测验证调度器效果

调度器改完,用fio(3.16版)做4KB随机写压测,对比deadline和noop的差异:

# 30秒压测,4KB随机写,队列深度32,4个并发进程
$ fio --name=io_probe --rw=randwrite --bs=4k --direct=1 --ioengine=libaio \
      --iodepth=32 --numjobs=4 --runtime=30 --filename=/tmp/fio_test

# deadline结果
write: IOPS=14800, BW=57.8MiB/s (60.6MB/s), lat=68.5ms

# 切换noop后再跑
$ echo noop > /sys/block/sdb/queue/scheduler
write: IOPS=16300, BW=63.7MiB/s (66.8MB/s), lat=61.2ms

仅切换调度器,IOPS提升10.1%,延迟下降10.7%。不算大,但在生产环境,10%的IO能力提升可能就决定你是否需要扩容。

线上优化前后完整对比

优化动作全部上线24小时后,采集同样时段的监控数据:

指标优化前优化后变化
磁盘%util99.3%15.6%↓ 84.3%
avgqu-sz5128.3↓ 98.4%
w_await654ms4.1ms↓ 99.4%
写带宽345MB/s45MB/s↓ 87%
慢查询/分钟260条2条↓ 99.2%
P99响应时间3.2s180ms↓ 94.4%
QPS峰值32006200↑ 93.8%

这条SQL本身从平均85秒降到了40ms,2100倍提升。磁盘IO从「瓶颈」变成了「冗余」。

附:巡检脚本

事后我写了个crontab脚本,每5分钟记录一次关键IO指标,方便下次故障时回溯:

#!/bin/bash
# /usr/local/bin/io_monitor.sh
LOG=/var/log/io_monitor.log
{
  echo "===== $(date '+%F %T') ====="
  iostat -x 1 2 | grep -E '^sdb' | tail -1
  iotop -bkLo -d 1 -n 1 | grep -E 'mysqld|Total' | head -5
} >> $LOG

# crontab -e
*/5 * * * * /usr/local/bin/io_monitor.sh

避坑:这些坑我全踩过

坑1:%util=100%不一定是磁盘饱和

iostat的%util统计的是「设备有请求在排队的时间占比」,不是磁盘的实际利用率。NVMe这种低延迟设备上,队列深度为1时%util也可能显示100%,因为每次IO的等待时间很短但一直在有请求。看到%util满格,先看avgqu-sz和await,这两个才代表真实的排队深度。

坑2:iotop空白的坑

有一次iotop跑了2分钟全屏幕空白。排查下来是权限问题——iotop需要root或CAP_SYS_ADMIN权限才能读/proc/PID/io。切换到root后正常。另外如果你用普通用户跑,一定要sudo。

坑3:innodb_log_file_size改大后重启失败

MySQL 5.6时代改redo log大小,要先把参数写进my.cnf,然后干净关闭(innodb_fast_shutdown=0),再删除或迁移旧的ib_logfile文件,最后启动。我当时直接改了my.cnf重启,报错:InnoDB: Cannot proceed because redo log size mismatch。MySQL 8.0用innodb_redo_log_capacity动态调整,无脑改文件的方式已经过时了。

坑4:innodb_flush_method=O_DIRECT不是万能药

O_DIRECT绕过操作系统page cache,每次读写直接落盘。看起来「减少一层拷贝」,但在机械盘+大内存场景反而更慢——MySQL丢掉了操作系统的预读和缓存合并能力。我们测试环境改完后,读多写少的业务P95延迟涨了30%。刷盘模式要结合存储和业务场景测试,不能抄别人的配置。

坑5:云盘(AWS EBS)上的iostat数据会骗人

EBS gp2卷有硬性IOPS上限:3 IOPS/GB。一块1TB的gp2上限约3072 IOPS。本地iostat看到的%util是基于本地设备时间算的,EBS被限流时本地看到的%util反而不高,但延迟已经涨了10倍。运维云上MySQL要看CloudWatch的VolumeQueueLength和BurstBalance,别只盯着iostat。

坑6:调度器配置重启失效

echo noop > /sys/block/sdb/queue/scheduler只对当前运行有效,重启后恢复默认。我同事把echo命令写在/etc/rc.local里还改了执行权限,结果CentOS 7默认没启用rc-local服务,重启后所有配置丢失。正确做法是用udev规则持久化,就是我前面写的那样。

小结

磁盘IO告警,先别急着买硬盘。用iostat看设备状态,用iotop定位进程,用慢日志揪出SQL,然后按「SQL → MySQL参数 → 内核调度器 → 文件系统」四层逐层优化。这次故障的全部操作加起来不到半天,0硬件成本,P99从3.2s降到180ms。