# ZVFS > 透明用户态 POSIX 文件系统,基于 SPDK Blobstore。 ZVFS 是一个 **透明用户态文件系统原型**,通过 `LD_PRELOAD` 劫持 POSIX I/O, 将应用程序的文件数据路径从 Linux 内核 I/O 栈重定向到 **SPDK 用户态 NVMe 存储路径**。 目标是在 **零业务代码修改** 的情况下,为数据库与向量检索系统提供更低延迟的存储访问。 目前已在 **PostgreSQL + pgvector** 场景完成功能验证。 --- # 设计思路 大多数用户态文件系统(如 FUSE)需要修改应用或挂载文件系统。用户态文件系统如果要通过VFS,需要多一到两次额外的用户态/内核态切换。ZVFS 的目标是对应用完全透明:应用按正常方式调用 POSIX API,底层存储路径被悄悄替换掉。 核心决策是控制面与数据面分离: 控制面复用 Linux VFS:目录树、权限、inode 生命周期全部由 Linux 管理,文件到 blob 的映射通过 xattr(user.zvfs.blob_id)持久化,无需额外的映射数据库。 数据面走 SPDK:read/write 等数据路径绕过内核,经 IPC 送到 ZVFS daemon,再通过 SPDK Blobstore 直接访问 NVMe。 ``` Application (PostgreSQL / RocksDB) │ POSIX API ▼ LD_PRELOAD Hook Layer │ Unix Domain Socket ▼ ZVFS Daemon ┌────┴────┐ │ │ Metadata IO Workers Thread (SPDK pollers) │ │ └────┬────┘ ▼ SPDK Blobstore │ NVMe SSD ``` SPDK 需要使用轮询模式,最好能独占CPU core,且metadata最好由同一个 spdk thread 管理,不适合嵌入任意应用进程。因此 daemon 统一持有所有 SPDK 资源,多个客户端进程共享同一个 daemon,通过 Unix Domain Socket 通信。 --- # 🧠 系统架构 ![](zvfs架构图.excalidraw.svg) 架构设计关键点: - **同步阻塞语义** - **零侵入接管应用 I/O** - 使用 `LD_PRELOAD` 拦截 POSIX API - 不需要修改应用代码 - **控制面复用 Linux** - ZVFS 不重新实现目录树,而是复用 Linux VFS。目录 / 权限 / inode 生命周期由 Linux VFS 管理。 - 文件与 blob 的映射通过:`xattr: user.zvfs.blob_id` - **SPDK 资源集中管理** - 文件内容存储在 SPDK Blobstore。直接访问 NVMe。 - SPDK 对 metadata 操作有 **单线程要求**,因此 daemon 设计为: - metadata 操作:create / resize / delete - data IO:read / write - **POSIX 语义兼容** - 用户态文件系统需要正确模拟 Linux FD 语义:`dup dup2 dup3 fork close_range` - 保证多个 fd 指向同一文件句柄时语义一致。 --- # 📦 构建 ```bash git clone ... git submodule update --init --recursive cd spdk ./scripts/pkgdep.sh ./configure --with-shared make -j cd .. make -j ``` --- # ▶️ 运行 启动 daemon: ``` ./src/daemon/zvfs_daemon ``` 运行测试: ``` LD_PRELOAD=./src/libzvfs.so ./tests/bin/hook_api_test ``` --- # 🔬 已实现功能 打开/关闭/删除 ``` open open64 openat openat64 fopen fopen64 creat creat64 fclose close close_range dup dup2 dup3 fork unlink unlinkat remove rename renameat ``` 读写层 ``` read pread pread64 readv preadv preadv64 preadv2 write pwrite pwrite64 writev pwritev pwritev64 pwritev2 fread_unlocked fread fscanf ``` 偏移/空间管理层 ``` lseek lseek64 truncate truncate64 ftruncate ftruncate64 fallocate posix_fallocate ``` 元数据层 ``` stat stat64 fstat fstat64 lstat lstat64 fstatat fstatat64 statx ``` 同步/控制层 ``` fsync fdatasync sync_file_range fcntl fcntl64 ioctl ``` --- # 🚀 性能 测试环境:VMware 虚拟机 + 模拟 NVMe,单线程阻塞 I/O。 > 注:VMware 模拟 NVMe 无法体现 SPDK 轮询模式对中断驱动 I/O 的延迟优势, > 以下数据用于评估 hook 层与 IPC 的额外开销,不代表真实硬件上的性能对比。 ### 顺序写吞吐 | Block Size | spdk_nvme_perf | ZVFS | |---|---|---| | 4K | 100 MiB/s | 94 MiB/s | | 128K | 1843 MiB/s | 1662 MiB/s | ZVFS 达到 **SPDK 原生性能约 90%**。 --- ### fio 随机写(16K,psync) | | kernel (psync) | ZVFS | |---|---|---| | IOPS | 1855 | 1353 | | 吞吐 | 28.0 MiB/s | 21.2 MiB/s | | avg clat | 492 µs | 692 µs | | sys% | 28.6% | 8.4% | > 当前 ZVFS 在该单线程 `psync` 随机写场景下达到 kernel `psync` 的约 73% IOPS。daemon 内部 `SPDK + reply_q` 已收敛到较稳定范围,剩余主要开销集中在 `client -> daemon` 请求进入阶段。 --- ### WRITE 请求端到端延迟分解(单位 µs) 基于 12 条 `WRITE` trace 样本统计,下面按调用栈层级展开平均耗时。由于四舍五入,父子项相加会有 `±1 µs` 误差。 ```text total 748 ├─ c2s 317 │ ├─ send 39 │ └─ server_rx_wait 278 ├─ server 336 │ ├─ rx_dispatch 12 │ ├─ dispatch_spdk 25 │ ├─ spdk 194 │ └─ reply_q 103 │ ├─ spdk_post 11 │ └─ cq_wait 91 │ ├─ kick 13 │ ├─ wake_sched 65 │ └─ wake_to_tx 12 └─ s2c 95 ├─ resp_wait 83 └─ parse 12 ``` 当前 `WRITE` 的主要额外开销已经比较清晰:一是 `c2s / server_rx_wait`,二是 `server` 内部的 `spdk` 与 `reply_q`。在 `reply_q` 中,`wake_sched` 已明显大于 `kick` 和 `wake_to_tx`,说明回包路径的主要损耗不在 `eventfd` 写入本身,而在 reactor 被唤醒后的调度等待。 --- ### pgbench(PostgreSQL TPC-B,单客户端) | | kernel | ZVFS | |---|---|---| | TPS | 39.1 | 38.2 | | avg latency | 25.6 ms | 26.6 ms | 端到端数据库工作负载下,IPC 开销被稀释,ZVFS 与 kernel 路径性能基本持平(~4% 差距)。 --- # ⚠️ 当前局限 - 不支持 mmap - 非对齐写存在 RMW 开销 - IPC 请求大小存在上限:大 I/O 需在 hook 层分片;改用共享内存 scatter-gather 可消除此限制。 --- ## future work - 支持 mmap:可通过 /dev/shm + userfaultfd 方向探索。 - 缓解非对齐写开销、`!O_DIRECT`语义:实现 类似 pagecache 的bufferpool - 修改IPC方式:使用更快的 Shared Memory - 减少通信、拷贝开销:将 I/O 操作迁移至 Application 进程。MetaData操作保留在 Daemon 中。 --- # 🧩 遇到的一些问题 ## SPDK metadata 线程模型 SPDK Blobstore metadata 回调必须在初始化线程执行, 需要严格区分: - metadata thread -io thread 否则会导致 callback 无法返回。resize barrier 卡死 ## spdk_for_each_channel() 在 resize / delete 中会触发 barrier, 如果某些线程未 poll 会导致系统卡死。 解决方式: 保证所有 IO thread 持续 poll thread 退出时释放 io_channel ## PostgreSQL tablespace hook 失效 PostgreSQL tablespace 通过 symbolic link 访问路径: pg_tblspc/xxx 简单字符串前缀匹配 /zvfs 会漏判。 解决:realpath() 后再判断路径 ## write 延迟显著高于预期 这次 fio 延迟排查里,最初 `WRITE` 延迟明显高于预期。沿端到端路径加轻量打点后发现问题并不在 SPDK 本体,而是同时叠加了无条件 RMW、VM 中 poller 调度抖动、线程未绑核,以及后期 trace 暴露出来的 reactor 唤醒后核心切换抖动。对应处理是:整块对齐写跳过 read phase、将 reactor/md/io 线程固定到指定 CPU,并把 io 线程数和绑核目标收敛到配置项中。修复后 `dispatch_spdk` 从毫秒级降到几十微秒,`WRITE` 平均延迟也回落到约 700 µs,但剩余尾延迟仍主要表现为请求进入与回包阶段的调度等待。