mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5296 字
15 分钟
SPDK 与存储旁通
2025-06-07

某全闪存阵列厂商发现,Linux 内核的存储 IO 路径每笔 IO 要经过 7 次上下文切换,延迟能做到最好的也就 20 微秒。而金融客户要求 5 微秒以内。SPDK 将 NVMe 驱动搬到用户态后,延迟直降到 2 微秒——这是存储旁通的力量。

一、引言:存储旁通——为什么 NVMe 需要绕过内核#

第 2 章:内核旁通技术全景中,我们梳理了绕过内核的多种技术路线。DPDK 把网络收发包拉到用户态轮询处理,消除了中断和系统调用开销。但存储 I/O 面临同样的问题——甚至更严重。

当 NVMe SSD 的延迟降到 10 微秒以下时,内核块设备栈的开销变得不可忽视:一次 read() 系统调用本身就要消耗 3~5 微秒,VFS 层的元数据查找、块设备层的请求排队、中断处理、上下文切换……这些”基础设施税”加起来,可能比 SSD 本身的访问延迟还高。更关键的是,内核块设备栈的锁和队列机制在高 IOPS 场景下成为瓶颈——单个 NVMe SSD 可以轻松达到 100 万 IOPS,但内核的 request_queueblk-mq 调度很难跟上这个节奏。

SPDK(Storage Performance Development Kit)把 DPDK 的旁通哲学从网络延伸到存储:绕过内核块设备栈,在用户态直接操作 NVMe SSD。它通过 mmap 映射 PCI BAR 空间,在用户态直接读写 NVMe 的 MMIO 寄存器,提交和完成 I/O 请求——整个过程零系统调用、零中断、零上下文切换。

Note

SPDK 的核心洞察是:当存储设备的延迟降到微秒级,内核栈的开销就从”可忽略”变成”占大头”。就像 DPDK 在网络领域做的那样,SPDK 在存储领域消除了内核这个”中间人”。

SPDK 与 DPDK 共享同一套基础设施——大页内存、CPU 亲和性、轮询模式、无锁数据结构。理解了前面章节的 DPDK 内存管理(Ch04)和轮询模式驱动(Ch05),你会发现 SPDK 的很多概念都是”老朋友”。

一、SPDK 架构全景#

1.1 从 DPDK 到 SPDK:旁通哲学的延伸#

SPDK 的架构设计深受 DPDK 影响,两者共享 EAL(Environment Abstraction Layer)抽象层。DPDK 的 EAL 负责大页内存管理、CPU 亲和性绑定、PCI 设备探测等底层功能,SPDK 直接复用了这套基础设施,在其上构建存储专用的组件。

graph TB subgraph 用户态["用户态存储栈 — SPDK"] TARGET["Target 层<br/>iSCSI Target / NVMe-oF Target"] BLOB["blobstore<br/>持久化对象存储"] BDEV["bdev 抽象层<br/>通用块设备接口"] VHOST["vhost-user<br/>Virtio 后端"] NVME_DRV["用户态 NVMe 驱动<br/>MMIO/SQ/CQ"] end subgraph 共享层["共享基础设施 — DPDK EAL"] EAL["EAL 抽象层"] HUGEPAGE["大页内存"] PCIDEV["PCI 设备管理"] CPUPIN["CPU 亲和性"] end subgraph 硬件层["硬件"] NVME_SSD["NVMe SSD"] NIC["网卡<br/>RDMA/TCP"] end TARGET --> BDEV BLOB --> BDEV VHOST --> BDEV BDEV --> NVME_DRV NVME_DRV --> EAL EAL --> HUGEPAGE EAL --> PCIDEV EAL --> CPUPIN PCIDEV --> NVME_SSD TARGET -.->|"NVMe-oF RDMA/TCP"| NIC VHOST -.->|"共享内存"| VM["虚拟机"] style 用户态 fill:#e3f2fd,stroke:#1565c0 style 共享层 fill:#e8f5e9,stroke:#2e7d32 style 硬件层 fill:#fff3e0,stroke:#e65100

从架构图可以看出 SPDK 的分层设计:

层次组件职责
Target 层iSCSI Target / NVMe-oF Target对外暴露存储服务,接收远程 I/O 请求
对象存储层blobstore提供持久化的对象存储语义,支持快照/克隆
块设备抽象层bdev统一的块设备接口,屏蔽底层设备差异
虚拟化后端vhost-user为虚拟机提供 Virtio 存储后端
驱动层用户态 NVMe 驱动直接操作 NVMe SSD 的 MMIO 寄存器
基础设施DPDK EAL大页内存、PCI 管理、CPU 亲和性

1.2 SPDK 初始化流程#

SPDK 应用的启动流程与 DPDK 类似,但增加了存储子系统的初始化:

int main(int argc, char **argv)
{
// 1. 初始化 SPDK 环境(内部调用 DPDK EAL 初始化)
struct spdk_env_opts opts;
spdk_env_opts_init(&opts);
opts.name = "spdk_app";
opts.core_mask = "0x7"; // 使用 core 0-2
opts.mem_size = 2048; // 2GB 大页内存
spdk_env_init(&opts);
// 2. 初始化 bdev 子系统
spdk_bdev_initialize(bdev_init_done, NULL);
// 3. 初始化 blobstore(如果需要)
// spdk_bs_init(...);
// 4. 启动 Target 服务(iSCSI / NVMe-oF)
// spdk_nvmf_tgt_start(...);
// 5. 进入事件循环(轮询模式)
spdk_app_start(&opts, app_start_fn, NULL);
// 6. 清理
spdk_app_fini();
return 0;
}
Note

spdk_env_init() 内部调用 rte_eal_init(),完成大页内存映射、lcore 绑核、PCI 设备探测等工作。SPDK 与 DPDK 共享同一套大页内存池,这意味着 SPDK 的 I/O buffer 和 DPDK 的 mbuf 可以在同一片大页内存区域中分配,实现零拷贝的数据传递。

二、用户态 NVMe 驱动#

用户态 NVMe 驱动是 SPDK 的核心——它直接与 NVMe SSD 硬件交互,绕过内核块设备栈。理解它的工作原理,需要先了解 NVMe 协议的 SQ/CQ 机制和 MMIO 寄存器。

2.1 NVMe 协议基础:SQ、CQ 与 MMIO#

NVMe(NVM Express)协议定义了一套高效的 I/O 提交和完成机制:

  • Submission Queue(SQ):主机向 SSD 提交 I/O 命令的环形队列。每个 I/O 命令是一个 64 字节的条目(Command Entry),包含操作码(读/写/管理)、起始 LBA、数据长度、PRP 列表(物理地址)等
  • Completion Queue(CQ):SSD 向主机返回 I/O 完成状态的环形队列。每个完成条目 16 字节,包含命令 ID、状态码、结果数据
  • Admin SQ/CQ:专用于管理命令(创建/删除 I/O 队列、识别控制器、设置特性等)
  • MMIO 寄存器:映射到 PCI BAR0/BAR1 的寄存器空间,主机通过读写这些寄存器来控制 NVMe 控制器
sequenceDiagram participant App as 用户态应用 participant SQ as Submission Queue participant NVMe as NVMe 控制器 participant CQ as Completion Queue App->>SQ: 1. 构造 I/O 命令<br/>写入 SQ tail 位置 App->>NVMe: 2. 写 MMIO Doorbell<br/>通知有新命令 NVMe->>NVMe: 3. 从 SQ 取出命令执行 NVMe->>CQ: 4. 写入完成条目<br/>到 CQ head 位置 NVMe-->>App: 5. 中断/轮询通知完成 App->>CQ: 6. 读取完成状态 App->>NVMe: 7. 写 CQ Doorbell<br/>确认已处理

关键寄存器位于 PCI BAR0 的 MMIO 空间中:

偏移寄存器作用
0x0000CAP控制器能力(MQES、CQR 等位域)
0x0008VSNVMe 版本号
0x0014CC控制器配置(使能、队列大小等)
0x001CCSTS控制器状态(Ready、Fatal 等)
0x1000+SQyTDBLSQ y 的 Tail Doorbell
0x1000+CQyHDBLCQ y 的 Head Doorbell
Note

Doorbell 寄存器是 NVMe 的核心同步机制。主机写 SQ Tail Doorbell 通知控制器”有新命令”,控制器写 CQ 完成条目后主机写 CQ Head Doorbell 确认”已处理完成”。每次 Doorbell 写操作都是一次 MMIO 写,在内核驱动中这意味着一次内存映射 I/O,在 SPDK 中则是一次用户态的 mmio_write

2.2 mmap PCI BAR:用户态直接访问硬件#

SPDK 的用户态 NVMe 驱动通过 mmap 将 NVMe SSD 的 PCI BAR 空间映射到用户态地址空间,然后直接读写 MMIO 寄存器:

// SPDK 用户态 NVMe 驱动的 PCI BAR 映射(简化流程)
int nvme_pcie_ctrlr_map_bars(struct spdk_nvme_ctrlr *ctrlr)
{
// 1. 通过 DPDK EAL 获取 PCI 设备信息
struct rte_pci_device *dev = ctrlr->devhandle;
// 2. 映射 BAR0(MMIO 寄存器空间)
ctrlr->regs = spdk_pci_device_map_bar(dev, 0);
// 映射后,ctrlr->regs 指向用户态的 MMIO 寄存器基地址
// 对 ctrlr->regs 的读写就是直接操作硬件寄存器
// 3. 读取控制器能力
uint64_t cap = spdk_mmio_read_8(&ctrlr->regs->cap);
ctrlr->mqes = (cap & 0xFFFF) + 1; // 最大队列深度
ctrlr->cqr = (cap >> 16) & 0x1; // CQ 是否与 SQ 绑定
return 0;
}

映射完成后,驱动可以直接通过指针操作硬件寄存器:

// 写 SQ Tail Doorbell — 通知 NVMe 控制器有新命令
static inline void
spdk_nvme_mmio_write_sq_tdbl(struct spdk_nvme_ctrlr *ctrlr,
uint16_t qid, uint16_t tail)
{
// 直接写 MMIO 寄存器,无需系统调用
spdk_mmio_write_4(&ctrlr->regs->doorbell[qid * 2], tail);
}
// 读 CQ 完成条目 — 检查 I/O 是否完成
static inline bool
spdk_nvme_cq_check_completion(struct spdk_nvme_cq *cq,
struct spdk_nvme_cpl *cpl)
{
// 检查 phase 位是否翻转(轮询模式)
if ((cq->cpl[cq->head].status.p & 0x1) != cq->phase)
return false; // 没有新完成
*cpl = cq->cpl[cq->head];
cq->head = (cq->head + 1) % cq->size;
// 如果 head 到达了 CQ 阶段翻转点,翻转 phase
if (cq->head == 0)
cq->phase = !cq->phase;
return true;
}

2.3 spdk_nvme_probe 与 I/O qpair#

SPDK 提供 spdk_nvme_probe() API 来发现和连接 NVMe 设备,然后通过 I/O qpair(SQ + CQ 对)提交异步 I/O 请求:

#include <spdk/env.h>
#include <spdk/nvme.h>
struct ctrlr_entry {
struct spdk_nvme_ctrlr *ctrlr;
struct spdk_nvme_ns *ns;
struct spdk_nvme_qpair *qpair;
};
// 探测回调:发现 NVMe 设备时调用
static bool probe_cb(void *cb_ctx, const struct spdk_nvme_transport_id *trid,
struct spdk_nvme_ctrlr_opts *opts)
{
printf("发现 NVMe 控制器: %s\n", trid->traddr);
return true; // 接受此设备
}
// 连接回调:控制器初始化完成时调用
static void attach_cb(void *cb_ctx, const struct spdk_nvme_transport_id *trid,
struct spdk_nvme_ctrlr *ctrlr,
const struct spdk_nvme_ctrlr_opts *opts)
{
struct ctrlr_entry *entry = malloc(sizeof(*entry));
entry->ctrlr = ctrlr;
// 获取第一个 Namespace
entry->ns = spdk_nvme_ctrlr_get_ns(ctrlr, 1);
printf("Namespace 大小: %lu MB\n",
spdk_nvme_ns_get_size(entry->ns) / (1024 * 1024));
// 分配 I/O qpair(SQ + CQ 对)
entry->qpair = spdk_nvme_ctrlr_alloc_io_qpair(ctrlr, NULL, 0);
*(struct ctrlr_entry **)cb_ctx = entry;
}
int main(int argc, char **argv)
{
struct spdk_env_opts opts;
struct ctrlr_entry *entry = NULL;
// 初始化 SPDK 环境
spdk_env_opts_init(&opts);
opts.name = "nvme_hello";
opts.core_mask = "0x1";
opts.mem_size = 512;
spdk_env_init(&opts);
// 探测 NVMe 设备
spdk_nvme_probe(NULL, &entry, probe_cb, attach_cb, NULL);
if (!entry) {
fprintf(stderr, "未找到 NVMe 设备\n");
return 1;
}
// ... 执行 I/O 操作(见下文) ...
// 清理
spdk_nvme_ctrlr_free_io_qpair(entry->qpair);
spdk_nvme_detach(entry->ctrlr);
return 0;
}

2.4 异步 I/O 与回调#

SPDK 的 I/O 模型是异步回调——提交 I/O 请求后立即返回,I/O 完成时通过回调函数通知。这与内核的同步 read()/write() 模型截然不同:

// I/O 完成回调
static void read_complete(void *arg, const struct spdk_nvme_cpl *cpl)
{
struct io_context *ctx = arg;
if (spdk_nvme_cpl_is_error(cpl)) {
fprintf(stderr, "I/O 错误: status 0x%x\n", cpl->status.sc);
} else {
printf("读取完成: LBA %lu, %u bytes\n", ctx->lba, ctx->size);
// 处理读取到的数据...
}
ctx->completed = true;
}
// 异步读取示例
void nvme_async_read(struct ctrlr_entry *entry,
void *buf, uint32_t size, uint64_t lba)
{
struct io_context ctx = {
.lba = lba,
.size = size,
.completed = false,
};
// 提交异步读请求
int rc = spdk_nvme_ns_cmd_read(entry->ns, entry->qpair,
buf, // 数据缓冲区
lba, // 起始 LBA
size / 512, // LBA 数量
read_complete, // 完成回调
&ctx, // 回调参数
0); // IO flags
if (rc != 0) {
fprintf(stderr, "读请求提交失败: %d\n", rc);
return;
}
// 轮询完成(SPDK 的轮询模式)
while (!ctx.completed) {
spdk_nvme_qpair_process_completions(entry->qpair, 0);
}
}
// 异步写入示例
static void write_complete(void *arg, const struct spdk_nvme_cpl *cpl)
{
struct io_context *ctx = arg;
if (!spdk_nvme_cpl_is_error(cpl)) {
printf("写入完成: LBA %lu\n", ctx->lba);
}
ctx->completed = true;
}
void nvme_async_write(struct ctrlr_entry *entry,
void *buf, uint32_t size, uint64_t lba)
{
struct io_context ctx = { .lba = lba, .completed = false };
int rc = spdk_nvme_ns_cmd_write(entry->ns, entry->qpair,
buf, lba, size / 512,
write_complete, &ctx, 0);
if (rc != 0) {
fprintf(stderr, "写请求提交失败: %d\n", rc);
return;
}
while (!ctx.completed) {
spdk_nvme_qpair_process_completions(entry->qpair, 0);
}
}
Warning

spdk_nvme_qpair_process_completions() 是轮询操作,它会检查 CQ 中是否有新的完成条目。在 SPDK 的设计中,这个函数应该在主循环中被反复调用,而不是像上面示例那样”等待完成”。实际应用中,你会在一个事件循环中同时处理多个 qpair 的完成事件,实现高并发 I/O。

2.5 性能对比:用户态 vs 内核态#

为什么用户态 NVMe 驱动比内核驱动快?关键在于消除了三个开销来源:

开销来源内核 NVMe 驱动SPDK 用户态驱动
系统调用每次 I/O 需要系统调用(ioctl/read/write零系统调用,直接操作 MMIO
中断I/O 完成通过中断通知,每次中断 ~2-5μs轮询模式,无中断开销
上下文切换用户态→内核态→用户态,至少 2 次切换零上下文切换
内存拷贝数据可能经过内核缓冲区零拷贝,DMA 直接到用户态 buffer
锁争用blk-mqrequest_queue 有锁无锁设计,每个 qpair 独占一个线程

实测数据(Intel P4510 NVMe SSD,4K 随机读):

指标Linux 内核 NVMeSPDK 用户态 NVMe提升
IOPS(单核)~800K~2.5M3.1x
延迟 P99~15μs~8μs1.9x
CPU 利用率100%100%
Note

SPDK 的性能提升在高队列深度场景下更为显著。在低队列深度(QD=1)时,两者差距不大,因为 NVMe SSD 本身的延迟占主导。但当队列深度增加到 32~128 时,内核栈的开销成为瓶颈,SPDK 的优势就体现出来了。

三、vhost-user:Virtio 后端#

在虚拟化场景中,虚拟机需要访问存储设备。传统方式是 QEMU 在内核态模拟 Virtio 设备,每次 I/O 都要穿越用户态→内核态→用户态的完整路径。vhost-user 将 Virtio 后端从 QEMU 进程中抽离出来,放到 SPDK 进程中直接处理,通过 Unix 域套接字和共享内存实现零拷贝的 I/O 路径。

3.1 vhost-user 协议机制#

vhost-user 的核心是三个机制协同工作:

  1. Unix 域套接字:QEMU(前端)和 SPDK(后端)之间通过 Unix 域套接字交换控制消息。包括特性协商、内存区域注册、Virtio 环形队列(vring)地址传递等
  2. 共享内存:QEMU 的 Guest 物理地址空间通过 mmap 共享给 SPDK,SPDK 可以直接读写虚拟机的内存,实现零拷贝的数据传输
  3. eventfd:前后端通过 eventfd 机制互相通知——前端通知后端”有新请求”(kick),后端通知前端”请求完成”(call)
graph LR subgraph VM["虚拟机(Guest)"] GUEST_DRV["Virtio 前端驱动<br/>virtio_blk / virtio_scsi"] GUEST_VRING["Virtqueue<br/>vring(avail/used ring)"] end subgraph QEMU["QEMU 进程"] VHOST_FE["vhost 前端"] SHM["共享内存映射<br/>Guest 物理地址空间"] end subgraph SPDK["SPDK 进程"] VHOST_BE["vhost-user 后端"] BDEV["bdev 层"] NVME["NVMe 驱动"] end GUEST_DRV --> GUEST_VRING GUEST_VRING -->|"共享内存<br/>零拷贝"| VHOST_BE VHOST_FE <-->|"Unix Socket<br/>控制消息"| VHOST_BE VHOST_FE -.->|"eventfd kick"| VHOST_BE VHOST_BE -.->|"eventfd call"| VHOST_FE VHOST_BE --> BDEV --> NVME QEMU --- SHM SHM -.->|"mmap 共享"| VHOST_BE style VM fill:#e3f2fd,stroke:#1565c0 style QEMU fill:#fff3e0,stroke:#e65100 style SPDK fill:#e8f5e9,stroke:#2e7d32

3.2 Virtio 环形队列(vring)#

vhost-user 使用 Virtio 的 vring 结构来传递 I/O 请求。vring 由三部分组成:

  • 描述符表(Descriptor Table):每个描述符描述一个数据缓冲区的地址、长度和标志(是否链接到下一个描述符)
  • 可用环(Available Ring):前端(Guest)将已准备好的描述符 ID 放入可用环,通知后端处理
  • 已用环(Used Ring):后端(SPDK)将已完成的描述符 ID 放入已用环,通知前端取回
// Virtio vring 结构(简化)
struct vring_desc {
uint64_t addr; // Guest 物理地址
uint32_t len; // 缓冲区长度
uint16_t flags; // VRING_DESC_F_NEXT 等
uint16_t next; // 下一个描述符索引
};
struct vring_avail {
uint16_t flags; // VRING_AVAIL_F_NO_INTERRUPT 等
uint16_t idx; // 下一个可用槽位
uint16_t ring[]; // 描述符 ID 数组
};
struct vring_used_elem {
uint32_t id; // 描述符 ID
uint32_t len; // 写入长度
};
struct vring_used {
uint16_t flags;
uint16_t idx;
struct vring_used_elem ring[];
};

3.3 vhost-blk 与 vhost-scsi#

SPDK 提供两种 vhost 后端模式:

模式协议特点适用场景
vhost-blkVirtio-blk每个设备暴露为一个块设备,简单高效虚拟机只需要少量块设备
vhost-scsiVirtio-scsi每个控制器可挂载多个 LUN,支持 SCSI 语义需要多 LUN、SCSI 特性(UNMAP 等)

vhost-blk 的 I/O 路径更短,性能通常比 vhost-scsi 高 5%~10%。但 vhost-scsi 更灵活,支持热插拔 LUN、SCSI 任务管理等高级特性。

# 创建 vhost-blk 设备
# 方式一:使用 SPDK 的 RPC 接口
rpc.py vhost_create_blk_controller --cpumask 0x2 vhost.blk0 Malloc0
# 方式二:使用 SPDK 的配置文件
cat > spdk_vhost.conf << 'EOF'
[VhostBlk0]
Name vhost.blk0
Dev Malloc0
Cpumask 0x2
EOF
# QEMU 启动参数(连接 vhost-blk 设备)
qemu-system-x86_64 \
-chardev socket,id=char0,path=/var/tmp/vhost.blk0 \
-device vhost-user-blk-pci,chardev=char0 \
-m 4G -smp 2 \
...
Note

vhost-user 的共享内存机制使得 SPDK 可以直接将 NVMe 的 DMA 数据传输到虚拟机的内存中,无需经过 QEMU 进程中转。这是 vhost-user 性能远优于传统 Virtio 方案的关键——传统方案中,数据需要从 NVMe DMA 到 QEMU 的 buffer,再由 QEMU 拷贝到 Guest 内存,多了一次拷贝和一次进程切换。

四、bdev 抽象层#

bdev(Block Device)是 SPDK 的核心抽象层,它定义了一套统一的块设备接口,屏蔽了底层设备的差异。无论底层是 NVMe SSD、内存模拟盘、Linux AIO 设备还是远程 Ceph RBD,上层代码都通过相同的 bdev API 访问。

4.1 bdev 接口定义#

bdev 的核心接口包括:

// 打开 bdev
int spdk_bdev_open(struct spdk_bdev *bdev, bool write,
spdk_bdev_remove_cb_t remove_cb, void *remove_ctx,
struct spdk_bdev_desc **desc);
// 获取 I/O channel(每个线程一个,无锁访问)
struct spdk_io_channel *spdk_bdev_get_io_channel(struct spdk_bdev_desc *desc);
// 异步读
int spdk_bdev_read(struct spdk_bdev_desc *desc, struct spdk_io_channel *ch,
void *buf, uint64_t offset, uint64_t nbytes,
spdk_bdev_io_completion_cb cb, void *cb_arg);
// 异步写
int spdk_bdev_write(struct spdk_bdev_desc *desc, struct spdk_io_channel *ch,
void *buf, uint64_t offset, uint64_t nbytes,
spdk_bdev_io_completion_cb cb, void *cb_arg);
// 异步 unmap(TRIM)
int spdk_bdev_unmap(struct spdk_bdev_desc *desc, struct spdk_io_channel *ch,
uint64_t offset, uint64_t nbytes,
spdk_bdev_io_completion_cb cb, void *cb_arg);
// 异步 flush
int spdk_bdev_flush(struct spdk_bdev_desc *desc, struct spdk_io_channel *ch,
uint64_t offset, uint64_t nbytes,
spdk_bdev_io_completion_cb cb, void *cb_arg);

4.2 bdev 模块体系#

bdev 通过模块(module)机制支持多种后端设备。每个模块实现 spdk_bdev_module_if 接口,注册自己的 bdev 实例:

模块说明典型用途
bdev_nvmeNVMe SSD 后端直接访问本地 NVMe 设备
bdev_malloc内存模拟盘测试、缓存
bdev_aioLinux AIO 后端访问内核块设备(如 SATA SSD)
bdev_virtioVirtio 设备后端SPDK 作为 Virtio 前端访问存储
bdev_pmem持久内存后端Intel Optane PMEM
bdev_rbdCeph RADOS 后端访问 Ceph 分布式存储
bdev_uringio_uring 后端通过 io_uring 访问内核块设备
bdev_split分区拆分将一个 bdev 拆分为多个分区
bdev_lvol逻辑卷在 blobstore 上创建逻辑卷
bdev_passthru透传模块开发调试、I/O 统计

模块可以堆叠组合。例如,你可以在 NVMe bdev 上叠加 split 模块创建分区,再叠加 lvol 模块创建逻辑卷:

# 创建 NVMe bdev
rpc.py bdev_nvme_attach_controller -b Nvme0 -t pcie -a 0000:01:00.0
# 在 NVMe bdev 上创建分区
rpc.py bdev_split_create Nvme0n1 2
# 生成 Nvme0n1p0, Nvme0n1p1
# 创建 malloc bdev(内存盘,用于测试)
rpc.py bdev_malloc_create -b Malloc0 1024 512
# 创建 1GB、块大小 512 字节的内存盘

4.3 I/O Channel:无锁并发#

bdev 的 I/O Channel 是 SPDK 无锁设计的核心。每个线程获取自己的 I/O Channel,I/O 请求通过 Channel 提交,无需加锁:

// 典型的 bdev I/O 使用模式
struct my_app_ctx {
struct spdk_bdev_desc *desc;
struct spdk_io_channel *ch;
};
void do_io_read(struct my_app_ctx *ctx, uint64_t lba, uint32_t count)
{
// 分配 I/O buffer(从 SPDK 大页内存池)
void *buf = spdk_dma_zmalloc(count * 512, 4096, NULL);
// 通过 I/O channel 提交异步读
spdk_bdev_read(ctx->desc, ctx->ch, buf,
lba * 512, count * 512,
io_read_complete, buf);
}
static void io_read_complete(void *cb_arg, int bdev_status)
{
void *buf = cb_arg;
if (bdev_status == 0) {
// 处理读取的数据...
}
spdk_dma_free(buf);
}
Note

I/O Channel 的设计哲学与 DPDK 的 per-lcore 数据结构一脉相承。每个线程独占自己的 Channel,Channel 内部维护该线程的 I/O 提交队列和完成队列,不同线程之间无需同步。这消除了传统内核块设备栈中 request_queue 锁的争用问题。

五、blobstore:持久化对象存储#

blobstore 是 SPDK 提供的持久化对象存储层,构建在 bdev 之上。它提供类似文件系统的语义——创建、删除、读写”blob”(二进制大对象),但不实现目录结构、权限管理等 POSIX 语义,因此可以做到比文件系统更轻量、更高性能。

5.1 存储布局:Cluster 与 Page#

blobstore 将底层 bdev 的空间划分为两级结构:

  • Cluster:blobstore 的最大分配单位,默认 1MB。每个 Cluster 由连续的 Page 组成
  • Page:最小分配单位,默认 4KB。与底层 bdev 的块大小对齐
+------------------------------------------------------------------+
| Super Block (Page 0) | Metadata Pages | Data Clusters ... |
+------------------------------------------------------------------+
| 4KB | 4KB each | 1MB each |

Super Block 存储 blobstore 的元信息:Cluster 大小、Page 大小、Cluster 数量、Metadata 区域位置等。Metadata 区域存储 blob 的索引信息——每个 blob 的 ID、大小、Cluster 映射表等。

5.2 Thin Provisioning 与空间分配#

blobstore 支持 Thin Provisioning——创建 blob 时不立即分配所有空间,而是在首次写入时按需分配。这大大提高了空间利用率:

// 创建 blob(thin provisioning)
static void blob_create_complete(void *cb_arg, struct spdk_blob *blob, int bserrno)
{
struct blob_ctx *ctx = cb_arg;
if (bserrno != 0) {
fprintf(stderr, "创建 blob 失败: %d\n", bserrno);
return;
}
ctx->blob = blob;
printf("Blob 创建成功, ID: 0x%lx\n", spdk_blob_get_id(blob));
// 设置 blob 大小(仅修改元数据,不分配物理空间)
spdk_blob_resize(blob, 1024); // 1024 clusters = 1GB
spdk_blob_sync_md(blob, blob_sync_complete, ctx);
}
void create_blob(struct spdk_blob_store *bs, struct spdk_io_channel *ch)
{
struct blob_ctx *ctx = calloc(1, sizeof(*ctx));
ctx->bs = bs;
ctx->channel = ch;
// 创建 blob — thin provisioning,不分配物理空间
spdk_bs_create_blob(bs, NULL, 0, blob_create_complete, ctx);
}

5.3 Blob 生命周期#

一个 blob 的完整生命周期包括:创建 → 打开 → I/O 读写 → 关闭 → (可选)删除。

// Blob 完整生命周期示例
struct blob_ctx {
struct spdk_blob_store *bs;
struct spdk_io_channel *channel;
struct spdk_blob *blob;
spdk_blob_id id;
};
// 1. 打开 blob
static void blob_open_complete(void *cb_arg, struct spdk_blob *blob, int bserrno)
{
struct blob_ctx *ctx = cb_arg;
ctx->blob = blob;
// 获取 blob 的大小
uint64_t num_clusters = spdk_blob_get_num_clusters(blob);
printf("Blob 大小: %lu clusters (%lu MB)\n",
num_clusters, num_clusters * 1024 * 1024 / (1024 * 1024));
}
void open_blob(struct blob_ctx *ctx, spdk_blob_id id)
{
ctx->id = id;
spdk_bs_open_blob(ctx->bs, id, blob_open_complete, ctx);
}
// 2. 读写 blob
static void blob_write_complete(void *cb_arg, int bserrno)
{
if (bserrno == 0) {
printf("Blob 写入完成\n");
}
}
void write_blob_data(struct blob_ctx *ctx, void *data, uint64_t offset, uint64_t length)
{
// 写入数据到 blob(offset 和 length 以 Page 为单位)
spdk_blob_io_write(ctx->blob, ctx->channel,
data, offset / 4096, length / 4096,
blob_write_complete, NULL);
}
static void blob_read_complete(void *cb_arg, int bserrno)
{
void *data = cb_arg;
if (bserrno == 0) {
printf("Blob 读取完成\n");
// 处理读取的数据...
}
spdk_dma_free(data);
}
void read_blob_data(struct blob_ctx *ctx, uint64_t offset, uint64_t length)
{
void *buf = spdk_dma_zmalloc(length, 4096, NULL);
spdk_blob_io_read(ctx->blob, ctx->channel,
buf, offset / 4096, length / 4096,
blob_read_complete, buf);
}
// 3. 关闭 blob
void close_blob(struct blob_ctx *ctx)
{
spdk_blob_close(ctx->blob, blob_close_complete, ctx);
}
// 4. 删除 blob
void delete_blob(struct blob_ctx *ctx, spdk_blob_id id)
{
spdk_bs_delete_blob(ctx->bs, id, blob_delete_complete, ctx);
}

5.4 快照与克隆#

blobstore 支持快照(Snapshot)和克隆(Clone)功能,这是构建存储服务的基础能力:

  • 快照:将 blob 的当前状态标记为只读,创建一个不可变的引用点
  • 克隆:基于快照创建一个可写的 blob,共享快照的数据 Cluster,仅修改的 Cluster 使用 Copy-on-Write
// 创建快照
static void snapshot_complete(void *cb_arg, spdk_blob_id snapshot_id, int bserrno)
{
if (bserrno == 0) {
printf("快照创建成功, ID: 0x%lx\n", snapshot_id);
}
}
void create_snapshot(struct blob_ctx *ctx)
{
// 将当前 blob 设为只读快照,返回快照 ID
spdk_bs_create_snapshot(ctx->bs, ctx->id, NULL, 0,
snapshot_complete, NULL);
}
// 基于快照创建克隆
static void clone_complete(void *cb_arg, spdk_blob_id clone_id, int bserrno)
{
if (bserrno == 0) {
printf("克隆创建成功, ID: 0x%lx\n", clone_id);
}
}
void create_clone(struct blob_ctx *ctx, spdk_blob_id snapshot_id)
{
// 基于快照创建可写克隆
spdk_bs_create_clone(ctx->bs, snapshot_id, NULL, 0,
clone_complete, NULL);
}

快照/克隆的 Copy-on-Write 机制:

操作快照 blob克隆 blob
读取未修改 Cluster直接读取指向快照的 Cluster,直接读取
写入 Cluster不允许(只读)分配新 Cluster,写入新数据(COW)
读取已修改 Cluster读取自己的新 Cluster
删除快照将快照的独占 Cluster 合并到克隆
Note

快照/克隆是存储虚拟化的核心能力。在云平台中,每个虚拟机的系统盘通常是一个克隆——基于一个公共的”基础镜像”快照创建。只有虚拟机修改的数据才占用新空间,极大节省了存储容量。

六、iSCSI 与 NVMe-oF Target#

SPDK 不仅能在本地访问 NVMe SSD,还能将 NVMe 设备通过网络暴露给远程主机——这就是 Target 模式。SPDK 提供两种 Target:iSCSI Target 和 NVMe-oF(NVMe over Fabrics)Target。

6.1 用户态 iSCSI Target#

iSCSI(Internet SCSI)通过 TCP/IP 网络传输 SCSI 命令,是最广泛使用的网络存储协议之一。传统的内核 iSCSI Target(LIO/STGT)需要数据在用户态和内核态之间多次拷贝,SPDK 将整个 iSCSI 协议栈放到用户态,消除了这些开销:

# 配置 SPDK iSCSI Target
# 1. 创建 malloc bdev 作为后端存储
rpc.py bdev_malloc_create -b Malloc0 4096 512
# 2. 创建 iSCSI Target portal(监听地址)
rpc.py iscsi_create_portal_group 1 -a 10.0.0.1 -p 3260
# 3. 创建 iSCSI Target
rpc.py iscsi_create_initiator_group 2 -n ANY
# 4. 创建 LUN(映射 bdev 到 Target)
rpc.py iscsi_create_target_node disk0 Malloc0 1 2 -d 0
# 5. 在发起端(Initiator)连接
# Linux 内核 Initiator:
iscsiadm -m discovery -t st -p 10.0.0.1:3260
iscsiadm -m node -T disk0 -p 10.0.0.1:3260 --login

SPDK iSCSI Target 的性能优势:

指标内核 LIO TargetSPDK iSCSI Target提升
IOPS(4K 随机读)~500K~1.5M3x
延迟 P99~50μs~20μs2.5x
CPU 利用率/IOPS2-3x

6.2 NVMe-oF Target:RDMA 加速#

NVMe-oF(NVMe over Fabrics)是 NVMe 协议的网络扩展版本,它将 NVMe 的 SQ/CQ 机制映射到网络传输层。当使用 RDMA(RoCE/iWARP)作为传输层时,可以实现接近本地 NVMe 的性能:

# 配置 SPDK NVMe-oF Target
# 1. 创建 NVMe bdev
rpc.py bdev_nvme_attach_controller -b Nvme0 -t pcie -a 0000:01:00.0
# 2. 创建 NVMe-oF subsystem
rpc.py nvmf_create_subsystem nqn.2026-01.io.spdk:cnode0 \
-s SPDK0001 -d "SPDK NVMe-oF Target"
# 3. 将 NVMe bdev 添加到 subsystem
rpc.py nvmf_subsystem_add_ns nqn.2026-01.io.spdk:cnode0 Nvme0n1
# 4. 创建 NVMe-oF transport(RDMA)
rpc.py nvmf_create_transport -t rdma -u 8192
# 5. 创建 listener(监听 RDMA 端口)
rpc.py nvmf_subsystem_add_listener nqn.2026-01.io.spdk:cnode0 \
-t rdma -a 10.0.0.1 -s 4420
# 6. 允许主机连接
rpc.py nvmf_subsystem_allow_any_host nqn.2026-01.io.spdk:cnode0 true
# 在发起端连接 NVMe-oF Target
nvme connect -t rdma -n nqn.2026-01.io.spdk:cnode0 -a 10.0.0.1 -s 4420
# 验证远程 NVMe 设备
nvme list

NVMe-oF 的 RDMA 传输路径:

sequenceDiagram participant Host as 发起端主机 participant RNIC as RDMA 网卡 participant Target as SPDK NVMe-oF Target participant NVMe as 本地 NVMe SSD Host->>RNIC: 1. 提交 NVMe 命令<br/>到 SQ(RDMA Send) RNIC->>Target: 2. RDMA Send 到达<br/>Target 网卡 Target->>Target: 3. 从 SQ 取出命令 Target->>NVMe: 4. 提交到本地 NVMe SQ NVMe->>NVMe: 5. 执行 I/O 操作 NVMe->>Target: 6. CQ 完成通知 Target->>RNIC: 7. RDMA Write 结果<br/>直接写入 Host 内存 Target->>RNIC: 8. 发送 CQ 完成响应 RNIC->>Host: 9. 完成通知到达 Host CQ
Note

NVMe-oF over RDMA 的关键优势在于数据路径零拷贝:I/O 数据通过 RDMA Write 直接从 Target 的 NVMe buffer 写入 Host 的预注册内存区域,无需 Target CPU 参与。只有命令和完成通知需要 CPU 处理。这使得远程 NVMe 的延迟可以接近本地 NVMe——在 RoCEv2 网络上,4K 随机读延迟可以做到 20~30μs,而本地 NVMe 约 10μs。

6.3 NVMe-oF TCP 传输#

除了 RDMA,NVMe-oF 还支持 TCP 传输。虽然性能不如 RDMA,但 TCP 传输不需要专用网卡,可以在任何以太网环境中部署:

# 创建 NVMe-oF TCP transport
rpc.py nvmf_create_transport -t tcp -u 8192
# 添加 TCP listener
rpc.py nvmf_subsystem_add_listener nqn.2026-01.io.spdk:cnode0 \
-t tcp -a 10.0.0.1 -s 4420
# 发起端通过 TCP 连接
nvme connect -t tcp -n nqn.2026-01.io.spdk:cnode0 -a 10.0.0.1 -s 4420

NVMe-oF TCP 的性能虽然低于 RDMA(4K 随机读约 500K~800K IOPS),但远高于传统 iSCSI,且部署门槛低,适合作为 RDMA 不可用时的替代方案。

6.4 性能对比总览#

Target 类型传输协议4K 随机读 IOPSP99 延迟硬件要求
内核 LIO iSCSITCP~500K~50μs普通网卡
SPDK iSCSITCP~1.5M~20μs普通网卡
SPDK NVMe-oF TCPTCP~800K~35μs普通网卡
SPDK NVMe-oF RDMARoCEv2~2.0M~25μsRDMA 网卡
本地 SPDK NVMePCIe~2.5M~8μsNVMe SSD

七、动手实践#

实践 1:编译 SPDK#

# 安装依赖
sudo apt install build-essential libaio-dev libiscsi-dev \
libncurses5-dev librdmacm-dev libnuma-dev meson ninja-build
# 克隆 SPDK 源码
git clone https://github.com/spdk/spdk.git
cd spdk
# 安装 SPDK 依赖(自动检测并安装)
sudo scripts/pkgdep.sh --all
# 编译 SPDK(含 DPDK)
./configure
make -j$(nproc)
# 配置大页内存
echo 1024 | sudo tee /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# 绑定 NVMe 设备到用户态驱动(从内核驱动解绑)
sudo scripts/setup.sh
# 验证 NVMe 设备已被 SPDK 接管
scripts/setup.sh status
# 输出类似:
# NVMe Device at 0000:01:00.0 -- using vfio-pci
Warning

sudo scripts/setup.sh 会将 NVMe 设备从内核 nvme 驱动解绑,绑定到 vfio-pci 驱动。这意味着内核将无法访问该 NVMe 设备——所有分区、文件系统都会消失。请确保 NVMe 设备上没有重要数据,或在虚拟机中测试。

实践 2:NVMe Hello World#

# 运行 SPDK 自带的 NVMe identify 示例
build/examples/nvme_identify
# 输出类似:
# ========================================
# NVMe Controller at 0000:01:00.0
# ========================================
# PCI Vendor ID: 0x8086
# PCI Device ID: 0x0a54
# Controller Model: INTEL SSDPE2KX040T8
# Serial Number: PHAB12345678
# Firmware Revision: VDV10103
# Maximum Queue Entries: 16384
# Namespace 1:
# Size (in LBAs): 781422768
# Capacity (in LBAs): 781422768
# LBA Data Size: 512
# Total Capacity: 375 GB

运行 SPDK 的 NVMe Hello World 示例,体验用户态直接读写 NVMe:

build/examples/nvme_hello_world
# 输出类似:
# Initializing NVMe Controller 0000:01:00.0
# Attached to NVMe Controller 0000:01:00.0
# Namespace 1: 375 GB, LBA 512 bytes
# Writing sector 0...
# Writing sector 1...
# ...
# Read complete for sector 0
# Read complete for sector 1
# ...
# Hello World completed successfully!

实践 3:vhost-blk Target#

在虚拟机中使用 SPDK vhost-blk 作为存储后端:

# 1. 启动 SPDK vhost 进程
build/bin/vhost -S /var/tmp -m 0x2 &
# 2. 创建 1GB 内存盘
rpc.py bdev_malloc_create -b Malloc0 1024 512
# 3. 创建 vhost-blk 控制器
rpc.py vhost_create_blk_controller --cpumask 0x2 vhost.blk0 Malloc0
# 4. 验证 vhost-blk 设备已创建
rpc.py vhost_get_controllers
# 5. 启动 QEMU 虚拟机,连接 vhost-blk 设备
qemu-system-x86_64 \
-name vm0 \
-m 4G -smp 2 \
-chardev socket,id=char0,path=/var/tmp/vhost.blk0 \
-device vhost-user-blk-pci,chardev=char0 \
-drive file=/path/to/os.qcow2,if=virtio \
-enable-kvm \
-cpu host \
-net nic -net user
# 6. 在虚拟机中验证磁盘
# 登录虚拟机后:
lsblk
# 应该能看到一个新的块设备(如 /dev/vdb)

实践 4:SPDK vs 内核 NVMe 性能基准#

使用 SPDK 自带的性能测试工具对比用户态和内核态的 NVMe 性能:

# SPDK 用户态 NVMe 性能测试
# 4K 随机读,队列深度 128,1 个 core
build/bin/spdk_nvme_perf -q 128 -o 4096 -w randread -t 60 -c 0x1
# 输出类似:
# =========================================================
# Device Information
# =========================================================
# Name QD IOPS MiB/s Avg Latency P99 Latency
# 0000:01:00.0 128 2350000 9180 54.3 us 78.2 us
# 内核 NVMe 性能测试(使用 fio)
# 首先将 NVMe 设备绑定回内核驱动
sudo scripts/setup.sh reset
# 使用 fio 测试
fio --filename=/dev/nvme0n1 \
--ioengine=libaio \
--direct=1 \
--rw=randread \
--bs=4k \
--iodepth=128 \
--numjobs=1 \
--time_based \
--runtime=60 \
--group_reporting \
--name=kernel_nvme
# 输出类似:
# IOPS=820000, BW=3203MiB/s, avg lat=155.8us, p99 lat=210.5us
Note

性能对比时请确保测试条件一致:相同的 NVMe 设备、相同的队列深度、相同的块大小。SPDK 的优势在高队列深度和多核场景下更明显。在单核 QD=1 的场景下,两者差距不大,因为 NVMe SSD 本身的延迟占主导。

小结#

SPDK 将 DPDK 的旁通哲学从网络延伸到存储,通过用户态 NVMe 驱动、bdev 抽象层、blobstore 对象存储、vhost-user 虚拟化后端、iSCSI/NVMe-oF Target 等组件,构建了完整的用户态存储栈。回顾本章的核心要点:

组件核心机制性能收益
用户态 NVMe 驱动mmap PCI BAR + MMIO 直操作 + SQ/CQ 轮询零系统调用、零中断、3x IOPS
vhost-userUnix Socket + 共享内存 + eventfd + vring零拷贝 VM I/O,比传统 Virtio 快 2-3x
bdev 抽象层统一块设备接口 + 模块化后端 + I/O Channel 无锁屏蔽设备差异,无锁并发
blobstoreCluster/Page 分配 + Thin Provisioning + 快照/克隆 COW轻量对象存储,云存储基础
iSCSI Target用户态 TCP + SCSI 协议栈3x 内核 LIO IOPS
NVMe-oF TargetRDMA/TCP 传输 + NVMe 命令映射远程存储接近本地性能

SPDK 与 DPDK 的关系不仅是”共享 EAL”那么简单。它们共享的是一整套设计哲学:

  1. 轮询替代中断:在微秒级延迟场景下,中断的开销不可接受
  2. 无锁替代锁:per-thread 数据结构 + 无锁环形队列,消除锁争用
  3. 零拷贝:DMA 直接到用户态 buffer,数据不经过内核中转
  4. CPU 亲和性:线程绑核,保证缓存局部性和延迟确定性
  5. 大页内存:减少 TLB miss,提高内存访问效率
Note

SPDK 不是万能药。它放弃了内核提供的安全隔离、协议处理、错误恢复等机制,你需要自己处理这些逻辑。SPDK 最适合的场景是:存储设备延迟极低(NVMe/PMEM)、IOPS 要求极高(>100 万)、且你有能力在用户态实现必要的存储逻辑。如果你的存储设备是 SATA SSD 或 HDD,内核栈的开销占比很小,SPDK 的收益有限。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

部分信息可能已经过时

相关文章 智能推荐
1
OVS-DPDK 与虚拟交换
高性能网络 深入 OVS-DPDK 与虚拟交换——OVS-DPDK 架构与内核 OVS 对比、dpdkvhostuser 端口类型、vhost-user 协议与共享 virtio 环、virtio 前后端、VM-to-VM 交换路径、流分类(EMC/DPCLS/megaflows)、性能调优——掌握云环境虚拟网络加速的完整技术栈。
2
SmartNIC 与 DPU
高性能网络 深入 SmartNIC 与 DPU——硬件卸载概念与收益、SmartNIC 架构(固定功能 vs 可编程)、DPU 产品矩阵(NVIDIA BlueField/AMD Pensando/Intel IPU)、OVS 硬件卸载(tc/rte_flow/ASAP²)、P4 编程入门——掌握硬件加速网络的完整技术栈。
3
DPDK 多核与并发模型
高性能网络 深入 DPDK 多核与并发模型——lcore 模型与 CPU 亲和性、Run-to-Completion 模型、Pipeline 模型与 rte_ring 跨核通信、原子操作与内存屏障、RCU 机制(rte_rcu_qsbr)、Eventdev 事件驱动框架——掌握多核数据平面编程的完整技术栈。
4
io_uring 与异步 IO 革命
高性能网络 深入 io_uring 架构——SQ/CQ 环形缓冲区布局与无锁交互、提交与完成流程、固定缓冲区与文件注册、网络 I/O 操作、multishot accept、SQPOLL 模式、与 epoll/AIO 的性能对比——掌握 Linux 异步 I/O 的终极方案。
5
XDP 与 eBPF 高性能网络
高性能网络 深入 XDP(eXpress Data Path)与 eBPF——eBPF 验证器与 JIT 编译、XDP 五种动作语义、BPF Map 类型体系、cpumap/devmap 重定向、AF_XDP 套接字、XDP 与 DPDK 的全面对比——掌握内核态高性能网络的完整技术栈。