一、引言:为什么每个后端工程师都需要理解 I/O 栈

在现代 Linux 系统中,一次磁盘读写的旅程远比想象中复杂。从用户空间的 read()/write() 调用开始,请求要穿越 VFS 层、文件系统层、Page Cache、通用块层、I/O 调度器、块设备驱动,最终到达物理存储设备。每个层次都有自己的队列、缓存与队列策略,任何一个环节的不合理配置都可能导致吞吐量骤增或延迟飙升。

对于运行数据库(MySQL、PostgreSQL)、消息队列(Kafka)、搜索引擎(Elasticsearch)等 I/O 密集型应用的生产服务器来说,理解 I/O 栈的运作机制是性能调优与故障排查的必修课。本文将从内核源码级别逐层剖析 Linux I/O 栈,并给出不同工作负载下的调度器选型与参数调优方案。

二、I/O 请求生命周期:一次磁盘读写的完整旅程

一次 I/O 请求在内核中的生命周期可以分为以下几个阶段:

  1. 用户空间系统调用:read()/write()/pread()/pwrite() 进入内核
  2. VFS 层(虚拟文件系统):根据 fd 找到对应的 file_operations,检查权限、偏移量等
  3. Page Cache(页缓存):读操作首先查找页缓存,命中则直接返回;未命中则触发页面分配与磁盘读取。写操作通常写入页缓存(buffered I/O),由后台线程回写
  4. 文件系统层:ext4、XFS、Btrfs 等文件系统进行extent分配、延迟分配、日志提交等操作
  5. 通用块层(Block Layer):将文件系统的逻辑块地址转换为磁盘的物理扇区地址,封装为 bio 结构体
  6. I/O 调度器:对 bio 进行合并、排序、优先级调整,减少磁头寻道(HDD)或优化吞吐(SSD/NVMe)
  7. 块设备驱动:NVMe 驱动、SCSI 驱动、virtio-blk 驱动等提交请求到硬件队列
  8. 硬件设备:数据最终在磁盘/SSD/NVMe 上完成读写

三、核心数据结构:bio、request 与 request_queue

3.1 bio 结构体——I/O 的最小单元

struct bio 是 Linux 块层中表示一次 I/O 操作的基本数据结构,定义在 include/linux/blk_types.h 中:

struct bio {
    struct bio          *bi_next;       // 链表指针
    struct block_device  *bi_bdev;      // 目标块设备
    unsigned int         bi_opf;        // 操作类型(READ/WRITE/FLUSH/DISCARD)
    unsigned short       bi_ioprio;     // I/O 优先级
    unsigned short       bi_flags;      // 状态标志(BIO_REQUEUSTED 等)
    blk_status_t         bi_status;     // 完成状态
    b_iter_t             bi_iter;       // 当前迭代位置
    bio_end_io_t        *bi_end_io;     // 完成回调函数
    void                *bi_private;    // 私有数据
    struct bio_vec      *bi_io_vec;     // 段数组(物理页 + 偏移 + 长度)
    unsigned int         bi_vcnt;       // 当前段数量
    atomic_t             __bi_remaining; // 剩余完成计数
    struct bio_set      *bi_pool;       // 内存池来源
};

一个 bio 可以通过 bio_vec 支持分散/聚集 I/O(scatter-gather),即一次 I/O 操作可以读写多个不连续的物理内存页,无需在用户空间合并缓冲区。

3.2 request 与 request_queue——调度的基本单位

多个 bio 可以合并为一个 struct request,这是 I/O 调度的最小单位。request_queue 是块设备上的请求队列,每个块设备有且只有一个:

struct request_queue {    spinlock_t              queue_lock;       // 队列锁(blk-mq 已废弃)
    struct blk_mq_ops      *mq_ops;          // 多队列操作函数表
    struct blk_mq_ctx      *queue_ctx;       // 软件提交队列
    struct blk_mq_hw_ctx   **queue_hw_ctx;   // 硬件调度队列
    struct elevator_queue  *elevator;        // I/O 调度器(elevator)
    unsigned int            nr_hw_queues;    // 硬件队列数量(blk-mq)
    unsigned int            nr_requests;     // 最大请求数
    unsigned int            dma_alignment;   // DMA 对齐要求
    sector_t                nr_sectors;      // 设备总扇区数
    struct backing_dev_info *backing_dev_info; // 写回控制信息
};

四、blk-mq 多队列架构:从单队列到并发的革命

4.1 单队列时代的性能瓶颈

在 Linux 3.13 之前,块层使用单队列架构(single-queue)。所有 CPU 共享一个 request_queue,通过一把全局自旋锁(queue_lock)保护。在 NUMA 架构多核服务器上,这导致严重的锁竞争:

  • 每个 CPU 提交 I/O 都需要获取同一把锁
  • 高并发场景下锁等待时间占总 I/O 时间的 40% 以上
  • NVMe SSD 的微秒级延迟被锁竞争完全淹没

4.2 blk-mq 多队列架构

Linux 3.13 引入了 blk-mq(Multi-Queue Block Layer),其核心思想是软硬件队列分离:

软件提交队列(per-CPU or NUMA node)
    ↓ 无锁提交
硬件调度队列(per-CPU group)
    ↓ 调度排序
硬件 dispatch queue(per-CPU group,发送驱动)
    ↓ 门铃机制
设备驱动提交队列(NVMe SQ)

blk-mq 的三个层次:

  1. Software staging queue(软件提交队列):每个 CPU 或 NUMA 节点独立,CPU 提交 I/O 时无需跨 CPU 同步
  2. Hardware dispatch queue(硬件调度队列):由驱动管理,可以映射到硬件的多个提交队列
  3. Driver submission queue(驱动队列):如 NVMe 的 Submission Queue(SQ),直接写入 PCIe MMIO 门铃寄存器即可通知硬件

对于 NVMe 设备,块队列数(nr_hw_queues)通常等于 CPU 核心数,每个队列深度可达 1024,彻底释放了 NVMe 的并行能力。

五、I/O 调度器:四种策略的场景匹配

5.1 CFQ(Completely Fair Queuing)— 已废弃

CFQ 是 Linux 2.6 时代的默认调度器,将每个进程的 I/O 请求放入独立的按时间片轮转的队列。设计目标是公平分配带宽,但在 blk-mq 时代已被废除(5.0 移除),因为:

  • 公平性逻辑引入的延迟代价过高
  • 对 SSD/NVMe 的硬件特性几乎无优化
  • 在高吞吐场景下 CPU 开销占比可达 10%

5.2 mq-deadline — 数据库与延迟敏感场景的默认选择

mq-deadline 是目前大多数发行版的 blk-mq 默认调度器。它的核心机制:

  • 维护两个有序队列(读、写),按扇区号(LBA)排序,减少磁头寻道
  • 读请求有 read_expire 超时(默认 500ms),防止读饿死写
  • 写请求有 write_expire 超时(默认 5s),超过则优先派发
  • fifo_batch 控制每次从排序队列取多少请求合并排序
  • writes_starved 控制在读饿死写之前允许读连续派发多少次

适用场景:数据库(MySQL InnoDB)、消息队列(Kafka)、延迟敏感的 OLTP 工作负载。mq-deadline 的优势是低延迟保证(读有严格超时),但又不像 Kyber 那样激进。

5.3 BFQ(Budget Fair Queuing)— 桌面与交互式场景的最佳选择

BFQ 是一种比例公平调度器,为每个进程分配"预算(budget)"(按激励的扇区数计量)。每个进程按预算比例获得 I/O 时间片:

  • 低延迟模式:自动检测非抢占式进程(通常是交互式应用),临时增加其预算以减少延迟
  • 权重控制:通过 io.bfq.weight 调整进程组权重
  • 深队列限流:max_budget 限制单次调度选中的最大扇区数,防止单个进程垄断设备

BFQ 在桌面系统和多租户容器场景表现优异,但在高 IOPS 设备(NVMe)上CPU 开销较高,且需要开启 low_latency 模式才能获得最佳交互体验。

5.4 none(No-op)— NVMe 后的极简主义

对于高度并行的 NVMe SSD,none 调度器(实际是简单的 FIFO 派发)反而可能是最优选择:

  • NVMe 硬件本身有多个并行提交队列,无需软件排序
  • 调度器操作引入的延迟在微秒级的 NVMe 上占比过高
  • 现代文件系统(XFS)已在完成排序融合,块层再做一次是重复劳动

生产建议:纯 NVMe 设备(特别是 PCIe 4.0/5.0 高端盘)使用 none;SATA SSD/HDD 使用 mq-deadline;云盘/guest 系统使用 mq-deadline 或 bfq。

# 查看当前调度器
cat /sys/block/nvme0n1/queue/scheduler
# 输出示例:[none] mq-deadline

# 动态切换(需要 root 或使用 udev 规则)
echo "none" > /sys/block/nvme0n1/queue/scheduler

# udev 规则(永久生效)
# /etc/udev/rules.d/60-ioscheduler.rules
ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"

六、Page Cache 与脏页回写

Page Cache 是 Linux 性能的核心支柱之一。写操作通过 buffered I/O 写入 Page Cache 后由后台线程异步回写,实现"写聚合"与"写折叠"。

6.1 脏页控制参数

内核通过 /proc/sys/vm/ 下的参数控制脏页行为:

参数默认值说明
dirty_background_ratio10触发后台回写的脏页占总内存的百分比
dirty_ratio20进程被迫同步回写(被阻塞)的脏页比例
dirty_background_bytes0绝对值模式(优先于 ratio),如 64MB
dirty_bytes0绝对值模式上限,如 128MB
dirty_writeback_centisecs500(5秒)flusher 线程唤醒间隔
dirty_expire_centisecs3000(30秒)脏页超过此年龄强制回写

数据库场景推荐配置:

# 降低回写阈值,提前触发后台刷盘,避免突峰
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10

# 缩短脏页寿命,减少在内存中积压的旧数据
vm.dirty_expire_centisecs = 300   # 3秒
vm.dirty_writeback_centisecs = 100 # 1秒

# 大页内存场景(32GB+ 内存),考虑使用绝对值
vm.dirty_background_bytes = 268435456  # 256MB
vm.dirty_bytes = 536870912            # 512MB

6.2 Direct I/O 与 Fsync

数据库通常自行维护缓存池(如 InnoDB buffer pool),使用 O_DIRECT 标志绕过 Page Cache 直接与磁盘交互:

  • 优点:避免双重缓存(DB 缓存 + Page Cache);减少内核态拷贝;满足 blockSize 对齐的 I/O 可以精确控制
  • 缺点:每次 I/O 必须是 blockSize(512B/4KB)的整数倍;不支持 buffered I/O 的预读;小随机读性能可能下降

fsync() 是另一种绕过后台回写的控制手段:强制将所有脏页 + 元数据刷新到磁盘,保证持久性。WAL(Write-Ahead Log)模式通常会每事务或每 N 条日志调用一次。

七、NVMe 与 SSD 的专属优化

7.1 NVMe 队列深度与 NUMA 亲和

NVMe 设备有 65535 个独立的 Submission Queue(SQ)/ Completion Queue(CQ)对。blk-mq 默认按 CPU 核心数创建硬件队列,但需要确保 CPU 与 PCIe 设备的 NUMA 节点一致:

# 查看 NVMe 设备所在的 NUMA 节点
cat /sys/block/nvme0n1/device/device/numa_node
# 输出:0

# 绑定中断到本地 NUMA 节点的 CPU
cat /proc/irq/IRQ_NUMBER/smp_affinity_list

7.2 I/O 合并参数

# 最大合并段数(受 DMA 硬件限制)
cat /sys/block/nvme0n1/queue/max_segments
# 通常 NVMe 为 128

# 合并的每个段最大字节数
cat /sys/block/nvme0n1/queue/max_segment_size
# 通常 NVMe 为 4294967295(无限制)

# 最优 I/O 大小(NVMe 通常 0 = 无限制,建议 128KB)
cat /sys/block/nvme0n1/queue/optimal_io_size

# 最小 I/O 大小(合并阈值)
echo 131072 > /sys/block/nvme0n1/queue/minimum_io_size

7.3 写放大与 TRIM

SSD 写入前必须先擦除(块级),导致写放大(Write Amplification)。TRIM/DISCARD 命令通知 SSD 哪些页已删除,使 FTL(Flash Translation Layer)可以预回收:

  • 周期性 TRIM:fstrim -av /(每周 crontab 执行一次)
  • 持续 TRIM:挂载选项 discard(每次删除即发送 TRIM),但对高写入负载可能影响性能
  • 生产推荐:高性能数据库挂载不使用 discard(后台周期 TRIM),避免 TRIM 延迟影响前台 I/O

八、生产级 I/O 故障排查实战案例

案例 1:MySQL 突发 IOPS 降为零

现象:生产 MySQL 在场景更新时,IOPS 从 5000 突降至 50,持续数分钟。

排查过程:

  1. iostat -x 1 发现 %util 仅 20%,队列 avgqu-sz 却达到 128
  2. echo t > /proc/sysrq-trigger + dmesg 发现大量 InnoDB 线程处于 D 状态(不可中断)
  3. cat /proc/vmstat 发现 dirty 页面从 512MB 暴涨至 4GB,触发 dirty_ratio 限制
  4. 根因:云平台云硬盘的 burst 额度耗尽,实际吞吐量被限制到极低水平,导致脏页积压超过阈值

修复:调整 vm.dirty_ratio = 40 + vm.dirty_background_ratio = 8,并升级云硬盘为 SSD 型。

案例 2:NVMe SSD 在容器化环境中的性能漂移

现象:Kubernetes 集群中 NVMe 本地盘的 P99 读延迟从 200μs 漂移至 2ms,但 CPU 与空闲率正常。

排查过程:

  1. nvme smart-log /dev/nvme0 显示 temperature > 85°C,throttle_time 非零
  2. 根因:NVMe 固件温度过高热节流,触发 SLC Cache 降速
  3. echo mq-deadline > /sys/block/nvme0n1/queue/scheduler 也无法恢复

修复:加强机箱散热 + 固件升级 + 通过 cgroup io.max 限制旁路容器带宽占用。

九、性能调优速查表

场景调度器关键调优
MySQL/PG(本地 NVMe)noneO_DIRECT + periodic TRIM + dirty_ratio=15
Kafka(NVMe/SSD)none禁用 OS Page Cache刷盘(log.flush.interval.ms=1000)
Elasticsearch(SSD)mq-deadlineindex.merge.scheduler.max_thread_count=1
虚拟机 guest(virtio-blk)mq-deadlinescsi_mod.use_blk_mq=y + 宿主机 BFQ
个人 PC(混合磁盘)bfq启用 low_latency + sata use_acpi=1
HDD RAID(NAS/备份)mq-deadlinenr_requests=256 + read_ahead_kb=4096

十、总结

Linux I/O 栈的设计从单队列到 blk-mq 多队列、从 CFQ 到 mq-deadline/bfq,反映了对硬件并行性认识的深化。现代 I/O 调优不再是"设好参数一劳永逸",而是理解工作负载特征(I/O 大小、顺序/随机、读/写比例、延迟 vs 吞吐敏感性)后有针对性地组合调度器、队列深度、Page Cache 参数与文件系统选项。

记住:最快的 I/O 路径是直接绕过不需要的层——能用 none 调度器就不选 mq-deadline,能用 O_DIRECT 就不 buffered,能用分区就不 RAID。但每次绕过都要确认牺牲的功能是可以接受的。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部