先分清:是网卡慢、磁盘慢,还是内存不够
「服务器 IO 好像有问题」是所有模糊故障描述里最常见的一句。用户说后台卡、备份慢、数据库偶尔超时,登上去 top 一看 load 12,iostat 一看磁盘 100% 忙——然后就去换 SSD、加内存、找云厂商投诉。事实上,load 高、磁盘忙、查询慢这三件事之间常常没有因果关系,盲目扩容只会把钱花在错误的地方。
本文给出一条可以照做的定位链:先用 iostat 判断「是不是 IO 瓶颈」,再用 iotop 定位「是哪个进程」,最后用 fio 验证「磁盘本身到底多快」。三步各解决一个不同的问题,跳步就会得出错误结论。文中的数字来自一台 4 核 8G、系统盘 100G 云盘的 Debian 12 测试机(顺序写约 120MB/s,随机 4K 写约 3000 IOPS)。
第一步:iostat 的正确读法
先装 sysstat(apt install sysstat),然后不要用不带参数的 iostat——那输出的是自开机以来的平均值,对排查当下问题毫无意义。正确姿势是带间隔采样两次以上:
iostat -xz 2 5几个关键列,按重要性排序:
%util:设备忙的百分比。名字叫「利用率」,但它对 SSD / 云盘严重失真——一块 NVMe 盘可以%util只有 20% 却已经达到 IOPS 上限,因为设备能并行处理大量请求,%util只反映「有没有请求在排队」的时间比例。看到%util100% 一定是瓶颈,但看到 40% 不代表没问题。r/sw/s:每秒完成的读写次数,也就是实际 IOPS。拿这个和磁盘规格对比才有意义。r_awaitw_await:每个请求的平均等待时间(含排队),单位毫秒。这是最可靠的指标。本地 SATA SSD 正常应在 1ms 以内,云盘 SSI 在 0.5~2ms,普通云盘在 5~15ms,机械盘 10~20ms。如果w_await长期超过 50ms,无论%util是多少都是问题。aqu-sz(旧版叫avgqu-sz):平均排队深度。持续大于 2 说明请求在堆积,磁盘供不上。rkB/swkB/s:吞吐,容易看但最不敏感——因为高吞吐不一定慢,低吞吐也可能是随机小 IO 卡死。
一次真实的误判:某站后台卡,iostat -x 显示 vda 的 %util 稳定在 95%,w_await 只有 0.8ms,aqu-sz 0.3。看上去「磁盘打满」,但这个组合说明设备处理得很轻松、只是请求源源不断——真正的原因是 PHP 端在死循环式地写日志,不是磁盘慢。如果当时直接换更贵的盘,问题一点都不会消失。
第二步:iotop 定位到进程,但要看对列
iotop -oPa 只显示有实际 IO 的进程,累积模式,是排查首选:
iotop -oPa如果没装或内核限制,用 pidstat -d 1 5 也能拿到同样信息,且更适合脚本化:
pidstat -d 1 5 | awk 'NR>3 && ($4+0)>0'注意 pidstat -d 的输出里 kB_rd/s 和 kB_wr/s 是累计的每秒速率,而 iodelay(旧内核没有这一列)表示进程因为 IO 阻塞的时长——iodelay 比吞吐更能说明「谁被卡住了」。一个只写 200KB/s 但 iodelay 高达 80% 的进程,比一个写 50MB/s 但 iodelay 是 5% 的进程更值得查。
常见「隐形大户」清单,按踩坑频率排序:
- MySQL 的 binlog 与 redo log:
innodb_flush_log_at_trx_commit=1下每个事务一次 fsync,小事务密集型应用能轻松打满 4K 随机写 IOPS。表现是mysqld的w/s高但kB_wr/s很低。 - PHP 的应用日志:
error_log或框架的 debug 日志忘了关,一个死循环每秒写几百行。 - rsync / 备份任务:定时备份撞上白天流量高峰,这个最常见也最容易解决(改时间)。
- Docker 的 json-file 日志驱动:默认无限增长,某个容器刷屏时单个
json.log能到几十 GB,读写它都在耗 IO。 - swap:内存不足时
kswapd会持续换页,iotop里看到kswapd0上榜就是内存问题而不是磁盘问题。
看到 kswapd0 或 jbd2/vda1-8(ext4 日志线程)占榜,结论就要往内存和文件系统参数上引,而不是「磁盘坏了」。
第三步:fio 测出磁盘的真实上限
定位到某个进程在压磁盘之后,下一个问题是「它压的量正常吗」。这需要一个基准:这块盘理论上能跑多少。
千万不要在线上盘直接跑 fio 的写测试——它会覆盖数据。安全做法有两种:测一个临时挂载的云盘;或者只测读,并且用 --filename 指向一个已有的、不重要的大文件,加 --readonly。
随机 4K 读(模拟数据库索引查找):
fio --name=randread --ioengine=libaio --direct=1 --rw=randread \
--bs=4k --iodepth=32 --numjobs=4 --size=2G --runtime=60 \
--filename=/mnt/testdisk/fiofile --output-format=terse | cut -f7,8顺序写(模拟备份、日志归档):
fio --name=seqwrite --ioengine=libaio --direct=1 --rw=write \
--bs=1M --iodepth=8 --numjobs=1 --size=4G --runtime=60 \
--filename=/mnt/testdisk/fiofile关键参数解释:--direct=1 绕过 page cache(否则测的是内存速度);--iodepth=32 模拟并发队列深度(云盘对队列深度敏感,深度 1 测不出上限);--numjobs 模拟多线程压力。
三个必须测的维度:4K 随机读、4K 随机写、1M 顺序写。云盘的一个典型特征是「突发额度」——前 30 秒能跑 3000 IOPS,之后掉到 500。所以测试要做满 60 秒并看后段数字,只跑 10 秒会得到过于乐观的结论。另外要对比 bw(带宽)与 iops 两组:一块盘很可能 iops=3000 看起来合格,但 clat p99 高达 200ms,那就是长尾问题,对交互式应用致命。
顺手确认一下调度器和队列:
cat /sys/block/vda/queue/scheduler # 云盘/SSD 推荐 none 或 mq-deadline
cat /sys/block/vda/queue/rotational # 0 = SSD
cat /sys/block/vda/queue/nr_requestsSSD 上把调度器设成 bfq 或者沿用机械盘的 cfq 是常见的性能损失;none(即 noop)对 NVMe 最合适,云盘的 virtio 块设备用 mq-deadline 也通常比 bfq 好。
串联起来:三个真实案例
案例一:load 12 但 %util 只有 8%
一台 4 核机器 load 常年在 10 以上,iostat 显示磁盘完全不忙。pidstat 里 %cpu 都很低,vmstat 1 显示大量 r 状态进程但 us 只有 30%。这种情况多半是 D 状态进程(不可中断睡眠)在等某个非磁盘资源——NFS 挂载僵死、某个网络文件系统超时、或者 flock 等锁。ps aux | awk '$8 ~ /D/' 能看到它们,cat /proc/<pid>/wchan 能看出在等什么。load 统计包含 D 状态进程,很多人以为 load 高就是 CPU 忙,这是最常见的误解。
案例二:备份期间站点全站 502
iostat 显示 w_await 从 2ms 涨到 300ms,aqu-sz 到 12。iotop 显示 rsync 和 mysqldump 同时在跑。fio 基准显示这块盘随机写只有 800 IOPS。结论:备份的写入把盘吃满,MySQL 的 fsync 排队,连接池被占满,FPM 超时返回 502。解决方案不是换盘,而是:给备份加 ionice -c 2 -n 7 降低调度优先级、--bwlimit 限制 rsync 速率、把备份挪到从库上跑(mysqldump --dump-slave 或直接连从库)。改完 w_await 回到 3ms。
案例三:磁盘没满,写文件报 No space
这个不属于 IO 性能,但排查时经常同时出现:iostat 正常,应用却报 ENOSPC。八成是 inode 用完(df -i)或者 /tmp 挂的是 tmpfs 而内存吃满。IO 类问题排查时永远顺手跑一次 df -h; df -i,这两个命令成本为零但能省下大量时间。
把「谁在写盘」变成可查的历史
上面三步都是在「此刻」排查,但个人站最常遇到的困境是:用户下午说卡,你晚上才登上去,现场早就消失了。临时抓一次 iotop 只能看到当下,这时候需要的是一份能回放的历史记录。
成本最低的办法是把 pidstat -d 的输出按分钟落盘,一天只有几百 KB,保留一周完全没压力:
#!/bin/bash
# cron: * * * * * /usr/local/bin/io-top.sh
out=/var/local/io-top/$(date +%F).log
mkdir -p "$(dirname "$out")"
echo "== $(date -Is)" >> "$out"
pidstat -d 1 3 | awk 'NR>3 && ($4+0)>0 {print $1, $4, $5, $6}' >> "$out"真出事时按时间点回查就非常有用了:grep -A6 "== 2026-10-20T15:0" /var/local/io-top/2026-10-20.log,能直接看到那一分钟是 mysqld 还是 tar 还是别的进程在大量读写。配合 sysstat 的 sar -d 历史数据,就能把「哪个进程」和「盘有多慢」两条线对齐到同一个时间轴上,不用再靠猜。
如果机器上有 Docker 且容器用的是默认的 json-file 日志驱动,还要单独看一下容器的日志写入量,因为容器内的写操作在宿主机上表现为 dockerd 或 containerd-shim 的 IO,iotop 里看不到容器名:
du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -5单个日志文件超过 1G 基本可以确定是某个容器在刷屏。给 /etc/docker/daemon.json 加上 {"log-driver":"json-file","log-opts":{"max-size":"50m","max-file":"3"}} 然后重启 docker,能一次性把这类隐形 IO 大户关进笼子——注意改配置需要重启 dockerd,所有容器都会跟着重启,选个低峰期做,并且事先确认关键容器都配了 restart: always。
做成例行巡检,而不是出事才看
定位能力再强,也不如提前发现趋势。sysstat 自带的数据采集是个人站最被低估的工具:
# /etc/default/sysstat
ENABLED="true"
# /etc/cron.d/sysstat 默认每 10 分钟采样一次,保留 7 天
dpkg-reconfigure sysstat # 或者直接改上面的文件之后任何时候都能回看历史:
sar -d -p -f /var/log/sysstat/sa20
sar -b -f /var/log/sysstat/sa20 | head -20这些数据在「昨天下午三点用户说卡」的时候价值巨大——你不需要复现问题,直接看当时的 tps、rtps、bwrtn 就能定性。
再进一步,把「w_await 超过阈值」做成告警,比 CPU、内存告警有用得多,因为磁盘长尾延迟是最容易被忽略、又最能拖垮站点响应时间的因素:
#!/bin/bash
# cron 每 5 分钟
w=$(iostat -xd 2 2 | awk '$1=="vda"{v=$11} END{print v}')
if [ "$(echo "$w > 50" | bc)" = "1" ]; then
echo "$(date -Is) vda w_await=${w}ms" >> /var/local/io-alert.log
ps -eo pid,comm,pcpu --sort=-pcpu | head -6 >> /var/local/io-alert.log
fi注意 iostat 的列号在不同版本会变(w_await 可能是第 11 或 12 列),上线前先用 iostat -xd 2 2 | head -3 确认表头位置,别硬编码错了列还不自知。
小结:三步的顺序不能乱
回顾整条链:iostat 判断是不是 IO 问题(看 w_await 和 aqu-sz,不只看 %util)→ iotop/pidstat 定位到进程与文件(看 iodelay,不只看吞吐)→ fio 给出磁盘基准(看 60 秒后的稳定值与 p99 延迟)。跳过第一步就会把 CPU 或内存问题误判成磁盘问题;跳过第三步就会在「磁盘本来就慢」的情况下与应用侧死磕。
最后一句经验:个人站遇到的 IO 问题里,真正需要升级硬件的不到三成,其余都是「某任务在不该跑的时候跑」「日志没限制大小」「swap 被频繁使用」这类配置问题。先按这三步把责任分清楚,再决定要不要花钱。