为什么磁盘 I/O 调优常常是"看不见的瓶颈"
很多个人站长在网站变慢时,第一反应是加 CPU、加内存、上 CDN,折腾一圈发现首页还是卡。问题往往不在算力,而在磁盘 I/O 的写入路径。Linux 内核为了"攒批"提高吞吐,默认允许大量数据停留在页缓存里延迟回写;一旦回写节奏和你的业务写入模式不匹配,就会出现一种非常典型的现象——CPU 空闲、内存充足、磁盘 util 却长时间 100%,服务表现为间歇性卡顿。
这种卡顿最难的地方在于它的"间歇性"。监控大盘上的平均值一切正常,CPU 使用率 15%,内存剩余一半,只有用户在你耳边说"网站刚才打不开了,现在又好了"。等你登录服务器去查,一切风平浪静。于是你换了更大的机器,问题却依旧存在——因为瓶颈从来不是资源总量,而是资源调度的节奏。
这篇文章只讲两个东西:I/O 调度器该选哪个,以及脏页回写的三个 sysctl 参数怎么调。不谈玄学,只讲可复现的观测和可回滚的改动。
在开始之前,先建立一个正确的预期:这两项调优不会让你的网站"变快一倍"。它们的价值在于消除尾延迟尖峰——让响应时间的 p99 从 800 毫秒降到 200 毫秒,让用户在访问时不再偶尔撞上那个"卡住的几秒钟"。对个人站点来说,一次超时带来的跳出,比整体慢 50 毫秒要昂贵得多。
先搞清楚:你的磁盘是"旋转"还是"闪存"
调度器的选择完全取决于底层设备类型。先用一条命令确认设备名和是否为机械盘:
# 查看块设备拓扑,ROTA=1 表示机械盘(旋转介质),ROTA=0 表示 SSD/NVMe
lsblk -d -o NAME,ROTA,SIZE,TYPE,MODEL
# 查看当前调度器与可用调度器(方括号内是当前生效值)
cat /sys/block/sda/queue/scheduler
# 典型输出: [mq-deadline] kyber bfq none结论只有一句话:机械盘用 bfq 或 mq-deadline,SSD/NVMe 用 none(或 mq-deadline)。原因是 SSD 没有寻道成本,内核排队算法带来的收益很小,反而多一层软件队列增加延迟;而机械盘需要内核帮它做寻道合并,bfq 对交互式负载(比如你一边跑备份一边访问网站)的公平性更好。
切换调度器的正确姿势(永久生效)
临时切换用 echo 即可,但重启会丢。生产上要用 udev 规则固化:
# 临时切换(立即生效,重启失效)
echo mq-deadline > /sys/block/sda/queue/scheduler
# 永久固化:写 udev 规则
cat > /etc/udev/rules.d/60-ioscheduler.rules <<'EOF'
# SSD / NVMe 使用 none
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none"
# 机械盘使用 bfq
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq"
EOF
# 让规则立即生效
udevadm control --reload-rules && udevadm trigger注意 heredoc 里的 &&:直接写在配置文件里是普通字符,但在你复制到终端执行时要确认 shell 正确解析。稳妥做法是先写文件,再单独执行 reload 命令。
脏页回写:三个参数决定"卡不卡"
这是本文的核心。Linux 写文件先进页缓存,标记为"脏页",由内核后台线程择机刷盘。三个关键参数:
sysctl vm.dirty_background_ratio # 达到该比例,后台线程开始异步回写
sysctl vm.dirty_ratio # 达到该比例,写入进程被强制同步阻塞
sysctl vm.dirty_expire_centisecs # 脏页最长存活时间(百分之一秒)
sysctl vm.dirty_writeback_centisecs # 回写线程唤醒间隔(百分之一秒)默认值一般是 dirty_background_ratio=10、dirty_ratio=20。问题就出在这里:假设你有 4GB 内存,那意味着最多可以攒 800MB 脏页,一旦触及 20% 的上限,后续所有 write 系统调用会被同步阻塞,直到回写进度降下来。对网站来说,表现就是"几秒钟完全没响应,然后又恢复正常"。
为什么大内存服务器反而更容易卡
因为 ratio 是按内存比例算的。内存越大,脏页的绝对上限越大,攒批时间越长,单次回写洪峰越猛。所以 16GB 内存的机器上,默认 20% 意味着 3.2GB 可以攒在内存里——这个洪峰用一块普通云盘根本来不及刷,阻塞时间会被显著拉长。
更稳的做法是用绝对字节数替代比例,让行为不随内存扩容而漂移:
cat > /etc/sysctl.d/90-dirty-writeback.conf <<'EOF'
# 后台回写启动阈值:256MB
vm.dirty_background_bytes = 268435456
# 强制阻塞阈值:1GB
vm.dirty_bytes = 1073741824
# 脏页最多存活 30 秒
vm.dirty_expire_centisecs = 3000
# 后台回写线程每 5 秒唤醒一次
vm.dirty_writeback_centisecs = 500
EOF
sysctl --system
# 验证
sysctl vm.dirty_background_bytes vm.dirty_bytes坑位提醒:vm.dirty_bytes 和 vm.dirty_ratio 是互斥的,设置其中一个会清零另一个。所以别在脚本里先写 ratio 又写 bytes,最后生效的只有一个,容易误判。用 sysctl vm.dirty_bytes vm.dirty_ratio 确认最终状态。
怎么验证改动有没有效果
光看参数值不够,要看真实回写行为。推荐用 /proc/vmstat 加 iostat 组合观察:
# 每 2 秒采样一次磁盘利用率与平均等待
iostat -x 2 5
# 关注这几列:
# %util :接近 100% 说明设备饱和
# await :单次 I/O 平均耗时(ms),超过 20ms 对网站就偏慢
# aqu-sz :平均队列长度,持续 > 2 说明排队严重
# 查看累计回写页数与因脏页达限而阻塞的次数
grep -E 'nr_dirty|nr_writeback|nr_dirty_threshold' /proc/vmstat
# 若 pgpgout 短时间内暴涨,且伴随 await 飙高,就是回写洪峰的典型特征
for i in 1 2 3; do grep -E '^pgpgout' /proc/vmstat; sleep 2; done判断标准很朴素:调优前你会看到 await 周期性尖峰(比如稳定在 5ms,每 30 秒突然跳到 50ms 以上),调整 dirty_bytes 让回写更平滑后,尖峰应该被抹平成一条更平但略高的线。总吞吐可能不变,但网站的响应抖动会明显改善。
数据库场景的额外注意事项
如果你在同一台机器上跑 MySQL,脏页参数调小会带来一个副作用:写入更频繁地落盘,可能拉低顺序写吞吐。这时候要做的是把回写压力"让"给数据库自己管理,思路是:
- 让 MySQL 的
innodb_flush_method使用O_DIRECT,绕过页缓存,减少内核脏页总量; - 把内核
vm.dirty_bytes适当调大一点(如 2GB),因为 O_DIRECT 的写入不再贡献脏页,剩余脏页主要来自日志文件; - 确保 MySQL binlog 与数据文件分在不同物理设备上,避免回写互相抢占。
这不是"谁对谁错",而是避免两层缓存互相打架。内核缓存 + 数据库缓存各自攒一批,一旦同时回写,磁盘队列会瞬间打满。
一个完整的调优案例:从"每天卡三次"到"察觉不到"
举个真实场景帮助理解。一台 2 核 4GB 的云服务器,跑 Nginx + PHP + MySQL,站长反馈"每天下午和凌晨会卡三次,每次三五秒"。查 CPU、内存、连接数都正常,只有 iostat 的 await 会在那几个时间点从 4ms 跳到 60ms 以上。
对照时间点发现:下午的卡顿对应访问高峰期日志写入量上升,凌晨的卡顿对应 MySQL 定时备份任务开始。两件事的共同点是短时间内产生大量脏页,触发了 dirty_ratio=20 的同步阻塞。
处理过程分三步。第一步,把 dirty_background_ratio 从 10 降到 5,让后台回写更早开始,避免攒到临界点才动手。第二步,把比例改成绝对字节数(dirty_background_bytes=128M、dirty_bytes=512M),让行为与内存大小解耦。第三步,给备份任务加 ionice -c3 降级,让它在磁盘空闲时才抢带宽。
结果不是"吞吐提升",而是 await 的那根尖刺被抹平了:从"平时 4ms、偶尔 60ms"变成了"稳定在 10-15ms"的平缓曲线。平均等待时间其实略高了一点,但用户再也感知不到那几秒的卡顿。这就是尾延迟优化的典型特征——用平均值的轻微让步,换取最差情况的大幅改善。
这个案例还说明一件事:I/O 调优很少是单一参数的问题。内核的回写策略、任务的优先级、数据库自己的刷盘方式,三者是互相影响的。只改一个参数往往看不到明显效果,你需要从"谁在什么时候产生写入"这个角度去梳理整条链路。
常见问题
Q:调了 dirty_bytes 但 iostat 的 await 没变化?
先确认设备是不是被虚拟化层挡住了。云主机的"磁盘"往往是网络存储,宿主机侧的队列你无法控制,此时本地参数只能改善"突发洪峰",无法突破底层带宽。用 fio 做一次基线测试就能知道天花板在哪。
Q:调度器从 bfq 换成 none 后,备份任务把网站拖慢了?
这正是 bfq 的价值所在——它对交互式 I/O 更友好。如果切 none 后出现争抢,要么换回 mq-deadline,要么用 ionice 给备份任务降级:ionice -c3 -n7 rsync ...,让它在空闲时才抢带宽。
Q:这些参数在容器里生效吗?
vm.* 是内核级全局参数,容器共享宿主内核,你在容器内改会影响宿主机所有负载。生产上应该在宿主机改,或者通过 /sys/fs/cgroup 的 blkio 控制器做容器级限制,而不是动全局 sysctl。
总结
磁盘 I/O 调优的本质,是让"攒批"的收益和"阻塞"的代价取得平衡。机械盘换 bfq 找回寻道效率,SSD 换 none 减少软件队列延迟;脏页参数从按比例改成按绝对字节数,避免内存扩容后行为漂移;再用 iostat 的 await 抖动而不是平均值来验证效果。这三步做完,你大概率能解释清楚那个"CPU 内存都很闲但网站就是卡"的悬案。
最后强调一点:任何调优都要有回滚路径。把改动写进 /etc/sysctl.d/,改之前先 sysctl -a | grep dirty 记下原始值,出问题删文件重启即可。比起"调到最优",能安全地退回默认值才是运维的底线。