1. 块层到底在管什么:从一次磁盘告警说起
前阵子帮朋友排查一台文件服务器的性能问题,现象很典型:iostat里%util长期贴着 100%,但吞吐量只有理论值的零头,await高得离谱。第一反应是盘不行了,换盘、换线、换背板,折腾一圈没变化。最后用blktrace抓了一段 trace 才看明白,问题根本不在硬件,而在块层——上层应用发下来的是大量 4K 随机写,而底层设备是机械盘,块层的合并和调度策略没调对,请求在队列里排成了长龙。
这件事让我重新把块层(Block Layer)的架构从头捋了一遍。很多人做存储、做数据库、做虚拟化,天天跟块设备打交道,但对中间这一层的演进其实只有一个模糊印象:知道有 BIO、有 request、有mq-deadline、有blk-mq,但说不清它们之间怎么串起来,更说不清为什么 Linux 要花十几年把这一层反复重构。
这篇就围绕Linux 块设备之 Block Layer 层架构演变这个主题,把我自己踩过的坑、读过的代码、做过的压测整理出来。内容会覆盖从单队列到多队列的演进逻辑、BIO 与 request 的分层设计、plug 机制、调度器的取舍,以及blk-mq之后软件队列和硬件队列怎么协作。适合做存储栈、内核、数据库调优、虚拟化这块的同行参考,也适合刚接触块设备、想搞明白"一个 write 到底怎么落到盘上"的朋友。
先给个全局印象:块层是文件系统和具体块设备驱动之间的一层抽象。往上,它要接住文件系统、直接 IO、页缓存回写发下来的 IO;往下,它要把这些 IO 整理、合并、排序,再交给驱动。它的核心矛盾从来没变过——上层希望 IO 越简单越好,下层希望 IO 越整齐越好,块层就是在这两者之间做翻译和调度。理解了这条主线,后面所有的架构变化都能对上号。
2. 从单队列到多队列:块层演进的主线逻辑
2.1 早期单队列模型为什么够用,又为什么不够用
最早的块层设计非常直观:整个块设备只有一个请求队列(request queue),所有 CPU 发下来的 IO 都往这个队列里塞,然后由一个调度器决定顺序,最后逐个下发给驱动。这个模型在机械盘时代是合理的,因为机械盘本身就是串行设备——磁头只有一个,你并发再多,物理上也只能一个一个寻道。单队列反而帮了大忙,它把随机 IO 排成近似顺序 IO,减少了寻道次数。
但问题随着两件事爆发了。第一是SSD 普及,固态盘内部是多通道并行的,能同时处理几十甚至上百个请求,单队列成了瓶颈,队列锁竞争严重,CPU 大量时间花在抢queue_lock上。第二是多核服务器,几十上百个核同时发 IO,全挤到一个队列上,锁争用直接把扩展性打死。我实测过一台 64 核机器跑 fio 随机读,单队列模式下核数加到 16 以后 IOPS 基本不涨了,perf一看全耗在__blk_run_queue的自旋锁上。
所以单队列的根本矛盾是:队列是全局共享资源,而现代硬件和 CPU 都是并行的。这个矛盾不解决,块层就永远是扩展性天花板。
2.2 blk-mq 的核心思想:每个 CPU 一个软件队列
blk-mq(multi-queue block layer)的解法很干脆:既然一个队列抢得厉害,那就拆成多个。它为每个 CPU(或每组 CPU)分配一个软件队列(software queue),每个软件队列有自己的锁,CPU 往自己的队列里塞 IO,几乎无竞争。然后这些软件队列再映射到设备真实的硬件队列(hardware queue)上,由驱动去消费。
这里有个关键设计点值得说清楚:软件队列的数量和硬件队列的数量是解耦的。软件队列通常按 CPU 数量来(nr_hw_queues和nr_sw_queues是两回事),硬件队列数量由设备决定,比如 NVMe 盘可能支持几十个队列,每个队列对应一个提交/完成通道。映射关系由块层维护,一个硬件队列可能对应多个软件队列。
这种设计带来的直接好处是:提交路径上的锁竞争基本消失。每个 CPU 操作自己的软件队列,只有在往硬件队列 flush 的时候才需要协调。我在同一台 64 核机器上再测,blk-mq模式下 IOPS 能随核数线性涨到 32 核左右才趋缓,提升非常明显。
2.3 演进时间线:几个关键节点
把演进过程按时间捋一下,方便建立坐标系:
| 阶段 | 关键特征 | 解决的问题 | 遗留问题 |
|---|---|---|---|
| 单队列时代 | 全局 request queue + IO 调度器 | 机械盘寻道优化 | 多核扩展性差 |
| 过渡期 | per-CPU plug + 批量下发 | 减少队列锁持有时间 | 队列本身仍是瓶颈 |
| blk-mq 引入 | 软件队列 + 硬件队列分离 | 锁竞争、多核扩展 | 调度器需重写 |
| blk-mq 调度器 | mq-deadline/bfq/kyber | 多队列下的 IO 排序 | 复杂度上升 |
| 持续优化 | tag 机制、IO polling | 降低延迟、提升吞吐 | 调优门槛变高 |
这张表不是学术分期,是我自己理解时用的一个粗框架。真正写代码的时候你会发现,很多机制是叠加存在的,比如 plug 机制在blk-mq时代依然在用,只是实现变了。
3. BIO 与 request:块层的两级抽象怎么配合
3.1 BIO 是什么,为什么需要它
BIO(Block IO)是块层最上层的抽象,可以理解成"一个 IO 请求的描述符"。文件系统要读一个页,或者应用做直接 IO,最终都会构造一个 BIO,里面记录了目标设备、起始扇区、长度、方向(读/写)、以及数据页的地址。
为什么要有 BIO 这一层?因为上层发下来的 IO 形态是碎片化的。文件系统可能一次提交几十个不连续的页,直接 IO 可能一次几 MB 但跨了不连续的扇区。BIO 把这些描述清楚,但不做合并、不做排序,它只是一个忠实的搬运工。BIO 提交后进入块层,块层再决定怎么处理。
这里有个容易混淆的点:BIO 不等于一个物理 IO。一个 BIO 可能被合并进已有的 request,也可能被拆成多个 request。BIO 是"逻辑请求",request 才是"物理请求"。
3.2 request 的诞生:合并与拆分
BIO 进入块层后,会尝试合并到已有的 request 里。合并分两种:
- 前向合并:新 BIO 的结束扇区正好接上某个 request 的起始扇区
- 后向合并:新 BIO 的起始扇区正好接上某个 request 的结束扇区
合并成功的话,多个 BIO 就变成一个更大的 request,下发给驱动时就是一次更大的 IO,效率更高。合并失败就新建一个 request。如果 BIO 太大,超过设备能处理的最大请求大小(max_sectors_kb),还要拆成多个 request。
我踩过一个坑:有次调一个自研块设备驱动,发现大块顺序写性能特别差,抓 trace 发现每个 BIO 都被拆成了 4K 的小 request。查了半天是驱动里max_sectors设得太小,块层只能按这个上限拆。把max_sectors_kb调大之后,顺序写吞吐直接翻倍。这个参数在/sys/block/<dev>/queue/下面能看能改,调优时值得先确认一下。
3.3 plug 机制:批量下发的关键
plug机制是块层里一个很巧妙的设计,理解它对理解整个提交路径很关键。简单说,plug 就是"先攒一攒再发"。当上层开始发 IO 时,块层先把请求攒在 per-CPU 的 plug 链表里,不急着往硬件队列送;等攒到一定量,或者显式 unplug 时,再一次性批量下发。
为什么要这么做?因为每次往硬件队列送请求都有开销(拿锁、更新 tag、通知驱动)。如果来一个发一个,开销占比很高。批量下发能把这部分开销摊薄。而且批量下发时,块层还能做一次全局的合并和排序,效果比单个处理好得多。
plug 的触发 unplug 的时机有几个:plug 链表满了、显式调用blk_finish_plug、或者调度器判断该 flush 了。文件系统在提交一批 IO 时通常会显式 plug/unplug 包起来,这也是为什么文件系统的批量写往往比裸直接 IO 更高效的原因之一。
提示:如果你在做性能分析,看到 IO 下发有明显的"一阵一阵"的批量特征,大概率就是 plug 机制在起作用,这是正常现象,不是抖动。
4. IO 调度器:多队列时代的选择与取舍
4.1 调度器到底在调度什么
IO 调度器的职责,是在请求下发给驱动之前,决定先发哪个、后发哪个、要不要合并。它的目标通常有三个,而且互相冲突:
- 吞吐:尽量合并成大 IO,减少寻道或通道切换
- 延迟:让每个请求尽快被处理,别饿死
- 公平:多个进程竞争时,按权重分配带宽
机械盘时代,调度器主要优化寻道,所以有了经典的电梯算法(elevator)。SSD 时代,寻道不再是问题,调度器的重点转向了延迟控制和公平性。
4.2 主流调度器对比
现在内核里常见的几个调度器,我按实际使用体验整理一下:
| 调度器 | 适用场景 | 核心机制 | 我的使用建议 |
|---|---|---|---|
| mq-deadline | 通用、单盘 | 读优先 + 截止时间 | 默认首选,机械盘和 SSD 都稳 |
| kyber | 低延迟设备 | 按延迟目标限流 | NVMe 上表现好,配置简单 |
| bfq | 桌面、多任务 | 按进程权重公平调度 | 桌面交互场景体验好,服务器慎用 |
| none | 高性能 SSD | 不排序,直接下发 | NVMe 高并发场景延迟最低 |
none这个调度器名字容易让人误解,以为它什么都不做。其实它只是不做排序,但合并和 tag 管理还是有的。在 NVMe 这种本身并行度极高的设备上,none往往是最优解,因为任何排序都是多余开销。我做过对比测试,同一块 NVMe 盘,none比mq-deadline在 4K 随机读上延迟低 15% 左右。
4.3 调度器怎么选:一个实操判断流程
选调度器不用纠结,按这个流程走基本不会错:
- 先看设备类型。NVMe 或高性能 SSD,直接上
none或kyber - 机械盘或混合场景,用
mq-deadline - 桌面环境、多任务交互明显卡顿,试
bfq - 拿不准就用
mq-deadline,它是兼容性最好的
切换方法很简单:
# 查看当前调度器 cat /sys/block/nvme0n1/queue/scheduler # 切换为 none echo none > /sys/block/nvme0n1/queue/scheduler注意:切换调度器会短暂影响在途 IO,生产环境建议在低峰期操作,并且先在小流量设备上验证。
5. 软件队列与硬件队列的协作细节
5.1 tag 机制:硬件队列的"号码牌"
硬件队列的深度是有限的,比如 NVMe 队列深度可能是 1024。块层怎么知道某个硬件队列还有没有空位?靠的就是tag。每个硬件队列维护一组 tag,提交请求前先申请一个 tag,拿到才能发;请求完成后释放 tag。
tag 机制解决了两个问题:一是流控,队列满了就等待,不会把驱动压垮;二是完成匹配,驱动完成 IO 后,通过 tag 就能找到对应的 request,不用额外维护映射表。
blk-mq的 tag 分配是 per 硬件队列的,而且做了无锁优化,高并发下开销很小。这也是blk-mq相比单队列的一个重要改进点。
5.2 提交路径的完整走位
把一次写 IO 的完整路径串一遍,方便建立整体认知:
- 应用调用
write(),进入文件系统或直接 IO 路径 - 构造 BIO,提交到块层
- BIO 尝试合并进当前 CPU 的 plug 链表里的 request
- plug 触发 unplug,request 进入软件队列
- 调度器从软件队列取出 request,申请硬件队列 tag
- request 下发到硬件队列,驱动消费
- 驱动完成 IO,通过 tag 回调块层,释放资源
这条路径里,软件队列到硬件队列的映射是blk-mq的核心。一个软件队列可能对应多个硬件队列,块层会根据负载做分发。这个分发策略对性能影响不小,尤其是在多 NUMA 节点机器上,跨节点的队列访问会带来额外延迟。
5.3 NUMA 感知:容易被忽略的优化点
在多路服务器上,块层的队列映射是 NUMA 感知的。软件队列会尽量映射到本地 NUMA 节点的硬件队列,减少跨节点内存访问。这个优化在普通机器上感知不到,但在多路大内存机器上,配置不当会导致明显的延迟抖动。
我遇到过一次:一台双路机器,NVMe 盘插在 CPU0 的 PCIe 槽上,但业务主要跑在 CPU1 上,结果 IO 延迟比预期高不少。后来通过绑核和调整中断亲和性,把 IO 相关的中断和处理都收敛到 CPU0 附近,延迟才降下来。这类问题在块层本身看不出来,得结合 NUMA 拓扑一起分析。
6. 常见问题与排查技巧实录
6.1 块层问题排查速查表
| 现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| IOPS 上不去 | 队列锁竞争 | perf 看自旋锁 | 确认是否启用 blk-mq |
| 延迟高且抖动 | 调度器不合适 | 对比不同调度器 | 换 none 或 kyber |
| 大块写性能差 | max_sectors 太小 | 看 queue 参数 | 调大 max_sectors_kb |
| 队列长期满 | 硬件队列深度不足 | 看 tag 使用率 | 换更高队列深度设备 |
| 多核扩展差 | 中断亲和性差 | 看 /proc/interrupts | 调整中断绑核 |
6.2 几个我踩过的坑
坑一:盲目相信默认调度器。内核默认调度器是跟着发行版走的,不一定适合你的负载。有次在一台全 NVMe 的机器上,默认是mq-deadline,换成none之后随机读 IOPS 涨了 20%。默认值只是"不会错",不是"最优"。
坑二:忽略 plug 的影响。做延迟测试时,如果测试工具本身用了 plug 批量提交,测出来的延迟是"批量延迟",不是单 IO 延迟。要测真实单 IO 延迟,得用O_DIRECT加单线程同步写,或者用 fio 的iodepth=1模式。
坑三:把块层问题和驱动问题混为一谈。块层负责调度和合并,驱动负责实际硬件操作。如果 trace 显示 request 已经下发到驱动但迟迟不完成,问题在驱动或硬件;如果 request 在软件队列里排队很久,问题在调度器或队列深度。分清楚这两段,排查效率会高很多。
6.3 blktrace 使用要点
blktrace是块层排查的利器,但用起来有几个要点:
# 抓取指定设备的 trace,输出到当前目录 blktrace -d /dev/nvme0n1 -o trace # 解析成可读格式 blkparse -i trace -o trace.txt # 只看关键事件 blkparse -i trace -f "%T %a %d %s %n"看 trace 时重点关注几个事件:Q(请求入队)、G(获取 request)、I(插入调度器)、D(下发驱动)、C(完成)。从Q到D的时间是块层内部耗时,从D到C是驱动和硬件耗时。这两段一分开,问题定位就清晰了。
提示:blktrace 本身有开销,高 IOPS 场景下可能影响被测系统,建议短时间抓取,或者用硬件 trace 方案。
7. 架构演变带来的实际影响与调优建议
7.1 对上层应用的影响
块层从单队列演进到blk-mq,对上层应用最直接的影响是并发模型变了。单队列时代,应用开太多并发线程反而会因为锁竞争降低性能;blk-mq时代,并发度可以放心提上去,直到打满硬件队列深度。所以老一代的"IO 线程数不要超过核数"的经验,在blk-mq下要重新评估。
数据库这块感受特别明显。以前调 MySQL 的innodb_io_capacity,要小心翼翼,怕并发太高把块层锁打爆。现在 NVMe +blk-mq环境下,这个值可以设得激进很多,因为块层能扛住。
7.2 对虚拟化的影响
虚拟化场景下,块层要处理的是"虚拟块设备"到"物理块设备"的映射。virtio-blk和virtio-scsi这些前端驱动,最终都要经过宿主机块层。blk-mq之后,virtio 也做了多队列支持,每个 vCPU 对应一个 virtqueue,减少了虚拟化层的锁竞争。如果你在跑虚拟机集群,确认一下 virtio 多队列有没有开,对 IO 性能影响很大。
7.3 调优的几个实用建议
最后给几条我自己验证过的调优建议:
- 先确认队列模型:
ls /sys/block/<dev>/mq/有内容说明是blk-mq,没有就是老单队列 - 调度器按设备选:NVMe 用
none,机械盘用mq-deadline,别一刀切 - 队列深度要匹配:
nr_requests和硬件队列深度要协调,太小浪费硬件能力,太大增加延迟 - 中断亲和性别忽略:多核机器上把块设备中断绑到固定核,减少 cache 抖动
- 压测要分层:先测裸设备,再测文件系统,最后测应用,一层层定位瓶颈
块层这十几年的演变,本质上是在追着硬件跑。硬件从串行变并行,块层就从单队列变多队列;硬件延迟从毫秒降到微秒,块层就引入 polling 和更轻量的调度。理解这条主线,比记住具体参数更重要,因为参数会变,但"块层在翻译上下层需求"这个本质不会变。我自己每次遇到新的存储设备或新的性能问题,都是回到这条主线上重新推一遍,基本都能找到方向。