HOOOS

为什么在极限性能场景下,SPDK 依然比 io_uring 快?

0 22 硬核架构说 SPDKiouringLinux内核
Apple

在当今的存储性能压测中,如果你把一块企业级 PCIe Gen4/Gen5 NVMe SSD 的性能推向极限,通常会发现一个现象:尽管 Linux 的 io_uring 已经将内核异步 I/O 的性能提升到了前所未有的高度,但在单核 IOPS 极限压测或极致低延迟(Microseconds 级别)场景下,SPDK(Storage Performance Development Kit)依然能跑出比 io_uring 更好的数据。

很多开发者会产生疑问:io_uring 已经支持了 IORING_SETUP_IOPOLL(内核轮询)和 IORING_SETUP_SQPOLL(提交线程轮询),几乎把系统调用的开销降到了零,为什么在极限场景下还是干不过 SPDK?

要回答这个问题,我们需要脱离简单的“系统调用”范畴,深入到 CPU 架构、内核虚拟文件系统(VFS)、PCIe 总线协议以及内存管理的最底层。


1. 架构的终极分歧:内核旁路(Kernel Bypass) vs. 内核优化

这是两者最根本的物理边界差异。

[ 用户态应用 ] ──(SPDK 驱动)──[ PCIe MMIO 直接读写 ]──> [ NVMe SSD ]
     │
     └─(io_uring)──> [ Linux 内核 (VFS / Block Layer / NVMe Driver) ] ──> [ NVMe SSD ]
  • io_uring 是“内核优化”:它的本质是提供了一条高效的、基于共享内存环形缓冲区(Ring Buffer)的通道,让用户态和内核态能够无锁地传递 I/O 请求。但是,I/O 请求一旦进入内核,依然要走 Linux 内核的既定路线
  • SPDK 是“内核旁路(Kernel Bypass)”:它彻底把 Linux 内核一脚踢开。SPDK 基于 DPDK 的 UIO/VFIO 技术,把 PCIe 网卡和存储设备的控制权直接拉到了用户态。SPDK 内部自己实现了一套纯用户态的 NVMe 驱动。

因为这个根本差异,导致了以下几个致命的性能损耗点:

操作系统上下文与 KPTI 损耗

即使 io_uring 开启了 SQPOLL(内核线程轮询),完全避免了 enter 系统调用,但在某些情况下,硬件中断的处理、页表切换(尤其是在开启了 Meltdown 漏洞防御 KPTI 的系统上)依然会隐式发生。而 SPDK 运行在纯粹的用户态,CPU 核心被强行绑定(Affinity),永远不会发生用户态到内核态的上下文切换。


2. 软件栈的厚度:消失的 VFS 与 Block 层

在 Linux 内核中,一个 I/O 请求(即使是通过 io_uring 下发的)通常要经历:
VFS (虚拟文件系统) -> 具体文件系统 (如 Ext4/XFS) -> 通用块设备层 (Block Layer) -> I/O 调度器 -> NVMe 驱动 -> 硬件

虽然内核开发者对这条路径做了极尽苛刻的优化,但它依然是一条“通用路径”,需要兼容各种奇奇怪怪的设备和文件系统语义。

  • VFS 和文件系统的元数据锁:只要你通过文件系统读写,就不可避免地会遇到 inode 锁、目录项缓存(dentry)查找、文件大小更新等逻辑。这些在多线程并发时是极大的瓶颈。
  • Block 层的额外抽象:内核需要维护 request_queue,需要做 I/O 合并(merging)、拆分(splitting),这些代码逻辑在处理每秒数百万次(MIOPS)的超高吞吐时,累积的 CPU 时钟周期非常可观。

SPDK 的做法则是“极简主义”
它没有 VFS,没有通用块设备层,甚至在默认情况下没有文件系统。SPDK 的应用直接面对的是裸露的 LBA(逻辑块地址)。
当 SPDK 需要写数据时,用户态驱动直接将数据封装成 NVMe 命令,通过写入 PCIe 映射的内存地址(MMIO)直接推给 SSD 控制器。这中间没有任何多余的软件抽象,代码路径极短。


3. 轮询(Polling)的彻底程度不同

在高并发、极低延迟的存储场景下,中断(Interrupt)是性能杀手。一个中断会导致 CPU 暂停当前任务,保存现场,执行中断处理程序(ISR),再恢复现场。这不仅消耗 CPU,还会彻底毁掉 CPU 的 L1/L2 缓存热度(Cache Locality)。

因此,io_uringSPDK 都引入了轮询(Polling)机制。但两者的实现深度不同:

  • io_uring 的轮询(IOPOLL):是由内核线程在内核态不断轮询 NVMe 的 Completion Queue(CQ)。虽然免去了中断,但轮询线程和应用线程之间依然需要通过 Ring Buffer 进行同步,且受限于内核调度器的调度策略。如果系统负载极高,内核轮询线程可能会被调度器短暂挂起或产生延迟。
  • SPDK 的 Poll Mode Driver (PMD):SPDK 的轮询是绝对的、无条件的、独占的。SPDK 的工作线程(Reactor)是一个死循环(while(1)),它在用户态直接、不停地读取 PCIe 映射的 NVMe 硬件完成队列。这个线程独占一个物理 CPU 核心,没有任何操作系统调度器能够干扰它。只要有数据进来,微秒级甚至纳秒级内就会被处理。

4. 内存管理与无锁(Lockless)架构的极致

内存管理:Hugepages vs. Page Cache / Registered Buffers

  • io_uring 为了提高性能,支持 io_uring_register_buffers,允许用户提前注册并固定(pin)内存,避免每次 I/O 都进行页面映射和安全检查。这已经很接近 SPDK 了。
  • SPDK 强制基于 DPDK 的大页内存(Hugepages,如 2MB 或 1GB 页面)。大页内存不仅减少了页表项,极大降低了 CPU TLB(Translation Lookaside Buffer)的 Miss 率,而且这些内存是在初始化时一次性物理连续分配并锁定的。SPDK 的驱动直接将这些物理地址写入 NVMe 队列,硬件直接进行 DMA 传输,中间没有任何虚拟地址到物理地址的二次转换延迟。

线程模型:Share-Nothing(无共享)

SPDK 采用的是极其极端的 Share-Nothing 架构
在多核场景下,传统的内核驱动通常需要通过锁(Lock)或无锁队列来协调多个 CPU 核心对同一个 SSD 的访问。而 SPDK 会为每个绑定的 CPU 核心分配一个独立的 NVMe 队列对(Queue Pair,包括 Submission Queue 和 Completion Queue)。

  • CPU-0 只往 Queue-0 写,只从 Queue-0 读。
  • CPU-1 只往 Queue-1 写,只从 Queue-1 读。
  • 核心之间完全不共享任何数据,不需要任何锁,甚至不需要原子操作(Atomic Operations)。这种将硬件资源物理隔离到每个 CPU 核心的设计,让 SPDK 的多核扩展性呈现出近乎完美的线性增长。

5. 数据对比:直观的性能差距

在单核极限测试下,业界公认的粗略对比数据如下(视具体 NVMe SSD 性能而定):

维度 传统 libaio io_uring (开启 IOPOLL/SQPOLL) SPDK
单核 IOPS 极限 ~30万 - 40万 ~100万 - 150万 ~300万 - 800万
平均延迟 毫秒级 / 数百微秒 数十微秒 数微秒 (接近物理硬件极限)
CPU 占用率 随 IOPS 波动 (多为 Sys 占用) 随 IOPS 波动 (部分 Sys 占用) 固定 100% (用户态死循环轮询)
多核扩展性 差 (受限于内核锁) 良好 极佳 (线性无损)

总结:如何选择?

既然 SPDK 这么快,为什么我们不把所有的存储系统都改成 SPDK?

因为 SPDK 极致性能的背后,是极其昂贵的工程代价

  1. CPU 资源的绝对消耗:SPDK 的绑核轮询意味着被绑定的 CPU 核心利用率永远是 100%,即使没有任何 I/O 流量。这在公有云或混合部署环境下是极大的浪费。
  2. 失去通用生态:SPDK 没有标准的文件系统(除非使用 SPDK 自带的、非常简陋的 BlobFS)。你不能在 SPDK 之上直接运行未经修改的 MySQL、PostgreSQL,或者使用标准的 lscp 命令。所有代码必须重写,适配 SPDK 的异步回调 API。
  3. 开发与调试地狱:由于运行在用户态且无锁,一旦发生内存越界或指针悬空,整个进程直接崩溃,且极难通过标准的内核工具进行观测和调试。

选型建议:

  • 选择 io_uring:如果你的应用是通用的数据库(如 RocksDB、MySQL)、Web 服务器或分布式存储系统,需要依赖标准文件系统(Ext4/XFS),并且希望在不重构整个系统的前提下获得极高的异步 I/O 性能。io_uring 是目前性价比最高、对开发最友好的选择。
  • 选择 SPDK:如果你在构建超高吞吐、极低延迟的专业存储软件(如 NVMe-over-Fabrics 存储网关、分布式块存储引擎系统的底层 Data Path、极高性能的分布式缓存系统),且有专门的物理 CPU 资源可以被完全“烧毁”用于轮询。那么为了压榨出硬件最后一滴血,SPDK 是唯一的选择。

点评评价

captcha
健康