Li Sheng | Backend / Distributed Storage Engineer Li Sheng | Backend / Distributed Storage Engineer
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
  • Go语言

  • C++

  • 算法题

  • 存储系统

    • 导航

    • 单机IO基础

    • IO语义与持久化

    • 块层与高速路径

      • 块层与I-O调度总览
      • blk-mq多队列模型
      • 高速存储路径总览
    • Ceph

    • DAOS

    • 归档

  • CephFS

  • 分布式系统

  • 计算机网络

  • Redis与缓存

  • Kubernetes

  • 技术笔记
  • 存储系统
  • 块层与高速路径
lisheng
2026-04-27

块层与I-O调度总览

# 块层与I-O调度总览

如果说前面的 Page Cache、写回、fsync 和文件系统日志,讨论的主要还是“上层修改如何形成内核状态、何时被承认为提交、崩溃后如何重新解释”,那么块层开始处理的就是另一个问题:这些已经决定要向下推进的修改,到了真正接近设备的位置,究竟要怎样被组织、排队、合并、限流和下发,才能既让设备高效工作,又不把整个系统拖进不可控的抖动里。也就是说,块层并不再直接回答“这次写在语义上算不算成功”,而是接手“这次写一旦要变成真实 I/O,请求路径该怎样长成一个设备愿意执行的形状”。从这一层开始,数据不再主要表现为缓存页、脏页或事务记录,而开始表现为逻辑块访问、请求队列深度、合并机会、调度策略和硬件并发能力。

把一条普通写路径沿着前文继续往下接,会更容易看清块层的位置。应用的 write 先把修改留在 Page Cache 中,写回机制在某些条件触发时把脏页推出去,文件系统再把这些页对应的逻辑修改翻译成更底层的块访问意图。到了这一步,系统面对的已经不再是“一个文件被改了”,而是“某些逻辑块需要读、写或刷出,某些请求可能相邻,某些请求存在顺序要求,某些请求虽然来自不同文件却会竞争同一设备队列”。块层正是在这里介入。它位于文件系统和设备驱动之间,既看不到上层业务语义,也还没真正进入硬件控制器内部,但它负责把上层提出的块 I/O 意图变成一组可执行、可排队、可调度、可观测的设备请求。这个位置决定了它天然是“语义已经被折叠、性能问题开始变得具体”的边界层。

块层存在的根本原因,是上层生成请求的方式与设备擅长处理请求的方式往往并不一样。应用和文件系统产生的 I/O 很可能是零碎、突发、交错、局部有序但全局混杂的,而设备更偏好某种更可控的节奏。机械盘时代,这种不匹配首先表现为寻道成本巨大,若请求顺序混乱,磁头就会把大量时间浪费在移动上;SSD 时代,机械寻道消失了,但请求尺寸、并发度、内部擦写放大和控制器队列利用率又成了新的主导因素;到了 NVMe 时代,设备本身已经具备很强的并发执行能力,软件路径反而可能先成为瓶颈。这意味着块层的工作从来不只是“排排队”,而是在不同硬件时代不断重写同一个基本任务:把上游世界里自然产生的 I/O 形状,转译成下游设备最容易高效兑现的 I/O 形状。

如果从状态变化角度看,块层接手的是一种已经脱离文件语义的请求流。文件系统提交下来的不再是“这个目录项要改”或“那个 inode 要落盘”,而是若干针对逻辑块地址区间的访问意图。这里首先发生的一件事,就是抽象层次的下降。上层还在按文件、页、事务讨论问题,块层开始按扇区范围、读写方向、同步属性、屏障要求和队列位置讨论问题。也因此,块层特别擅长处理那些与“内容是什么”无关、而与“访问模式长什么样”高度相关的问题。两个来自不同文件的写请求,只要最终落在相邻块范围内,就有可能被合并;一个来自 fsync 的同步写与一个后台写回的异步写,即便最终写向相近区域,也可能因为时序语义不同而被区别对待。块层并不理解业务含义,但它对请求形状、边界和约束非常敏感。

这里最值得建立的第一个模型,是“请求组织”而不是“请求转发”。很多人直觉上以为块层只是把文件系统交下来的 I/O 原样递给驱动,实际上真正有价值的部分恰恰发生在中间这段组织过程里。请求可能被拆分,因为单次 I/O 太大、不满足设备限制,或者跨越了不适合一次处理的边界;请求也可能被合并,因为多个相邻访问如果打包下发,会减少软件管理成本、降低设备命令开销,并改善顺序性。除此之外,请求还会进入某种队列结构,被赋予先后顺序、接受调度器的排序和挑选,并在适当时机下发到底层驱动。于是一个来自上层的“我要写出这些脏页”的动作,在块层里会被展开成一连串更细粒度的过程:形成 bio,决定能否 merge,进入 request queue,可能被重排、延迟、限流,最后才真正离开内核软件栈走向设备。

而一旦请求开始排队,第二个模型就出现了:块层不仅在组织 I/O,也在管理竞争。因为设备是共享资源,同一时刻可能同时承接文件系统回写、日志提交、预读、用户态直接 I/O、交换分区 I/O 乃至多个进程和 cgroup 的并发访问。所有这些请求若完全无约束地一起冲向设备,结果通常并不是“更快”,而是队列深度失控、延迟不可预测、局部热点被放大、尾延迟显著恶化,甚至反过来拖累写回和内存回收。于是块层要做的就不只是找机会合并请求,还要决定哪些请求先走、哪些请求后走、队列应该压多深、是否需要给某些请求更强的时序保障、是否要避免某类后台流量把前台同步请求淹没。I/O 调度器因此本质上是在做资源竞争治理,而不仅是在做顺序排序。

这也解释了为什么理解 I/O 调度器时,盯着算法名字往往不如盯着它试图平衡的目标有用。调度器真正面对的是几组经常彼此冲突的诉求。吞吐希望尽量合并并批量下发,以减少单位请求的固定成本;低延迟希望请求不要在队列里等待太久;公平性希望某个进程或某类工作负载不要长期饿死;尾延迟控制希望后台大流量不要把少量关键同步请求埋掉;而设备适配又要求软件策略不要和硬件特性相互抵消。在 HDD 时代,这些目标里的“顺序性”和“减少寻道”权重极高,因此很多策略会愿意为整体吞吐牺牲部分单请求即时性;到了 SSD,随机访问代价下降,策略重心开始更多转向并发利用、写放大抑制和软件排队成本;到了 NVMe,多队列和深并发成为常态,软件若继续用为机械盘设计的重型串行调度方式,反而可能先把 CPU 和锁竞争耗光。于是同样叫做“调度”,在不同硬件背景下,实际在调的东西已经发生了迁移。

从系统行为上看,块层还是前面几篇中那些“后台状态推进”最终显形的地方。Page Cache 里积累的脏页,不会永远只是内存里的标记,它们一旦被回写,就会变成块层队列里的真实压力;文件系统日志中的提交与检查点,也不会永远停留在元数据结构层面,它们同样需要在块层里占据队列、争抢下发机会;fsync 所要求的同步推进,也必须最终体现为某些请求尽快穿过块层并配合下游 flush 语义完成。因此,很多看起来像“存储慢了”的现象,真正开始可观测时往往已经是在块层。应用可能只看到 write 卡顿、fsync 抖动或 tail latency 上升,但底层常见的现实是:请求合并效果变差了、队列堆深了、后台回写压上来了、日志提交与普通数据写争抢设备了,或者某种调度策略不再适配当前设备与工作负载的组合了。

这里第三个很重要的模型,是“反压如何向上游传播”。块层如果只是一个单向出口,理解起来会简单得多;但真实系统里,它还是一个压力反馈点。设备处理不过来,请求会在块层排队;队列排得过深,等待时间上升;等待时间上升,fsync 和同步写首先变得难看;后台写回若迟迟清不动,脏页会继续积累;脏页积累到阈值后,内核会开始限制新的写入,甚至把应用线程卷入回写路径;内存回收又可能因为脏页太多而更加艰难。于是从设备拥塞到应用卡顿,中间并不是一跳完成,而是沿着“块层队列 -> 写回效率 -> 脏页压力 -> 内存压力 -> 上层写入受阻”这一条链层层传导。只看上层现象时,这些问题容易被误判成“磁盘突然很慢”或“程序写文件卡住”;放回块层模型里看,就会发现它本质上是一个请求排队与资源反馈失衡的问题。

异常路径和边界条件在块层同样重要,只是表达方式和前几篇不同。到了这里,讨论的重点不再是日志事务是否完整,而是某类请求是否具有更强的顺序约束、是否必须在普通写之前或之后被观察到、是否需要配合 flush、barrier 或 FUA 才能满足上层语义。对块层来说,这些约束意味着某些请求并不能被随意重排、合并或延迟。也就是说,块层虽然主要服务性能,但它并不是一个可以无视语义的纯优化层。文件系统、日志系统和同步接口从上游带下来的那些“这一步必须先完成”或“这一步完成之后才能宣称提交”的要求,会在这里转化成请求属性和下发顺序约束。块层做得再聪明,也必须在这些边界之内调度;一旦把不该打乱的顺序打乱,前面那些关于提交边界和崩溃恢复的承诺就会被直接破坏。

也因此,今天看块层,真正值得关心的常常不是“哪个调度器名字更高级”,而是当前瓶颈究竟落在哪一层。如果设备本身很慢,调度器和合并策略往往仍然具有很强影响;如果设备已经很快,软件路径上的锁、上下文切换、缓存抖动和队列映射可能更重要;如果工作负载以同步小写为主,那么尾延迟和 flush 路径可能比峰值吞吐更关键;如果工作负载以后台批量回写为主,那么队列深度利用与合并效率可能更重要。工程分析里最常见的误区,就是一听到“块层”就立刻开始背算法或调参数,却没有先回答几个更基础的问题:请求到底是怎样到达这里的,当前堆积的是哪一类请求,它们是前台同步流量还是后台整理流量,设备是否已经吃满,软件栈是否先耗尽了 CPU,队列映射是否合理。离开这些上下文,单看调度器名字几乎没有意义。

如果把这一篇压缩成一个统一模型,我会把块层看成“把上游已经决定要执行的修改,翻译成设备可执行请求并治理其竞争关系的那一层”。它做的第一件事是改变请求形状,把文件系统世界里的页和事务变成逻辑块访问;第二件事是改变执行节奏,把零散请求组织成可排队、可合并、可调度的流;第三件事是把设备压力反向传回系统,让写回、内存和应用延迟都受到下游现实约束。只要抓住这三个动作,后面再去看 bio 与 request 的区别、I/O 调度器演化、blk-mq 多队列模型、以及 NVMe 时代为什么软件路径本身会成为瓶颈,都会自然很多。

因此,这篇文章在这组笔记中的作用,是把前面几篇里那些已经确定要落到设备上的状态,正式接到“设备前的排队世界”中去。前面的 文件系统日志与崩溃恢复 解释了系统崩溃后怎样重新认定哪些状态算数,后面的 blk-mq多队列模型 则会进一步回答,在单队列块层已经不适配高并发 SSD/NVMe 的背景下,请求队列本身为什么也必须演化成新的结构。继续往下拆时,最自然的分支就是 bio 与 request 的职责边界、调度器演化逻辑、块层观测工具,以及写回压力是怎样一步步传导到设备队列上的。

Edit (opens new window)
Last Updated: 2026/04/27, 14:16:46
文件系统日志与崩溃恢复
blk-mq多队列模型

← 文件系统日志与崩溃恢复 blk-mq多队列模型→

最近更新
01
待完成专题池
04-28
02
待完成专题池
04-28
03
为什么 Kubernetes CSI 插件架构要拆成 Controller、Node 与 Sidecar
04-28
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式