3. 操作系统IO栈调优:Page Cache、I/O调度器与块层合并

存储性能优化,说到底就是跟操作系统IO栈打交道。我做了这么多年存储,发现很多人一上来就调内核参数,结果越调越慢。为什么?因为没搞懂IO栈里每个环节在干什么。

这一章,咱们就聚焦三个核心点:Page Cache、I/O调度器、块层合并与队列深度。这三样东西,决定了你的存储系统是“飞起来”还是“卡成狗”。

操作系统IO栈调优核心结构 应用程序 (App / DB / 文件系统) Page Cache (页缓存) 块层 (Block Layer) — 合并与队列 I/O 调度器 (mq-deadline / kyber / none) 驱动 / 硬件 (NVMe / SSD / HDD) 每个环节都可能成为瓶颈。调优的关键:先定位瓶颈在哪一层,再动手。

3.1 Page Cache:被低估的性能放大器

Page Cache 是 Linux 内核用内存来缓存磁盘数据的一种机制。说白了,就是拿内存当“快照”,减少对后端存储的直接访问。

我遇到过不少开发,写文件时发现速度忽高忽低,查了半天,最后发现是 Page Cache 在“捣乱”。

3.1.1 写回机制与脏页

当你写一个文件时,数据先进入 Page Cache,标记为“脏页”(Dirty Page)。内核后台有个 flusher 线程,定期把这些脏页刷到磁盘。

这里有个关键参数:dirty_ratio 和 dirty_background_ratio。

参数默认值含义影响
dirty_ratio20%脏页占内存上限(同步阻塞阈值)达到后写操作同步等待刷盘,延迟飙升
dirty_background_ratio10%后台 flusher 启动阈值超过后内核开始异步刷盘,不影响前台
dirty_expire_centisecs3000 (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 压力太大了。

调优建议: 对于 NVMe SSD 这类低延迟设备,可以适当调低 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-deadline
NVMe SSD,追求低延迟 → kyber
NVMe 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 重启
注意: 切换调度器前,务必确认你的内核版本支持 blk-mq。可以用 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 SSD64 - 128AHCI 协议本身有队列深度限制
NVMe SSD256 - 1024NVMe 原生支持高深度,建议 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% 以上。但记住——先测量,再调优。不要凭感觉改参数,用数据说话。