某全闪存阵列厂商发现,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_queue 和 blk-mq 调度很难跟上这个节奏。
SPDK(Storage Performance Development Kit)把 DPDK 的旁通哲学从网络延伸到存储:绕过内核块设备栈,在用户态直接操作 NVMe SSD。它通过 mmap 映射 PCI BAR 空间,在用户态直接读写 NVMe 的 MMIO 寄存器,提交和完成 I/O 请求——整个过程零系统调用、零中断、零上下文切换。
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 直接复用了这套基础设施,在其上构建存储专用的组件。
从架构图可以看出 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;}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 控制器
关键寄存器位于 PCI BAR0 的 MMIO 空间中:
| 偏移 | 寄存器 | 作用 |
|---|---|---|
| 0x0000 | CAP | 控制器能力(MQES、CQR 等位域) |
| 0x0008 | VS | NVMe 版本号 |
| 0x0014 | CC | 控制器配置(使能、队列大小等) |
| 0x001C | CSTS | 控制器状态(Ready、Fatal 等) |
| 0x1000+ | SQyTDBL | SQ y 的 Tail Doorbell |
| 0x1000+ | CQyHDBL | CQ y 的 Head Doorbell |
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 voidspdk_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 boolspdk_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); }}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-mq 和 request_queue 有锁 | 无锁设计,每个 qpair 独占一个线程 |
实测数据(Intel P4510 NVMe SSD,4K 随机读):
| 指标 | Linux 内核 NVMe | SPDK 用户态 NVMe | 提升 |
|---|---|---|---|
| IOPS(单核) | ~800K | ~2.5M | 3.1x |
| 延迟 P99 | ~15μs | ~8μs | 1.9x |
| CPU 利用率 | 100% | 100% | — |
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 的核心是三个机制协同工作:
- Unix 域套接字:QEMU(前端)和 SPDK(后端)之间通过 Unix 域套接字交换控制消息。包括特性协商、内存区域注册、Virtio 环形队列(vring)地址传递等
- 共享内存:QEMU 的 Guest 物理地址空间通过
mmap共享给 SPDK,SPDK 可以直接读写虚拟机的内存,实现零拷贝的数据传输 - eventfd:前后端通过 eventfd 机制互相通知——前端通知后端”有新请求”(kick),后端通知前端”请求完成”(call)
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-blk | Virtio-blk | 每个设备暴露为一个块设备,简单高效 | 虚拟机只需要少量块设备 |
| vhost-scsi | Virtio-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 0x2EOF
# 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 \ ...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 的核心接口包括:
// 打开 bdevint 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);
// 异步 flushint 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_nvme | NVMe SSD 后端 | 直接访问本地 NVMe 设备 |
| bdev_malloc | 内存模拟盘 | 测试、缓存 |
| bdev_aio | Linux AIO 后端 | 访问内核块设备(如 SATA SSD) |
| bdev_virtio | Virtio 设备后端 | SPDK 作为 Virtio 前端访问存储 |
| bdev_pmem | 持久内存后端 | Intel Optane PMEM |
| bdev_rbd | Ceph RADOS 后端 | 访问 Ceph 分布式存储 |
| bdev_uring | io_uring 后端 | 通过 io_uring 访问内核块设备 |
| bdev_split | 分区拆分 | 将一个 bdev 拆分为多个分区 |
| bdev_lvol | 逻辑卷 | 在 blobstore 上创建逻辑卷 |
| bdev_passthru | 透传模块 | 开发调试、I/O 统计 |
模块可以堆叠组合。例如,你可以在 NVMe bdev 上叠加 split 模块创建分区,再叠加 lvol 模块创建逻辑卷:
# 创建 NVMe bdevrpc.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);}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. 打开 blobstatic 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. 读写 blobstatic 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. 关闭 blobvoid close_blob(struct blob_ctx *ctx){ spdk_blob_close(ctx->blob, blob_close_complete, ctx);}
// 4. 删除 blobvoid 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 合并到克隆 |
快照/克隆是存储虚拟化的核心能力。在云平台中,每个虚拟机的系统盘通常是一个克隆——基于一个公共的”基础镜像”快照创建。只有虚拟机修改的数据才占用新空间,极大节省了存储容量。
六、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 Targetrpc.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:3260iscsiadm -m node -T disk0 -p 10.0.0.1:3260 --loginSPDK iSCSI Target 的性能优势:
| 指标 | 内核 LIO Target | SPDK iSCSI Target | 提升 |
|---|---|---|---|
| IOPS(4K 随机读) | ~500K | ~1.5M | 3x |
| 延迟 P99 | ~50μs | ~20μs | 2.5x |
| CPU 利用率/IOPS | 高 | 低 | 2-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 bdevrpc.py bdev_nvme_attach_controller -b Nvme0 -t pcie -a 0000:01:00.0
# 2. 创建 NVMe-oF subsystemrpc.py nvmf_create_subsystem nqn.2026-01.io.spdk:cnode0 \ -s SPDK0001 -d "SPDK NVMe-oF Target"
# 3. 将 NVMe bdev 添加到 subsystemrpc.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 Targetnvme connect -t rdma -n nqn.2026-01.io.spdk:cnode0 -a 10.0.0.1 -s 4420
# 验证远程 NVMe 设备nvme listNVMe-oF 的 RDMA 传输路径:
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 transportrpc.py nvmf_create_transport -t tcp -u 8192
# 添加 TCP listenerrpc.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 4420NVMe-oF TCP 的性能虽然低于 RDMA(4K 随机读约 500K~800K IOPS),但远高于传统 iSCSI,且部署门槛低,适合作为 RDMA 不可用时的替代方案。
6.4 性能对比总览
| Target 类型 | 传输协议 | 4K 随机读 IOPS | P99 延迟 | 硬件要求 |
|---|---|---|---|---|
| 内核 LIO iSCSI | TCP | ~500K | ~50μs | 普通网卡 |
| SPDK iSCSI | TCP | ~1.5M | ~20μs | 普通网卡 |
| SPDK NVMe-oF TCP | TCP | ~800K | ~35μs | 普通网卡 |
| SPDK NVMe-oF RDMA | RoCEv2 | ~2.0M | ~25μs | RDMA 网卡 |
| 本地 SPDK NVMe | PCIe | ~2.5M | ~8μs | NVMe 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.gitcd spdk
# 安装 SPDK 依赖(自动检测并安装)sudo scripts/pkgdep.sh --all
# 编译 SPDK(含 DPDK)./configuremake -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-pcisudo 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 个 corebuild/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性能对比时请确保测试条件一致:相同的 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-user | Unix Socket + 共享内存 + eventfd + vring | 零拷贝 VM I/O,比传统 Virtio 快 2-3x |
| bdev 抽象层 | 统一块设备接口 + 模块化后端 + I/O Channel 无锁 | 屏蔽设备差异,无锁并发 |
| blobstore | Cluster/Page 分配 + Thin Provisioning + 快照/克隆 COW | 轻量对象存储,云存储基础 |
| iSCSI Target | 用户态 TCP + SCSI 协议栈 | 3x 内核 LIO IOPS |
| NVMe-oF Target | RDMA/TCP 传输 + NVMe 命令映射 | 远程存储接近本地性能 |
SPDK 与 DPDK 的关系不仅是”共享 EAL”那么简单。它们共享的是一整套设计哲学:
- 轮询替代中断:在微秒级延迟场景下,中断的开销不可接受
- 无锁替代锁:per-thread 数据结构 + 无锁环形队列,消除锁争用
- 零拷贝:DMA 直接到用户态 buffer,数据不经过内核中转
- CPU 亲和性:线程绑核,保证缓存局部性和延迟确定性
- 大页内存:减少 TLB miss,提高内存访问效率
SPDK 不是万能药。它放弃了内核提供的安全隔离、协议处理、错误恢复等机制,你需要自己处理这些逻辑。SPDK 最适合的场景是:存储设备延迟极低(NVMe/PMEM)、IOPS 要求极高(>100 万)、且你有能力在用户态实现必要的存储逻辑。如果你的存储设备是 SATA SSD 或 HDD,内核栈的开销占比很小,SPDK 的收益有限。
参考资料
- SPDK 官方文档 — SPDK 架构设计、API 参考与开发指南
- SPDK GitHub 仓库 — 源码与示例程序
- NVMe 规范 1.4 — NVMe 协议的权威参考,SQ/CQ/MMIO 寄存器定义
- NVMe-oF 规范 1.1 — NVMe over Fabrics 协议定义
- Virtio 规范 1.1 — vhost-user 的 Virtio 环形队列定义
- DPDK 官方文档 — SPDK 底层 EAL 的参考
- SPDK NVMe Driver Design — 用户态 NVMe 驱动的详细设计文档
- SPDK bdev 模块开发指南 — 如何开发自定义 bdev 模块
- SPDK blobstore 设计文档 — blobstore 内部实现细节
- SPDK vhost-user 设计文档 — vhost-blk/scsi 的架构与配置
- SPDK NVMe-oF Target 配置指南 — NVMe-oF Target 的部署与调优
- Linux 内核块设备栈 — 理解内核块设备栈以对比 SPDK 的优势
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






