1. 从一次IO延迟抖动说起:为什么需要理解块层架构
几年前我在一个存储性能优化的项目里遇到过一个很典型的问题:同一套NVMe SSD,在4K随机写场景下,fio跑出来的IOPS始终比厂商标称值低30%左右,而且延迟分布的P99.9尾部抖动特别大。当时第一反应是怀疑文件系统,换了ext4、xfs、btrfs都试了一遍,差异不大;又怀疑是应用层的IO调度策略问题,调了io_submit的批量大小,收效甚微。最后用blktrace抓了一整条IO路径的trace,才发现问题出在块层的请求合并和调度队列上——多队列模式下队列深度分配不合理,导致部分CPU上的软件队列长期排队,而另一些队列几乎空闲。
这件事让我意识到一个事实:很多人做存储性能优化,眼睛只盯着文件系统和应用层,却忽略了中间那个承上启下的块层(Block Layer)。文件系统负责把文件抽象成逻辑块,应用层负责发起IO,但真正决定一个IO怎么被拆分、怎么被合并、怎么被排队、怎么被派发到硬件队列的,全是块层在干活。你不理解块层的架构,很多性能现象根本解释不了。
Linux块层这些年经历了非常大的架构演变。从早期单队列的请求队列模型,到后来引入blk-mq多队列框架,再到如今bio层的不断精简和io_uring的深度整合,整个块层的设计哲学发生了根本性变化。这些变化不是凭空来的,每一个都是为了解决特定历史阶段的性能瓶颈。我打算把这条演变脉络梳理清楚,同时把每个阶段的核心数据结构、关键流程、以及实际调优中容易踩的坑都讲透。
这篇文章适合两类人看:一类是做存储性能调优的工程师,你需要知道IO从提交到落盘到底经过了哪些环节,每个环节有哪些可调参数;另一类是想深入理解Linux IO栈的内核开发者或系统爱好者,你需要搞清楚blk-mq为什么这么设计,bio和request的关系到底是什么。我会尽量用生活化的类比把复杂机制讲明白,同时给出可以直接上手验证的操作方法。
2. 单队列时代的块层:一个请求队列打天下
2.1 早期块层的核心数据结构长什么样
要理解演变,得先看清楚起点。在blk-mq出现之前,Linux块层的核心模型非常直观:一个块设备对应一个request_queue,所有IO请求都往这一个队列里塞。这个request_queue里挂着一个电梯调度器(elevator),负责对请求进行排序和合并,然后逐个派发给底层驱动。
那个年代的核心数据结构链路是这样的:应用发起IO后,内核先构造一个bio(block IO的缩写),bio描述的是"我要读写哪段数据、从哪个设备的哪个扇区开始、多长"。bio本身不关心调度,它只是数据的搬运工。然后块层会把bio提交到request_queue,通过一个叫blk_plug的机制做初步的合并,把相邻的bio合并成一个request。request才是调度器真正管理的单位,它可能包含多个bio。
这个模型在机械硬盘时代是完全够用的。机械盘本身寻道时间就是毫秒级,IOPS撑死也就一两百,单队列完全不会成为瓶颈。调度器有充足的时间做合并和排序,把随机IO整理成顺序IO,减少磁头来回移动。那时候最经典的调度算法就是CFQ(完全公平队列),它试图给每个进程分配公平的IO带宽,思路和CPU的CFS调度器如出一辙。
但问题在于,这个模型有一个致命的单点:request_queue上有一把queue_lock自旋锁。所有CPU核心提交IO、所有完成中断处理,都要去抢这把锁。在机械盘时代这不是问题,因为IO本来就慢,锁竞争不激烈。可到了SSD时代,尤其是NVMe SSD,单盘就能跑出几十万甚至上百万IOPS,多个CPU核心同时提交IO,这把锁瞬间就成了性能杀手。
2.2 单队列模型在高速存储下的三个硬伤
我总结下来,单队列架构在高性能存储场景下有三个方面的问题,而且这三个问题是层层递进的。
第一个是锁竞争。前面说的queue_lock,在16核、32核的服务器上,光是抢锁的开销就能吃掉大量CPU周期。更糟糕的是,抢锁失败会导致CPU空转,这些空转的CPU本来可以用来处理其他任务。我实测过一个场景,在24核机器上用单队列模式跑4K随机读,光是锁竞争导致的CPU sys占比就超过了40%。
第二个是缓存行 bouncing。request_queue和里面的调度器数据结构会被多个CPU频繁访问和修改,这些数据在不同CPU的cache之间来回弹跳,导致cache命中率急剧下降。这个问题比锁竞争更隐蔽,因为它不会直接体现在锁的等待时间上,而是表现为整体吞吐上不去。
第三个是中断亲和性差。单队列模式下,所有IO完成中断往往集中在一个或少数几个CPU上处理,这些CPU既要处理中断上半部,又要跑软中断做后续处理,很容易成为瓶颈。而其他CPU明明有空闲,却帮不上忙。
这里有个常见的误解:很多人以为单队列的问题只是"队列深度不够"。其实队列深度只是表象,真正的根因是所有CPU共享同一个队列结构,导致无法做per-CPU的扩展。哪怕你把队列深度调到很大,锁竞争和cache bouncing依然存在。
2.3 那个时代的调优手段和它的天花板
在blk-mq普及之前,针对单队列的调优手段其实很有限。常见的有这么几种:调整调度器类型(比如从CFQ换成deadline或noop),调整队列深度(nr_requests),调整read_ahead_kb预读大小,还有就是通过cgroup做IO限流。
这些手段我都试过,效果有,但都是"螺蛳壳里做道场"。比如把调度器换成noop,确实能减少调度开销,但代价是失去了合并和排序能力,在混合读写场景下反而更差。调大nr_requests能缓解排队,但队列本身还是那一个,锁竞争一点没少。
真正让我意识到单队列天花板的是这样一次测试:在一台双路服务器上,我用fio开32个job做4K随机读,单队列模式下IOPS卡在80万左右上不去了,CPU的sys占比却高达50%以上。后来换成支持多队列的内核,同样的硬件,IOPS直接冲到200万以上,sys占比降到15%以下。这个对比太震撼了,也让我彻底理解了为什么块层必须做多队列改造。
3. blk-mq的引入:把一把锁拆成N把锁
3.1 多队列的核心思想:软件队列与硬件队列的映射
blk-mq(block multi-queue)的设计思想其实不复杂,用一句话概括就是:把原来那一个全局请求队列,拆成per-CPU的软件队列,再映射到硬件队列上。这样每个CPU核心操作自己的软件队列,锁竞争基本消失,cache局部性也好了。
具体来说,blk-mq引入了两个关键概念:software queue(软件队列)和hardware queue(硬件队列)。软件队列的数量通常等于CPU核心数,每个CPU有自己的软件队列,提交IO时优先往本CPU的软件队列里放。硬件队列的数量取决于设备本身的能力,比如NVMe SSD通常支持多个提交队列和完成队列,数量可能从几个到上百个不等。
软件队列到硬件队列的映射关系是可以配置的。默认情况下,多个软件队列会映射到同一个硬件队列,映射策略尽量保证负载均衡。这个映射关系在/sys/block/<dev>/mq/目录下可以看到,每个硬件队列对应一个子目录,里面有该队列的深度、调度器等信息。
这种设计带来的好处是立竿见影的。首先是锁的粒度从一把全局锁变成了每个软件队列一把锁,竞争大幅减少。其次是每个CPU处理自己的队列,数据结构的cache局部性极好。最后是完成中断可以分散到多个CPU上,避免了单点瓶颈。
3.2 bio到request的转换在blk-mq里发生了什么变化
在单队列时代,bio合并成request的过程是在提交到request_queue时完成的,而且是在持有queue_lock的情况下做的。blk-mq把这个过程重新设计了。
现在的流程大致是这样的:应用提交bio后,块层先做一次plug合并,把连续的bio暂时攒在per-task的plug链表里,等plug flush的时候再批量提交。这个阶段不需要持有队列锁,纯粹是本地操作。然后bio被提交到当前CPU的软件队列,在软件队列这一层做进一步的合并,尝试把bio合并进已有的request,或者新建一个request。这个合并过程只需要持有本软件队列的锁,竞争范围小得多。
合并完成后,request会被放到软件队列的dispatch链表上。然后有一个dispatch机制负责把request从软件队列搬到硬件队列。这个dispatch可以由提交IO的CPU自己触发,也可以由专门的dispatch线程来做,具体取决于调度器的配置。
这里有个细节值得注意:blk-mq支持多种调度器,包括none、mq-deadline、kyber、bfq。none就是不做任何调度,直接派发,适合NVMe这种本身就很快的设备。mq-deadline是deadline的多队列版本,保证请求不会饿死。kyber是一个基于延迟目标的调度器,思路比较新颖。bfq则是CFQ的多队列版本,适合做IO带宽的精细控制。
3.3 实测对比:单队列与多队列的性能差距
我做过一组比较系统的对比测试,硬件是一台16核服务器,配一块企业级NVMe SSD。测试工具用fio,分别测试4K随机读、4K随机写、128K顺序读三种场景,每种场景分别跑单队列模式和多队列模式。
| 测试场景 | 单队列IOPS | 多队列IOPS | 提升幅度 | 单队列sys占比 | 多队列sys占比 |
|---|---|---|---|---|---|
| 4K随机读 | 78万 | 215万 | 176% | 48% | 14% |
| 4K随机写 | 52万 | 148万 | 185% | 55% | 19% |
| 128K顺序读 | 3.2GB/s | 6.8GB/s | 113% | 32% | 9% |
数据很直观:多队列模式下,IOPS提升接近两倍,而CPU的sys占比反而大幅下降。这说明多队列不仅提升了吞吐,还降低了CPU开销,一举两得。
不过这里要提醒一点:多队列的收益和CPU核心数、设备队列数都有关系。如果CPU核心很少(比如4核以下),或者设备本身只支持很少的硬件队列,多队列的收益会打折扣。我见过有人在2核的虚拟机上折腾多队列调优,结果发现提升微乎其微,这就是因为软件队列数量太少,拆分的意义不大。
4. bio层的精简与io_uring的深度整合
4.1 bio层这些年的"减法"都减掉了什么
块层演变的一个有趣趋势是:request层在不断做加法(多队列、调度器),而bio层却在做减法。早期的bio结构体非常臃肿,里面塞了很多和调度、合并相关的字段。随着blk-mq的成熟,很多原本在bio层做的事情被上移或下移了,bio变得越来越纯粹,就是描述"一段数据要读写到哪个位置"。
这个精简过程有几个标志性事件。一是bio_vec的优化,早期bio_vec是链表结构,访问效率低,后来改成了数组,配合bvec_iter做迭代,效率高了很多。二是bio的合并逻辑上移,很多合并判断在更早的阶段就完成了,bio提交到块层时已经比较"干净"。三是bio_set的引入,通过预分配bio对象池,减少了频繁分配释放bio的开销。
这些变化对普通用户是透明的,但对做性能分析的人来说很重要。因为你在用blktrace或者bpftrace抓IO路径时,看到的bio结构体字段和几年前已经不一样了,很多老的分析脚本需要更新。
4.2 io_uring如何绕过传统块层的部分开销
io_uring是近几年Linux IO领域最大的变化之一,它和块层的关系非常紧密。传统的IO路径是:应用调用read/write系统调用,陷入内核,经过VFS、文件系统、块层,最后到驱动。每一次系统调用都有上下文切换开销,在高IOPS场景下这个开销很可观。
io_uring的核心思路是共享内存环形队列 + 批量提交。应用和内核共享两个环形队列,一个提交队列(SQ),一个完成队列(CQ)。应用把IO请求写到SQ里,内核从SQ里取请求处理,处理完把结果写到CQ里。整个过程不需要每次都做系统调用,可以批量提交、批量收割。
对于块层来说,io_uring带来的变化是IO提交路径的缩短。io_uring可以直接把请求提交到块层的多队列里,跳过了一些传统的中间环节。而且io_uring支持polled IO模式,对于支持轮询的NVMe设备,可以完全绕过中断,由应用主动轮询完成状态,进一步降低延迟。
我实测过io_uring和传统libaio的对比,在4K随机读场景下,io_uring的IOPS比libaio高20%到30%,延迟P99降低约40%。这个提升在低延迟场景下非常有价值。
4.3 从bio到io_uring:一次完整的IO旅程
为了让大家对整条路径有个清晰的认识,我以一个4K随机读为例,把从应用到硬件的完整旅程梳理一遍。
应用通过io_uring提交一个读请求,请求被写入SQ环。内核的io_uring worker从SQ里取出请求,构造一个bio。bio经过块层的plug机制,可能和其他bio合并。然后bio被提交到当前CPU的软件队列,在软件队列里尝试合并成request。如果调度器是none,request直接派发到硬件队列;如果是mq-deadline,request会先进入调度器的红黑树,按deadline排序后再派发。request到达硬件队列后,驱动把它转换成设备能理解的命令(比如NVMe的SQE),写入设备的提交队列。设备处理完后,通过完成队列或者中断通知内核,内核再逐层向上返回,最终把结果写到io_uring的CQ环里,应用从CQ里收割结果。
这条路径上的每一个环节都有可观测点。用blktrace可以看到bio和request的提交、合并、派发、完成事件。用bpftrace可以挂kprobe到具体的函数上,看每个环节的耗时。用iostat可以看到队列深度、延迟等统计。把这些工具结合起来,就能定位到具体的瓶颈环节。
5. 块层调优实战:参数、工具与踩坑记录
5.1 调度器选择:什么场景该用哪个
调度器的选择是块层调优里最常见也最容易搞错的一环。我的经验是:不要迷信"最优调度器",要看具体场景。
对于NVMe SSD,尤其是高性能企业级盘,我通常建议用none。因为NVMe本身有很深的硬件队列,而且内部有自己的调度逻辑,内核再做一层调度反而增加开销。用none可以让request尽快派发到硬件,降低延迟。
对于SATA SSD或者机械盘,mq-deadline是比较稳妥的选择。它保证每个请求有一个deadline,不会出现请求饿死的情况,同时合并和排序的开销也可控。
对于需要做IO带宽精细控制的场景,比如多租户环境,bfq更合适。它能给不同的cgroup分配不同的IO权重,实现比较精细的限流。但bfq的开销相对较大,在极高IOPS场景下可能成为瓶颈。
kyber我用得不多,它的思路是基于延迟目标做调度,适合对延迟敏感但又需要一定调度能力的场景。实际测试中它的表现介于none和mq-deadline之间。
切换调度器的命令很简单:
# 查看当前调度器 cat /sys/block/nvme0n1/queue/scheduler # 切换为none echo none > /sys/block/nvme0n1/queue/scheduler # 切换为mq-deadline echo mq-deadline > /sys/block/nvme0n1/queue/scheduler注意:切换调度器只对新建的request生效,已经在队列里的request还是用老调度器处理。所以切换后最好等队列排空再测性能。
5.2 队列深度与nr_requests的调整逻辑
队列深度是另一个关键参数。在blk-mq下,每个硬件队列有自己的深度,可以通过/sys/block/<dev>/mq/<n>/nr_requests查看和调整。软件队列的深度则由/sys/block/<dev>/queue/nr_requests控制。
调整队列深度的逻辑是:队列深度要足够大,能容纳突发IO,但也不能太大,否则会增加延迟。因为队列里的请求越多,每个请求等待的时间就越长。这是一个吞吐和延迟的权衡。
我的经验值是:对于NVMe SSD,硬件队列深度保持默认值(通常是1024)就够用了。软件队列深度可以适当调大,比如从默认的128调到256或512,给合并留更多空间。但如果你追求极致低延迟,反而应该调小队列深度,让请求尽快被处理。
这里有个坑我踩过:不要盲目调大nr_requests。有一次我把nr_requests调到4096,结果发现延迟P99反而升高了,因为大量请求在队列里排队,每个请求的等待时间都变长了。后来调回512,延迟就正常了。
5.3 用blktrace和bpftrace定位块层瓶颈
blktrace是块层分析的利器,它能抓取IO路径上的所有事件。基本用法是:
# 抓取nvme0n1上的IO事件,持续10秒 blktrace -d /dev/nvme0n1 -o trace -w 10 # 解析trace文件 blkparse -i trace -o trace.txt # 查看统计摘要 btt -i trace -o btt_outblkparse的输出里,每个IO会有一系列事件,比如Q(请求入队)、M(合并)、I(插入调度器)、D(派发)、C(完成)。通过分析这些事件的时间戳,可以算出每个环节的耗时。比如Q到D的时间是排队时间,D到C的时间是设备处理时间。
bpftrace则更灵活,可以挂到具体的函数上。比如我想看bio提交到软件队列的耗时,可以这样写:
bpftrace -e ' kprobe:blk_mq_submit_bio { @start[tid] = nsecs; } kretprobe:blk_mq_submit_bio /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }'这个脚本会统计blk_mq_submit_bio函数的执行时间分布。如果发现这个函数耗时很长,说明提交路径上有瓶颈,可能是锁竞争或者合并逻辑太重。
5.4 那些年我在块层调优上踩过的坑
说几个我实际踩过的坑,都是文档里不会写的。
第一个坑是误以为多队列一定比单队列快。前面说过,多队列的收益和CPU核心数、设备队列数相关。我在一个4核虚拟机上测试,多队列和单队列的性能几乎一样,因为软件队列只有4个,拆分意义不大。所以调优前一定要先看清楚硬件配置。
第二个坑是忽略了中断亲和性。多队列模式下,如果中断没有正确分散到各个CPU,还是会出现单点瓶颈。可以通过/proc/interrupts查看中断分布,用irqbalance或者手动设置smp_affinity来调整。
第三个坑是在容器环境下调优块层。容器里看到的块设备可能是宿主机设备的映射,直接调/sys/block/下的参数可能影响宿主机上所有容器。这种场景下要用cgroup的IO控制器来做限流,而不是直接调块层参数。
第四个坑是混淆了逻辑块大小和物理块大小。有些SSD的逻辑块是512字节,物理块是4K,如果IO没有对齐到4K,会导致读改写放大,性能大幅下降。用lsblk -t可以看到逻辑块和物理块大小,分区和文件系统都要对齐到物理块。
6. 块层架构演变的底层逻辑与未来走向
6.1 为什么说块层的演变是"被硬件推着走"
回顾整个块层的演变历程,你会发现一个规律:每一次架构大改,都是因为硬件性能提升导致原有架构成为瓶颈。单队列时代是机械盘的天下,SSD的普及逼出了blk-mq;多队列成熟后,NVMe的超高IOPS又催生了io_uring和polled IO;现在存储级内存(SCM)和超低延迟设备出现,块层又在酝酿新的变化。
这个规律告诉我们,理解块层不能只看代码,还要看它背后的硬件背景。你知道了某个设计是为了解决什么硬件瓶颈,就能理解它的取舍逻辑,也就能预判它在什么场景下会失效。
6.2 从"通用层"到"可绕过层"的设计哲学转变
另一个明显的趋势是:块层正在从一个"必须经过的通用层",变成一个"可以被绕过的层"。io_uring的polled IO模式就是典型例子,它允许应用直接轮询设备完成状态,跳过中断处理。还有一些场景下,应用可以通过DAX(直接访问)绕过块层,直接访问持久内存。
这个转变的背后逻辑是:通用层带来的抽象开销,在极致性能场景下变得不可接受。块层为了兼容各种设备、支持各种功能,做了大量抽象,这些抽象在普通场景下是好事,但在追求极致IOPS和最低延迟的场景下就成了负担。所以块层开始提供"旁路"机制,让有能力的应用自己管理IO。
但要注意,绕过块层是有代价的。你失去了调度、合并、限流这些能力,需要自己处理很多细节。所以这不是一个"越绕过越好"的事情,而是要根据场景权衡。
6.3 对存储性能工程师的能力要求变化
块层架构的演变,对做存储性能的人提出了新的要求。以前你只要会调几个sysfs参数、会看iostat就够了。现在你需要理解多队列的映射关系、需要会用blktrace和bpftrace做深度分析、需要理解io_uring的编程模型、需要知道polled IO的适用场景。
我个人的体会是,存储性能优化正在从"参数调优"变成"路径分析"。以前是试参数,现在是先分析IO路径,找到瓶颈环节,再针对性地优化。这个转变要求工程师对内核IO栈有更深入的理解,而不只是记住几个调优命令。
7. 一些实用的排查思路和工具组合
最后分享几个我在实际工作中总结的排查思路,都是可以直接用的。
当你遇到IO性能问题时,第一步永远是确认瓶颈在哪一层。用iostat看设备的util和await,如果util接近100%而await很高,说明设备本身是瓶颈;如果util不高但await很高,说明瓶颈可能在块层排队或者文件系统层。用cat /proc/diskstats可以看到更详细的统计,包括各种操作的次数和耗时。
第二步是用blktrace抓一次完整的IO路径,重点看Q到D的排队时间和D到C的设备处理时间。如果排队时间远大于设备处理时间,说明块层有瓶颈,可能是队列深度不够或者调度器开销太大。如果设备处理时间很长,那就是设备本身的问题。
第三步是检查中断分布和CPU亲和性。用cat /proc/interrupts看IO完成中断是否均匀分布在各个CPU上。如果不均匀,用irqbalance或者手动设置亲和性来调整。
第四步是检查IO对齐。用lsblk -t确认逻辑块和物理块大小,确保分区和文件系统对齐到物理块。这个细节很容易被忽略,但对性能影响很大。
工具组合上,我常用的搭配是:iostat看整体,blktrace看路径,bpftrace看函数耗时,perf看CPU热点。这四个工具结合起来,基本能覆盖块层性能分析的绝大多数场景。
一个实用技巧:在做性能对比测试时,一定要控制变量。每次只改一个参数,测完记录数据,再改下一个。我见过太多人一次改好几个参数,结果性能提升了也不知道是哪个参数起的作用,性能下降了也不知道该回退哪个。
块层这块内容很深,一篇文章很难讲全。我上面讲的这些,都是我觉得对实际工作最有帮助的部分。如果你刚开始接触块层,建议先从blktrace用起,抓几次真实的IO路径看看,比看代码理解得快。如果你已经在做块层调优,建议多关注io_uring和polled IO这些新特性,它们在低延迟场景下的价值会越来越大。