1. 为什么我劝你别再忽略Linux的I/O调度器
如果你手头管着几台Linux服务器,或者日常在跟数据库、虚拟化、文件存储打交道,那么“存储变慢”“IO延迟抖动”这类问题你大概率不陌生。排查的时候很多人第一反应是看磁盘类型、文件系统、RAID卡缓存,往往忽略了内核里那个默默干活的中间层——I/O调度器。
先把这个概念讲清楚。I/O调度器是Linux内核块设备层的一部分,它处在通用块层和底层驱动之间,负责把上层的读写请求排个队、做做合并、定个顺序,再发给硬盘。说得直白一点,它就相当于餐厅门口的排号员——客人(进程)一窝蜂进来,它不能让所有人都挤进厨房,得安排好谁先谁后、哪些可以拼桌、哪些得单独开灶。
为什么这个东西值得专门写一篇?因为很多人不管磁盘换了SSD还是NVMe,调度器始终用的是系统默认值,从来没有根据实际负载去调过。而事实上,调度器的选型和参数,对数据库这类高并发随机读写场景的影响是肉眼可见的,有的场景甚至能差出30%以上的延迟。这篇东西就是想把I/O调度器的原理、选型、参数调优和排障经验一次说透。
这篇文章适合这几类人:对Linux内核IO栈有一定了解但没细抠过调度器的后端工程师、被数据库性能问题折磨的DBA、以及所有维护高负载存储服务的运维同学。新人看了也能建立一条完整的排查思路,老手也能对照着看看有没有踩漏的细节。
我把这些年压箱底的经验整理成文,尽量少讲空泛理论,多说可以落地执行的操作。下面先看看I/O调度器在内核IO路径里到底站在哪一环。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞清楚I/O路径:调度器站在哪一环
2.1 内核IO栈的基本流程
要理解调度器的价值,得先从整个I/O链路说起。一次最常见的读写在Linux里大致要经过这么几个环节:
- 用户进程发起read/write系统调用
- 走到虚拟文件系统(VFS)层,再往下是具体的文件系统(比如ext4、xfs、btrfs)
- 文件系统把逻辑地址映射到块设备的逻辑块地址,然后提交给通用块层(Generic Block Layer)
- 通用块层把请求组建成bio结构,接着就进入I/O调度层
- 调度器处理完后,请求变成request结构,下发到驱动层
- 驱动把请求翻译成SCSI/NVMe等协议,最终交给硬件读写
这个链路里,I/O调度器位于通用块层之下、驱动层之上。它接收上游疯狂涌入的读写请求,按照自己的一套策略决定:哪些请求可以合并成一个大的连续请求,哪些请求应该优先响应,哪些可以往后稍一稍。
很多人会问:现在的NVMe固态硬盘那么快,调度器是不是多余了?其实不完全是。NVMe盘的队列是硬件级的,深度可以做得很深,这种情况下调度器介入反而可能成为瓶颈,某些场景下更倾向直接绕过。但SATA SSD和机械盘的开销远大于调度器本身,调度器仍然有大作用。所以你选不选、用哪个、怎么调,都得看硬件和负载类型。
2.2 单队列与多队列:两代调度框架的本质区别
老读者可能对I/O调度器的认识还停留在CFQ、deadline、noop这“老三样”。这没错,但现在的内核(从4.x开始)已经全面切到了多队列块层(blk-mq)框架。这是理解所有后续内容的大前提。
在多队列框架下,每个CPU核都有独立的软件提交队列,硬件侧也可能有多个硬件队列。请求从软件队列出来,进入调度层,再分发到各个硬件队列。这套设计把原来一把全局锁的瓶颈拆掉了,让高并发下的IO吞吐大幅提升。
好,接下来必须讲到一个关键现实:并不是所有调度器都兼容blk-mq。在很老的内核里,调度器列表通常是noop、deadline、cfq;而新内核(典型如5.x以上)里,你能看到的基本是none、mq-deadline、bfq、kyber这几个。这个变化背后的逻辑和取舍,下一节逐一说清楚。
3. 主流I/O调度器逐个拆解:原理与适用场景
3.1 mq-deadline:默认选项,为什么它能“通吃”大多数场景?
mq-deadline是现在很多Linux发行版和内核的默认调度器(比如常见的服务器发行版默认就是它)。它的核心思想是“保质期优先”:每个请求都有一个截止时间,超过这个时间就必须被处理,避免有些请求长时间饿死。
具体实现上,它维护了读写两个队列,每个队列里又按扇区顺序排序,同时还有一个“过期队列”记录截止时间。调度的时候优先处理临近截止时间的请求,同时尽量按扇区顺序批量处理,达到“顺序吞吐好、随机延迟可控”的平衡。
从实际表现来看,mq-deadline在混合读写场景(比如邮件服务器、文件服务器、普通业务数据库)下表现很稳。它不会让任何一种负载特别吃亏,调度开销也不高。如果你的场景特点就是“负载不极端,不知道选什么好”,那就选它,作为默认值它是有道理的。
3.2 BFQ:桌面与交互式负载的公平性大师
BFQ(Budget Fair Queuing)是BFS调度器作者Con Kolivas的作品,核心目标是“公平”。它会根据进程的IO权重分配带宽和响应延迟,保证不会有一个疯狂读写的进程把其他进程的IO时间全吃光。
要说BFQ最出彩的地方,是它的交互延迟表现。比如你一边在系统里跑着重IO任务,一边打开桌面软件、拖动窗口,BFQ能极大减少“感觉卡顿”的情况。这是因为它对进程分桶调度、以微秒级粒度在队列之间切换,优先响应那些短小的交互型请求。
但代价也很明显:CPU开销相对较高,对高吞吐场景(比如持续跑大流量、日志写入)反而会压制绝对吞吐量。我见过有人在数据库服务器上因为“听说BFQ公平”就换了它,结果性能下来了,这就是没搞清楚场景的典型教训。所以我的建议是:桌面系统、交互型环境可以考虑BFQ,业务承载型服务器谨慎使用。
3.3 Kyber:追求延迟稳定性的现代化新秀
Kyber是Facebook开发的调度器,思路和传统几个完全不同。它不按照截止时间或权重来排队,而是实时监控每个请求从进入到完成的时间差,动态调整提交到硬件队列的请求数量。换句话说,它是靠“反馈控制”来控制延迟的。
如果某个队列的延迟变高了,Kyber会减少发送给硬件的请求速率,等延迟回落再逐步放开。这种思路很像TCP的拥塞控制窗口,只不过它控制的是IO并发度。
它特别适合负载波动大、对延迟敏感但没法精细调参的场景。因为Linux把调度器一个显著问题摆上台面:传统调度器对调参要求高、队列深度固定,而Kyber自动适配。缺点是吞吐量不一定是最优的,而且内核配置选项若未开启,你也用不了它。
3.4 none:什么时候该彻底绕开调度器?
none调度器意味着IO请求几乎不做重排和合并,直接用最简路径发往驱动层。这听起来很原始,但在高性能NVMe设备上是相当合理的方案,因为NVMe盘的命令处理快到调度器的开销反而成了负担。
早年硬件上还有个noop调度器,它的逻辑更简单:不进任何算法,只在FIFO排队时尝试合并。none和noop的核心区别是:none连合并都基本不管,完全交给驱动和硬件。
什么时候用none?典型是高规格NVMe SSD、纯顺序读写场景、以及上层已经有非常成熟的I/O排队逻辑(比如某些数据库直接把块设备当日志盘用)。这种情况下调度器越透明越好,反正你需要的不是公平而是极致的低延迟。
3.5 新旧调度器对比速查表
为了方便你快速做选型,我把常用调度器的特点整理成一张表。你可以直接对照着看:
| 调度器 | 核心思路 | 优势 | 劣势 | 典型场景 |
|---|---|---|---|---|
| mq-deadline | 截止时间+扇区排序 | 综合均衡,默认稳 | 极端负载下不够极致 | 大多数服务器默认 |
| BFQ | 进程权重公平调度 | 交互式延迟极佳 | CPU开销高、高吞吐受限 | 桌面系统、交互环境 |
| Kyber | 延迟反馈控制 | 自动适配负载,延迟稳定 | 绝对吞吐非最优 | 延迟敏感、负载波动场景 |
| none | 绕过调度直通驱动 | 开销极低、延迟最小 | 无公平性保护,易饿死 | 高性能NVMe、纯顺序读写 |
| noop(旧内核) | 简单FIFO+合并 | 开销低、实现简单 | 无复杂调度能力 | 旧式环境、内存盘等 |
这张表只是“第一手选型参考”,真正要定下来还得结合自己的负载实测。下一个问题就是:怎么在当前系统上查看和切换调度器。
4. 实操:查看、切换与调优I/O调度器
4.1 查看当前生效的调度器
不同内核版本、不同发行版,查出来的信息格式会有差异。最常用的命令是:
bash复制cat /sys/block/sda/queue/scheduler
输出通常长这样(以某个新内核系统为例):
code复制mq-deadline [bfq] none
中括号括起来的就是当前生效的调度器。你看,这个系统当前用的是bfq。有时候你会看到输出里只有一个选项,比如只有none,那多半是内核编译时把别的调度器裁掉了,或者设备本身不支持(常见于某些NVMe设备按内核配置默认不挂调度器)。
可选的调度器列表是在内核编译阶段就固定的。比如有些精简内核只编译了mq-deadline和none,那你再想要bfq也没有。这时候得检查内核配置或者考虑换内核。
4.2 如何临时切换调度器
切换调度器非常简单,直接把名字写进scheduler文件即可。比如把sda切到mq-deadline:
bash复制echo mq-deadline > /sys/block/sda/queue/scheduler
切完之后用上一条命令验证,中括号应该已经落在mq-deadline上了。需要注意:这种切换是临时的,重启之后会恢复默认值。如果你想持久化,比较常见的做法是在udev规则里写一条。比如在/etc/udev/rules.d/60-iosched.rules里写:
code复制ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/scheduler}="mq-deadline"
写完之后重新触发udev规则:
bash复制udevadm control --reload-rules
udevadm trigger
这样以后插入或新发现的块设备都会自动套用你指定的调度器。对多盘服务器来说,这种做法比在rc.local里写脚本要干净得多。
4.3 关键可调参数:别只会换名字,还得会调参数
调度器开关只是第一步,真正的调优空间在参数里。以mq-deadline为例,你可以看看这些参数:
bash复制ls /sys/block/sda/queue/iosched/
常见的参数有fifo_batch、writes_starved、front_merges等。比如writes_starved默认是2,它表示读请求比写请求多几次优先级。读请求写得多会延迟写请求,但对数据库这类读敏感负载,适当调大writes_starved能降低读延迟。如果写入压力大,反过来调小。
kyber也有自己的参数,主要是read_lat_nsec这些延迟目标值。在内核4.14之后的版本中,kyber的read_lat_nsec、write_lat_nsec等参数会被导出到sysfs中,你可以根据业务对延迟的容忍程度去调整。比如默认读延迟目标可能是2毫秒,如果你的业务要求更高,可以往低了调,但太激进会压低吞吐。
调整方式也很简单,例如:
bash复制echo 1000000 > /sys/block/sda/queue/iosched/read_lat_nsec
参数的数值单位、默认值各家内核版本不一样,务必先读一下当前值再调,别凭记忆拍脑袋。
4.4 针对不同存储介质的选型建议
存储介质从机械盘(HDD)到普通SATA SSD,再到NVMe,甚至到内存盘(如zram),对调度器需求完全不同,简单总结一下:
- 机械盘HDD:物理寻道是致命瓶颈,调度器要做大量合并和排序。默认
mq-deadline即可,追求桌面体验可以试试bfq,能明显缓解卡顿。 - SATA SSD:响应已经快很多,但固件处理能力有限,推荐
mq-deadline,参数可适当优化,不太建议none。 - NVMe SSD:高速设备建议直接
none,或者用mq-deadline但保持长队列深度。如果负载是随机小IO且对延迟要求高,kyber可能更合适,需要实测。 - 内存盘zram/ramdisk:物理介质无寻道成本,
none/noop优先,避免多余的调度开销。
4.5 队列深度与调度器配合
很多人忽略一个点:/sys/block/sda/queue/nr_requests这个参数直接影响了调度器能排多长的队。队越长,调度器能做合并、重排的空间越大,但排队延迟也会上升;队越短,IO更直接但合并机会变少。
对于机械盘,nr_requests可以适当调大,给调度器足够的“吞吐空间”去排序。对于NVMe固态盘,如果已经是none调度器,nr_requests的作用更多是控制内存占用和背压,不需要刻意调很大。
我建议的调试路径是:先确定调度器,再同时观察nr_requests调整后对延迟和吞吐的影响,两者是配套调整的,不能只动一个。
5. 如何评估调度器效果:一个可复用的测量方法
5.1 先记录基线,再动手调
很多人调优全凭感觉,这个是调优的大忌。正确做法是先建立基线数据。最简单的工具组合是iostat配合fio压测。先跑一轮固定负载的fio,记录下IOPS、带宽、平均延迟和P99延迟,再做调度器切换和参数调整,然后再跑同样负载,对比数据。
一个比较实用的命令组合:
bash复制fio --name=randread --rw=randread --bs=4k --size=4G --iodepth=32 --numjobs=4 --runtime=60 --group_reporting
测试随机读就用randread,测试随机写就用randwrite,顺序读写把rw=read或rw=write。跑之前先用iostat -x 1记录一段基线,跑完再记录一段。重点看%util、await、svctm(如果内核版本还支持的话)、wrqm/s这些指标。
5.2 延迟测试中用到的坑:缓存与预热
我不止一次看到有人用fio测试时,忘了文件已被page cache缓存,测出来的数据成百上千MB/s,实际上全是缓存命中。为了避免这种问题,建议用libaio、direct=1绕过缓存:
bash复制fio --name=randread --ioengine=libaio --direct=1 --rw=randread --bs=4k --size=4G --iodepth=32 --runtime=60
direct=1等同于使用O_DIRECT,绕过页缓存直接发到块层,这样测的才是调度器能影响的范围。另外测试之前缓存也可能残留,先用echo 3 > /proc/sys/vm/drop_caches清一下,但这在生产环境要谨慎操作。
5.3 监控延迟分布:平均值不够,要看P99
调度器调完,平均延迟可能变化不大,但高百分位延迟(P99、P999)可能天差地别。很多运维只看iostat的await,这是个严重误区。因为极端延迟尖刺对数据库这种应用才是致命的。
我用fio测的时候,一般会加上--lat_percentiles=1 --percentile_list=50:99:99.9:99.99,这样输出里直接能看到各百分位延迟。对比调度器效果时,重点看P99.9的变化,而不是被平均值迷惑。
6. 实战排障:调度器相关的常见问题与排查技巧
6.1 明明改了调度器,性能却更差?先检查这两个地方
第一种情况:你改了调度器但没改nr_requests。比如从mq-deadline切到none,但nr_requests还是非常大的值。请求队列虽然等于没有了调度器,但排队长度依然深不可测,延迟自然下不来。遇到这种情况,把nr_requests调小(比如256或者128)再观察。
第二种情况:设备是多路径设备(比如dm-multipath、云计算平台的虚拟磁盘),你改的/sys/block/sda/queue/scheduler可能根本不是真正下发命令的路径。这种环境下调度器可能没有意义,并且不一定暴露出来给我们改。排查时先确认设备类型:
bash复制lsblk -d -o name,type,tran
如果type是mpath,那就得看底层的路径设备,而不是只看dm设备。
6.2 使用iostat判断调度器是否需要更换
iostat -x 1是一个永不过时的工具。大家看的时候重点看几个指标:
avgqu-sz:平均队列长度,如果特别高,说明大量请求堆积在调度层,延迟很难看。await:IO请求平均处理时间,高的话可能是调度等待加硬件处理的总和。%util:设备忙绿率,100%说明设备打满了,这时候换调度器也难以改善,得从硬件或负载层面想办法。
如果avgqu-sz高但%util不高,说明请求堆积在队列里而磁盘并没有满负荷,调度器或队列深度设置可能有问题,方向就对了。
6.3 嵌入式系统和内核裁剪场景下的调度器差异
嵌入式Linux很多是裁剪内核,可能只编译了一个调度器,甚至没有传统调度器选项。在这种环境里,调度器往往不是性能关键,反而是中断、GPIO、DMA这类问题主导。如果你在嵌入式板卡上发现IO延迟异常,别把时间全耗在调度器上,先看驱动是不是走对了DMA通道、中断是否频繁丢失。这是很多从服务器转嵌入式的人最容易犯的错误——用服务器思维去排查嵌入式问题。
另外嵌入式里常用的ramdisk、mtd块设备,很多压根就没有调度器概念。你在/sys/block下未必能找到对应的scheduler文件。这时候不用慌,不是系统坏了,是这个设备类型在内核里不是传统的块设备。
7. 调优之外:内核版本与编译选项对调度器的影响
7.1 同一份配置,换内核版本行为可能完全不同
调度器的行为跟随内核版本演进非常明显。比如kyber在早期版本只能调整很少的参数,后续版本增加了很多内部调优项;bfq在不同内核版本里默认权重策略也有改动。
如果你用一套参数在一台机器上调好了,升级内核之后立刻发现延迟变了,不要太惊讶。建议每次内核大版本升级后,重新跑一遍你的fio测试集,确认调度器相关的参数仍然符合预期。
7.2 编译内核时如何定制调度器列表
如果你有编译内核的需求,可以在make menuconfig里找到这块配置。相关选项位于:
code复制Device Drivers -> Block devices -> Block layer
其中会列出MQ deadline I/O scheduler、BFQ I/O scheduler、Kyber I/O scheduler、NOOP I/O scheduler等选项。要使用哪个,就把它编进内核(*)或编成模块(M)。如果是模块,还要确认它有没有被加载到initramfs里,否则系统启动早期可能用不上。
需要注意的是,从内核5.x开始,noop调度器正式被移除,由none替代。如果你在网上找到老教程还在让你用noop,在新内核上执行echo noop会直接报错,要改写成echo none。
7.3 容器和虚拟化环境下的特殊考量
容器里的I/O调度器其实不在容器内部生效,容器进程的I/O请求最终还是会落到宿主机块设备上,由宿主机调度器统一处理。你在容器里看到/sys/block往往是只读或者被namespace隔离过的,改了也白改。
虚拟机场景也一样。虚拟机的磁盘(vda、vdb)可能是QEMU/KVM的virtio-blk设备,调度器的行为最终取决于宿主机端如何处理。所以排查云主机IO性能问题时,先确认底层存储架构再决定要不要折腾调度器,别把责任全甩给guest内核。
8. 我的实际调优经验与几个容易踩坑的地方
8.1 一次数据库实时写入延迟排查的复盘
去年排查一个MySQL实例,写入延迟会周期性飙到几百毫秒。起初以为是存储阵列抖动,后来在系统层抓iostat -x 1,发现await高得离谱,但%util只有60%左右,明显的请求堆积。再看调度器,是一台老机器,用的还是旧内核的cfq。于是先切到mq-deadline,延迟的尖刺立刻降低了一半多,再把writes_starved从默认值稍微调低,让写请求更快得到调度,整个写入链路变得平滑。
这个案例其实很有代表性。很多“存储变慢”的假象,根因不在盘上,而在排队逻辑上。你在排查这类问题时,把iostat和调度器信息先抓出来,往往能省掉一大堆沟通成本。
8.2 桌面环境下BFQ带来的实际体感差异
我自己办公电脑上跑着不少重IO任务(比如编译、虚拟机镜像),同时还要保证编辑器、浏览器不卡。默认的mq-deadline能扛住,但窗口拖动偶尔会有轻微迟滞感。换到bfq之后,交互延迟明显改善,编译任务慢了那么一点点,但整体“跟手”了不少。这就是BFQ的公平策略带来的体感红利。
如果你也是“一台电脑干所有事”的重度用户,强烈建议试试bfq。但如果你跑的是24小时不落地的业务服务器,我还是建议mq-deadline或none,别拿业务吞吐去换桌面体验。
8.3 新内核里一个很反直觉的小问题
新内核的none调度器在某些场景下不会完全“不管”。比如当块设备是一个需要做I/O屏障或flush处理的设备时,驱动层仍然需要等待所有之前提交的请求完成,这点跟调度器无关。因此有人测完none发现延迟没降太多,很可能就是这类同步机制在发挥作用。
所以结论是:如果none没有给你带来压倒性提升,也不要觉得是配置有问题。先把问题拆成“调度延迟”和“驱动同步延迟”两段,用blktrace去细看请求的时间戳分布,才能真正定位到瓶颈在哪。
8.4 生产环境调优的最小化操作套路
最后分享一套我自己在用的最小化流程,照着走不会出大错:
- 记录基线:fio跑一轮,存下完整输出;同时用
iostat -x记录一段实况。 - 查调度器:
cat /sys/block/sda/queue/scheduler,确认当前是什么。 - 根据介质和负载选型:机械盘用
mq-deadline,SATA SSD用mq-deadline,NVMe尝试none或kyber,桌面系统尝试bfq。 - 切换后立刻用同一套fio再跑一轮,对比IOPS、带宽、P99延迟三个核心指标。
- 根据结果微调参数(如
nr_requests、writes_starved、read_lat_nsec),每调一项就重测一次。 - 稳定后写入udev规则,让配置在重启后仍然生效。
- 在监控系统里保留调度器和近期性能指标,便于日后参照。
说起来似乎很简单,但真的能一步步坚持下来的人不多。多数人要么直接换调度器不重测,要么测了但不用percentile数据,最后得出一个“似乎没用”的结论,可惜了。
如果你手头的场景是按上面流程测出来的结果并不理想,也欢迎带着具体数值和负载特征来交流,我会基于实际数据帮你一起分析。I/O调度这层看似不起眼,但调好了,它能带来的收益往往比你想的更直接。
