NVMe 存储栈深入:PCIe 队列对、多队列调度与 io_uring 的结合

NVMe 通过 PCIe 直连与高深度的多队列把闪存盘的随机 IO 性能推向数百万级 IOPS,但这要求整条存储栈——从驱动队列、块层 blk-mq、IO 调度器到用户态 io_uring——都以极低延迟协作。本文从 PCIe 事务与队列对模型讲起,深入 NVMe 多队列映射、blk-mq 与 IO 调度器,再剖析 io_uring 与 NVMe 结合后的轮询与内核绕过路径,最后给出生产环境压测与调优清单。

NVMe(Non-Volatile Memory Express)是当下高性能存储的事实标准。一块现代 NVMe SSD 能轻松达到 7000 MB/s 的顺序读与百万级 IOPS,远超 SATA SSD。但真正难的地方不在硬件,而在软件栈:如果 IO 路径里任何一个环节(锁、中断、调度、上下文切换)拖后腿,硬件的极限就完全发挥不出来。

本文沿一条完整的 IO 路径展开:从 PCIe 上的队列对模型,到 NVMe 多队列与 MSI-X 中断,再到 Linux 块层的 blk-mq 与 IO 调度器,最后深入 io_uring 与 NVMe 的结合(用户态轮询、内核绕过)。它是 https://plumephp.com/os-io-stack/(VFS 到块设备)的下游深潜,建议先读那篇建立整体框架。


为什么是 NVMe?从 AHCI 的瓶颈说起

SATA 时代的 AHCI 协议把每个盘固定为一个命令队列,深度只有 32,命令必须串行进出,且要经过 AHCI 控制器转换。SATA 链路本身也只有 6 Gbps 带宽,就算闪存介质再快,协议栈就是天花板。

NVMe 的三大设计革命:

  1. 走 PCIe 直连:PCIe 3.0 x4 就有约 3.9 GB/s 带宽,PCIe 4.0/5.0 翻倍,消除了 SATA 的带宽瓶颈。
  2. 每队列 64K 深度 × 最多 64K 队列:彻底摆脱"单队列"的串行化,为多核并行铺路。
  3. 精简命令集:读写命令、完成状态都在内存中共享,只有通知(doorbell)走 MMIO,减少协议开销。

一句话:AHCI 让存储适配串行磁盘时代的并发模型,NVMe 让存储拥抱多核 CPU 的并行模型。


PCIe 上的通信模型:事务、MMIO 与 DMA

NVMe 设备本质是一个 PCIe 端点(Endpoint)。CPU 与设备之间通过 PCIe 的事务层交互,主要分三类:

  • MMIO(Memory-Mapped I/O):CPU 向设备寄存器发起读写。NVMe 用 MMIO 写"门铃(doorbell)“来通知设备有新命令。
  • DMA 读:设备主动从主存读取数据(例如读命令请求)。
  • DMA 写:设备把数据写入主存(例如完成队列条目、数据块本身)。
CPU(驱动)
  │  ① 写命令到 提交队列(SQ)  共享内存
  ▼
SQ 条目(在宿主内存)
  │  ② MMIO 写 doorbell(告知设备)
  ▼
NVMe 控制器
  │  ③ DMA 读 SQ → 取命令 → 访问闪存
  │  ④ DMA 写数据到宿主内存 + 写完成队列(CQ)
  ▼
CQ 条目(在宿主内存)
  │  ⑤ 中断(MSI-X)通知驱动
  ▼
CPU(驱动读 CQ,回收命令)

关键洞察:命令与完成都在内存中,CPU 与设备之间只需一次 MMIO 写。这就是 NVMe 延迟低的结构性原因——现代一轮 NVMe 读写可低至 20-30 微秒。

MSI-X 中断

NVMe 充分利用 MSI-X:每个队列对可以拥有独立的中断向量,且中断可以被每 CPU 路由。这样每个 CPU 核心只处理自己那组队列的中断,避免传统共享中断在核间打架。这为后面的"每 CPU 队列"奠定了硬件基础。


NVMe 多队列:从"一个队列"到"每核一队列”

队列对(Queue Pair)

NVMe 的 IO 队列以队列对为最小单位:一个提交队列(Submission Queue, SQ)+ 一个完成队列(Completion Queue, CQ)。默认实现中,SQ 的完成条目都落到同一个 CQ。每条命令是一个 64 字节的提交条目,完成条目为 16 字节。

  • SQ 深度:一个队列最多 65536(2^16)条。
  • 队列数量:最多 65536 个队列对(受控制器能力与内存限制,实际通常数百)。

每 CPU 队列

现代 Linux 驱动(nvme 模块)为每个 CPU 分配一个(或多个)队列对,使 IO 提交完全无锁:

# 查看 NVMe 设备与队列信息
nvme list
nvme id-ctrl /dev/nvme0 | grep -i "nn\|maxcmd"
# 每个 CPU 一个队列:驱动默认启用
dmesg | grep nvme | grep "queue"

IO 提交路径因此变成:应用系统调用 → 文件系统 → 块层 → 本 CPU 的 NVMe 队列 → doorbell。整条路径上没有全局锁,这是百万 IOPS 的软件前提。

队列深度与延迟的关系

队列越深,控制器可以越早开始执行未完成的命令(流水线),但也会放大延迟波动;队列太浅又容易空转。生产上一般保持默认(每队列 1024 或 256),配合 io_uring 的高并发提交,效果最佳。


块层深入:bio、request 与 blk-mq

NVMe 直接面向块层。理解 NVMe 性能,必须先理解 Linux 块层的数据结构。

bio 与 request

  • bio:一次"高层 IO 请求"的抽象,描述数据要读写到哪里(块区间)、数据页在哪里、操作类型。文件系统把一次逻辑操作拆成若干 bio。
  • request:块层把合并后的 bio 打包成的"面向设备的最小执行单元"。NVMe 驱动再把它翻译成一个或多个 NVMe 命令。

传统单队列块层(request_queue + request_fn)存在全局队列锁,多核争用严重。这就是为什么 NVMe 需要 blk-mq。

blk-mq:多队列块层

blk-mq(block multi-queue)从网络栈的 RSS 思想借鉴而来,把块请求队列拆分为三层:

        blk-mq
  ┌───────────────────────────────┐
  │ 软件队列(per-CPU,软队列)      │  ← 无锁提交(per-cpu)
  ├───────────────────────────────┤
  │ 硬件队列(per-NVMe-queue)      │  ← 与设备队列一一映射
  ├───────────────────────────────┤
  │ 调度器(可选,插在软队列上)      │  ← mq-deadline / kyber / none
  └───────────────────────────────┘
  • 软件队列:每个 CPU 一个,IO 提交进本 CPU 软队列,无需加锁。
  • 硬件队列:映射到 NVMe 硬件队列,通过"合并窗口"批量把软队列请求搬给硬件。
  • 调度器:在软队列之上可选启用,见下节。

blk-mq 带来的直接收益:多核提交无竞争、中断与队列亲和、批量提交减少 doorbell 次数。现代内核中 SCSI、NVMe、virtio-blk 全部迁移到 blk-mq。

IO 调度器:none、mq-deadline 与 kyber

传统机械盘需要调度器(如 CFQ)在物理寻道上做电梯合并。NVMe 上物理随机访问开销趋近于零,调度器的合并价值大幅下降,反而引入延迟。因此 NVMe 场景的调度器选择如下:

调度器特点NVMe 适用性
none不调度,直接透传最佳延迟,推荐(尤其配 io_uring 轮询)
mq-deadline按截止时间保证读写公平一般工作负载默认,容忍少量延迟
kyber依据延迟目标自适应需要公平且延迟可控的中间态
# 查看/设置 NVMe 设备的调度器
cat /sys/block/nvme0n1/queue/scheduler
echo none > /sys/block/nvme0n1/queue/scheduler

# 查看队列信息
cat /sys/block/nvme0n1/mq/0/cpu_list
cat /sys/block/nvme0n1/queue/nr_requests

io_uring 与 NVMe 的结合

io_uring 是 Linux 5.1 引入的异步 IO 框架,它和 NVMe 是"天作之合":io_uring 提供共享内存环与最小化系统调用,NVMe 提供高并发队列。关于 io_uring 的完整机制(SQ/CQ 环、io_uring_enter、IORING_SETUP_IOPOLL)已在 https://plumephp.com/os-high-performance-io/ 中详解,这里聚焦它与 NVMe 的三个深度结合点。

用户态轮询(IOPOLL)

IORING_SETUP_IOPOLL 让 io_uring 在完成路径上不再依赖中断,而是驱动在提交侧忙轮询 CQ:

struct io_uring_params params = { .flags = IORING_SETUP_IOPOLL };
io_uring_queue_init(1024, &ring, &params);

for (;;) {
    // 批量提交
    int n = io_uring_submit(&ring);
    // 忙轮询完成
    unsigned head;
    for (;;) {
        struct io_uring_cqe *cqe;
        io_uring_peek_batch_cqe(&ring, &cqe, 1);
        if (cqe) break;   // 有完成,退出轮询
    }
    io_uring_cqe_seen(&ring, cqe);
}

忙轮询把"中断→上下文切换→调度"的延迟完全从关键路径上抹掉,单轮 IO 延迟可从 ~30μs 压到 10μs 左右。代价是轮询期间 CPU 忙转,适合延迟第一的场景(如存储引擎、量化系统)。典型配置 queue.poll_delay 控制轮询退避。

io_uring 与 NVMe 直通(passthrough)

一般读写走文件系统;但 io_uring 还支持 NVMe passthrough——直接把 nvme_cmd 提交给设备,绕过文件系统。这在数据库/存储中间件里用于执行 NVMe 原生命令(如 dataset management、write zeroes、FUA)。

内核绕过与 SPDK

若延迟要求到极致,可以完全绕过内核:SPDK(Storage Performance Development Kit) 用用户态驱动(DPDK 的存储版)直接控制 NVMe 队列,避免系统调用、上下文切换、中断。SPDK 下随机读延迟可低至个位数微秒。代价是实现复杂、不兼容标准 POSIX 接口。取舍如下:

方案延迟量级复杂度兼容性
标准 POSIX + buffered100μs+低最好
标准 POSIX + direct/io_uring20-40μs中好
io_uring IOPOLL10-15μs中高较好
SPDK 用户态驱动<10μs高差(需专用路径)

生产实践:NVMe 性能调优清单

把理论落到工程,常见调优动作:

# 1) 队列调度器:延迟敏感用 none
echo none > /sys/block/nvme0n1/queue/scheduler

# 2) 提高请求合并窗口(nr_requests)
echo 1024 > /sys/block/nvme0n1/queue/nr_requests

# 3) IRQ 亲和:让中断绑到处理 IO 的 CPU
# 查看中断分布
grep -E "nvme" /proc/interrupts
# 用 irqbalance 或手动写 smp_affinity_list

# 4) 磁盘对齐与块大小
# mkfs 时对齐 4K,启用 discard(TRIM)
mkfs.ext4 -O ^has_journal -E stride=128,stripe_width=128 /dev/nvme0n1

# 5) 考虑 io_uring 专用引擎(如 liburing / 存储中间件)

压测方法

# fio 经典随机读压测
fio --name=randread --rw=randread --bs=4k --direct=1 \
    --ioengine=libaio --iodepth=256 --numjobs=16 \
    --filename=/dev/nvme0n1 --time_based --runtime=30s --group_reporting

# io_uring 引擎对比
fio --name=randread --rw=randread --bs=4k --direct=1 \
    --ioengine=io_uring --iodepth=256 --numjobs=16 \
    --filename=/dev/nvme0n1 --time_based --runtime=30s

对比 libaio 与 io_uring 的结果,通常 io_uring 在队列深度高时吞吐相当、延迟更低。iostat -x 1 观察 %util、svctm、await 判断是否到达设备极限。

常见误区

  • 误区一:调度器越大越好。NVMe 上 mq-deadline 反而可能引入 10-20% 延迟抖动,延迟敏感场景应选 none。
  • 误区二:只关注 IOPS。随机 4K 与顺序 128K 是两种完全不同的负载,压测必须贴真实业务。
  • 误区三:忽略碎片。未开启 TRIM/discard 的 SSD 长期写入后 GC 放大,性能衰减明显。

NVMe 命令集与管理命令

Admin 命令 vs IO 命令

NVMe 把命令分成两类:

类别用途代表命令
Admin Command设备管理、配置、创建队列Identify、Create IO SQ/CQ、Get Log Page、Format NVM
IO Command数据传输Read、Write、Flush、Compare、Write Zeroes

驱动初始化时的第一件事,就是通过 Admin 队列发送 Identify 命令,读取设备的能力与特性(命名空间数量、队列上限、电源状态),然后按能力创建 IO 队列对。理解命令分类,是读懂 nvme-cli 工具输出的前提。

常用管理命令

# 查看命名空间
nvme list-ns /dev/nvme0
# 查看设备能力(Identify)
nvme id-ctrl /dev/nvme0
# 固件升级(先在状态字段选择槽位)
nvme fw-activate /dev/nvme0 --slot=1
# 安全擦除
nvme format /dev/nvme0n1 --ses=1
# 查看设备健康信息(温度/寿命/坏块)
nvme smart-log /dev/nvme0

多路径与故障切换

企业级 NVMe 通常支持多控制器路径。Linux 的 nvme-multipath 让一个命名空间经由多个 PCIe 路径呈现,配合 DM 多路径实现故障切换与负载均衡。lsblk 中看到的 /dev/nvme0n1 与 /dev/dm-* 别名,就是这种映射的体现。多路径在高可用存储架构中几乎是标配。


ZNS 与下一代 SSD:把写放大交还给软件

传统 SSD 的 GC 放大问题

闪存以"块"为单位擦除,写入新数据时若块里有旧数据必须先搬移,导致写放大(Write Amplification)。SSD 控制器内部的 GC(垃圾回收)在后台搬运数据,在随机写密集场景下放大倍数可高达 3-5 倍——既浪费闪存寿命,也引入延迟抖动(后台 GC 与前台 IO 争抢介质)。

ZNS:分区命名空间

ZNS(Zoned Namespace) 把闪存组织成"只能顺序写"的 Zone,把 GC 的主动权从控制器交还给软件(文件系统/应用)。应用按 Zone 顺序写、整 Zone 回收,写放大趋近 1。对数据库日志、WAL、顺序写入的中间件尤其有利。

传统 SSD:应用随机写 → 控制器内部 GC 搬移 → 写放大 3-5x
ZNS SSD: 应用按 Zone 顺序写 → 无内部 GC → 写放大 ~1x

与文件系统的配合

支持 ZNS 的生态包括 f2fs(Flash-Friendly File System)、zonefs 以及 NVMe Zoned API。对多数读者,重点在于理解"顺序写优先"的 IO 模式对现代闪存的重要性——这与应用层设计(如把日志、临时文件设计成顺序写)直接相关,也是存储中间件在 IO 模式设计上的常见考量。


结语

NVMe 是把"闪存介质快"变成"应用感知快"的关键一环,但它考验的是整条软件栈的协同:PCIe 队列对让 CPU 与设备低延迟对话,每核队列让多核无锁并行,blk-mq 把块层从全局锁中解放,io_uring 把异步与轮询推到极致,SPDK 则在延迟红线场景完成最后一公里的内核绕过。

理解 NVMe 存储栈,就是理解"硬件给了你百万 IOPS 的能力,软件如何一分不浪费地接住它"。当你能脱口而出"为什么这里要用 none 调度器"“为什么 io_uring IOPOLL 能省 20μs”,你就真正掌握了高性能存储的工程直觉。


延伸阅读

  1. NVM Express 规范:nvmexpress.org 的 Base Specification(队列模型章节)
  2. Linux Kernel Documentation: Documentation/block/blk-mq.rst
  3. liburing 示例与 io_uring 内核文档:Documentation/admin-guide/io_uring.rst
  4. SPDK 官方文档:spdk.io(用户态 NVMe 驱动)
  5. 《Linux 内核设计与实现》块层与 IO 调度章节(结合 https://plumephp.com/os-io-stack/)

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 虚拟化与容器隔离:从 KVM/QEMU 到 namespace 与 cgroup
  2. 系统启动全流程:从 BIOS/UEFI、GRUB 到内核与 systemd
  3. 操作系统安全加固与可信计算:LSM、内核加固、TPM 与容器逃逸防护