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 的三大设计革命:
- 走 PCIe 直连:PCIe 3.0 x4 就有约 3.9 GB/s 带宽,PCIe 4.0/5.0 翻倍,消除了 SATA 的带宽瓶颈。
- 每队列 64K 深度 × 最多 64K 队列:彻底摆脱"单队列"的串行化,为多核并行铺路。
- 精简命令集:读写命令、完成状态都在内存中共享,只有通知(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, ¶ms);
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 + buffered | 100μs+ | 低 | 最好 |
| 标准 POSIX + direct/io_uring | 20-40μs | 中 | 好 |
| io_uring IOPOLL | 10-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”,你就真正掌握了高性能存储的工程直觉。
延伸阅读
- NVM Express 规范:
nvmexpress.org的 Base Specification(队列模型章节) - Linux Kernel Documentation:
Documentation/block/blk-mq.rst - liburing 示例与
io_uring内核文档:Documentation/admin-guide/io_uring.rst - SPDK 官方文档:
spdk.io(用户态 NVMe 驱动) - 《Linux 内核设计与实现》块层与 IO 调度章节(结合 https://plumephp.com/os-io-stack/)
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。