一场凌晨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小时后,采集同样时段的监控数据:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 磁盘%util | 99.3% | 15.6% | ↓ 84.3% |
| avgqu-sz | 512 | 8.3 | ↓ 98.4% |
| w_await | 654ms | 4.1ms | ↓ 99.4% |
| 写带宽 | 345MB/s | 45MB/s | ↓ 87% |
| 慢查询/分钟 | 260条 | 2条 | ↓ 99.2% |
| P99响应时间 | 3.2s | 180ms | ↓ 94.4% |
| QPS峰值 | 3200 | 6200 | ↑ 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。