第 08 章 块层与 IO 栈——从一个 bio 到盘片上的磁畴

本章追一个问题:上一章那个 page cache 未命中的读请求,变成了一个 bio 提交给块层。接下来这段路上,它会被排队、被合并、被重排、被拆分、被交给驱动、被 DMA 搬运,最后引发一次硬件中断。 你会看到:块层的全部工作,是在”电梯”和”公平”之间做取舍。而这个取舍的前提假设,在 SSD 时代已经全盘崩塌——于是有了 blk-mq 这场重写。 顺带,我们把 iostat 每一列彻底讲清。我打赌你至少读错过两列。


一、先建立一个物理直觉:磁盘为什么慢?

墨:老哥,咱先不谈软件。你说机械硬盘慢,慢在哪?给我拆成物理动作。

你:……磁头要移动到对应的磁道,然后等盘片转到那个扇区?

墨:对,拆得很好。一次机械盘随机读,时间构成:

阶段物理动作典型耗时
寻道(seek)磁头臂从当前磁道移动到目标磁道3~10 ms ← 大头
旋转延迟(rotational latency)等盘片转到目标扇区。7200rpm = 8.3ms 一圈,平均等半圈~4 ms ← 大头
传输(transfer)真正读那 4KB 数据~0.05 ms

墨:看清楚了吗?真正传输数据只占 0.5%,99.5% 的时间在”把磁头挪到对的地方”。

这个事实,推导出了整个块层最初的全部设计动机:

既然”挪磁头”占了几乎全部时间,那么块层最重要的工作就是:让磁头少挪、挪得有序。

于是有了两个核心手段:

  1. 合并(merge):相邻的请求拼成一个大请求 —— 一次寻道搬更多数据;
  2. 排序(sort):把请求按磁道位置排好序,让磁头单向扫过去 —— 就像电梯。

📦 类比:电梯算法(这也是 IO 调度器的正式名字来源)

  • 一栋 20 层的楼,电梯在 5 层。请求依次是:去 18 层、去 3 层、去 17 层、去 4 层。
  • 笨办法(FIFO):按顺序服务 → 5→18→3→17→4,来回跑了 13+15+14+13 = 55 层。
  • 电梯算法:排序后单向扫 → 先往上 5→17→18,再往下 →4→3,只跑 13+1+14+1 = 29 层。
  • 磁头就是电梯,磁道就是楼层。 Linux 的 IO 调度器在内核里的正式称呼就是 elevator(电梯)——你在 dmesg 里能看到 io scheduler ... registered (elevator)。

墨:这就是为什么老 Linux 的启动参数叫 elevator=deadline。这个名字本身就是一段化石,记录着”磁头很贵”的那个时代。


二、一个 IO 的完整下行路径

墨:把链条完整走一遍。上一章我们停在”page cache 未命中,要向磁盘要数据”。

文件系统层(ext4/xfs)
  ↓  把"文件的第 N 页"翻译成"块设备的第 M 个扇区"
  ↓  (这一步要查 extent 树 / 间接块,可能自己也要读磁盘)
  ↓
构造 bio 结构
  ├─ 目标设备
  ├─ 起始扇区
  ├─ 长度
  ├─ 方向(读/写)
  └─ 数据页的指针(注意:是页指针,不是拷贝!零拷贝的基础)
  ↓
submit_bio()  ← 进入块层
  ↓
【块层第一关:合并】
  ↓  这个 bio 能和队列里已有的请求拼起来吗?
  ↓  ├─ 前向合并(front merge):我的结尾接着别人的开头
  ↓  └─ 后向合并(back merge):我的开头接着别人的结尾
  ↓  能合就合成一个 request(省一次寻道!)
  ↓
【块层第二关:IO 调度器】
  ↓  排序、按策略决定谁先走(deadline/bfq/none...)
  ↓  这里可能等待,攒一攒好合并更多(plugging)
  ↓
【块层第三关:派发到硬件队列】
  ↓  单队列时代:一个全局队列 + 一把大锁
  ↓  blk-mq 时代:per-CPU 软队列 → 多个硬件队列
  ↓
设备驱动(nvme / ahci / scsi / virtio-blk)
  ↓  把 request 翻译成设备命令(NVMe 的 SQE / SCSI 的 CDB)
  ↓  写进设备的提交队列,敲门铃(doorbell 寄存器)
  ↓
【硬件:设备控制器】
  ↓  取命令,自己的内部调度(FTL/NCQ),读闪存/盘片
  ↓  通过 DMA 把数据直接搬进内存那些页(不经过 CPU!)【第 09 章】
  ↓  完成后发 中断(MSI-X)
  ↓
中断处理 → 软中断(blk_done_softirq) → 标记 bio 完成 → 唤醒等待的进程
  ↓
进程从 D 状态醒来,继续跑

墨:几个关键认知,标出来:

  1. bio 里存的是页的指针,不是数据拷贝。 所以从 page cache 到磁盘,数据从头到尾只有一份,由 DMA 引擎直接从那些内存页搬走。这就是”零拷贝”在块 IO 上的体现。
  2. 进程在等 IO 时是 D 状态(不可中断睡眠)。 上一章讲过,它计入 load average。
  3. 完成通知是靠中断的,而中断处理必须极快(第 09 章的主题),所以真正的收尾工作(唤醒进程等)推给软中断做。
  4. 设备内部还有自己的一层调度(SSD 的 FTL、SATA 的 NCQ)。这一点在 SSD 时代变得极其重要——它意味着内核的排序可能是白费功夫,甚至是负优化。

三、blk-mq:一场被 SSD 逼出来的重写

墨:这一节是本章的核心。它是”规模和硬件变化,逼死旧设计”的绝佳教材。

3.1 旧设计的三个前提假设

单队列时代(blk-sq)的块层,建立在三个假设上:

假设在机械盘时代成立吗在 NVMe 时代呢
① 设备很慢,几百 IOPS。所以内核花点 CPU 排序完全值得✅ 成立❌ 崩了。NVMe 百万 IOPS,内核排序的 CPU 开销成了瓶颈本身
② 设备只能一次处理一个请求,所以要一个队列排好序喂它✅ 成立❌ 崩了。NVMe 支持 64K 个队列,每队列 64K 深度,天生并行
③ 顺序访问远快于随机访问,所以排序收益巨大✅ 成立(差 100 倍)❌ 基本崩了。SSD 无机械寻道,随机/顺序差距小得多(且 FTL 有自己的映射)

墨:老哥你看,三个前提全部失效。这时候会发生什么?我们看具体的崩塌形态。

3.2 单队列的具体瓶颈:一把大锁

老块层的结构:每个块设备一个请求队列,一把 spinlock 保护它。

在一台 32 核机器上跑高并发 IO:

  • 32 个 CPU 全都想往这同一个队列里塞请求;
  • 都要抢同一把锁;
  • 锁竞争把 CPU 吃穷了 —— 实测在高 IOPS 下,大量 CPU 时间耗在 spinlock 上;
  • 更糟的是缓存行乒乓(cache line ping-pong):那把锁和队列头部的数据结构,在 32 个核的缓存之间来回失效、反复同步。这个开销比锁本身更大;
  • 而且请求可能在 CPU A 上提交、在 CPU B 上完成,跨核唤醒 + 缓存不亲和,雪上加霜。

墨:实测数据(blk-mq 论文里的):单队列块层在约 80 万 IOPS 时就撞墙了,而当时的高端 SSD 阵列已经能做几百万。软件成了瓶颈。

3.3 blk-mq 的解法:两层队列 + 消灭全局锁

blk-mq(multi-queue block layer,2013 年进内核,3.13) 的设计:

   CPU0      CPU1      CPU2      CPU3        ← 每个 CPU 一个软队列
    │         │         │         │            (per-CPU,无锁或极少锁竞争!)
  ┌─▼─┐     ┌─▼─┐     ┌─▼─┐     ┌─▼─┐
  │软 │     │软 │     │软 │     │软 │        ← software staging queues
  │队 │     │队 │     │队 │     │队 │           在这里做合并、调度
  └─┬─┘     └─┬─┘     └─┬─┘     └─┬─┘
    └────┬────┘         └────┬────┘
    ┌────▼────┐         ┌────▼────┐
    │硬件队列0│         │硬件队列1│           ← hardware dispatch queues
    └────┬────┘         └────┬────┘              数量 = 设备支持的队列数
         │                   │
    ┌────▼───────────────────▼────┐
    │       设备(NVMe)           │           ← 设备内部自己并行处理
    └─────────────────────────────┘

关键设计决策:

  1. per-CPU 软队列:每个 CPU 有自己的队列,提交请求时基本不用抢锁,缓存亲和性好(数据在本核的缓存里)。这一招直接消灭了那把全局大锁。
  2. 多个硬件队列:映射到设备的多个真实队列。NVMe 可以让每个 CPU 对应一个硬件队列,从提交到完成全程不跨核。
  3. 中断也绑核:MSI-X 让每个硬件队列的完成中断投递到提交它的那个 CPU,于是”提交→完成→唤醒”全在同一个核上,缓存全程热的。
  4. 调度器变成可选的插件:因为对 NVMe 来说”排序”可能是纯浪费,所以 blk-mq 允许 none(完全不调度,直接派发)。

墨:老哥,你从这个演化里能提炼出什么规律?

你:……硬件变了,软件的假设就废了,得重写?

墨:对,但我要说得更精确一些:

规律:一个优化,一定是建立在某个”什么贵什么便宜”的成本假设上的。当硬件让这个成本关系反转时,原来的优化就变成了负担。

  • 机械盘时代:磁头贵,CPU 便宜 → 花 CPU 做排序换少寻道,大赚。
  • NVMe 时代:CPU 贵(相对),IO 便宜 → 花 CPU 排序反而拖慢,该做的是”尽量少插手,直接喂给设备”。

这就是为什么 NVMe 上正确的调度器往往是 none。 不是”none 更高级”,是前提变了,不插手才是最优。

墨:再往外推一层——这个规律在你的日常里到处都是:

  • 内存便宜了,所以现在到处用缓存换计算;
  • 网络快了(RDMA/25G),所以”能不能不走网络”的优化收益变小了,分布式架构更激进;
  • SSD 便宜了,所以”减少写放大”从生死问题变成成本问题;
  • 你写 Go 代码时的”用 map 缓存结果 vs 每次重算”,也是同一个成本判断。

每次你看到一个”最佳实践”,都该问一句:它建立在什么成本假设上?那个假设在我的场景还成立吗? 这是本教程反复要你练的思维。


四、IO 调度器:三个在赌不同东西的赌徒

墨:现在讲具体的调度器。Linux 现在(blk-mq 时代)主要有四个选项:

cat /sys/block/nvme0n1/queue/scheduler
# [none] mq-deadline kyber bfq
#  ↑ 方括号里是当前生效的

4.1 none —— 不赌,直接送

  • 策略:FIFO,来了就往硬件队列送,不排序不调度。
  • 赌的是:设备自己足够聪明(NVMe 的内部调度 + 高并行度),内核插手只会添乱。
  • 适用:NVMe SSD、高端 SAS SSD、以及虚拟化环境里的 virtio-blk(因为真正的调度在宿主机那一层做,客户机里再调度就是重复劳动)。
  • 代价:完全没有公平性保证。一个进程可以用大量 IO 把设备队列塞满,饿死别人。没有”读优先于写”这种保护。

4.2 mq-deadline —— 赌”不能让谁饿死”

  • 策略:

    • 维护两套结构:一个按扇区排序的红黑树(为了合并和电梯效应),两个按到达时间排序的 FIFO 队列(读一个、写一个);
    • 给每个请求设一个”截止时间”:读请求默认 500ms,写请求默认 5000ms;
    • 正常情况按扇区顺序服务(电梯);但一旦有请求快到截止时间了,立刻插队服务它。
  • 赌的是:① 排序还是有用的(对机械盘、或有内部寻址成本的设备);② 绝不能让任何请求无限期饿死。

  • 为什么读的截止时间比写短 10 倍?——这个设计决策特别值得体会:

    • 读通常是同步的:进程 read() 卡在那儿等着,读不回来它就动不了。读延迟直接等于用户感知的延迟。
    • 写通常是异步的:write() 已经返回了(page cache 的谎言!),真正落盘慢一点,没人在等。
    • 所以内核的判断是:读的延迟是”有人在等的债”,写的延迟是”没人催的债”。优先还有人催的。
    • 这是个非常普适的设计原则:区分”同步的、有人等的”和”异步的、后台的”,给前者更高优先级。 你在写 Go 服务时也该这么想:用户请求路径 vs 后台批处理,不该一样对待。

  • 相关参数(在 /sys/block/<dev>/queue/iosched/ 下):

    • read_expire(默认 500ms):读请求的截止期限。调小 = 读延迟更有保障,代价是打断顺序性更频繁、吞吐降。
    • write_expire(默认 5000ms):同理。
    • writes_starved(默认 2):在处理写之前,最多允许连续处理几批读。这个参数直白地表达了”写可以被饿一点,但不能饿死”。
    • fifo_batch(默认 16):一次按顺序派发多少个请求才去检查截止时间。调大 = 顺序性更好、吞吐高,代价是截止时间的响应变粗糙。

4.3 bfq(Budget Fair Queueing)—— 赌”交互体验”

  • 策略:给每个进程(或 cgroup)一个IO 预算,按权重公平分配磁盘时间。还有一堆启发式:检测交互型应用给它加速、检测顺序流给它更多预算……
  • 赌的是:延迟公平性比总吞吐更重要。
  • 适用:桌面系统(你复制大文件时,还想让浏览器点得动)、机械盘、需要 cgroup IO 权重隔离的场景。
  • 代价:CPU 开销显著(它是最复杂的调度器)。在高 IOPS 设备上,它自己就是瓶颈——在 NVMe 上用 bfq 通常是错的。
  • 它的独特价值:bfq 支持 cgroup 的 io.weight(按比例分配 IO),这是做 IO 隔离的重要能力。mq-deadline 和 none 不支持权重。

4.4 kyber —— 赌”延迟目标可以自适应”

  • 策略:你给它设目标延迟(read_lat_nsec/write_lat_nsec),它自动调节队列深度来达到这个目标。发现延迟超了就减少在飞请求数,延迟宽裕就放开。
  • 赌的是:与其让人调参,不如让调度器自己找平衡点。
  • 适用:高速设备上想要延迟保证但不想用 bfq 那么重的场景。
  • 设计理念很现代:它是闭环反馈控制,而不是固定规则。

    这跟 K8s 那本里的控制循环、跟 TCP 的拥塞控制,是同一个思路:测量 → 反馈 → 调整,而不是靠预设的静态参数。

4.5 怎么选?—— 一张决策表

设备/场景推荐理由
NVMe SSD,吞吐优先none设备自身高并行 + 内部调度,内核插手是负担
NVMe SSD,需要延迟保证或 cgroup IO 隔离mq-deadline 或 kyber要一点秩序,但别用 bfq(太重)
SATA/SAS SSDmq-deadline中等并行度,轻量调度有收益
机械硬盘,服务器mq-deadline电梯排序收益巨大 + 防饿死
机械硬盘,桌面/交互bfq交互体验优先,值得付 CPU 代价
虚拟机内的 virtio-blknone真正的调度在宿主机做,客户机重复调度纯浪费
需要 cgroup io.weight 权重隔离bfq(唯一支持)只有它做比例公平

⚠️ 一个常见的错误做法:抄一份”生产调优参数”到所有机器上,里面写着 elevator=deadline。结果一台 NVMe 的机器被套上了不需要的调度器,损失 10~20% 的 IOPS。参数必须匹配设备特性,没有普适的最佳值。

# 临时切换
echo none > /sys/block/nvme0n1/queue/scheduler
# 永久:udev 规则(推荐,因为能按设备类型区分)
# /etc/udev/rules.d/60-scheduler.rules
ACTION=="add|change", KERNEL=="nvme*", ATTR{queue/scheduler}="none"
ACTION=="add|change", KERNEL=="sd*", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq"

五、把 iostat 彻底讲清——一半人读错的地方

墨:老哥,这一节我要花大力气讲,因为 iostat 是最常用又最常被误读的工具。我见过太多次基于误读做出的错误决策。

iostat -x 1
Device  r/s   w/s  rkB/s  wkB/s  rrqm/s  wrqm/s  %rrqm  %wrqm  r_await  w_await  aqu-sz  rareq-sz  wareq-sz  %util
nvme0n1  120  340   4800  27200      2      45   1.6    11.7     0.35     0.82    0.42      40.0      80.0   98.5

5.1 逐列解读

列含义怎么用
r/s w/s每秒完成的读/写请求数(IOPS)和设备标称 IOPS 对比
rkB/s wkB/s每秒读/写的数据量(带宽)和设备标称带宽对比
rrqm/s wrqm/s每秒被合并的请求数合并多 = 你的 IO 有连续性,块层在帮你
%rrqm %wrqm合并请求占比顺序负载应该很高;接近 0 说明纯随机
r_await w_await读/写请求的平均耗时(ms),包含在队列里排队的时间见下面详解
aqu-sz平均队列长度(旧版叫 avgqu-sz)判断饱和度的真正指标
rareq-sz wareq-sz平均单个请求的大小(KB)小 = 碎片化 IO;大 = 大块顺序
%util设备”有 IO 在处理”的时间占比⚠️ 最容易被误读的一列

5.2 误读之一:%util = 100% 不等于磁盘满了

墨:这是最大的坑。%util 的定义是:采样周期内,“设备至少有一个 IO 在处理中”的时间占比。

在机械盘时代,这个指标很有意义——机械盘一次基本只能处理一个请求,所以”一直在忙”≈“饱和”。

但在 SSD/NVMe 上,这个指标基本失效了!

你想:一块 NVMe 能同时处理 64 个甚至上千个请求。假设它一直有 1 个请求在处理,其余能力全空闲——%util 就是 100%,但设备只用了 1/64 的能力!

在支持并行的设备上,%util 只能告诉你”设备没完全空着”,不能告诉你”设备快撑不住了”。

我见过太多次:运维看到 NVMe %util 98% 就下结论”磁盘瓶颈,要换更快的盘”,实际上设备只用了两成能力,真正的瓶颈在别处(应用并发不够、fsync 太频、CPU 处理不过来)。这个误判会让你花几十万买错东西。

那该看什么判断饱和?

  1. aqu-sz(平均队列长度):队列里平均积压多少个请求。
    • 接近 0 → 设备很闲,请求一来就处理;
    • 持续很大(比如几十上百)→ 请求在排队,这才是真的积压。
  2. await 的趋势和分布:如果 IOPS 上升的同时 await 也明显上升,说明设备在接近饱和(排队论:利用率越接近 1,排队时间非线性暴涨)。
  3. 和设备标称能力对比:r/s + w/s 对比标称 IOPS,rkB/s + wkB/s 对比标称带宽。
  4. 最准的办法:压测出你这块盘的”拐点”。用 fio 逐步加压,画出 “IOPS vs 延迟” 曲线,找到延迟开始陡增的那个点。那才是你这块盘在你的负载下的真实饱和点。

5.3 误读之二:await 是平均值,而且它包含排队

墨:两个要点:

① await 包含排队时间,不只是设备处理时间。

await = 在队列里等的时间 + 设备真正处理的时间

所以 await 高有两种完全不同的原因:

  • 设备本身慢(处理时间长)→ 换盘有用;
  • 队列太长,在排队(设备可能很快,但请求太多)→ 换盘可能没用,该减少 IO 或提高并行度。

怎么区分? 看 aqu-sz:

  • await 高 + aqu-sz 小 → 设备本身慢(每个请求都要处理很久);
  • await 高 + aqu-sz 大 → 在排队,设备可能没问题,是请求太多了。

⚠️ 老版 iostat 有个 svctm 列(服务时间)想表达”纯设备时间”,但它的计算方式在多队列时代是错的、不可信的,新版 sysstat 已经移除了它。如果你还在看 svctm,忘掉它。

② await 是平均值,平均值会骗人。

上一章那个 fsync 卡 300ms 的事故就是例子:await 平均 1ms,但某个请求(或某个事务整体)卡了 300ms。

要看分布,用这些工具:

# BCC/bpftrace:IO 延迟直方图 —— 这是块层排障的第一利器
biolatency-bpfcc -m 10        # 每 10 秒打一次延迟直方图(毫秒)
 
#     msecs      : count    distribution
#         0 -> 1 : 4821     |****************************************|
#         2 -> 3 : 320      |**                                      |
#       ...
#       128 -> 255: 12      |                                        |  ← 长尾!
#       256 -> 511: 3       |                                        |  ← 就是这些在杀你的 P99
 
# 逐个 IO 追踪:谁在发 IO、多大、多久
biosnoop-bpfcc
# TIME  COMM      PID    DISK   T SECTOR    BYTES  LAT(ms)
# 0.000 mysqld    2891   nvme0n1 W 12345678  16384   0.42
# 0.001 kworker   89     nvme0n1 W 98765432 524288  12.30   ← 谁发的这个大写?

biolatency 的直方图比 iostat 的平均值有价值一个数量级。 平均值告诉你”整体还行”,直方图告诉你”有 3 个请求卡了半秒”——而后者才是你 P999 的元凶。养成看分布不看平均的习惯。

5.4 误读之三:合并率低不一定是问题

%rrqm/%wrqm 接近 0,有人会说”合并率太低,IO 不高效”。

但这取决于负载性质:

  • 数据库 OLTP 的随机小 IO,本来就无法合并(它们确实不相邻);
  • 这种情况合并率低是正常的,不是问题;
  • 如果是顺序写日志/大文件,合并率却很低,那才是问题 —— 说明有东西在打断合并(比如太频繁的 fsync、或者 O_DIRECT 小 IO、或者 dirty_bytes 太小导致碎片化写回)。

指标要结合负载语义解读,没有”绝对好”的数值。 这是本节最重要的一句话。


六、队列深度与相关参数

6.1 /sys/block/<dev>/queue/nr_requests

  • 控制什么:块层软队列能容纳的请求数(默认 128 或 256)。
  • 控制背景:队列是个缓冲区。它存在的意义是”攒够请求好合并、好排序、好喂满设备”。
  • 权衡:
    • 调大(如 1024):能攒更多请求 → 合并机会多、设备被喂得更满 → 吞吐提升;代价是队列里排队时间变长 → 单个请求的延迟变差,而且内存占用增加。
    • 调小(如 32):延迟更可控(排队短),但吞吐可能上不去。
  • 一句话:这是吞吐 vs 延迟最直接的一个旋钮。批处理/备份场景调大,延迟敏感场景调小。

6.2 /sys/block/<dev>/queue/rotational

  • 控制什么:告诉内核”这是不是旋转介质”(1=机械盘,0=SSD)。
  • 它影响什么:内核和某些文件系统会根据这个值改变行为(比如是否做激进的预读、是否倾向顺序化)。
  • 为什么要关心:有些 RAID 卡、虚拟化设备、云盘会误报这个值。一块 SSD 被报成 rotational=1,内核就会为它做一堆没用的机械盘优化。
  • 检查:cat /sys/block/*/queue/rotational,和实际设备对照。误报要手动纠正。

6.3 /sys/block/<dev>/queue/rq_affinity

  • 控制什么:IO 完成处理在哪个 CPU 上做。
    • 0:在收到中断的那个 CPU 上完成;
    • 1:迁移到提交这个 IO 的 CPU 组(同一个 NUMA/LLC 域)上完成;
    • 2:严格迁移到提交它的那个具体 CPU 上完成。
  • 控制背景:IO 完成后要做的事(唤醒进程、拷贝数据到用户 buf)会访问那个进程的数据结构。如果在提交它的那个核上做,数据还在缓存里,快;跨核做,缓存全冷。
  • 权衡:2 缓存亲和性最好,但可能造成某些 CPU 负载不均;1 是折中,通常是好默认。
  • 这个参数是”缓存局部性 vs 负载均衡”的又一个体现(和第 05 章 sched_migration_cost_ns 同源)。

6.4 /sys/block/<dev>/queue/max_sectors_kb

  • 控制什么:单个 IO 请求的最大大小。
  • 权衡:调大 → 大块顺序 IO 效率高(一次搬更多);但单个巨大请求会阻塞后面的小请求,造成延迟尖刺(队头阻塞)。
  • 典型冲突:备份任务喜欢大请求,线上查询怕被大请求挡住。这也是 IO 隔离要解决的问题之一。

6.5 cgroup 的 IO 控制:io.max 与 io.weight

墨:这是容器时代最实用的一组:

io.max(硬限制,cgroup v2)

# 限制某 cgroup 对某设备:读 1000 IOPS、写 500 IOPS、读带宽 10MB/s
echo "259:0 rbps=10485760 wiops=500 riops=1000" > /sys/fs/cgroup/mygroup/io.max
  • 权衡:硬上限提供确定的隔离保证,即使那个进程写得再疯也不会影响别人。代价是它闲置时的能力也不能借给别人(和 CPU quota 一样的问题),而且同样会造成 throttling 尖刺。

io.weight(相对权重,需要 bfq)

echo "default 100" > /sys/fs/cgroup/mygroup/io.weight
  • 权衡:只在争抢时按比例分配,不争抢时谁都能用满 → 利用率高;但没有确定的上限保证(和 cpu.shares 完全同构)。
  • ⚠️ 前提:需要 bfq 调度器。这就带来一个矛盾——你为了 IO 隔离用 bfq,却在 NVMe 上付出 bfq 的 CPU 开销。这是个真实存在的两难,目前没有完美解(cgroup v2 的 io.cost/io.latency 是在尝试解决)。

io.latency(cgroup v2,延迟目标)

  • 给一个 cgroup 设”我的 IO 延迟目标是 X ms”,内核会在这个组超标时节流别的组来保护它。
  • 设计思路是”保护优先级高的”,而不是”限制所有人” —— 这比 io.max 的静态限制更贴合真实需求(线上服务要延迟保证,批处理可以被压)。
  • 这是个很好的演化:从”限制资源量”进化到”保证服务质量”。

七、真实事故:一个”换了 NVMe 却没变快”的数据库

墨:走诊断环。这个案子的价值在于它推翻了一个”理所当然”的结论。

现象

一个 MySQL 实例,原来跑在 SATA SSD 上,QPS 约 8000。为了提升性能,换到 NVMe(标称随机读 IOPS 是原来的 8 倍)。

结果:QPS 只从 8000 涨到 9200,提升 15%。 花了大钱,几乎没效果。

DBA 的困惑:iostat 显示原来 SATA SSD 的 %util 是 99%,明明是磁盘瓶颈啊!

墨:老哥,你听到”%util 99% 所以是磁盘瓶颈”这句话,现在该有什么反应?

你:……这个判断本身可能就是错的?

墨:对!这个案子的根因,是最初的诊断就错了。 我们重新走一遍。

重新诊断:第一步,推翻”磁盘瓶颈”的结论

在旧的 SATA SSD 上重新看指标(如果当时的数据还有的话,或者在新机器上复现):

Device  r/s   w/s  r_await  w_await  aqu-sz  %util
sda    2100   400   0.45     0.60     1.1     99.2

关键:aqu-sz 只有 1.1!await 只有 0.45ms!

墨:解读一下。aqu-sz = 1.1 意味着什么?

你:队列里平均只有一个请求……所以设备几乎没有排队?

墨:完全正确。 而 %util 99% 是因为——SATA SSD 处理一个请求要 0.45ms,而请求几乎是一个接一个连续来的,所以”设备总是有 IO 在处理” → %util 99%。

但它一点都不拥塞! 队列是空的,来一个处理一个,处理得还很快。

正确的解读:%util 99% + aqu-sz 1.1 + await 0.45ms = 设备”一直在被用,但完全没有压力”。

这说明什么?说明IO 请求是串行发出来的 —— 上一个完成了才发下一个。设备的并行能力压根没被利用!

瓶颈不在设备,在”发请求的那一方”。

推断:为什么请求是串行的?

墨:MySQL 为什么不并发发 IO?几个可能:

  1. 单线程执行的查询:一个 SQL 在执行计划里做的是”读一页 → 处理 → 根据结果读下一页”(比如索引回表、嵌套循环 join)。这在逻辑上就是串行的,天然无法并行。
  2. innodb_read_io_threads / innodb_write_io_threads 太少;
  3. innodb_buffer_pool_size 太小,导致本该命中缓存的读全打到磁盘;
  4. 连接数/并发度不够,同时在跑的查询本来就少。

验证:

SHOW ENGINE INNODB STATUS\G
-- 看 Buffer pool hit rate
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

结果:buffer pool 命中率只有 91%。 而一个健康的 OLTP 系统应该在 99.5% 以上。

再看配置:innodb_buffer_pool_size = 8G,而机器有 64G 内存,数据集约 50G。

墨:画面出来了!

真正的根因:buffer pool 太小(8G 管 50G 数据),命中率只有 91%,导致 9% 的读要打磁盘。

而这些读在执行计划上是串行的(读一页处理完再读下一页),所以:

  • 设备”一直在忙”(%util 99%),因为总有一个请求在处理;
  • 但完全没有排队(aqu-sz 1.1),因为一次只有一个请求;
  • 换更快的盘,只能让每次串行等待从 0.45ms 变成 0.1ms —— 收益就是那 15%!

瓶颈是”串行的等待次数太多”,不是”每次等待太久”。

调整 → 观察 → 改进

改进 1:调大 buffer pool(治本)

innodb_buffer_pool_size = 48G      # 8G → 48G,覆盖整个数据集
innodb_buffer_pool_instances = 8   # 减少内部锁竞争
  • 原理:把命中率从 91% 提到 99.8%,磁盘读的次数减少 97%。既然瓶颈是”串行等待的次数”,那就减少次数。
  • 观察:QPS 从 9200 → 31000!提升 240%。
  • ⚠️ 配套必做:调大 buffer pool 后,内存压力上升。必须同时:
    • vm.swappiness = 1(第 06 章:绝不能让 buffer pool 被 swap 出去);
    • 处理 NUMA(innodb_numa_interleave=ON,避免单 node 用满);
    • 确认 vm.min_free_kbytes 够(留出直接回收的缓冲);
    • innodb_flush_method = O_DIRECT(第 07 章:避免和 page cache 双重缓存,48G buffer pool + page cache 再存一份就爆了)。

改进 2:提高 IO 并行度

innodb_read_io_threads = 8
innodb_write_io_threads = 8
  • 原理:让 InnoDB 能同时在飞更多 IO,把 NVMe 的并行能力用起来(让 aqu-sz 真正涨上去)。
  • 观察:aqu-sz 从 1.1 涨到 6~8,QPS 再涨到 34000。

改进 3:调度器改 none

echo none > /sys/block/nvme0n1/queue/scheduler
  • 原理:NVMe 上内核排序是负担。
  • 观察:约 3% 提升(不大,但免费)。

改进 4:纠正监控与认知(最重要的改进)

  • 把 aqu-sz、await 分布(biolatency)、buffer pool 命中率加入核心监控面板;
  • 把 %util 从”磁盘瓶颈告警”里移除,或者改成”%util 高 且 aqu-sz > N 且 await 上升”的复合条件;
  • 团队内部做一次分享:%util 在 SSD 时代的失效。
  • 理由:这次买错了硬件,根因是一个指标误读。修正认知的价值,大于这次的所有技术调整。

复盘

原始误判:%util 99% → "磁盘瓶颈" → 换 NVMe → 只涨 15%
  ↓
重新诊断:%util 99% 但 aqu-sz 1.1、await 0.45ms
  → 关键解读:一直在忙 ≠ 有压力。队列空 = 请求是串行发的
  → 结论翻转:瓶颈不在设备,在"发请求的那一方"
  ↓
定位:buffer pool 8G 管 50G 数据,命中率 91% → 串行磁盘读次数太多
  ↓
改进:buffer pool 48G(+ swappiness/NUMA/O_DIRECT 配套)→ QPS 9200→31000
      提高 IO 线程并行度 → 34000
      调度器改 none → 小幅提升
      纠正监控指标和团队认知 ← 最有价值的一项

四个教训:

  1. %util 100% 在并行设备上不代表饱和。 判断饱和看 aqu-sz、await 趋势、和标称能力的对比。这个误读每年让无数团队买错硬件。
  2. “一直在忙”和”有压力”是两件事。 队列空但一直忙 = 请求串行发出 = 瓶颈在发起方,不在设备。
  3. 减少 IO 次数,永远优于加快每次 IO。 缓存命中率的提升(97% 的 IO 消失)带来 240% 收益,换硬件只带来 15%。这是性能优化的第一优先级排序:先消除工作,再加速工作。
  4. 一个错误的指标解读,可以让你花几十万买错东西,而且事后还找不到原因(因为”我明明按指标做的决策”)。所以”读懂指标”这件事本身,就是最高杠杆的投资。

八、参数总表:块层的旋钮

参数位置控制什么拿什么换什么
scheduler/sys/block/*/queue/IO 调度策略none:最低开销、最高吞吐 ↔ 无公平性。mq-deadline:防饿死 + 读优先 ↔ 少量开销。bfq:交互公平 + cgroup 权重 ↔ CPU 开销大。kyber:自适应延迟目标
nr_requests同上软队列容量调大:合并多、吞吐高 ↔ 排队久、延迟差。最直接的吞吐/延迟旋钮
read_ahead_kb同上预读窗口调大:顺序读飞升 ↔ 随机读浪费带宽和缓存。必须匹配负载
rotational同上是否旋转介质影响内核策略;RAID/云盘可能误报,要核对
rq_affinity同上IO 完成在哪个 CPU 处理2:缓存最热 ↔ 可能负载不均。1:折中
max_sectors_kb同上单请求最大尺寸调大:大块 IO 效率高 ↔ 大请求阻塞小请求(队头阻塞)
iosched/read_expire.../iosched/读请求截止期(默认 500ms)调小:读延迟有保障 ↔ 打断顺序性、吞吐降
iosched/write_expire同上写请求截止期(默认 5000ms)同上。读写差 10 倍体现”同步的债优先还”
iosched/writes_starved同上写之前最多连续处理几批读直白表达”写可以被饿一点,但不能饿死”
iosched/fifo_batch同上一批派发多少个再查截止时间调大:顺序性好、吞吐高 ↔ 截止响应粗糙
io.maxcgroup v2IOPS/带宽硬上限确定的隔离 ↔ 闲置能力浪费 + throttling 尖刺
io.weightcgroup v2(需 bfq)相对权重高利用率 ↔ 无确定上限。需 bfq,在 NVMe 上有开销矛盾
io.latencycgroup v2延迟目标(保护高优组)从”限量”进化到”保 QoS”,更贴合真实需求
ionice -c/-n进程级IO 优先级类需 bfq/cfq 才充分生效。-c 3(idle)适合备份任务

九、这章的认知钉子

  1. 块层的原始设计动机是”磁头很贵” —— 合并和排序(电梯算法)。这个动机在 SSD 时代基本消失。
  2. blk-mq 是被硬件逼出来的重写:单队列大锁 + 缓存乒乓在高 IOPS 下成了瓶颈本身。解法是 per-CPU 软队列 + 多硬件队列 + 中断绑核。
  3. 通用规律:任何优化都建立在”什么贵什么便宜”的成本假设上。硬件让成本关系反转时,优化会变成负担。 所以看到”最佳实践”,先问它的成本假设在你这儿还成立吗。
  4. mq-deadline 给读 500ms、写 5000ms 的截止期,体现”有人等的债优先还” —— 这个原则应该内化到你所有的系统设计里。
  5. %util 100% 在并行设备上不代表饱和。 看 aqu-sz、await 趋势、对比标称能力。这是本章最重要的实战认知。
  6. await 包含排队时间,且是平均值。 await 高 + aqu-sz 小 = 设备慢;await 高 + aqu-sz 大 = 在排队。要看分布就用 biolatency。
  7. “一直在忙”≠“有压力”。 队列空而设备忙,说明请求是串行发的,瓶颈在发起方。
  8. 减少 IO 次数永远优于加快每次 IO。 优化的第一优先级是”消除工作”,不是”加速工作”。
  9. 指标要结合负载语义解读(合并率低在随机负载下是正常的)。没有绝对好的数值。
  10. NVMe 上正确的调度器往往是 none,虚拟机里也是。 抄别人的 elevator=deadline 可能白损失 20% IOPS。

**


实战补遗一:IO 调度器选错——mq-deadline 反而比 none 慢的怪事

墨:第 8 章讲了块层 NVMe 用 none、SATA 用 mq-deadline。但”选对调度器”没那么理所当然,讲个选反的事故。

你:NVMe 不是延迟极低、自带队列深度大,用 none(no-op)最合适吗?还能选错?

墨:对 NVMe 单机盘,none 确实对。但事故出在混合存储 + 错误全局设置。一台机器有:① NVMe 系统盘(低延迟)② 一块大容量 SATA SSD 做”温数据”存储(跑后台分析任务)。运维照着”NVMe 用 none”的帖子,把所有盘的 scheduler 都设成 none(包括那块 SATA SSD)。

结果:温数据盘上的后台分析任务,顺序大 IO 和随机小 IO 混着来,none 调度(纯 FIFO、不排序)下,随机写小请求插队到顺序读前面,顺序读被切碎,iostat 看 r_await 从 2ms 飙到 15ms,后台任务吞吐掉一半。而系统盘(NVMe)用 none 是对的,不受影响——问题出在把”NVMe 的真理”错用到 SATA 上。

推断:调度器要按介质特性分盘设,不是全局一刀切。NVMe 内部自有深队列+并行,NVMe 控制器自己就做了 IO 合并排序,内核再调度是多余(none 对);SATA SSD 控制器弱、队列浅,需要内核 mq-deadline(给读写排个相对序、防饿死)或 bfq(公平)来优化。验证:cat /sys/block/<dev>/queue/scheduler 看每盘的当前调度器,再 iostat -x 对比两块盘的 await/aqu-sz(平均队列深度),SATA 盘 aqu-sz 高且 await 高说明请求堆积、调度器没管好。

调整:按盘分别设——NVMe 保持 none,SATA SSD 设 mq-deadline(或写多读少的温数据盘用 bfq):

echo none > /sys/block/nvme0n1/queue/scheduler
echo mq-deadline > /sys/block/sda/queue/scheduler

再观察:SATA 盘 await 回到 3ms、顺序读不再被随机写切碎,后台任务吞吐恢复。

你:bfq 和 mq-deadline 怎么选?都是”有调度”的。

墨:侧重点不同:

  • mq-deadline:目标是保证延迟上界(deadline 防请求饿死),适合”企业存储/数据库”——不追求极致公平,求稳、求可预测延迟;
  • bfq(Budget Fair Queueing):目标是按进程公平分配带宽 + 低延迟交互,适合”桌面/多用户混部”——谁都能流畅用,但单任务绝对吞吐可能略低于 deadline;
  • none:无调度,适合 NVMe 等自带强队列的快盘,少一层内核开销。

所以:数据库/温数据服务器盘 → mq-deadline(要可预测);桌面/多租户 → bfq(要公平);NVMe 系统盘/纯快盘 → none。还有个隐藏点:/sys/block/<dev>/queue/rotational(0=SSD,1=HDD)会影响内核默认调度器选择——如果这值标错(SSD 标成 1),内核可能默认给你 mq-deadline 而非 none,这也是”为什么我的 NVMe 没用 none”的常见原因。

🔧 思考题:容器里 cat /sys/block/sda/queue/scheduler 看到的是宿主的值还是容器的值?你能给容器单独的盘设调度器吗?

【参考答案】

看到的是宿主节点级值——/sys/block/<dev>/queue/scheduler 挂在宿主 sysfs,容器(除非特权和挂了宿主 sysfs)看不到也改不了,且改了影响整块物理盘所有容器。所以容器里想”给我的盘设调度器”是找错门(和 read_ahead_kb、zone_reclaim_mode 同性质)。

正确做法:在宿主/init 阶段按盘介质特性设好调度器(自动化脚本识别 NVMe→none、SATA SSD→mq-deadline、HDD→mq-deadline),容器侧别碰。如果容器需要”不同 IO 性格”,靠 cgroup v2 的 io.max(限带宽,如 echo "8:0 rbps=104857600" > io.max 限某盘读速)、io.weight(相对权重)在容器级做隔离,而不是改块设备调度器。口诀:调度器是”盘级”(宿主设),io.max/weight 是”容器级”(cgroup 设),两层别混——又是”分清作用域”的老主题。



实战补遗二:SSD 写放大与 fio 真实延迟测法——别被 iops 数字骗了

墨:老哥,你测过磁盘性能吗?用 dd 还是 fio?

你:一般用 dd 看吞吐。

墨:那我再问一句:dd 出来的”写速度 500M/s”,你敢拿来当这块盘的真实能力吗?今天说 SSD 的写放大和怎么用 fio 测到真实的延迟分布。

现象:一台云主机挂的”高性能云盘”,dd if=/dev/zero of=test bs=1M count=2000 显示 600M/s,但线上写日志的服务一压测就卡,P99 写延迟 80ms。

推断:盘应该很快(dd 都 600M/s 了),是应用的问题。

可能推错:第一,dd 测的是顺序大块写 + 可能走 page cache(没 oflag=direct 时写进 cache 就返回,根本没落到盘);第二,SSD 有”写放大”——你写 1 个逻辑块,SSD 控制器可能搬了 3~5 个物理块(GC、磨损均衡),随机小写放大更狠;第三,云盘有”突发额度”,前几秒快、额度用尽就掉速。

调整:用 fio 测贴近真实负载的模式,而不是 dd:

fio --name=randwrite --rw=randwrite --bs=4k --numjobs=4 \
    --ioengine=libaio --direct=1 --size=4g --runtime=60 \
    --time_based --group_reporting --percentile_list=99.0:99.9

--direct=1 绕过 page cache(测真盘),--percentile_list 给出 P99/P99.9 延迟分布——这才是线上卡不卡的关键。

再观察:fio 一跑,4K 随机写 P99 延迟 60ms、IOPS 只有 3000,跟 dd 的 600M/s 完全两码事。根因是线上是大量 4K 随机写(日志、小对象),而 dd 测的是 1M 顺序写,两者天差地别。写放大让随机写实际擦写量翻几倍,盘控制器 GC 忙时延迟飙到 80ms。

改进:

  • 永远用 fio + --direct=1 + 贴近业务的 rw/bs/numjobs 测,别信 dd。
  • 关注 clat(提交到完成) 的百分位,不只看 bandwidth/iop。
  • 写放大靠”增大写入块、减少随机小写、留 OP(over-provisioning)“缓解;云盘注意突发额度耗尽后的稳态性能。

🔧 思考题:为什么 --direct=1(O_DIRECT)测出来的延迟,比不加它更”接近真实业务写盘”?它绕过的 page cache,在真实业务里到底是帮还是坑?

【参考答案】

dd 不加 oflag=direct 时,write 只是把数据拷进内核 page cache 就返回,真正的落盘由内核回写线程异步做——你测到的是”内存拷贝速度”,不是磁盘速度,所以虚高。加 --direct=1(即 O_DIRECT)绕过 cache,每次写都真正下发到块层等盘确认,测到的是端到端真实延迟,所以更能反映”线上写日志卡不卡”。

page cache 在真实业务里是双刃剑:好处是写吞吐高、读命中快;坑是 (1) 回写风暴——cache 堆满后内核一次性刷盘,造成周期性写延迟尖峰;(2) 占内存挤掉别的;(3) 你以为写成功了,故障时可能还没落盘(掉电丢 cache 数据,除非 fsync)。所以测磁盘真实能力必须绕 cache,但业务里要不要绕 cache 又是另一回事:日志类可 buffered + 定期 fsync;数据库 WAL 必须 fdatasync 保证持久。

fio 关键字段:clat=命令完成延迟(最能体现盘快慢),lat=提交到完成总延迟,slat=提交开销。看 clat 的 clat_ns 百分位。


实战补遗三:IO 调度器 none 与 mq-deadline 的取舍——多队列下软件队列深度怎么调

墨:老哥,你 NVMe 盘用哪个 IO 调度器?默认是什么?

你:记得 NVMe 用 none,机械盘用 mq-deadline?

墨:方向对,但”为什么 NVMe 用 none”以及”none 不是真的没调度”很多人讲不清。这节把块层调度器讲透,补第 8 章 IO 调度器选错那节。

现象:一台 NVMe 机器,运维照着”老服务器模板”把调度器设成 mq-deadline,结果 4K 随机写延迟比 none 高了 20%。他以为 deadline 更”有序”应该更快,反而慢了。

推断:调度器”整理队列”应该降延迟。

可能推错:调度器的价值是”在慢盘上合并/排序 IO,减少磁头寻道”。NVMe 是电子的、无寻道,且原生多队列(每个核一个硬件队列),自身并行度极高。mq-deadline 在 NVMe 上做的”排序/合并”是软件层的额外开销,反而挡在 NVMe 原生高并发前面——它要维护红黑树(read/write 队列)、做合并判断,这些对”本就能并行”的 NVMe 是纯拖累。所以 NVMe 用 none(即 none/noop 的现代版,blk-mq 下的 none 调度器)直接把 IO 透传给硬件队列,延迟最低。

调整:看 cat /sys/block/nvme0n1/queue/scheduler 显示 [none] mq-deadline kyber bfq,中括号是当前。调度器选择:

  • NVMe(SSD,无寻道):none——让硬件多队列原生并发,软件不挡路。
  • SATA HDD(机械,有寻道):mq-deadline——保证请求按 deadline 排序、避免饿死,合并相邻 IO 减寻道。
  • 混合负载/桌面:bfq(公平调度,交互流畅)、kyber(低延迟自适应)。

再观察:还有个常被忽略的——软件队列深度(nr_requests)+ 硬件队列数。NVMe 多队列下,/sys/block/nvme0n1/queue/nr_requests 是每块软件队列的深度,配合 mq-deadline 时才明显起作用;none 下 IO 直接下发,队列深度由设备 queue_depth(/sys/block/nvme0n1/device/queue_depth)决定。调 nr_requests 太大(如 256→1024)会增加设备侧排队延迟(IO 在设备里等更久才服务),调太小又喂不饱 NVMe 并行度——常见甜区 128~256。这跟”调度器选 none”配合才有低延迟。

改进:

  • NVMe 调度器设 none(echo none > .../scheduler),别用 mq-deadline/bfq(纯开销)。
  • HDD 用 mq-deadline(减少寻道、防饿死)。
  • nr_requests 配合设备 queue_depth 调,NVMe 甜区 128~256,别盲目拉大(增设备排队延迟)。
  • 监控 iostat -x 的 aqu-sz(平均队列深度)、await、svctm,判断是”队列太浅喂不饱”还是”太深等太久”。
  • 容器里盘是虚拟的(overlay/virtio),调度器在宿主 virtio 层,容器内调 scheduler 多半无效(与第 23 章容器调参找错门同理)。

🔧 思考题:为什么 HDD 上”合并相邻 IO、排序减少寻道”能降延迟,而 NVMe 上同样的操作反而增延迟?“none” 调度器是真的”什么都不做”吗?

【参考答案】

HDD 的物理瓶颈是寻道(磁头移动到柱面)+ 旋转延迟。随机 IO 时磁头反复横跳,每次寻道几毫秒,吞吐和延迟都烂。调度器(deadline/CFQ)做的”排序(elevator,按扇区顺序服务,把相邻请求合并成一次寻道覆盖)“和”合并(相邻扇区的写合成一次)“直接削减寻道次数——这是 HDD 提速的核心,软件调度开销(微秒级)远小于省下的寻道(毫秒级),怎么都值。

NVMe 没有机械寻道,延迟是电子级(微秒甚至亚微秒),且硬件本身就是多队列并行(每个 CPU 核对应一个硬件提交/完成队列,命令并行下发、并行完成)。此时软件再做”排序/合并”:

  1. 省不下寻道(本就没有);
  2. 反而引入微秒级软件开销(维护队列、合并判断、红黑树操作);
  3. 更严重的是——它把”本可并行下发”的 IO 先收进软件队列排个序,等于在 NVMe 原生高并发前加了个串行瓶颈,挡掉了并行度,延迟反而升。

所以 NVMe 用 none:把 IO 原样透传给硬件队列,让硬件自己并行,软件零干预。注意 none 不是”完全无调度”,而是”不做排序合并、直接提交”,仍在 blk-mq 框架里(多队列分发、完成中断处理都在)。相对地 mq-deadline 在 NVMe 上是”为了排序而牺牲并行”,得不偿失。

nr_requests 与 queue_depth 的关系:软件队列深度(nr_requests)决定”提交队列里能挂多少未完成的 IO”,硬件 queue_depth 是设备并行处理能力。两者配合:queue_depth 大(NVMe 常 321024)→ nr_requests 设够(128256)才能喂饱并行;但 nr_requests 远超 queue_depth 无意义,反而让 IO 在软件侧排队等窗口(增加 aqu-sz、推高 await)。所以”none 调度器 + 合理 nr_requests”是 NVMe 低延迟组合,调错调度器(如 deadline)或乱拉 nr_requests 都会把 NVMe 搞慢——这正是第 8 章”IO 调度器选错”事故的本质(在 NVMe 上套了 HDD 的模板)。

容器补充:overlay2/virtio-blk 的调度器在宿主的 virtio 层,容器内看到的 /sys/block/.../queue/scheduler 可能是 virtio 暴露的(常 none 或 mq-deadline),且受宿主 qemu 配置影响;容器内改它多数不生效或影响的是虚拟层,真正调盘在宿主侧。iostat -x 在容器内看的是虚拟设备,延迟包含 virtio 一层,要分清”盘真慢”还是”虚拟化层慢”。

下一章预告(第 09 章 中断、软中断与驱动)**:磁盘读完了要发中断通知 CPU。但中断这件事本身有个致命矛盾——它必须极快(不能耽误别的事),又必须干很多活(处理数据包/IO 完成)。内核怎么破这个矛盾?我们会拆开上半部/下半部、softirq、NAPI、ksoftirqd,看万兆网卡每秒 1000 万个包时中断风暴是怎么把 CPU 打死的,以及中断亲和性、RSS/RPS/XPS 这些旋钮在解什么问题。这一章是理解网络性能的地基。


🔧 思考题(带参考答案)

思考题 1(基础): 为什么 mq-deadline 给读请求的截止期限(500ms)比写请求(5000ms)短 10 倍?这个设计原则还能应用到哪些地方?

【参考答案】

原因:读通常是同步的(有人在等),写通常是异步的(没人在等)。

  • 读:进程调用 read(),如果 page cache 未命中,进程会进入 D 状态阻塞等待。这个等待时间直接就是用户感知到的延迟。读慢一毫秒,用户就多等一毫秒。
  • 写:进程调用 write(),数据进 page cache 后立刻返回(第 07 章那个”谎言”)。真正落盘是内核后台异步做的,此时没有任何进程在等它。写慢一点,除了脏页多攒一会儿,没人受影响。

所以内核的判断是:读的延迟是”有人在催的债”,写的延迟是”没人催的债”。资源紧张时,优先还有人催的。

(writes_starved=2 这个参数进一步表达了同样的意思:写可以被读插队最多 2 批,但不能被无限饿死——优先但不能不管。)

这个原则的其他应用场景:

  1. 你自己的 Go 服务:用户请求路径 vs 后台批处理/异步任务,不该用同一个 worker pool、同一个数据库连接池、同一个优先级。前者有人在等,后者没有。
  2. Kubernetes 的 API Priority and Fairness (APF):给交互式请求(kubectl)和后台控制器的 list-watch 分不同优先级,正是同一个思路。
  3. CPU 调度(第 05 章):CFS 天然让”刚睡醒的交互型任务”优先,也是同一个原则的不同实现。
  4. 网络 QoS:交互流量(SSH、游戏、VoIP)优先于批量流量(下载、备份)。
  5. 数据库:OLTP 查询优先于报表查询;WAL 写(有事务在等提交)优先于 checkpoint 写(后台)。
  6. 消息队列:实时消息 topic 和离线数据 topic 分离,不共享 broker 资源。

更抽象的表述:

系统设计中最有价值的分类之一,是”这个操作背后有人在同步等待吗”。 有人等 = 延迟就是体验,必须优先;没人等 = 延迟可以被交易掉,用来换吞吐。

很多性能问题的根源,恰恰是这两类工作被混在一起、共享同一个资源池。 分离它们,常常是最高性价比的架构改进。

思考题 2(指标解读): iostat -x 1 输出如下两组数据,请分别判断设备是否饱和,以及瓶颈可能在哪。

组 A:  r/s=5000  w/s=100  r_await=0.20  aqu-sz=1.0   %util=99.8
组 B:  r/s=5000  w/s=100  r_await=25.0  aqu-sz=120   %util=99.8
【参考答案】

两组的 %util 都是 99.8%,但状况完全相反。这正是 %util 无用的最好证明。

组 A:设备完全没有压力,瓶颈在”发请求的一方”

  • aqu-sz = 1.0:队列里平均只有 1 个请求 → 没有任何排队,来一个处理一个;
  • r_await = 0.20ms:每个请求都处理得极快,而且这个时间里基本没有排队成分;
  • %util = 99.8% 只是因为请求一个紧接一个连续到来,设备”总有活干”,但并行能力完全没被用上(NVMe 能同时处理几十个,现在只有 1 个)。
  • 结论:设备远未饱和。瓶颈在请求的发起方是串行的——可能是:
    • 应用逻辑上串行(读一页 → 处理 → 读下一页,如索引回表、嵌套循环);
    • 应用并发度/IO 线程数不足;
    • 单线程程序。
  • 正确的改进方向:提高并行度(更多 IO 线程/异步 IO/io_uring)、或者减少 IO 次数(加大缓存命中率)。换更快的盘收益很小(本章那个 MySQL 事故就是这个)。

组 B:设备真的饱和了,请求在大量排队

  • aqu-sz = 120:队列里平均积压 120 个请求 → 严重排队;
  • r_await = 25ms:平均等 25ms,其中绝大部分是排队时间(设备本身处理一个可能只要 0.2ms);
  • 结论:设备处理能力跟不上请求速率,真正饱和。
  • 改进方向:
    • 减少 IO(缓存、索引优化、减少全表扫描)——首选;
    • 分散 IO(多盘、分库分表);
    • 换更快/更多的设备(这种情况下换硬件才真的有用);
    • 如果是混合负载,考虑 IO 隔离(cgroup io.max/io.latency),别让批处理挤死线上。
    • 可以适当调小 nr_requests 来控制排队长度(牺牲一点吞吐换延迟可控)。

判定法则总结:

%util 高 + aqu-sz 小 + await 小  → 设备闲,瓶颈在发起方(串行/并发不足)
%util 高 + aqu-sz 大 + await 大  → 设备真饱和
%util 高 + aqu-sz 小 + await 大  → 设备本身单请求处理慢(慢盘/远端存储/网络存储)

补充:光看 iostat 还不够,最好用 biolatency 看延迟分布——组 B 的平均 25ms 背后可能是”大部分 5ms + 少数 500ms”的长尾,那又是另一个故事(可能有大 IO 队头阻塞、或者设备内部 GC)。

思考题 3(设备匹配): 一台机器上有两块盘:sda 是机械硬盘(存归档数据和备份),nvme0n1 是 NVMe(跑 MySQL)。请给出这两块盘的调度器和相关参数配置,并说明理由。另外说明怎么让配置在重启后依然生效。

【参考答案】

nvme0n1(NVMe,跑 MySQL,随机小 IO 为主):

echo none > /sys/block/nvme0n1/queue/scheduler      # 或 mq-deadline
echo 16 > /sys/block/nvme0n1/queue/read_ahead_kb    # 从默认 128 调小
echo 1024 > /sys/block/nvme0n1/queue/nr_requests    # 视需要
cat /sys/block/nvme0n1/queue/rotational             # 确认是 0

理由:

  • scheduler=none:NVMe 有高并行度(多队列)和自己的内部调度(FTL),内核排序是纯开销。如果需要一点延迟保证或读优先保护,可以用 mq-deadline;绝不用 bfq(CPU 开销在高 IOPS 下自己成瓶颈)。
  • read_ahead_kb 调小(16~32KB):MySQL OLTP 是随机小 IO(16KB 页)。默认 128KB 预读意味着每读一页 16KB,磁盘被要求读 128KB —— 8 倍带宽放大,而且预读来的数据大概率没用,还会挤占 page cache。(注意:如果用了 innodb_flush_method=O_DIRECT,预读对数据文件不生效,但对其他文件仍有影响。)
  • nr_requests:MySQL 的话可以适度调大以提升吞吐,但要观察 await;如果延迟敏感就别动默认值。
  • 确认 rotational=0:某些 RAID 卡/虚拟化层会误报,导致内核做机械盘优化。

sda(机械硬盘,归档和备份,大块顺序 IO):

echo bfq > /sys/block/sda/queue/scheduler           # 或 mq-deadline
echo 4096 > /sys/block/sda/queue/read_ahead_kb      # 调大,顺序读友好
echo 128 > /sys/block/sda/queue/nr_requests
cat /sys/block/sda/queue/rotational                 # 应该是 1

理由:

  • scheduler=bfq 或 mq-deadline:机械盘寻道极贵(3~10ms),电梯排序收益巨大,值得花 CPU。
    • 选 bfq 的理由:如果需要 ionice/io.weight 来保证备份任务不影响别人(bfq 是唯一支持权重的);
    • 选 mq-deadline 的理由:更轻量,且能防止写请求饿死读请求。
    • 服务器上通常 mq-deadline 更稳妥,bfq 更适合有明确 IO 隔离需求时。
  • read_ahead_kb 调大(2~4MB):备份/归档是大块顺序读写,大预读能把随机寻道次数降到最低,吞吐显著提升。
  • 配合 ionice:让备份进程 ionice -c 3(idle 类),这样它只在磁盘空闲时干活,不影响其他任务(需 bfq/cfq 才充分生效)。

关键点:两块盘的配置几乎完全相反。 这正是”不能抄一套参数用到所有设备”的证明——参数必须匹配设备物理特性和负载模式。

让配置重启后生效 —— 用 udev 规则(推荐):

# /etc/udev/rules.d/60-block-tuning.rules
 
# NVMe 设备:none 调度器,小预读
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", \
  ATTR{queue/scheduler}="none", ATTR{queue/read_ahead_kb}="16"
 
# 旋转介质(机械盘):mq-deadline,大预读
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", \
  ATTR{queue/scheduler}="mq-deadline", ATTR{queue/read_ahead_kb}="4096"
 
# 非旋转的 SATA SSD:mq-deadline,中等预读
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", \
  ATTR{queue/scheduler}="mq-deadline", ATTR{queue/read_ahead_kb}="128"

然后 udevadm control --reload && udevadm trigger --subsystem-match=block。

为什么用 udev 而不是 /etc/rc.local 或 sysctl?

  1. udev 能按设备类型/属性区分(通过 ATTR{queue/rotational} 等匹配),而不是硬编码设备名——设备名会变(sda 可能变成 sdb);
  2. 热插拔设备也能自动应用(ACTION=="add");
  3. 这些参数在 /sys 下,不是 sysctl 参数,不能写进 /etc/sysctl.conf —— 这是个常见错误。
  4. 内核启动参数 elevator= 在 blk-mq 时代已经废弃(会有 warning),别再用。

验证配置生效:

for d in /sys/block/{sda,nvme0n1}/queue; do
  echo "=== $d"
  echo "scheduler: $(cat $d/scheduler)"
  echo "read_ahead_kb: $(cat $d/read_ahead_kb)"
  echo "rotational: $(cat $d/rotational)"
  echo "nr_requests: $(cat $d/nr_requests)"
done

思考题 4(进阶·总账): 你运维一个混合负载的节点:上面同时跑着 ① 线上 MySQL(延迟敏感)、② 每晚的全量备份(大块顺序读写)、③ 一个日志采集 agent(持续小写入)。三者共用一块 NVMe。请设计一套 IO 层的隔离与调优方案,说明每项的权衡,并给出你要监控的指标。

【参考答案】

这题要把块层、cgroup、文件系统和上一章的 fsync 知识全串起来。

首先,识别三类负载真正在乎什么(这是所有决策的前提):

负载在乎可以牺牲
MySQLP99 延迟、缓存命中率少量吞吐
备份总完成时间(几小时内完成即可)延迟完全无所谓,可以被任意压制
日志 agent不能被饿死(丢日志=丢观测能力)单条延迟、少量丢失可接受

方案:

1. 物理隔离优先(如果预算允许,这是最优解)

  • 把备份的目标盘(或整个备份数据集)放到独立设备;至少让备份读源盘、写另一块盘。
  • 日志放独立分区/独立文件系统。
  • 理由:上一章那个事故的核心教训——fsync/journal 是按文件系统共享的,同一文件系统上的邻居会连带你。物理/文件系统隔离是唯一彻底的解法。
  • 代价:硬件成本。但对生产 MySQL 节点通常值得。
  • 如果做不到,至少让 MySQL 的 redo log(WAL) 单独放一个文件系统——它在最关键的同步路径上。

2. 调度器选择:mq-deadline(而非 none)

  • 理由:虽然 NVMe 上 none 吞吐最好,但这里是混合负载,我们需要:
    • 读优先保护(read_expire=500ms vs write_expire=5000ms):MySQL 的读是同步的、有人等的;备份的大量写是异步的。这个内置的读写不对称正好保护了 MySQL。
    • 防饿死:none 没有任何公平性,备份可以把队列塞满饿死 MySQL。
  • 代价:相比 none 损失约 5~15% 峰值 IOPS。用峰值吞吐换延迟保护,在混合负载下是对的交易。
  • 不选 bfq 的理由:NVMe 上 bfq 的 CPU 开销太大,可能自己成为瓶颈。除非你必须用 io.weight(见下面的取舍讨论)。

3. cgroup 硬限制备份(核心隔离手段)

# 假设 NVMe 是 259:0,给备份 cgroup 限速
echo "259:0 rbps=209715200 wbps=209715200" > /sys/fs/cgroup/backup/io.max   # 200MB/s
  • 理由:备份是唯一可以被任意压制的负载。给它一个硬上限,把剩余能力留给 MySQL。
  • 权衡:备份变慢(比如从 1 小时变 2.5 小时)。只要还在窗口内就完全可接受 —— 这是”把痛苦转移给最能承受痛苦的负载”这一混部原则的直接应用。
  • 配套:ionice -c 3 也加上(双保险,虽然在 mq-deadline 下效果有限)。

4. 用 io.latency 保护 MySQL(比限制别人更贴合需求)

echo "259:0 target=2000" > /sys/fs/cgroup/mysql/io.latency   # 目标 2ms
  • 理由:io.max 是”限制别人”,io.latency 是”保护我”。后者更贴合真实需求——我不在乎备份用多少,我只要我的延迟达标。当 MySQL 的延迟超过 target 时,内核会自动节流其他组。
  • 这是个重要的思路进化:从”静态限量”到”动态保 QoS”。
  • 权衡:需要 cgroup v2;备份速度变成不可预测的(取决于 MySQL 的压力)。

5. 日志 agent:保底不饿死

  • 给它一个小但有保证的 io.weight/或不设 io.max(它量本来就小);
  • 绝不把它和备份放同一个受限 cgroup;
  • 理由:第 05 章说过同样的道理——可观测性组件被饿死,等于你在系统压力最大时失去眼睛。宁可给它一点浪费的余量。
  • 额外:让 agent 用攒批写 + 低频 fsync(甚至不 fsync),避免它的小写入频繁打断 MySQL 的 IO 合并(上一章的教训)。

6. 参数层面

# NVMe 通用
echo 16 > /sys/block/nvme0n1/queue/read_ahead_kb   # MySQL 随机 IO 为主,小预读
# 但如果备份和 MySQL 共盘,这会让备份的顺序读吞吐变差 → 这就是共盘的代价之一
# 解法:让备份程序用 posix_fadvise(SEQUENTIAL) 主动要求预读,而不是全局调大
 
echo 256 > /sys/block/nvme0n1/queue/nr_requests    # 别太大,控制排队长度
echo 2 > /sys/block/nvme0n1/queue/rq_affinity      # 缓存亲和

7. MySQL 侧的配合(减少 IO 才是根本)

  • innodb_buffer_pool_size 尽可能大(减少 IO 次数 > 加速 IO,本章事故的第一教训);
  • innodb_flush_method = O_DIRECT(避免和 page cache 双重缓存);
  • innodb_flush_neighbors = 0(NVMe 上不需要”顺便刷相邻页”这个机械盘优化——又一个成本假设失效的例子);
  • innodb_io_capacity / innodb_io_capacity_max 按 NVMe 实际能力设(默认值是为机械盘定的,太保守)。

8. 备份侧的配合

  • nice -n 19 ionice -c 3 起备份;
  • 用 posix_fadvise(POSIX_FADV_DONTNEED)(或 nocache 工具)主动丢弃读过的缓存 —— 理由:备份会顺序读几百 G,把 MySQL 需要的 page cache 全冲刷掉(第 07 章讲的双 LRU 只能部分缓解)。这一项常被忽略但收益很大。
  • 限速工具:pv -L 200m / rsync --bwlimit(应用层限速,和 cgroup 双保险)。
  • 挂载 noatime(备份读几百万文件,atime 更新会产生大量元数据写)。

9. 必须监控的指标

  • 块层:aqu-sz(不是 %util!)、r_await/w_await、biolatency 的延迟直方图(尤其 P99/P999 长尾);
  • PSI:/proc/pressure/io 的 some/full;
  • cgroup:各组的 io.stat(实际用量)、被节流情况;
  • MySQL 侧:buffer pool 命中率、fsync 耗时分布、慢查询与 IO 等待的关联;
  • 备份窗口:备份期间 MySQL 的 P99 变化(这是验证隔离是否有效的唯一标准);
  • 日志 agent:是否有丢失/积压(证明它没被饿死)。

10. 验证方法(不能只配不验)

  • 在备份窗口内外分别采集 MySQL 的 P99,对比;
  • 做一次故意的压力测试:手动跑一次全速备份,看 MySQL 延迟劣化多少 → 这告诉你隔离方案的真实强度;
  • 理由:所有隔离配置都是权衡,权衡是否奏效只能靠对照数据,不能靠”我配了所以应该没问题”。

串起来的一句话:

混合 IO 负载的隔离,核心是四步——① 识别每类负载真正在乎什么(延迟/吞吐/不被饿死);② 优先物理隔离关键路径(WAL/redo log,因为 fsync 和 journal 是共享的);③ 把不可避免的压力精准转移给最能承受的那一类(压制备份);④ 用”保护高优”(io.latency)替代”限制所有人”(io.max),并且一切以对照数据验证。

而所有 IO 优化的第一优先级,永远是减少 IO 次数(缓存、索引)—— 那比任何调度器和参数的收益都大一个数量级。