问题背景
线上服务器监控报警,CPU us 不高,但 iowait 高达 40%、50%,磁盘 util 100%。这时候工程师的第一反应往往是"磁盘坏了",但实际情况远比这复杂。iowait 高只是现象,背后可能藏着 MySQL 慢查询、Docker 日志风暴、Nginx 写 access log、文件系统碎片、Swap 滥用、甚至是内核调度问题。
这篇文章从 Linux I/O 栈的全貌出发,讲清楚 iowait 到底是什么、不是什么、怎么一步步定位根因,最后给出常见的故障复盘案例。
Linux I/O 栈全貌:从应用到磁盘
Linux 的 I/O 路径是一层一层串起来的,每一层都有自己的队列、缓冲和调度逻辑。理解这个栈,是排查 I/O 问题的前置条件。
第 1 层:应用层
应用通过系统调用发起 I/O 请求,最常见的是 read() 和 write()。应用程序本身不直接跟磁盘打交道,它只管写文件描述符。具体怎么写、写到哪块磁盘、由内核决定。
常见的 I/O 发起方:
MySQL:InnoDB 的脏页刷新、binlog 写入、redo log 刷盘
Nginx/Apache:access log、error log 写入
Docker:容器日志、存储层写
Python/Java 进程:业务日志、文件缓存
系统守护进程:rsyslog、auditd
第 2 层:VFS(虚拟文件系统层)
VFS 是 Linux 内核提供的一层抽象,它统一了不同文件系统的接口。无论你用的是 ext4、XFS、NFS 还是 tmpfs,在应用层看来都是统一的 open()、read()、write() 接口。
VFS 的核心数据结构:
struct file:已打开文件的抽象
struct dentry:目录项缓存
struct inode:文件元数据
struct super_block:文件系统超级块
VFS 层还有一个关键机制:页缓存(Page Cache)。所有文件的读写都会经过页缓存。写操作默认是"写回"(write-back)模式:数据先写入页缓存,之后由内核线程异步刷到磁盘。这意味着 write() 系统调用通常会立即返回,但数据还没真正落盘。
第 3 层:具体文件系统(ext4/XFS/btrfs)
文件系统层负责把文件操作翻译成对底层块设备的请求。它要管理:
inode 和 block 的分配
元数据的组织(目录结构、文件大小、时间戳)
块寻址和扩展
文件系统的日志(ext4 的 journal)
不同文件系统在 I/O 调度策略、日志模式、空间分配方式上有显著差异,这会直接影响 I/O 性能表现。
第 4 层:通用块设备层(Block Layer)
这是 I/O 栈中最复杂的一层。Block Layer 接收来自文件系统的块请求(bio),并负责:
I/O 调度:把多个相邻扇区的请求合并,减少磁盘寻道次数
请求排队:不同进程的 I/O 请求进入同一个队列
调度算法选择:CFQ、Deadline、NOOP、MQ-Deadline(blk-mq)
Linux 4.13 之后默认使用 mq-deadline,之前默认是 CFQ。调度算法的选择对 I/O 延迟影响很大。
关键数据结构:
struct bio:代表一个 I/O 请求
struct request:经过调度器合并后的磁盘请求
struct request_queue:请求队列
第 5 层:设备驱动层
设备驱动把块请求翻译成针对具体硬件的操作指令。机械硬盘(HDD)走 SCSI/SATA 协议,SSD 走 NVMe 协议,虚拟化环境走 virtio-blk 或 NVMe 模拟。
设备驱动的性能差异:
HDD:受寻道时间和转速限制,顺序读尚可,随机 I/O 极差
SATA SSD:受 SATA 通道带宽限制,顺序读约 550MB/s
NVMe SSD:走 PCIe 通道,延迟低、并发强,顺序读可达数 GB/s
虚拟磁盘:受宿主机 I/O 队列和物理磁盘双重影响
第 6 层:物理磁盘
最终的存储介质。机械硬盘有磁头寻道、盘片旋转的物理限制;SSD 有读写放大、NAND 颗粒寿命的问题。
iowait 到底是什么
iowait 是 top 和 vmstat 输出中的一个指标,全称是 "I/O wait"。它表示 CPU 处于空闲状态,但有未完成的 I/O 请求正处于不可中断的等待状态。
理解 iowait 有几个关键点:
iowait 不等于磁盘 I/O 繁忙
iowait 高不等于磁盘 util 高。如果只有 1 个 CPU 核心的 iowait 高,而其他核心忙碌,整体 iowait 会被均摊,看起来不高但磁盘已经很繁忙。反过来,iowait 高也不一定是磁盘慢——可能是 NFS、tmpfs、内存分配等待等场景。
iowait 是 CPU 级别的指标
iowait 是从 CPU 视角看的指标。具体来说:
iowait = CPU 在非空闲状态(user/nice/system)之外,剩余时间中花在 I/O 等待上的比例
如果你有 8 核 CPU,其中 1 核 100% iowait,剩下 7 核 0%,top 显示的整体 iowait 大约是 12.5%。所以看到整体 iowait 20% 时,实际上可能是 1 核 100% iowait(4 核机器)或者 2 核全满(8 核机器)。
iowait 高的本质是 CPU 饿着
iowait 高的意思是:CPU 想干活,但活被 I/O 卡住了,只能等。CPU 本身没有损坏,但利用率上不去,系统吞吐量下降。
iowait 和 CPU idle 的区别
CPU idle:CPU 没事干,没有任何工作
iowait:CPU 没事干,但有 I/O 请求正在等待
两者在 top 里都显示为 idle,但含义完全不同。一个是系统负载极低,一个是系统被 I/O 阻塞了。
排查工具链:从宏观到微观
排查 iowait 问题的工具有好几个,每个工具关注不同的层级。
1. vmstat:看整体趋势
# vmstat 1:每秒采样一次 vmstat 1 10
输出示例:
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 2 3 0 812340 123456 4567890 0 0 1200 500 3000 4500 5 3 0 92 0
关键列:
r:运行队列长度,待运行的进程数
b:不可中断睡眠状态的进程数(通常是被 I/O 阻塞)
bi(blocks in):从磁盘读入的块数/秒
bo(blocks out):写入磁盘的块数/秒
wa(wait I/O):iowait 占用的 CPU 百分比
重点关注:若 b 列长期大于 0,说明有进程被阻塞在 I/O 上。若 wa 长期高于 30%,说明 iowait 是主要瓶颈。
2. iostat:看磁盘 I/O 细节
# 安装 sysstat 包 # CentOS: yum install sysstat # Ubuntu: apt install sysstat # 基本用法:-x 显示扩展信息,-k 以 KB 为单位,1 表示每秒 iostat -xzk 1 5
输出示例:
Linux 5.4.0-xxxx (hostname) 05/19/2026 _x86_64_ (8 CPU) avg-cpu: %user %nice %system %iowait %steal %idle 3.21 0.00 1.45 45.23 0.00 50.11 Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 0.00 12.00 0.00 120.00 0.00 16384.00 272.73 45.00 375.00 0.00 375.00 8.26 99.20
重点字段解读:
%util:设备利用率,接近 100% 说明磁盘已经饱和。这是判断磁盘是否是瓶颈的最直接指标。
await:平均 I/O 响应时间(毫秒),包括排队时间和实际服务时间。
avgqu-sz:平均队列深度,磁盘请求的排队长度。如果持续高于 4(机械硬盘)或者高于 32(SSD),说明 I/O 超过了磁盘处理能力。
r_await / w_await:读写分离的平均响应时间。
svctm(service time):平均服务时间,已经废弃(不可靠),仅做参考。
rrqm/s / wrqm/s:每秒合并的读写请求数。合并越多,效率越高。
实战判断:
%util 接近 100% 且 await 很高 → 磁盘本身是瓶颈
%util 接近 100% 但 await 很低 → 磁盘性能很好但请求太多,块层排队严重
%util 不高但 iowait 高 → 可能是 NFS、内存压力、或 CPU 层面的等待
3. iotop:定位具体进程
# 需要 root 权限 iotop -o -b -n 2 -d 5
参数说明:
-o:只显示有 I/O 活动的进程
-b:批处理模式(非交互)
-n 2:刷新 2 次
-d 5:每次间隔 5 秒
输出示例:
Total DISK READ: 0.00 B/s | Total DISK WRITE: 16.39 G/s Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 16.39 G/s TID PRIO USER DISK READ DISK WRITE SWAPIN IO COMMAND 18123 be/4 mysql 0.00 B/s 16.39 G/s 0.00 % 99.99 % mysqld 18234 be/3 root 0.00 B/s 0.00 B/s 0.00 % 0.00 % kworker/u256:2
重点:IO 列显示该进程的 I/O 占用百分比。如果 MySQL 的 IO 列接近 100%,基本可以确定是数据库的 I/O 导致的问题。
4. pidstat:进程级别 I/O 统计
# 查看每个进程的 I/O 统计,每秒一次 pidstat -d 1 5
输出示例:
Linux 5.4.0-xxxx (hostname) 05/19/2026 _x86_64_ (8 CPU) 0310 PM UID PID kB_rd/s kB_wr/s iodelay command 0311 PM 0 18123 0.00 16384000.00 0 mysqld 0311 PM 0 18234 0.00 120.00 0 rsyslogd
kB_rd/s:每秒读取 KB 数
kB_wr/s:每秒写入 KB 数
iodelay:I/O 延迟(以时钟周期计),反映进程等待 I/O 的时间
command:进程名
iodelay 越大,说明进程花在等待 I/O 上的时间越多。
5. /proc/diskstats:原始磁盘统计
cat /proc/diskstats
这是 iostat 数据的来源。如果需要自定义监控或者写脚本采集,可以用这个接口。
输出字段(按顺序):
设备号(major:minor)
设备名
读完成数
读合并数
读扇区数
读花费时间(毫秒)
写完成数
写合并数
写扇区数
写花费时间(毫秒)
I/O 当前进度(正在进行中的 I/O)
I/O 花费时间(毫秒,累计值)
Weighted I/O time(累计 I/O 时间,含排队)
计算平均 I/O 响应时间:
(总 I/O 时间 / (读完成数 + 写完成数)) = 平均每次 I/O 的毫秒数
6. blktrace:追踪每个 I/O 请求
如果普通工具不够用,需要看每个 I/O 请求的细节,用 blktrace:
# 安装:yum install blktrace 或 apt install blktrace # 对特定设备追踪 10 秒 blktrace -d /dev/sda -o /tmp/blktrace -w 10 # 分析结果 blkparse -i /tmp/blktrace -d | head -50
blktrace 可以看到每个 I/O 请求从应用发出、到 VFS、到块设备层的完整耗时分布。这个工具一般用于深度性能分析,生产环境慎用,会产生大量数据并影响性能。
7. free 和 /proc/meminfo:看内存压力
free -h cat /proc/meminfo | grep -E "^(MemTotal|MemFree|MemAvailable|Cached|Buffers|SwapTotal|SwapFree|SwapCached)"
内存压力会导致两个 I/O 相关问题:
Swap 使用:如果 SwapFree 持续下降,说明系统在使用 Swap,而 Swap 在磁盘上,会产生大量 I/O
Page Cache 回收:内存紧张时,内核会回收 Page Cache,导致原本可以通过缓存满足的读 I/O 变成磁盘直接读
检查 Swap 是否在大量使用:
# 看 Swap 使用情况
vmstat 1 10 | awk '{print $2,$3,$4}'
# 如果 si(swap in)和 so(swap out)长期不是 0,说明在换页
常见根因与排查路径
根因一:MySQL InnoDB 脏页刷盘
MySQL InnoDB 有自己的缓存池(Buffer Pool),数据页在内存中修改后变成脏页,由后台线程定期刷到磁盘。如果脏页积累过多或者磁盘写入速度跟不上, InnoDB 的 page_cleaner 线程会产生大量 I/O。
排查步骤:
看 MySQL 的 I/O 写入量:
pidstat -d 1 5 -p $(pgrep -x mysqld)
看 InnoDB 脏页状态:
-- 登录 MySQL mysql -u root -p -- 查看脏页比例和刷新状态 SHOW ENGINE INNODB STATUSG -- 关注以下指标: -- Pages made flushd:累计页刷新数 -- InnoDBBufferPool 的脏页比例
看 innodb 相关配置:
-- 查看关键参数 SHOW VARIABLES LIKE '%innodb%flush%'; SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct'; SHOW VARIABLES LIKE 'innodb_buffer_pool_pages_dirty';
关键参数说明:
innodb_max_dirty_pages_pct:脏页比例上限,默认 75(MySQL 5.6),超过这个比例会强制刷新
innodb_io_capacity:InnoDB 能承受的 I/O 吞吐量上限,默认 200(机械硬盘),SSD 应该设置更高
innodb_flush_method:刷新方式,O_DIRECT(绕过 OS 页缓存)或 fsync(通过 OS 页缓存)
修复方案:
# 方法1:调整 innodb_io_capacity(如果是 SSD) # 在 my.cnf 中添加或修改: # innodb_io_capacity = 2000 # SSD 推荐 2000-10000 # innodb_io_capacity_max = 4000 # 方法2:增加 Buffer Pool 大小,减少磁盘访问 # innodb_buffer_pool_size = 16G # 建议设置为可用内存的 60-80% # 方法3:调整脏页刷新策略 # innodb_max_dirty_pages_pct = 50 # 降低阈值,更频繁刷新
风险提醒:修改 innodb_flush_method 或增大 innodb_io_capacity 可能导致 I/O 峰值更高,短期更卡。生产环境建议在低峰期操作,并提前备份配置。
根因二:Nginx/Apache 日志写入
每个 HTTP 请求都会写入 access log,如果 QPS 很高(几千甚至几万),日志写入 I/O 会非常频繁。
排查步骤:
看 nginx 进程的写 I/O:
pidstat -d 1 -p $(pgrep -x nginx | tr ' ' ',') 2 5
看日志文件的写入速度:
# -c 只显示变化的字节,-a 显示属性变化,-f 持续监控 tail -f /var/log/nginx/access.log | pv -rate > /dev/null
看 nginx 配置中的日志设置:
# nginx.conf 中查看 access_log /var/log/nginx/access.log combined buffer=16k flush=5s; error_log /var/log/nginx/error.log warn;
修复方案:
# 方案1:关闭 access log(不推荐生产环境,但临时救火有效) access_log off; # 方案2:降低日志级别,只记录 error access_log /var/log/nginx/access.log error; # 方案3:开启日志缓冲,减少系统调用 access_log /var/log/nginx/access.log combined buffer=64k gzip=4; # 方案4:日志写入 tmpfs(内存文件系统),避免磁盘 I/O # 在 /etc/fstab 中添加: # tmpfs /var/log/nginx tmpfs defaults,size=512m 0 0 # 注意:重启后日志会丢失,需要定期同步到磁盘 # 方案5:使用 syslog 协议将日志发送到远程日志服务器 access_log syslog:server=192.168.1.100:514,facility=local7,tag=nginx,severity=info combined;
验证方式:
# 重载 nginx 配置 nginx -s reload # 确认配置生效 nginx -t # 之后观察 iostat,看 %util 是否下降
根因三:Docker 容器日志
Docker 容器的日志默认由 dockerd 接收并写入 /var/lib/docker/containers/
排查步骤:
看 Docker 日志文件大小:
find /var/lib/docker/containers -name "*-json.log" -exec ls -lh {} ; | sort -k5 -h | tail -20
看 dockerd 进程的 I/O:
pidstat -d 1 -p $(pgrep -x dockerd) 2 5
看具体容器的日志量:
# 看容器最近 100 行日志的行数增长速率 watch "docker logs --tail 1002>&1 | wc -l"
修复方案:
# 方案1:限制容器日志大小(在 docker-compose.yml 中)
# docker-compose.yml
logging:
driver: "json-file"
options:
max-size: "50m" # 单个日志文件最大 50MB
max-file: "5" # 最多保留 5 个文件
# 方案2:限制容器日志直接写入系统日志(降低 dockerd 压力)
# /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "5"
},
"storage-driver": "overlay2"
}
# 修改后重启 dockerd
systemctl restart dockerd
# 方案3:手动清理历史日志(高风险,需确认容器正常运行)
# 先停止容器日志写入
# truncate -s 0 /var/lib/docker/containers//*-json.log
# 方案4:改用 journald 日志驱动
# /etc/docker/daemon.json
{
"log-driver": "journald",
"log-opts": {}
}
# 之后重启 dockerd
systemctl restart dockerd
风险提醒:修改 dockerd 配置会重启 Docker 服务,导致所有容器停止。生产环境需要在维护窗口操作,提前通知用户,并确认容器支持重启后自动拉起(使用 restartpolicy)。
根因四:文件系统碎片与日志模式
ext4 文件系统在频繁的小文件写入后会产生碎片,导致文件读取时磁头移动次数增加,I/O 延迟上升。
排查步骤:
查看文件系统的碎片情况:
# 安装 e2fsprogs 包 yum install e2fsprogs -y # 查看 ext4 文件系统碎片 e2fsck -n /dev/sda1 2>&1 | grep -i fragment # 或者用 debugfs 查看 debugfs -R "frag /" /dev/sda1 2>/dev/null
查看文件系统日志模式:
dumpe2fs /dev/sda1 | grep -i "journal mode" tune2fs -l /dev/sda1 | grep "Journal"
ext4 的日志模式:
journal:所有数据写入前先写日志,最安全但最慢
ordered(默认):只记录元数据日志,数据写入在元数据提交之后
writeback:不记录数据,只记录元数据,最快但不安全
修复方案:
# 方法1:在线调整 ext4 日志模式为 writeback(提升写入性能) tune2fs -o journal_data_writeback /dev/sda1 tune2fs -O "^has_journal" /dev/sda1 tune2fs -O "has_journal" /dev/sda1 # 注意:调整为 writeback 后,如果突然断电可能丢失数据 # 确保有 UPS 和硬件 RAID # 方法2:对于 XFS 文件系统,看是否可以优化 # XFS 日志默认在外置设备上,性能更好 # 方法3:定期碎片整理(需要卸载文件系统或者单用户模式) # CentOS 7 以后: umount /data xfs_frags /dev/sda2 # 检查碎片 xfs_fsr /dev/sda2 # 碎片整理,可能需要数小时 mount /data
风险提醒:修改文件系统参数和碎片整理都需要谨慎操作。生产环境建议先在测试环境验证,并确保有完整备份。碎片整理期间性能会严重下降。
根因五:Swap 使用
当物理内存耗尽,系统会把不活跃的内存页换出到 Swap 空间。如果 Swap 所在的磁盘是机械硬盘,大量的换入换出会导致严重的 I/O 风暴。
排查步骤:
查看 Swap 使用情况:
# 确认哪个设备是 Swap swapon -s # 确认 Swap 使用量和换入换出速率 vmstat 1 10 # 看具体哪些进程在换入换出 cat /proc/$(pgrep -x mysqld)/status | grep -i swap
确认 Swap 是否在持续增长:
# 每秒采样一次,监控 si(swap in)和 so(swap out)列
vmstat 1 | awk '{print $3,$4,$7,$8}'
看内存分配情况:
# 看哪些进程占用的内存最多 ps aux --sort=-%mem | head -20
修复方案:
# 方案1:临时关闭 Swap(仅限内存充足时) swapoff -a # 关闭所有 Swap swapon -a # 重新开启 # 方案2:降低 Swap 优先级(让系统尽量用物理内存) # 在 /etc/sysctl.conf 中添加: vm.swappiness = 10 # 默认 60,值越低越少使用 Swap # 立即生效: sysctl -p # 方案3:把 Swap 放到 SSD 上 # 创建一个 SSD 上的 Swap 文件 fallocate -l 8G /mnt/ssd/swapfile chmod 600 /mnt/ssd/swapfile mkswap /mnt/ssd/swapfile swapon /mnt/ssd/swapfile # 在 /etc/fstab 中添加: # /mnt/ssd/swapfile none swap sw 0 0 # 方案4:确认 MySQL 的内存配置是否合理 # MySQL 5.7: 确保 innodb_buffer_pool_size <= 物理内存 * 0.8 # 避免所有进程内存之和超过物理内存
根因六:批量写入任务
运维过程中常见的定时任务:备份脚本、rsync 同步、日志切割(logrotate)、数据库全量导出、大文件压缩等。这些任务往往在半夜或高峰期跑,产生大量 I/O 把正常业务拖垮。
排查步骤:
看谁在写磁盘:
# 高频观察
while true; do echo "=== $(date) ==="; ps aux --sort=-%mem | awk '{print $2,$3,$4,$11}' | head -15; sleep 3; done
看定时任务:
# 看 crontab crontab -l cat /etc/crontab ls -la /etc/cron.d/
看最近修改过的文件:
# 找出最近 1 分钟内写入超过 100MB 的文件 find / -type f -mmin -1 -size +100M 2>/dev/null
修复方案:
# 方案1:使用 ionice 限制 I/O 优先级 # cron 任务中使用 ionice 限制 0 2 * * * ionice -c 3 -n 7 /backup/backup.sh # ionice 参数说明: # -c 3:空闲类(idle),只有磁盘空闲时才执行 # -c 2:最佳努力类(best effort),可以设置 -n 优先级(0-7,越低越优先) # -c 1:实时类(real time),最高优先级,生产环境慎用 # 方案2:使用 cgroups 限制 I/O # 创建 cgroup 限制写入带宽 mkdir /sys/fs/cgroup/blkio/limited echo"8:0 1048576" > /sys/fs/cgroup/blkio/limited/blkio.throttle.write_bps_device echo $(pgrep -f backup.sh) > /sys/fs/cgroup/blkio/limited/tasks # 方案3:使用 rsync 的限速参数 rsync -avz --bwlimit=10240 /source/ /dest/ # 方案4:调整 logrotate 时间 # /etc/logrotate.conf 中把 daily 改成 weekly 或 monthly # 减少日志切换频率 # 方案5:备份任务安排到低峰期 # 安排在凌晨 3-5 点,业务最空闲的时段 0 3 * * * ionice -c 3 /backup/backup.sh >> /var/log/backup.log 2>&1
实战案例:从 iowait 高到定位 MySQL 脏页刷新
案例背景
某台 16 核 64GB 内存的物理机,运行 MySQL 5.7.30,数据库大小约 300GB。最近一周监控显示:
CPU iowait 从平时的 5% 上升到 35-45%
iostat 显示 sda 的 %util 持续在 95% 以上
await 从 5ms 上升到 400ms 以上
数据库查询延迟明显上升,从 10ms 上升到 500ms+
第 1 步:初步判断
先用 top 看整体 CPU:
top -b -n 1
%Cpu(s): 3.2 us, 1.5 sy, 0.0 ni, 58.2 id, 37.1 wa, 0.0 hi, 0.0 si, 0.0 st
37% 的 iowait 确认了问题。再用 iostat 看磁盘:
iostat -xzk 1 5
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 0.00 12.00 0.00 120.00 0.00 16384.00 272.73 45.00 375.00 0.00 375.00 8.26 99.20
%util 99.20%,w/s 120 次/秒,写入 16MB/s,avgqu-sz 45,说明队列严重积压,写 I/O 响应时间高达 375ms。
第 2 步:定位进程
用 iotop 找元凶:
iotop -o -b -n 3 -d 3
Total DISK READ: 0.00 B/s | Total DISK WRITE: 16.39 G/s TID PRIO USER DISK READ DISK WRITE SWAPIN IO COMMAND 18123 be/4 mysql 0.00 B/s 16.39 G/s 0.00 % 99.99 % mysqld
mysqld 进程占了 99.99% 的 I/O 带宽,写入速度 16.39 G/s(这个数字异常大,说明单位可能是块设备报的累计值,实际写入速率需要用 pidstat 确认)。
第 3 步:分析 MySQL I/O 来源
登录 MySQL 查看状态:
mysql> SHOW ENGINE INNODB STATUSG
找到关键段落:
--- LOG --- Log sequence number 28495678912 Log flushed up to 28495456789 Pages flushed up to 28494000000 Last checkpoint at 28493000000 100 pending log writes, 200 pending chkp writes
100 个待写入的日志写操作,200 个待检查点的脏页刷新。这就是 I/O 压力的来源。
再看 InnoDB 配置:
mysql> SHOW VARIABLESLIKE'innodb_%flush%'; +---------------+-------+ | Variable_name | Value | +---------------+-------+ | innodb_flush_log_at_trx_commit | 1 | | innodb_flush_method | O_DIRECT | +---------------+-------+ mysql> SHOWVARIABLESLIKE'innodb_io_capacity%'; +------------------------+-------+ | Variable_name | Value | +------------------------+-------+ | innodb_io_capacity | 200 | | innodb_io_capacity_max | 2000 | +------------------------+-------+ mysql> SHOWSTATUSLIKE'Innodb_buffer_pool_pages_dirty'; +------------------------------+-------+ | Variable_name | Value | +------------------------------+-------+ | Innodb_buffer_pool_pages_dirty | 20480 | +------------------------------+-------+
发现问题:
innodb_io_capacity 只有 200,这个值是给机械硬盘设计的,对于 NVMe SSD 来说严重偏低
Innodb_buffer_pool_pages_dirty 有 20480 个脏页,每个 16KB,总共约 320MB 脏页等待刷新
innodb_flush_log_at_trx_commit = 1,每次事务提交都会刷 redo log,I/O 压力会比较大
第 4 步:确认根因
综合以上信息,根因分析:
innodb_io_capacity = 200 严重偏低,后台刷新线程每次只能处理很少的脏页
脏页积累速度(业务写入) > 脏页刷新速度(innodb_io_capacity 限制)
脏页堆积到 innodb_max_dirty_pages_pct 阈值后,InnoDB 被迫强制刷新,阻塞前台查询
大量写 I/O 积压在块层队列中,avgqu-sz 达到 45,await 高达 375ms
第 5 步:修复方案
风险评估:修改 innodb_io_capacity 是在线参数,可以动态调整,不需要重启数据库,风险可控。但调整后短时间内刷新速度加快,磁盘 I/O 会更集中。
操作步骤:
-- 先备份当前配置 mysqld --help --verbose | grep my.cnf -- 备份 my.cnf cp /etc/my.cnf /etc/my.cnf.bak.$(date +%Y%m%d) -- 动态调整参数(当前 session 生效) SETGLOBAL innodb_io_capacity = 2000; SETGLOBAL innodb_io_capacity_max = 4000; -- 确认生效 SHOWVARIABLESLIKE'innodb_io_capacity%'; -- 动态调整 innodb_max_dirty_pages_pct(MySQL 5.7 可在线调整) SETGLOBAL innodb_max_dirty_pages_pct = 60; -- 写进配置文件,永久生效 -- 在 [mysqld] 段添加: -- innodb_io_capacity = 2000 -- innodb_io_capacity_max = 4000
# 编辑 my.cnf vim /etc/my.cnf # 在 [mysqld] 段添加或修改 [mysqld] innodb_io_capacity = 2000 innodb_io_capacity_max = 4000 innodb_max_dirty_pages_pct = 60
第 6 步:验证效果
修改后观察:
# 1. 监控 iowait 是否下降 vmstat 1 30 # 2. 监控磁盘 util 是否下降 iostat -xzk 1 30 # 3. 监控脏页是否在正常下降 mysql -u root -p -e "SHOW STATUS LIKE 'Innodb_buffer_pool_pages_dirty';" every 10s
预期效果(5-10 分钟后):
iowait 从 35-45% 下降到 5-10%
%util 从 95% 下降到 30-50%
await 从 400ms 下降到 10-30ms
avgqu-sz 从 45 下降到 5 以下
Innodb_buffer_pool_pages_dirty 稳定在 2000 以下
第 7 步:回滚方案
如果调整后出现其他问题(比如 I/O 峰值把磁盘带宽占满影响其他服务),立即回滚:
SET GLOBAL innodb_io_capacity = 200; SET GLOBAL innodb_io_capacity_max = 2000;
同时还原 my.cnf:
cp /etc/my.cnf.bak.$(date +%Y%m%d) /etc/my.cnf systemctl restart mysqld # 需要在维护窗口操作
诊断流程图:iowait 排查 7 步法
┌─────────────────────────────────────────────┐ │ 1. vmstat 1 10 │ │ 发现 b 列 > 0 或 wa 列持续 > 20% │ └──────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 2. iostat -xzk 1 5 │ │ 判断 %util 是否 > 80% │ │ 判断 avgqu-sz 是否过高 │ │ 判断是读还是写为主 │ └──────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 3. iotop -o -b -n 3 │ │ 找出具体是哪个进程在产生 I/O │ └──────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 4. pidstat -d 1 -p│ │ 确认进程的 I/O 读写速率和延迟 │ └──────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 5. 进一步分析: │ │ - MySQL: SHOW ENGINE INNODB STATUS │ │ - Nginx: access log 配置检查 │ │ - Docker: docker logs / 容器日志大小 │ │ - Swap: free + vmstat 看 si/so │ │ - 定时任务: crontab -l │ └──────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 6. 确认根因后,制定修复方案 │ │ - 调整参数(在线 or 维护窗口) │ │ - 关闭/限流/迁移 I/O 来源 │ │ - 升级硬件(SSD) │ └──────────────────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ 7. 验证效果 │ │ - 监控 iostat / vmstat 趋势 │ │ - 业务延迟是否恢复正常 │ │ - 准备回滚方案 │ └─────────────────────────────────────────────┘
高风险操作汇总
| 操作 | 风险等级 | 风险描述 | 缓解措施 |
|---|---|---|---|
| 修改 innodb_flush_method | 高 | 切换刷盘策略,可能导致数据丢失或 I/O 暴增 | 确保 UPS、RAID、有备份 |
| 调整 innodb_io_capacity | 中 | 调高后刷新更快但短期 I/O 更集中 | 先在从库测试,低峰期操作 |
| 关闭 Swap | 高 | 内存不足时无兜底,可能 OOM | 先确认物理内存充足 |
| 修改 dockerd 日志配置 | 高 | 重启 Docker,所有容器中断 | 确认容器有 restartpolicy |
| 在线碎片整理 | 高 | 整理期间性能严重下降 | 单用户模式,低峰期操作 |
| truncate 容器日志 | 中 | 日志丢失,排查问题困难 | 先 docker logs 导出重要日志 |
| ionice 限制 I/O | 低 | 可能导致任务执行时间变长 | 确保 cron 有合理的超时设置 |
内核参数调优:控制 I/O 行为
Linux 内核提供了大量可调参数(sysctl),可以控制 I/O 行为。合理调整这些参数,可以从系统层面改善 I/O 性能或限制 I/O 滥用。
1. 调整内核 I/O 调度器
调度器决定了请求如何排序和合并。不同调度器适合不同场景:
# 查看当前调度器
cat /sys/block/sda/queue/scheduler
# noop [deadline] cfq 输出中 [] 包围的是当前值
# deadline 调度器:适合数据库、SSD、随机读写场景
# cfq 调度器:适合桌面系统和通用 Linux,对实时性要求高的场景表现差
# noop 调度器:适合 SSD、虚拟机、RAID 卡带缓存的场景
# mq-deadline 调度器:blk-mq 版本的 deadline,更好的并发支持
# 临时修改(重启后失效)
echo deadline > /sys/block/sda/queue/scheduler
# 永久修改(在 udev 规则或启动脚本中)
# /etc/udev/rules.d/60-io-scheduler.rules
ACTION=="add|change", SUBSYSTEM=="block", KERNEL=="sd[a-z]", ATTR{queue/scheduler}="deadline"
调度器选择建议:
机械硬盘(HDD):deadline 或 cfq
SATA SSD:deadline 或 noop
NVMe SSD:noop 或 mq-deadline
虚拟化环境:noop(virtio-blk 或 pvscsi 本身有调度)
数据库服务器:deadline(避免 cfq 的"公平"调度导致的延迟不稳定)
2. 调整块设备队列深度
# 查看队列深度 cat /sys/block/sda/queue/nr_requests # 默认 128,适合机械硬盘 # SSD 和 RAID 卡可以设置为 512-1024 # 临时调整 echo 512 > /sys/block/sda/queue/nr_requests # 永久修改(在 /etc/rc.local 或 systemd service 中) # echo 512 > /sys/block/sda/queue/nr_requests
队列深度过小会导致高并发下请求排队不足,队列深度过大会增加延迟。生产环境建议根据磁盘数量和并发连接数调整。
3. 调整 read_ahead_kb(预读)
# 查看预读大小(单位 KB) cat /sys/block/sda/queue/read_ahead_kb # 默认 128KB # 临时调整(适合顺序读多的场景,如数据仓库) echo 256 > /sys/block/sda/queue/read_ahead_kb # 永久修改 # echo 256 > /sys/block/sda/queue/read_ahead_kb # 适合随机读多的场景(如数据库)可以降低预读 echo 16 > /sys/block/sda/queue/read_ahead_kb
预读的作用是:当内核读取一个扇区时,提前把相邻的后续扇区也读入缓存。如果业务是大量顺序读(如备份、ETL),增大预读能显著提升吞吐。如果是随机读(如数据库),增大预读只会浪费 I/O 带宽。
4. 调整内核脏页刷新参数
内核的内存管理子系统会定期把脏页(已修改但未写回磁盘的页)刷新到磁盘。相关参数:
# 查看当前值 cat /proc/sys/vm/dirty_background_ratio # 脏页占可用内存的比例,默认 10 cat /proc/sys/vm/dirty_ratio # 强制同步的脏页比例,默认 20 cat /proc/sys/vm/dirty_expire_centisecs # 脏页被认为是"可刷新"的时间,单位是 0.01 秒,默认 3000(30 秒) cat /proc/sys/vm/dirty_writeback_centisecs # 后台刷新线程运行间隔,默认 500(5 秒) # 临时调整(降低脏页比例,适合数据库服务器) echo 5 > /proc/sys/vm/dirty_background_ratio echo 10 > /proc/sys/vm/dirty_ratio # 永久修改(在 /etc/sysctl.conf 中) # vm.dirty_background_ratio = 5 # vm.dirty_ratio = 10 # vm.dirty_expire_centisecs = 3000 # vm.dirty_writeback_centisecs = 500
重要:这些参数直接影响数据库的 I/O 模式。如果 dirty_background_ratio 太高,后台刷新线程会在磁盘已经很繁忙时继续往磁盘写数据,导致 I/O 拥塞。如果 dirty_ratio 太高,当进程写入大量数据时,可能在最糟糕的时机触发强制刷盘(此时进程被阻塞,用户请求积压)。
数据库服务器的推荐配置(MySQL/ PostgreSQL):
dirty_background_ratio = 5(更积极的后台刷新)
dirty_ratio = 10-15(更低的强制刷盘阈值)
dirty_expire_centisecs = 500(脏页 5 秒后就可被刷新,更快响应)
5. 开启 I/O 统计并实时监控
# 开启 I/O 延迟统计(需要内核支持 blk_mq)
echo 1 > /sys/block/sda/queue/iotail_latency
# 实时监控 I/O 延迟分布
# 安装 bpftrace(高级工具,需要 root)
# bpftrace -e 'kprobe:blk_account_io_start { @ = lhist(args->bytes, 0, 4096, 512); }'
# 或者用 iostat 持续监控并记录
iostat -xzk 1 >> /var/log/iostat.log &
# 注意:这个日志会持续增长,需要 logrotate 处理
6. 限制单进程的 I/O 带宽
如果某个进程(如备份脚本)占用了过多 I/O,影响正常业务,可以用 cgroups v2 限制:
# 创建 IO 限制组(cgroups v2) mkdir -p /sys/fs/cgroup/io/limited # 限制 /dev/sda 的写入带宽为 50MB/s echo"8:0 wbps=52428800" > /sys/fs/cgroup/io/limited/max.bps.write # 限制 IOPS 为 1000 echo"8:0 wiops=1000" > /sys/fs/cgroup/io/limited/max.iops.write # 把备份进程加入限制组 echo $(pgrep -f backup.sh) > /sys/fs/cgroup/io/limited/tasks # 监控限制效果 cat /sys/fs/cgroup/io/limited/io.stat
实战案例 2:Nginx 日志写入导致的 iowait 飙升
案例背景
某台 4 核 8GB 内存的 Web 服务器,运行 Nginx,每天 QPS 约 2000 次。最近发现 iowait 经常在业务高峰期(上午 10-12 点)飙升到 30-40%,CPU idle 掉到 20%,导致部分 HTTP 请求超时。
第 1 步:初步排查
# 1. 看 CPU 状态 top -b -n 1 | head -20 # us 40%, sy 10%, wa 35%, id 15% # 2. 看磁盘 I/O iostat -xzk 1 3 # %util 接近 100%,w/s 200+,写为主 # 3. 找进程 iotop -o -b -n 5 -d 2
输出显示:Nginx 主进程的 IO 占比 80% 以上。
第 2 步:分析日志配置
# 看 Nginx 日志配置 nginx -T | grep -A5 "access_log"
配置是:
access_log /var/log/nginx/access.log combined; error_log /var/log/nginx/error.log warn;
没有缓冲、没有压缩、没有限时刷新。每次请求都同步写日志。
第 3 步:修复方案
# 修改 nginx.conf
http {
# 开启日志缓冲,减少系统调用
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
# access log 加缓冲,16KB 缓冲区,5 秒刷新一次
access_log /var/log/nginx/access.log main buffer=16k flush=5s;
# error log 用 info 级别,减少写入量
error_log /var/log/nginx/error.log info;
# 开启异步日志写入(需要配置缓冲)
# 注意:异步日志在进程崩溃时可能丢失最后几秒的日志
}
第 4 步:验证
# 重载配置 nginx -s reload # 观察 iostat iostat -xzk 1
重载后观察:
%util 从 100% 降到 30-40%
w/s 从 200+ 降到 40-60
iowait 从 35% 降到 5-10%
HTTP 请求延迟明显改善,P99 从 800ms 降到 150ms
第 5 步:更激进的方案(可选)
如果业务能接受日志丢失(短暂的服务中断不丢即可),可以写入 tmpfs:
# 在 /etc/fstab 中添加 tmpfs /var/log/nginx tmpfs defaults,size=256m 0 0 # 或者直接挂载 mount -t tmpfs -o size=256m tmpfs /var/log/nginx # 将现有日志迁移 cp /var/log/nginx/access.log /tmp/backup_access.log > /var/log/nginx/access.log # 清空现有日志 # 定期同步到磁盘(每分钟) # /etc/cron.d/sync-nginx-logs * * * * * root rsync -av /var/log/nginx/ /backup/nginx_logs/ >> /var/log/sync-nginx.log 2>&1
风险:tmpfs 中的数据在重启后会丢失。需要确保有定期同步机制,且在日志同步的窗口期内能接受最多 1 分钟的日志丢失。
实战案例 3:系统升级 SSD 后的 I/O 优化
背景
一台运行 3 年的物理服务器,从机械硬盘(7200 转 SATA)升级到 NVMe SSD。升级后运维团队期望 I/O 问题彻底消失,但实际观察发现 iowait 仍然存在,%util 仍然偏高。
问题分析
升级 SSD 后出现新问题,说明瓶颈从"磁盘物理速度"转移到了"其他层级"。可能的原因:
调度器仍然是 cfq:cfq 的设计基于"磁盘有寻道时间"的假设,SSD 没有寻道延迟,cfq 的公平调度策略反而增加了不必要的上下文切换和延迟。
队列深度不够:机械硬盘的队列深度通常很低,SSD 支持更高的队列深度,但内核默认值可能没有充分利用。
文件系统日志模式不合适:ext4 默认的 ordered 模式每次写数据前要先写日志,对于 SSD 这种快速设备,日志写入产生的额外 I/O 仍然占比不小。
诊断步骤
# 1. 确认 SSD 是否被识别为旋转设备 cat /sys/block/sda/queue/rotational # 1 = 机械硬盘,0 = SSD # 2. 查看当前调度器 cat /sys/block/sda/queue/scheduler # 如果是 cfq,SSD 也可能变慢 # 3. 查看队列深度 cat /sys/block/sda/queue/nr_requests # 默认 128,SSD 可以开到 512 # 4. 查看 I/O 合并情况 cat /sys/block/sda/queue/nr_requests iostat -x 1 | grep sda # 如果 %util 仍然高但 r/s + w/s 不高,说明瓶颈在别处
优化方案
# 1. 切换到 noop 调度器(NVMe SSD 最适合) echo noop > /sys/block/sda/queue/scheduler # 2. 增大队列深度 echo 512 > /sys/block/sda/queue/nr_requests # 3. 调整脏页刷新参数(对所有类型磁盘都有效) echo 5 > /proc/sys/vm/dirty_background_ratio echo 10 > /proc/sys/vm/dirty_ratio echo 1000 > /proc/sys/vm/dirty_writeback_centisecs # 4. 如果用 ext4,可以考虑切换到 XFS # XFS 在高并发写入时性能更好,日志管理更高效 # 但迁移文件系统需要备份数据、重新格式化
# 永久化这些配置:/etc/rc.local 或 systemd service cat > /etc/systemd/system/tune-ssd.service <<'EOF' [Unit] Description=Tune SSD I/O settings After=local-fs.target [Service] Type=oneshot ExecStart=/bin/bash -c 'echo noop > /sys/block/sda/queue/scheduler' ExecStart=/bin/bash -c 'echo 512 > /sys/block/sda/queue/nr_requests' ExecStart=/bin/bash -c 'echo 5 > /proc/sys/vm/dirty_background_ratio' ExecStart=/bin/bash -c 'echo 10 > /proc/sys/vm/dirty_ratio' [Install] WantedBy=multi-user.target EOF systemctl enable tune-ssd.service systemctl start tune-ssd.service
验证优化效果
# 用 fio 做基准测试 yum install fio -y # CentOS # apt install fio # Ubuntu # 顺序写测试(64KB 块,4 线程,1GB 数据) fio --name=seqwrite --filename=/tmp/fio_test --size=1G --rw=write --bs=64k --numjobs=4 --iodepth=32 --ioengine=libaio --direct=1 --runtime=30 --time_based=1 # 随机读测试(4KB 块,16 线程,1GB 数据) fio --name=randread --filename=/tmp/fio_test --size=1G --rw=randread --bs=4k --numjobs=16 --iodepth=64 --ioengine=libaio --direct=1 --runtime=30 --time_based=1 # 查看结果中的 iops 和 lat 指标 # NVMe SSD 在优化后,随机读 iops 应该达到 10 万以上 # 延迟(clat)P99 应该低于 1ms
优化后实际效果:
%util 从 80-90% 降到 20-30%(消除了不必要的调度开销)
延迟稳定性提升,P99 延迟从 5ms 降到 0.8ms
吞吐量提升约 40%(队列深度增加后并发能力增强)
监控体系:从被动告警到主动预防
建立 I/O 基线
在系统正常时建立 I/O 基线,便于在异常时快速对比判断。
# 建立基线脚本(每日定时执行)
#!/bin/bash
# /usr/local/bin/io-baseline.sh
DATE=$(date +%Y%m%d_%H%M%S)
OUTPUT_DIR="/var/log/io-baseline"
mkdir -p $OUTPUT_DIR
# 采集当前 I/O 状态
{
echo"=== $(date) ==="
echo"--- vmstat ---"
vmstat 1 5
echo"--- iostat ---"
iostat -x 1 5
echo"--- disk usage ---"
df -h
echo"--- inodes ---"
df -i
echo"--- mount ---"
mount | grep "^/dev"
echo"--- loadavg ---"
uptime
} > $OUTPUT_DIR/baseline_$DATE.txt
# 只保留最近 30 天
find $OUTPUT_DIR -name "baseline_*.txt" -mtime +30 -delete
Prometheus + node_exporter 监控指标
在 Prometheus 中配置 node_exporter,可以采集以下 I/O 相关指标:
# prometheus.yml 中添加 node_exporter scrape_configs: - job_name: 'node_exporter' static_configs: - targets: ['localhost:9100']
关键查询:
# 平均 I/O 等待时间百分比(每台机器)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# 磁盘使用率
node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_free_bytes{mountpoint="/"}
# 每台机器的 I/O util(需要 node_exporter 0.18+)
rate(node_disk_io_time_seconds_total[5m]) * 100
# 每台机器的写入吞吐量
rate(node_disk_written_bytes_total[5m])
Grafana 面板配置
推荐的 Grafana 面板布局(从左到右,从上到下):
第一行:CPU 使用率分解(user、system、iowait、idle)第二行:磁盘 util(%util)和队列深度(avgqu-sz)第三行:读写吞吐量(r/s、w/s)和带宽(rkB/s、wkB/s)第四行:I/O 响应时间(await、r_await、w_await)第五行:内存使用率和 Swap 换入换出速率
告警规则配置:
# alertmanager.rules
groups:
-name:io_alerts
rules:
# iowait 持续 5 分钟高于 30%
-alert:HighIOWait
expr:100-(avgby(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m]))*100)>30
for:5m
labels:
severity:warning
annotations:
summary:"High iowait on {{ $labels.instance }}"
description:"iowait is {{ $value }}% for more than 5 minutes"
# 磁盘 util 持续 5 分钟高于 90%
-alert:DiskUtilHigh
expr:rate(node_disk_io_time_seconds_total[5m])*100>90
for:5m
labels:
severity:critical
annotations:
summary:"Disk {{ $labels.device }} utilization is critical"
总结
iowait 只是一个症状指标,它告诉你"CPU 在等 I/O",但不会告诉你"谁在产生 I/O"。排查 iowait 的核心思路是:
先确认是否真的是磁盘问题:用 iostat 看 %util,如果磁盘利用率很低但 iowait 高,可能是 NFS、网络 I/O 或 CPU 层面的等待。
找到 I/O 产生者:用 iotop 和 pidstat 定位具体进程,不要凭感觉猜测。
分析 I/O 类型:是读还是写,是顺序还是随机,是元数据还是数据,这决定了修复方向。
理解每层 I/O 栈的特性:VFS 的页缓存、文件系统的日志模式、块层的调度算法、设备的物理限制,每一层都可能成为瓶颈。
修复时考虑全局:调高 InnoDB 的 I/O 容量可能解决了 MySQL 问题,但如果这台机器还有其他服务,可能把整台机器的 I/O 带宽占满,反而更糟。
永远准备回滚方案:改配置之前先备份,改完之后监控效果,如果变差立即回滚。
建立监控基线:在系统正常时建立 I/O 基线,异常时快速对比,缩短故障定位时间。
内核参数不是万能药:调度器、队列深度、脏页参数等系统级调优能提升性能,但无法替代应用层的 I/O 优化(如缓存、异步写入、批量操作)。
iowait 排查没有银弹,每一次都需要结合业务场景、硬件配置、负载特征综合判断。工具只是手段,工程师的分析思路才是核心。
全部0条评论
快来发表一下你的评论吧 !