NVMe 的并行世界
为什么队列深度 1 只能跑出标称 IOPS 的零头?多队列、并行 die、写放大 —— 从协议层理解 SSD,让引擎的 IO 模式顺着硬件脾气来。
学完这节你能做到
- 解释队列深度与 IOPS/延迟曲线的关系
- 说清 NVMe 多队列与 CPU 亲和的配合
- 列出对 SSD 友好的三种写模式
一个老现象:QD1 为什么只能跑出零头
先复现一个你压测时一定见过的现象。同一块标称 100 万随机读 IOPS 的 NVMe,fio 跑 4k randread:
fio --name=t --filename=/dev/nvme0n1 --rw=randread --bs=4k \
--direct=1 --ioengine=io_uring --iodepth=1 --numjobs=1 --runtime=30
# 结果大约:IOPS 1.2 万,平均延迟 80us
# 只改一个参数:--iodepth=128
# 结果大约:IOPS 90 万,平均延迟 140us
队列深度 1 时,这块「百万 IOPS」的盘只跑出 1% 出头。你当年可能把这当成常识背下来了 (「压测要拉高 iodepth」),这节课把背后的原理讲透 —— 因为 forge 引擎的 IO 模式设计, 全部由这个原理推导出来。
NVMe 协议速览:天生为并行而设计
NVMe 和它取代的 AHCI/SATA 的本质区别不是接口速度,是队列模型:
| SATA/AHCI | NVMe | |
|---|---|---|
| 队列数 | 1 个 | 最多 64K 个 |
| 单队列深度 | 32 | 最多 64K |
| 队列归属 | 全局一个,所有 CPU 抢锁 | 每个 CPU 核一对 SQ/CQ |
| 中断 | 全局 | 每队列独立(MSI-X) |
「每个 CPU 核一对提交/完成队列」意味着:多核同时发 IO 互不加锁、互不通信。
你在运维时调过 irqbalance、把网卡多队列中断绑核 —— NVMe 是同一个玩法,
内核的 blk-mq(多队列块层)就是为了接住它才重写的。上一课 io_uring 的 SQ/CQ
双环设计直接致敬于此:软件队列对硬件队列,一路并行到底。
盘的另一头同样是并行的:一块 NVMe 内部有多个通道(channel),每通道挂多个 NAND die, 每个 die 能独立执行读写。标称 100 万 IOPS 是几十个 die 同时干活的总和 —— 单个 NAND die 的 4k 读延迟约 50 到 100 微秒,一个 die 单独干只有一两万 IOPS。
QD 与延迟曲线:把 fio 数据重新读一遍
现在能解释开头的现象了。QD1 意味着:发一个 IO,等它回来,再发下一个 —— 任何时刻盘内只有一个 die 在干活,其余全部围观。IOPS 上限就是 1 秒除以单次延迟: 80 微秒延迟对应 1.25 万 IOPS,和实测严丝合缝。
这就是排队论里的 Little 定律,你可以只记结论:在途请求数 = IOPS × 延迟。 想吃满几十个 die 的并行度,就必须让在途请求数(也就是 QD)盖过 die 的数量。
拉高 QD 的代价是延迟曲线分三段,你压测时都见过:
- 线性段(QD 1 到几十):IOPS 随 QD 近似线性涨,延迟基本不变 —— 并行度还没吃满,加量不加价
- 拐点(QD 到达盘的饱和点):IOPS 逼近标称值,延迟开始爬升 —— 请求开始在盘内排队
- 纯排队段(继续加 QD):IOPS 不再涨,延迟随 QD 线性恶化,P99 起飞
最优工作点在拐点附近:再低浪费盘,再高纯攒延迟。所以 forge 的 IO 调度要做的是 限制在途 IO 数量(一个信号量的事),而不是无脑把请求全灌进 ring。 里程碑压测时你要亲手把这条曲线测出来,拐点在哪块盘上是多少,由数据说话。
QD1 时百万 IOPS 的盘只跑出约 1.2 万,根本原因是什么?
FTL、写放大与 GC:SSD 内部也有一个存储引擎
读性能靠并行,写性能的水更深。NAND 有个物理死规矩:页(约 16K)只能整页写, 且必须先擦后写;擦除的单位是块(几 MB),擦除又慢又损耗寿命。 而主机发来的是 4k 的逻辑覆盖写 —— 中间全靠 FTL(闪存转换层)翻译:
- 所有写都追加到新页,旧页只做标记失效 —— 盘内就是一个日志结构存储
- 维护一张「逻辑地址到物理页」的映射表 —— 盘内还有一个内存索引
- 失效页攒多了,把块里的存活页搬走、整块擦除 —— 盘内也有 GC
你在 L1 亲手写过 Bitcask:追加写、内存索引、后台 GC —— 恭喜,你已经实现过一个 FTL 的 软件同构体。这也解释了两个运维老现象:
- 写放大(WAF):GC 搬运存活数据是额外的内部写。随机小写打散数据,WAF 常到 3 以上; 顺序大块写 WAF 接近 1。你见过「SSD 越用越慢」「SMART 里写入量远大于主机写入量」,就是它。
- 性能悬崖:压测头几分钟很快(预留空间还没用完),持续随机写半小时后 IOPS 掉一半 —— GC 开始和前台写抢通道和 die。所以正经压测要预写整盘 + 够长的 ramp time, 下一课的 forge-bench 会把这个口径固化下来。
对齐、条带与 4k:forge 引擎参数怎么定
把协议层的知识落成 forge-store 的三条硬规矩:
- 一切按 4k 对齐:偏移、长度、buffer 地址。O_DIRECT 本来就强制对齐, 更深一层是 NAND 页和 FTL 映射粒度 —— 跨页的非对齐写会触发盘内读改写,一次变两次半。
- 写路径坚持追加、批量刷:L1 的 WAL + Bitcask 追加模型对 FTL 是天然友好的 (顺序写、整页写、WAF 低)。group commit 把多个小写攒成一次大写,主机侧和盘内侧双赢。
- 删除要下发 TRIM:你只在自己的索引里标记删除,FTL 不知道那些页已经没用了,
GC 还会忠实地搬运垃圾。forge-store 的 GC 回收 extent 后,对被释放的区间发
fallocatePUNCH_HOLE 或设备级 discard,把「这块不要了」告诉盘。
对 SSD 友好的三种写模式,一句话版本:大块顺序写、对齐的整页写、批量提交的追加写。 反过来,最伤盘的就是海量非对齐随机覆盖小写 —— Weka 用日志式小写聚合躲开它, forge 的 L1 架构同样躲开了,这不是巧合,是同一个物理约束推出的同一个答案。
持续 4k 随机写半小时后,SSD 的 IOPS 从 30 万掉到 12 万。最可能的解释是?
这份数据就是里程碑②的「标尺」:下一课 forge-bench 跑出来的数字要和它对表。
review 要点:确认 randrepeat=0 和 ramp_time 真的在命令里
(AI 常漏,漏了数字会虚高);找拐点时看延迟列 —— IOPS 涨幅趋缓而 P99 开始翻倍的
那一档就是。把这块盘的标称 IOPS 从厂商 spec 查出来记进项目 README,验收要用。
帮我写一个 bash 脚本 sweep_qd.sh,用 fio 对指定 NVMe 设备做队列深度扫描,为 forge 项目建立硬件基线: 1. 参数:第一个参数为目标设备或文件路径,第二个可选参数为 runtime 秒数(默认 30); 2. 对 iodepth 取 1、2、4、8、16、32、64、128、256 依次跑 4k randread:direct=1、ioengine=io_uring、randrepeat=0、ramp_time=10、numjobs=1,输出用 --output-format=json; 3. 用 jq 从每次结果里抽取:iodepth、IOPS、平均延迟(usec)、P99 延迟(usec),汇总成一张 TSV 表打印; 4. 脚本要检查:目标路径存在、fio 和 jq 已安装、如果目标是块设备则警告这是只读测试不会破坏数据; 5. 最后追加跑一组 4k randwrite 同样的扫描(如果目标是文件而非生产设备),并在输出中标注读写两组。 跑完把 TSV 贴给我,并回答:读曲线的拐点在哪个 iodepth?拐点处的 IOPS 是标称值的百分之多少?
小结
- NVMe 的本质升级是队列模型:每核一对 SQ/CQ、盘内几十个 die 并行 —— 快靠的是并行度
- 在途请求数 = IOPS × 延迟;QD1 时 IOPS 被单次延迟锁死,只能跑出标称值的零头
- QD-延迟曲线三段式:线性段、拐点、纯排队段;引擎的最优工作点在拐点附近,靠限制在途 IO 实现
- SSD 内部就是一个日志结构存储(FTL = 追加写 + 映射表 + GC),写放大和性能悬崖都由它解释
- forge 引擎三条硬规矩:4k 对齐、追加 + 批量刷、删除下发 TRIM —— 顺着硬件脾气来