3. 操作系统IO栈调优:Page Cache、I/O调度器与块层合并
存储性能优化,说到底就是跟操作系统IO栈打交道。我做了这么多年存储,发现很多人一上来就调内核参数,结果越调越慢。为什么?因为没搞懂IO栈里每个环节在干什么。
这一章,咱们就聚焦三个核心点:Page Cache、I/O调度器、块层合并与队列深度。这三样东西,决定了你的存储系统是“飞起来”还是“卡成狗”。
3.1 Page Cache:被低估的性能放大器
Page Cache 是 Linux 内核用内存来缓存磁盘数据的一种机制。说白了,就是拿内存当“快照”,减少对后端存储的直接访问。
我遇到过不少开发,写文件时发现速度忽高忽低,查了半天,最后发现是 Page Cache 在“捣乱”。
3.1.1 写回机制与脏页
当你写一个文件时,数据先进入 Page Cache,标记为“脏页”(Dirty Page)。内核后台有个 flusher 线程,定期把这些脏页刷到磁盘。
这里有个关键参数:dirty_ratio 和 dirty_background_ratio。
| 参数 | 默认值 | 含义 | 影响 |
|---|---|---|---|
dirty_ratio | 20% | 脏页占内存上限(同步阻塞阈值) | 达到后写操作同步等待刷盘,延迟飙升 |
dirty_background_ratio | 10% | 后台 flusher 启动阈值 | 超过后内核开始异步刷盘,不影响前台 |
dirty_expire_centisecs | 3000 (30秒) | 脏页最长存活时间 | 超过后强制刷盘,防止数据丢失 |
dirty_ratio 从 20% 改到 50%,想着能提高写入吞吐。结果业务高峰时,脏页堆积到 50%,所有写线程瞬间卡死,延迟从 2ms 飙到 500ms。后来我才明白——Page Cache 不是越大越好,关键是要匹配你的刷盘能力。
3.1.2 如何监控 Page Cache 状态
我个人习惯用 /proc/meminfo 和 sar -B 来盯脏页情况。
# 查看脏页总量
grep -E "Dirty|Writeback" /proc/meminfo
# 用 sar 看每秒脏页刷盘量
sar -B 1 5
# 手动触发刷盘(调试用)
sync
如果发现 Dirty 值持续偏高,或者 pgpggc(页面回收)频繁,说明你的 Page Cache 压力太大了。
dirty_expire_centisecs 到 500(5秒),让脏页更快落盘,减少突发延迟。对于 HDD,保持默认或适当调高反而更有利。
3.2 I/O 调度器:选对了吗?
I/O 调度器负责决定“谁先谁后”访问磁盘。Linux 内核从 5.0 开始,默认使用 multi-queue block layer (blk-mq),调度器也换成了新的三剑客:mq-deadline、kyber、none。
你想想看,老式的 CFQ、deadline 已经被淘汰了。现在新内核上,你只有这三个选择。
3.2.1 三种调度器对比
| 调度器 | 适用场景 | 特点 | 延迟 | 吞吐 |
|---|---|---|---|---|
| mq-deadline | 通用场景,HDD 和 SSD 都可用 | 按 deadline 排序,保证读写不饿死 | 中等 | 高 |
| kyber | 低延迟场景,NVMe SSD 首选 | 基于令牌桶,动态控制队列深度 | 低 | 中等 |
| none | 高性能 NVMe,或上层已做调度 | 直通模式,不做任何合并排序 | 最低 | 最高 |
我记得有一次帮客户调一个全闪存储集群,他们用的默认 mq-deadline,4K 随机写延迟一直在 200μs 左右。我建议换成 none,延迟直接降到 80μs。为什么?因为 NVMe 的控制器本身已经做了很优秀的队列调度,内核再插一手反而多余。
HDD 或混合存储 →
mq-deadlineNVMe SSD,追求低延迟 →
kyberNVMe SSD,追求极致吞吐 →
none
3.2.2 如何切换调度器
# 查看当前调度器
cat /sys/block/nvme0n1/queue/scheduler
# 临时切换(重启失效)
echo none > /sys/block/nvme0n1/queue/scheduler
# 永久修改(grub 或 udev 规则)
# 在 /etc/default/grub 中添加:
# GRUB_CMDLINE_LINUX="scsi_mod.use_blk_mq=1 nvme_core.default_ps_max_latency_us=0"
# 然后 update-grub 重启
dmesg | grep blk_mq 查看。老内核(4.x 以下)可能没有 kyber 和 none。
3.3 块层合并与队列深度调优
块层是 IO 栈里最容易被忽略的一环。它做两件事:合并(Merge) 和 排序(Sort)。合并能把多个小 IO 拼成大 IO,减少磁盘寻道;排序能减少磁头移动。
但对于 NVMe SSD 来说,合并和排序反而可能增加 CPU 开销,得不偿失。
3.3.1 合并策略
块层合并分为两种:
- 前向合并(Front Merge):新 IO 可以接在已有 IO 的前面
- 后向合并(Back Merge):新 IO 可以接在已有 IO 的后面
你可以通过 /sys/block/<dev>/queue/nomerges 控制:
# 0 = 启用所有合并(默认)
# 1 = 仅禁用简单合并
# 2 = 禁用所有合并(推荐 NVMe 使用)
echo 2 > /sys/block/nvme0n1/queue/nomerges
我做过测试:在 NVMe 上关闭合并后,4K 随机写的 CPU 占用率降低了 15%,而吞吐几乎没有变化。因为 NVMe 的并行能力足够强,小 IO 直接发下去反而更高效。
3.3.2 队列深度(Queue Depth)
队列深度决定了每个 CPU 核可以同时向设备提交多少个 IO 请求。这个值太大会导致延迟增加,太小会浪费设备并行能力。
| 设备类型 | 推荐队列深度 | 说明 |
|---|---|---|
| HDD (SATA) | 32 - 64 | 机械盘并行能力有限,深度不宜过大 |
| SATA SSD | 64 - 128 | AHCI 协议本身有队列深度限制 |
| NVMe SSD | 256 - 1024 | NVMe 原生支持高深度,建议 512 起步 |
# 查看当前队列深度
cat /sys/block/nvme0n1/queue/nr_requests
# 修改队列深度(临时)
echo 512 > /sys/block/nvme0n1/queue/nr_requests
# 注意:nr_requests 是每个队列的请求数,实际总深度 = nr_requests × 队列数
fio 测试时,可以同时调整 iodepth(应用层队列深度)和 nr_requests(内核层队列深度)。我一般建议应用层深度设为内核层深度的 2-4 倍,避免应用层成为瓶颈。
3.3.3 其他关键参数
- read_ahead_kb:预读大小。顺序读场景可以调大(如 4096 KB),随机读场景调小(如 64 KB)。
- max_sectors_kb:单次 IO 最大大小。NVMe 建议设为 1280 KB(1280 * 512 = 640K 扇区),HDD 保持默认 512 KB。
- rq_affinity:请求亲和性。设为 1 可以让 IO 请求尽量在提交它的 CPU 上处理,减少跨核开销。
# 查看和修改这些参数
cat /sys/block/nvme0n1/queue/read_ahead_kb
echo 4096 > /sys/block/nvme0n1/queue/read_ahead_kb
cat /sys/block/nvme0n1/queue/max_sectors_kb
echo 1280 > /sys/block/nvme0n1/queue/max_sectors_kb
cat /sys/block/nvme0n1/queue/rq_affinity
echo 1 > /sys/block/nvme0n1/queue/rq_affinity
Page Cache 调的是“内存与磁盘的节奏”,I/O 调度器选的是“排队策略”,块层调的是“合并与并发”。这三层调好了,你的存储性能至少能提升 30% 以上。但记住——先测量,再调优。不要凭感觉改参数,用数据说话。