引言

Linux 块设备层(Block Layer)是存储 I/O 路径上承上启下的关键子系统:向上为文件系统(ext4/XFS/Btrfs)、数据库(MySQL/PostgreSQL/RocksDB)提供统一的块 I/O 抽象,向下通过设备驱动程序与 SSD/NVMe 硬件交互。在 HDD 时代,单队列(single-queue)架构足以应对;但随着 NVMe SSD 将存储延迟从毫秒级压缩到微秒级(10μs 以下),传统块层成为全球存储系统的性能瓶颈。Linux 3.13 引入的 blk-mq(Multi-Queue Block Layer)彻底重构了块 I/O 路径,通过硬件多队列映射、per-CPU 调度器和无锁请求分发,让 Linux 存储栈单核 IOPS 从 ~100K 飙升至百万以上。

本文将从块 I/O 栈的整体架构出发,深度剖析 blk-mq 的数据结构与调度机制、I/O 调度器演进、写回(writeback)与脏页管理、bio/request 双层模型与合并优化、NVMe 专属优化路径、故障注入与监控体系——完整呈现 Linux 高性能存储 I/O 栈的工程全貌。

1. 块 I/O 栈整体架构

理解 Linux 块 I/O 栈,需要先看清代用户态的一次 write() 调用跨越的层层边界:

┌───────────────────────── User Space ─────────────────────────┐
│  write() / read()                                              │
│    ↓                                                           │
│  VFS (Virtual File System)                                     │
│    ↓                                                           │
│  Filesystem Layer (ext4/XFS/Btrfs)                             │
│    │  • 文件分配、extent 管理、日志(journal)                     │
│    │  • 将文件偏移转换为块偏移(logical block address)            │
│    ↓                                                           │
│  Page Cache                                                    │
│    │  • 缓存热数据页(radix tree / XArray 索引)                  │
│    │  • 预读(readahead)触发异步读                              │
│    │  • 写回(writeback)线程刷脏页                               │
│    ↓                                                           │
├───────────────────────── Kernel Block Layer ─────────────────┤
│  ① bio 层:I/O 请求抽象(segment、方向、完成回调)                │
│  ② request 层:驱动级排队、合并、调度                            │
│  ③ I/O 调度器:mq-deadline / BFQ / Kyber / none                │
│  ④ blk-mq 硬件队列映射:per-CPU / per-NUMA-node                  │
│    ↓                                                           │
├───────────────────────── Device Driver Layer ─────────────────┤
│  NVMe 驱动 / SCSI 驱动 / virtio-blk / Xen blkfront              │
│    ↓                                                           │
├───────────────────────── Hardware ────────────────────────────┤
│  NVMe SSD / SATA SSD / HDD / Persistent Memory (Optane)        │
└───────────────────────────────────────────────────────────────┘

关键性能指标维度:

  • IOPS:每秒 I/O 操作数,衡量随机读写的并发处理能力
  • Throughput:带宽(MB/s),衡量顺序读写的吞吐能力
  • Latency:单次 I/O 延迟(μs),从发出请求到完成中断的时间
  • Latency tail (p99/p999):尾延迟,衡量延迟一致性

2. bio 与 request:双层 I/O 请求模型

Linux 块层的核心数据结构有两种:bio(Block I/O)和 request。两者处于块栈不同阶段,各司其职。

2.1 bio —— I/O 的基本原子单元

bio 是块 I/O 的传输描述符,由文件系统层或页缓存层创建,代表"要将哪些内存页面读写到哪些磁盘扇区上"。

struct bio {
    struct bio          *bi_next;      // 链表指针
    struct block_device *bi_bdev;      // 目标块设备
    unsigned int        bi_opf;        // 操作类型(READ/WRITE/FLUSH/DISCARD)
    unsigned short      bi_flags;      // 状态标志
    unsigned short      bi_ioprio;     // I/O 优先级
    struct bvec_iter    bi_iter;       // 当前段迭代器
    bio_end_io_t        *bi_end_io;    // 完成回调函数
    void                *bi_private;   // 私有数据(文件系统/驱动使用)
    struct bio_vec      bi_io_vec[];   // 物理段数组(bvec)
};

struct bio_vec {
    struct page *bv_page;   // 物理页指针
    unsigned int bv_len;    // 段长度(字节)
    unsigned int bv_offset; // 页内偏移
};

bio 的 segment 合并机制:

单个 bio 由多个 bio_vec(段)组成,每个段指向一个物理页内的连续字节范围。相邻段可以合并以减少 DMA 描述符数量。这一操作发生在 I/O 调度器的入口:

// 合并逻辑(简化)
struct bio *front_merge: // 向前检查——请求尾部 + bio 头部在同一扇区
    if (req->bi_sector + req->nr_sectors == bio->bi_sector)
        merge_into_tail(req, bio);

struct bio *back_merge:  // 向后检查——bio 尾部 + 请求头部在同一扇区
    if (bio->bi_sector + bio->nr_sectors == req->bi_sector)
        merge_into_head(req, bio);

合并效果:随机 4KB 写入经合并后可转化为顺序大块写入,IOPS 可提升 3-5x,同时显著降低闪存的写入放大(write amplification)。

2.2 request —— 驱动级排队单元

request 是块设备驱动直接操作的 I/O 单元。一个 request 可以包含多个 bio 的合并结果。request 经过 I/O 调度器排序后,由 blk-mq 派发到硬件队列。

struct request {
    struct request_queue *q;          // 所属请求队列
    struct blk_mq_ctx   *mq_ctx;      // 软件队列上下文
    struct blk_mq_hw_ctx *mq_hctx;    // 硬件队列上下文
    unsigned int        cmd_flags;    // 命令标志
    unsigned long       rq_flags;     // 请求状态
    sector_t            __sector;     // 起始扇区
    unsigned int        __data_len;   // 总数据字节数
    struct bio          *bio;         // 第一个 bio
    struct bio          *biotail;     // 最后一个 bio(可链式追加)
    // ...
};

bio → request 转换路径:

bio 提交
   ↓
blk_mq_submit_bio()          // blk-mq 入口
   ↓
blk_mq_bio_to_request()      // 如果可合并,追加到已有 request
   ↓                         // 否则分配新 request
elevator_dispatch()          // 调度器(mq-deadline 等)排序
   ↓
blk_mq_dispatch_rq_list()    // 派发到硬件队列(SQ)
   ↓
驱动层的 queue_rq() 回调    // NVMe: nvme_queue_rq()
   ↓
写入 SQ 提交队列 Doorbell   // 通知硬件有新命令

3. blk-mq 革命 — 从单队列到多队列

理解 blk-mq 的革命性,需要先回顾旧单队列架构的瓶颈。

3.1 单队列(Legacy Block Layer)的瓶颈

                单请求队列
  ┌────┐ ┌────┐ ┌────┐ ┌────┐
  │ CPU0│ │ CPU1│ │ CPU2│ │ CPU3│
  └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘
     │      │      │      │
     └──────┴──────┴──────┘
              │ 全局自旋锁 request_queue.lock
              ▼
      ┌──────────────────────┐
      │   全局请求队列(1个)   │
      │   单一 I/O 调度器      │
      │   单一硬件队列         │
      └──────────┬───────────┘
                 │
         慢速硬件(HDD)

三个致命瓶颈:

  • 全局锁 contention:所有 CPU 在提交 I/O 时竞争同一把队列锁,NVMe 场景下单锁每秒仅能处理 ~2M IOPS,多核争用后性能反降
  • 单一调度窗口:所有请求进入同一调度器,调度和合并的全局数据结构跨 NUMA 访问延迟巨大
  • 单一硬件队列:SSD/NVMe 支持多提交队列(如 NVMe 64K 队列),单队列无法利用硬件并行能力

3.2 blk-mq 三代架构演进

第一代(Linux 3.13, ~2014):软件队列 + 硬件队列分离

┌─ CPU0 ─┐   ┌─ CPU1 ─┐   ┌─ CPU2 ─┐   ┌─ CPU3 ─┐
│sw ctx 0 │   │sw ctx 1 │   │sw ctx 2 │   │sw ctx 3 │
└────┬────┘   └────┬────┘   └────┬────┘   └────┬────┘
     │             │             │             │
     ▼             ▼             ▼             ▼
┌──────── hw ctx 0 ────────┐     ┌──────── hw ctx 1 ────────┐
│ (mapped to CPU 0,1)      │     │ (mapped to CPU 2,3)      │
└────────────┬─────────────┘     └─────────────┬─────────────┘
             │                                 │
             ▼                                 ▼
        NVMe SQ 0/ CQ 0                  NVMe SQ 1/ CQ 1

第二代(Linux 5.x+):per-CPU 默认映射 + Tagset 共享

  • 软件队列按 CPU 编号分配,每个 CPU 独立软件上下文(blk_mq_ctx),无锁提交
  • 硬件队列按 NUMA 节点或 CPU 组映射,减少跨 NUMA 访问
  • Tagset 跨硬件队列共享,统一 request 分配池

第三代(Linux 6.x+):io_uring 直通 + 轮询模式

  • io_uring 固定缓冲区直接与 blk-mq 硬件队列绑定,跳过 bio 层部分开销
  • blk-mq polling:NVMe 轮询完成模式(无需 IRQ),降至 sub-μs 级别调度延迟
  • IORING_SETUP_SQPOLL + IOPOLL 组合实现全路径零系统调用块 I/O

3.3 blk-mq 核心数据结构

struct blk_mq_tag_set {
    struct blk_mq_ops *ops;          // 硬件队列操作回调
    unsigned int       nr_hw_queues;  // 硬件队列数量(通常 = CPU 数或 NUMA 节点数)
    unsigned int       queue_depth;   // 每个硬件队列深度(NVMe: 1-64K)
    unsigned int       nr_maps;       // 映射表数量(至少 1)
    struct blk_mq_queue_map *map;    // 队列映射表
    struct blk_mq_tags  **tags;      // 每个硬件队列的 tag 集合
};

struct blk_mq_hw_ctx {
    struct request_queue   *queue;        // 父硬件队列
    unsigned int           index;        // 硬件队列索引
    struct blk_mq_ctx      **ctxs;       // 映射到此 hw ctx 的软件 ctx 列表
    struct sbitmap         ctx_map;      // 软件 ctx 到位图
    struct request         *dispatch;    // 当前派发请求
    unsigned long          state;        // 状态位(busy/restart等)
    // 轮询相关
    struct polling_q_data  *poll_data;   // 轮询模式数据
    hrtimer                poll_timer;   // 轮询超时定时器
};

struct blk_mq_ctx {
    struct request_queue  *queue;
    unsigned int          cpu;           // CPU 编号
    struct blk_mq_ctx     *flush_rq;     // 用于 flush 的专属 request
    // 无锁提交路径
    struct llist_head     rq_lists[2];   // 本地和远程请求链表
    // I/O 统计
    unsigned long         rq_count[2];   // read/write 计数
};

3.4 队列映射策略与 NUMA 感知

blk-mq 允许驱动自定义队列映射函数。NVMe 驱动使用 blk_mq_pci_map_queues() 实现 NUMA 感知映射:

// NUMA 感知队列映射示例(4 CPU、2 NUMA、2 NVMe queue)
//
// NUMA Node 0: CPU 0-1 ──→ hw_ctx 0 ──→ NVMe SQ 0/CQ 0
// NUMA Node 1: CPU 2-3 ──→ hw_ctx 1 ──→ NVMe SQ 1/CQ 1
//
// 优势:
// • 本地 NUMA 内存直接 DMA,避免跨 NUMA 数据搬运
// • CPU 中断绑定到本地 NUMA 核(设备中断亲和性)
// • 单节点内存带宽节流(如 DDR4 25.6GB/s)不成为瓶颈

4. I/O 调度器:四种策略的权衡

blk-mq 时代引入了专为多队列设计的 I/O 调度器,每种针对不同负载特征做了优化。

4.1 none(No-op Dispatcher)

策略:完全不排序、不合并。bio 到达后直接分配 request 并派发到硬件队列。

适用场景:NVMe SSD(设备内部有并行处理单元,OS 级排序反而增加延迟)、io_uring 轮询模式(用户态自行管理顺序)。

缺点:HDD/ SATA SSD 上可能造成严重的请求乱序 → 大幅增加寻道时间。

为什么 NVMe 上用 none:NVMe 内部有约 256 个并行门铃(门铃寄存器对),能同时处理海量 I/O;OS 调度的 μs 级优化在 NVMe 10μs 延迟面前收益极小,反而引入额外 CPU 开销。

4.2 mq-deadline(带截止时间的 FIFO 调度)

策略:将读/写请求分别放入两个排序队列(按扇区地址)和两个 FIFO 队列(按到达时间),优先派发即将超过截止时间的请求(读默认 500ms,写默认 5000ms)。

mq-deadline 数据结构:
┌─────────────────────────────────────────────────────────┐
│  sort_list[READ]: 按 LBA 排序的红黑树(顺序读合并)      │
│  sort_list[WRITE]: 按 LBA 排序的红黑树                   │
│  fifo_list[READ]: 按时间排序的 FIFO(FIFO 轮转)          │
│  fifo_list[WRITE]: 按时间排序的 FIFO                      │
└─────────────────────────────────────────────────────────┘

调度逻辑:
if (fifo_list[READ] 中有请求超期)
    优先派发超期读请求
else if (sort_list[READ] 中有相邻请求)
    派发 LBA 排序的读请求(可能连续)
else if (fifo_list[WRITE] 中有请求超期)
    派发超期写请求
else
    派发排序的写请求

关键参数:
• read_expire = 500ms    (读请求超时上界)
• write_expire = 5000ms  (写请求超时上界)
• writes_starved = 2     (读优先,每 N 次读后才派发一次写)
• batching = 16      (排序合并窗口大小)
• front_merges = 0       (前端合并,blk-mq 默认禁用)

适用场景:数据库(MySQL/MariaDB)、延迟敏感型应用(HDD 上的 Web 服务器)、混合读写负载。

4.3 BFQ(Budget Fair Queueing,预算公平队列)

策略:为每个进程/ cgroup 分配预算(budget),按时间片轮转派发请求。BFQ 追踪每个进程的 I/O 请求数量,在大请求之前优先服务小请求量进程。

BFQ 的预算分配机制:

process A: budget = 32 sectors → serve until exhausted → switch
process B: budget = 32 sectors → serve until exhausted → switch
process C: budget = 32 sectors → ...

关键参数:
• low_latency = 1  (优先保障交互式进程的低延迟)
• slice_idle = 8ms (空闲等待时间,增加合并机会)
• timeout_sync = 0(同步请求超时)
• weight = 100     (cgroup 权重,支持 io.cost模型)

写入优先级惩罚:BFQ 对写入应用 1.5-2x 的 weight penalty,
因为写入最终会由 writeback 异步完成,但读取必须同步等待。

适用场景:桌面系统、多租户云环境、需要保障吞吐公平性的场景。Red Hat Enterprise Linux / Fedora 默认启用。

4.4 Kyber — 专为快速存储设计的延迟目标调度器

策略:非排序调度,而是为读写延迟目标(target latency)动态调整并发度。若延迟超过目标则减少并发,否则逐步增加。

Kyber 自适应并发控制:

• read_target_latency = 7ms(默认)
• write_target_latency = 15ms(默认)

控制回路:
  if measured_latency > target:
      reduce_in_flight_requests()  // 减少并发度
  else:
      increase_in_flight_requests()  // 增加并发度

并发度范围:
• minimum: 1(严格保序)
• maximum: queue_depth - 1(最大吞吐)

适用场景:NVMe 等低延迟存储设备(Facebook/Meta 开发并内部大量使用)。

5. 块 I/O 合并与分发优化

blk-mq 的合并策略决定了它在高并发场景下是放大还是节省工作量。

5.1 请求合并位置(Merge Points)

blk-mq 中的四种合并:

① Last Merge(最后合并)
   新到达 bio 的起始扇区 = 当前正在构建的 request 末尾扇区
   → 直接 append bio 到现有 request(最常用,路径最短)

② Front Merge(前合并)
   新到达 bio 的末尾扇区 = 当前 request 起始扇区
   → 将 bio 插入 request 头部(需要修改 request 的起始扇区)

③ Plug Merge(插拔合并)
   "塞子"临时拦住 I/O 提交,等到同进程批量提交时再放开
   → 增大会计窗口,大幅提升相邻 I/O 的合并成功率

④ Scheduler Merge(调度器合并)
   在 I/O 调度器内部的排序队列中合并
   → 跨进程合并(代价更高,需要哈希表查找)

5.2 插拔机制(Plug/Unplug)的工程价值

blk-mq 的插拔机制是极容易被忽视但影响巨大的优化。

工作流程:
1. 进程 A 调用 submit_bio() → blk_mq_submit_bio()
2. 检测到当前 CPU 的 plug 正在生效
3. 不立即派发,将 bio 加入 plug 缓存
4. 进程 A 连续提交多个 bio...
5. plug 计数达到阈值(blk_plug_max_bio)或超时
6. unplug → 将缓存的 bio 批量送入 blk-mq
7. 调度器在一次调度窗口内看到全部 bio → 合并效率最大化

典型场景:
• 数据库 checkpoint:fsync 前会批量刷脏页
  → plug 机制让数百个 4KB 写合并成几个 MB 顺序写
• 文件系统日志提交:一个事务的元数据 + 数据写成一个 request chain

实测:在 NVMe 上,plug 机制可提升顺序写 20-30%,随机写合并率提升 40%。

6. NVMe 专属优化路径

NVMe 作为现代高速存储协议,Linux 内核提供了多条 I/O 路径优化。

6.1 NVMe 提交队列(SQ)与 Doorbell 机制

NVMe 使用 64 字节的提交队列条目(SQE)和 16 字节的完成队列条目(CQE):

NVMe 命令结构(64 字节 SQE):

Offset  | Size | Field
1:0     | 2    | Command Identifier(唯一 ID,用于匹配 CQE)
3:2     | 2    | FUSE/PSDT
5:4     | 2    | Command Opcode(READ=02h, WRITE=01h, FLUSH=00h)
9:8     | NSID | Namespace Identifier
31:12   | -    | Reserved
63:32   | -    | Metadata Pointer(PRP 或 SGL)
71:64   | 8    | Data Pointer PRP1
79:72   | 8    | Data Pointer PRP2
95:80   | -    | CDW10-CDW15(命令参数:LBA、Length 等)

关键优化:SQ Tail Doorbell
• 驱动写入一个 SQE 后,只需更新一个 MMIO 寄存器(Doorbell)
• Doorbell 写入触发 NVMe 控制器 DMA 读取新 SQE
• 与 io_uring 的 SQ ring 配对,可实现门铃批量更新(batch doorbell writes)

6.2 SQ 轮询模式(Polled Mode)

blk-mq 轮询模式完全跳过 IRQ 完成路径:

传统 IRQ 中断模式:
  提交 I/O → 硬件处理 → MSI-X 中断 → 中断处理 → 唤醒等待线程
  往返延迟:2-5μs(含中断开销)

Polled Mode:
  提交 I/O → 硬件处理 → CPU 持续读取 CQ head doorbell(busy-loop)
  往返延迟:sub-1μs,无需 IRQ、无需上下文切换

CPU 代价:一个核心 100% busy-loop 等待 CQ head 前进
收益:p99.9 延迟降低 50-70%,适合超低延迟场景

启用方式:echo "none" > /sys/block/nvme0n1/queue/scheduler && echo 1 > /sys/block/nvme0n1/queue/io_poll

6.3 SR-IOV 与 NVMe over Fabrics (NVMe-oF)

NVMe-oF 将本地 NVMe 命令封装在 RDMA/TCP 帧中,使远程共享存储具备本地 NVMe 延迟特性。

NVMe-oF 架构:

┌─────────────────┐         RDMA/         ┌─────────────────┐
│  NVMe-oF Host   │────→ RoCEv2 / TCP ────→│  NVMe-oF Target │
│  (initiator)    │        ~10μs RTT        │  (NVMe SSD)     │
└─────────────────┘                         └─────────────────┘

关键参数:
• nr_io_queues: 每个目标设备的队列深度(通常为 1-32)
• queue_depth: 每个队列的命令数(通常 128-1024)
• io_poll_delay: 轮询延迟(-1=自适应,0=立即轮询)

7. 写回(Writeback)与脏页管理

I/O 栈最微妙的子系统之一是脏页的异步写回机制。它直接决定数据安全性与性能的平衡。

7.1 writeback 线程模型

Linux writeback 线程:

• bdi_writeback 结构:每个 backing device 一个
• wb_workfn():writeback 工作函数,周期性执行
• wb_check_start:检查是否需要触发写回
• wb_check_old_data:定期将脏数据同步到磁盘
• wb_check_background:后台刷盘,达到 dirty_background_ratio 时触发

触发时机:
1. 脏页比例超过阈值(默认 dirty_background_ratio=10%, dirty_ratio=20%)
2. 脏页超过一段时间未写入(dirty_expire_centisecs=30s 默认)
3. 显式调用 sync() / fsync()
4. 页缓存回收时发现脏页(直接同步写回)

7.2 Dirty Throttling — 脏页限流算法

当脏页比例逼近 dirty_ratio 时,内核会对进程限流(bandwidth-based throttling):

Dirty Throttling 算法(简化):

// 计算当前设备的实际写带宽
write_bps = (written * 1000) / (jiffies - very_dirty_period_start);

// 目标带宽
target_bps = (dirty_pages * PAGE_SIZE) / (2 * dirty_expire_centisecs);

// 计算让进程休眠的纳秒数:
pause_ns = (dirty_pages_above_background * PAGE_SIZE * 1000)
           / target_bps

// 限流对象:直接由当前写脏页的进程按比例分担
// 若进程 A 贡献了 50% 脏页,它承担 50% 的 pause 时间

这种按比例限流的算法精妙之处在于:不区分进程类型,只按脏页贡献量限流,避免了传统全局锁或优先级机制的竞态问题。

7.3 writeback 的 cgroup 控制(v2)

cgroup v2 的 io.cost 模型可以直接限制块设备的带宽:

# 限制 /dev/nvme0n1 读写带宽
echo "8:0 rbps=104857600 wbps=52428800" > io.max

# 参数说明:
# 8:0        — 设备 major:minor
# rbps       — 读取带宽限制(bytes/s)
# wbps       — 写入带宽限制
# riops      — 读取 IOPS 限制
# wiops      — 写入 IOPS 限制
# cost.qos   — 服务质量(Dynamic QoS)

8. 故障注入与监控体系

8.1 blk-mq 的故障注入框架

Linux 内核提供 blk-debugfs 和 fault-injection 框架验证块 I/O 栈的容错能力:

# /sys/block/nvme0n1/mq/0/fault_inject 示例
echo 10 > /sys/kernel/debug/fail_make_it_fail/probability
echo 100 > /sys/kernel/debug/fail_make_it_fail/interval
echo "nvme" > /sys/kernel/debug/fail_make_it_fail/task-filter

# 或直接通过 debugfs 注入特定 I/O 错误
echo 5 > /sys/block/sda/mq/0/fault_inject/probability  # 5% 概率失败
echo 1 > /sys/block/sda/mq/0/fault_inject/times       # 持续 N 次

8.2 blktrace — I/O 路径时间分布可视化

blktrace 是 Linux I/O 栈的第二重要的分析工具(仅次于 ftrace):

# 捕获块 I/O 事件
blktrace -d /dev/nvme0n1 -o nvme_trace &

# 稍后停止
killall blktrace

# 处理并可视化
blkparse -i nvme_trace -d nvme_trace.bin
btt -i nvme_trace.bin

# btt 输出关键指标:
Q2Q:   各 I/O 端到端总时间(Queue-to-Queue)
Q2G:   从提交到进入调度器时间(Queue-to-Grant)
G2I:   从 scheduled 到插入请求队列(Grant-to-Insert)
I2D:   从插入到 DMA 完成(Insert-to-Device)
D2C:   从设备完成到 completion 信号(Device-to-Complete)
Q2C:   全路径总时间(Queue-to-Complete = Q2G+G2I+I2D+D2C)

典型热点分布解读:

阶段耗时热点含义
Q2G高plug/unplug 上限概率偏低;cgroup 限流
G2I高I/O 调度器 sort_list/bfq_bfqq 锁争用
I2D高硬件队列已满(queue_depth 不足);设备瓶颈
D2C高MSI-X 中断延迟;IRQ 亲和性未绑定

8.3 BPF 直观测块 I/O 栈

配合上一篇文章讲到的 eBPF,可用 BPF 程序在运行时观测块 I/O 栈的关键指标:

// BPF 程序追踪 I/O 延迟分布(BCC 脚本示例)
b = BPF(text='''
#include <linux/blkdev.h>

BPF_HASH(start, struct request *);
BPF_HISTOGRAM(dist);

int trace_req_start(struct pt_regs *ctx, struct request *req) {
    u64 ts = bpf_ktime_get_ns();
    start.update(&req, &ts);
    return 0;
}

int trace_req_done(struct pt_regs *ctx, struct request *req) {
    u64 *tsp = start.lookup(&req);
    if (tsp == 0) return 0;
    u64 delta = bpf_ktime_get_ns() - *tsp;
    dist.increment(bpf_log2l(delta / 1000));  // 转 μs 并对数分桶
    start.delete(&req);
    return 0;
}
''')

b.attach_kprobe(event="blk_mq_start_request", fn_name="trace_req_start")
b.attach_kprobe(event="blk_mq_end_request", fn_name="trace_req_done")

# 输出示例:
# avg = 324 usecs, duration = 120 seconds
# usecs               : count     distribution
#     0 -> 1          : 0        |                                        |
#     2 -> 3          : 0        |                                        |
#     4 -> 7          : 1        |                                        |
#     8 -> 15         : 3        |*                                       |
#    16 -> 31         : 12       |*****                                   |
#    32 -> 63         : 25       |***********                             |
#   64 -> 127         : 45       |******************                      |
#   128 -> 255        : 78       |********************************        |
#   256 -> 511        : 120      |****************************************|
#   512 -> 1023       : 65       |**************************              |
#  1024 -> 2047       : 28       |************                            |
#  2048 -> 4095       : 8        |***                                     |

8.4 iostat 与 /proc/diskstats 解读

/proc/diskstats 提供块设备的核心统计:
字段:
1  major      主设备号
2  minor      次设备号
3  device     设备名
4  reads      成功完成的读次数
5  reads_merged  合并的读次数
6  sectors_read  读取的扇区总数
7  time_reading   读操作耗时(ms)
8  writes     完成写次数
9  writes_merged 合并写次数
10 sectors_write 写入扇区总数
11 time_writing  写操作耗时(ms)
12 in_flight  当前飞行中的 I/O 数
13 time_in_queue 所有 I/O 在队列中总耗时(ms)
14 discards   丢弃完成次数

iostat -x 输出关键指标:
• rrqm/s / wrqm/s:每秒合并的请求数(合并率越高,写放大越低)
• r/s / w/s:每秒读写请求数
• r_await / w_await:每次 I/O 的平均等待时间(含排队+服务)
• %util:设备忙碌比例(100% = 饱和,不一定达到带宽上限)
• aqu-sz:平均队列深度(如果持续接近 queue_depth,需要扩队列)

9. 性能调优实战清单

针对不同场景的块层调优参数速查表:

9.1 NVMe 数据库场景(OLTP)

# 调度器:使用 none(NVMe 内部并发)
echo none > /sys/block/nvme0n1/queue/scheduler

# 增大队列深度
echo 1024 > /sys/block/nvme0n1/queue/nr_requests

# 关闭合并(NVMe 不需要 OS 级合并)
echo 2 > /sys/block/nvme0n1/queue/nomerges

# 增大队列物理区块大小
echo 4096 > /sys/block/nvme0n1/queue/max_hw_sectors_kb

# 轮询模式启用(低延迟)
echo 1 > /sys/block/nvme0n1/queue/io_poll

# 中断亲和性绑定(每个 CPU 一个中断向量)
echo 0f > /proc/irq/32/smp_affinity

9.2 SATA SSD Web 服务器场景

# 调度器:mq-deadline(保障读低延迟)
echo mq-deadline > /sys/block/sda/queue/scheduler

# 适度队列深度
echo 256 > /sys/block/sda/queue/nr_requests

# 保留 front merge 关闭(blk-mq 默认)
echo 2 > /sys/block/sda/queue/nomerges

# 启用 write-back 缓存(如果设备支持)
echo write through > /sys/block/sda/queue/write_cache  # write back / write through

# 预读窗口增大
echo 256 > /sys/block/sda/queue/read_ahead_kb

9.3 HDD 顺序写大文件(数据仓库)

# 调度器:BFQ(公平分配带宽)
echo bfq > /sys/block/sdb/queue/scheduler

# 最大化队列深度(允许更多合并)
echo 4096 > /sys/block/sdb/queue/nr_requests

# 启用所有合并类型
echo 0 > /sys/block/sdb/queue/nomerges

# 大幅预读
echo 4096 > /sys/block/sdb/queue/read_ahead_kb

# 增大物理 I/O 合并限制
echo 1024 > /sys/block/sdb/queue/max_sectors_kb

# 使用内核 writeback 线程参数优化
echo 50 > /proc/sys/vm/dirty_background_ratio
echo 80 > /proc/sys/vm/dirty_ratio
echo 100 > /proc/sys/vm/dirty_expire_centisecs

10. 未来趋势与演进方向

10.1 OpenSSD / ZNS(Zoned Namespaces) — 主机端 FTL

NVMe ZNS SSD 将闪存转换层(FTL)部分暴露给主机,文件系统可直接管理物理擦除块,减少写放大:

ZNS 核心概念:
• Zone(区):固定大小(如 256MB)的顺序写入区域
• Zone Capacity:小于 Zone Size(可减少 over-provisioning)
• Zone State:Empty / Open / Full / ReadOnly / Offline
• 写约束:只能对 Open Zone 写入,必须顺序写

Linux 支持:
• zonefs:直接暴露 Zone 的文件系统(无 FTL)
• Btrfs 5.12+:原生 Zoned 模式(顺序写 Zone 转换)
• F2FS:支持 Zoned Block Device(zoned FTL)

10.2 Key-Value Command Set(KV Command Set)

NVMe KV 命令集允许设备直接暴露 key-value 语义,跳过文件系统层:

KV 操作:
• Store(key, value)
• Retrieve(key) → value
• Delete(key)
• Exist(key)

优势:
• 消除文件系统翻译层(ext4/XFS 的 inode、extent 管理)
• 直接与 KV 存储引擎对接(RocksDB、TiKV)
• 可利用 NVMe 内部的 KV 加速硬件(部分 SSD SoC 支持)

10.3 SPDK(Storage Performance Development Kit)的用户态驱动

业界正逐步将块设备驱动从内核态移到用户态(SPDK):

SPDK 绕过内核的关键技术:
• Userspace NVMe 驱动:通过 vfio/uio 直接映射 BAR 寄存器
• 无锁环形队列:每个核独立 SQ/CQ,完全无锁
• 大页(2MB/1GB)内存映射:避免 TLB miss
• Polled Mode:用户态直接 busy-loop 读取 CQ

性能优势:
• SPDK 单核 NVMe IOPS 可达 6-7M(内核 blk-mq 为 2-3M)
• 延迟:SPDK ~10μs vs 内核 ~25μs
• CPU 效率:SPDK 100%(busy-loop)vs 内核 ~60%(含中断处理)

结语

Linux 块设备层从单队列到 blk-mq 的演进,是一次面向高速存储硬件的架构重构。blk-mq 通过软件队列与硬件队列的分离映射,将传统块层的全局锁、单一调度器和单一队列彻底打散为 per-CPU、NUMA 本地的并行通路;I/O 调度器从单一的 CFQ 演变为 mq-deadline、BFQ、Kyber、none 四种策略各有所长;写回机制的动态脏页限流算法在没有全局队列的情况下实现了精确的带宽公平控制;配合 NVMe 硬件的 Doorbell 机制、轮询模式和 io_uring 直通路径,让 Linux 存储栈的单核 IOPS 从 100K 时代进入了百万以上的新纪元。

理解块 I/O 栈——从文件系统 bio 的创建到 blk-mq 的硬件队列映射、从 I/O 调度器的排序策略到 NVMe 的 Doorbell 通知——对于构建数据库、分布式存储引擎、消息队列等存储密集型系统至关重要。正如 eBPF 革新了网络栈的可观测性,blk-mq 正在为存储栈开启一个硬件感知、NUMA 亲和的并行调度新时代。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部