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 跑出来的数字要和它对表。
你不用读脚本 —— 让 AI 把它实际发出去的压测命令行原样打印一遍,
randrepeat=0 和 ramp_time 是最常被悄悄漏掉的两个,
漏了数字会虚高,看打印出来的命令行就能当场抓住。找拐点时盯延迟列:
IOPS 涨幅趋缓而 P99 开始翻倍的那一档就是。
目标:给 forge 项目建立这块盘的硬件基线 —— 一张 4k 随机读的「队列深度 - IOPS - 延迟」曲线表。后面 forge-bench 跑出来的数字全部对着这张表评分,所以它必须经得起复查。 行为规格: 1. 一个脚本吃两个参数:目标设备或文件路径,以及可选的单档运行秒数(默认 30); 2. 队列深度按 1、2、4、8、16、32、64、128、256 依次扫,每档跑 4k 随机读,口径固定为 direct=1、io_uring 引擎、randrepeat=0、ramp_time=10、单作业 —— 这三个口径参数少一个,整张表就作废; 3. 每档记下队列深度、IOPS、平均延迟(微秒)、P99 延迟(微秒),汇总成一张对齐的表打印出来;每档的原始结果也要落盘保留,方便我事后复查某一档; 4. 开跑前先自检:目标路径存在、依赖的压测与解析工具都已安装;目标是块设备时明确警告这是只读测试、不会破坏数据; 5. 目标是普通文件(而非生产设备)时,再追加一组同样口径的 4k 随机写扫描,输出里读写两组要能一眼分清; 6. 任何一档跑失败要立刻停下报错,不许默默跳过给我留一张缺行的表。 交付:脚本 + 一次完整跑出来的表格 + 你对这张表的读法 —— 读曲线的拐点在哪一档,拐点处的 IOPS 是厂商标称值的百分之多少。
- 扫描时另开一个终端看 iostat -x 1,盘的 r/s 要和当前这一档报出来的 IOPS 同数量级,对不上说明压根没打到盘
- 对照实验:单独跑一次 QD1,把 direct=1 去掉。IOPS 应该暴涨十倍以上 —— 涨了才证明原来那张表是真的在压盘,不是 page cache 在替你考试
- 整张表连跑两遍,同一档的 IOPS 差别超过 5% 就说明 ramp_time 不够,或者盘还没进稳态,表不能用
- 看表尾几档:IOPS 不再涨而 P99 开始接近翻倍,那一档就是拐点。如果一路涨到 256 都不见拐点,八成是设备被别的负载占着,或者根本没压满
- 把这块盘的标称 4k 随机读 IOPS 从厂商 spec 查出来记进项目 README,给拐点那一档算个百分比 —— 里程碑②验收要用这个数
- 这张表里 IOPS 从哪一档开始不再增长?盘的哪一部分在这个点上被填满了?
- 同一个队列深度下,平均延迟和 P99 为什么会差出好几倍?多出来的时间花在哪儿了?
- 如果改成 8 个作业、每个队列深度 16,总在途请求数和单作业 QD128 一样,结果会一样吗?为什么?
小结
- NVMe 的本质升级是队列模型:每核一对 SQ/CQ、盘内几十个 die 并行 —— 快靠的是并行度
- 在途请求数 = IOPS × 延迟;QD1 时 IOPS 被单次延迟锁死,只能跑出标称值的零头
- QD-延迟曲线三段式:线性段、拐点、纯排队段;引擎的最优工作点在拐点附近,靠限制在途 IO 实现
- SSD 内部就是一个日志结构存储(FTL = 追加写 + 映射表 + GC),写放大和性能悬崖都由它解释
- forge 引擎三条硬规矩:4k 对齐、追加 + 批量刷、删除下发 TRIM —— 顺着硬件脾气来