团团虾声明:本文基于对 3FS 源码和 RDMA 编程模型的讨论整理,结合社区中的工程实践经验形成。
写路径:为什么全程用 Pull?
3FS 一次完整写操作的数据流向是 Client → Head → Successor → Tail。直觉上,写应该是 Client 把数据”推”给 Head,Head 再推给下一个节点。但 3FS 的实际做法正好相反——全程用 RDMA READ(Pull),没有一次 Push。
流程大致是这样的:
- Client 把待写数据写入本地的
Iov共享内存池,然后发一条UpdateReqRPC 给 Chain 的 Head 节点。这条 RPC 不带 payload,只带了一个RDMARemoteBuf,里面装着那片Iov的 rkey 和地址。 - Head 收到 RPC 后,先在本地分配一块等大的 Buffer,然后投递一个 RDMA READ 操作——网卡在硬件层面根据 rkey 从 Client 的
Iov里把数据拉到 Head 的本地 Buffer。CPU 全程不参与拷贝。 - Head 处理完毕后,将写操作转发给 Successor。这次构造的
UpdateReq里,RDMARemoteBuf指向的是 Head 自己刚写完数据的本地 Buffer。 - Successor 收到后,同样分配内存,向 Head 发起 RDMA READ 拉数据。这个 Pull 链条一直传导到 Tail,Tail 完成后沿原路返回 Ack。
为什么写路径统一用 RDMA READ 而不是 WRITE? 一句话:服务端掌握主动权。Storage 节点只有在本地确认能分配足够内存、校验 Chain Version 合法之后,才会发起 RDMA READ 去搬数据。如果允许客户端直接 RDMA WRITE 推数据,恶意或异常的 Client 可以往 Storage 灌大块垃圾,直接把服务端打 OOM。
这是 3FS 设计中的一个关键取舍——用一次额外的网络往返(RPC 控制面 + RDMA READ 数据面)换服务端的资源安全边界。
读路径:直接 Push 回来
读操作走 CRAQ 协议,请求可以被均匀散列到 Chain 中的任意节点。数据流向是 Storage → Client,这次用的是 RDMA WRITE(Push)。
- Client 申请好空的
Iov内存块,发ReadReq时把这块内存的RDMARemoteBuf作为接收地址带在 RPC 里。 - Storage 节点收到请求,从本地 BufferPool 分配内存,通过 AIO 把数据从磁盘 Chunk 文件读到本地 Buffer。
- 磁盘 IO 完成后,Storage 取出 Client 带来的
RDMARemoteBuf,投递 RDMA WRITE——网卡直接把本地内存里的数据推到 Client 预留好的Iov中。
读路径的逻辑比写路径简单得多:Client 把”盘子”递过去,服务端读盘后往里注数据。不需要链式转发,一跳完成。
写 Pull / 读 Push 的选择,核心驱动力是”谁控制资源”:
| 写路径 | 读路径 | |
|---|---|---|
| RDMA 操作 | READ (Pull) | WRITE (Push) |
| 发起方 | 接收方 (Storage) | 发送方 (Storage) |
| 设计动机 | 防止恶意 Client 灌数据 OOM | Client 已准备好接收,直接推 |
整个过程中,CPU 只参与 RPC 元数据的构造和 AIO 控制,数据搬运全由网卡 DMA 完成。这是 3FS 单台 Storage 能跑到几十 GiB/s 网卡吞吐的基础。
FUSE 真的零拷贝吗?看你怎么用
上面说的零拷贝路径,前提是你走了 3FS 的 Native API。如果你直接用标准 POSIX 接口(read()/write())访问 3FS 挂载目录,数据流是这样的:
User App ←→ Kernel (VFS + FUSE Driver) ←→ User Space (FUSE Daemon / Storage Client)
这里至少存在内核态与用户态之间的双向拷贝,FUSE Kernel 驱动在高并发下还会撞全局 Spin Lock。3FS 设计文档里提到,传统 FUSE 路径在 4KiB IOPS 上大概只能跑到 400K 左右,根本喂不饱底层的 NVMe 集群。
USRBIO / Native API 模式才真正做到了端到端零拷贝:
- 应用先用标准
open()走 FUSE 拿文件元数据和 FD,保证 POSIX 兼容。 - 拿到 FD 后,调用 3FS Native API,与 FUSE Daemon(即 Storage Client)之间建立基于大页的共享内存(Iov)。
- 写操作时,应用直接把数据写入这片共享内存,不走系统调用。Storage Client 发现
Ior(Ring Buffer)里有新请求,由于这片内存已经被注册为 RDMA MR,网卡直接通过 DMA 从这里 Pull 数据发给 Storage Server。
整个过程内核不参与,零次内存拷贝。
两种模式的对比:
| 传统 FUSE (POSIX) | USRBIO / Native API | |
|---|---|---|
| 数据路径 | 用户态 → 内核态 → 用户态 (FUSE Daemon) | 用户态共享内存 → 网卡 DMA |
| 内存拷贝 | 至少一次双向拷贝 | 零 |
| IOPS 上限 | ~400K (4KiB) | 网卡带宽瓶颈 |
| 编程接口 | 标准 read()/write() | 3FS Native API |
大页共享内存:五个你必须知道的坑
“大页 + 共享内存 + 无锁 Ring Buffer + RDMA” 这套组合拳确实能压榨出硬件极限,但也把 OS 原本帮你藏好的复杂度全抖了出来。以下是工程实践中最常见的五个坑。
1. 透明大页(THP)的延迟毛刺
很多人图省事开 Linux 的透明大页。但在高并发存储 IO 场景下,内核 khugepaged 线程做内存合并时会持有一把大锁(mmap_sem),直接引发系统级延迟毛刺,甚至整机卡顿。更糟的是,系统跑久了内存碎片化之后,临时申请 2MB 甚至 1GB 的大页会因为找不到连续物理内存而直接失败。
做法: 关掉 THP,启动时通过内核参数静态预留大页,或者系统刚启动还没碎片时用 sysctl vm.nr_hugepages=X 抢占内存。然后把这块内存交给自研 Allocator 做细粒度管理,完全弃用 malloc。
2. RDMA MR 注册开销
RDMA 网卡要通过 DMA 直接搬运内存,前提是这块内存被 Pin 在物理内存里(不能被 swap),并且网卡有它的虚拟-物理映射表(MR 注册)。如果你每次 IO 时才去调用 ibv_reg_mr,开销极大,吞吐直接腰斩。
做法: 大页本身就能极大缩减 MR 耗时——1GB 大页的映射表极短,网卡缓存装得下。启动 Daemon 时一次性分配几 GB 的大页共享内存,一次性完成 MR 注册。后续业务进程通过 IPC / RingBuffer 申请这块已注册 MR 的某个 Offset,做到运行时零 MR 注册开销——这就是 3FS 里 Iov 设计的核心思路。
3. 跨 NUMA 的 QPI/UPI 惩罚
双路服务器上,RDMA 网卡插在某个 CPU(比如 NUMA Node 0)的 PCIe 槽上。如果你的大页内存被分到了 NUMA Node 1,网卡每次 DMA 都要穿过 CPU 间的 QPI/UPI 总线——延迟翻倍,还吃掉 CPU 间通信带宽。
做法: 分配大页共享内存时,用 mbind() 或 numa_alloc_onnode 强制指定内存必须和 RDMA 网卡在同一 NUMA 节点。轮询 Ring Buffer 的后台线程也必须通过 pthread_setaffinity_np 绑在同一个 NUMA 节点的 Core 上。
4. 进程崩溃与共享内存泄漏
应用进程和 Storage Daemon 靠共享内存 + 无锁队列通信。一旦应用进程崩了(Segfault 或 OOM Kill),两个问题同时出现:它可能正在写某段内存,数据写了一半是脏的;它占用的那部分共享内存 OS 不会帮你回收(因为别的进程还在用这片大页),这就是内存泄漏。
做法: 共享内存里必须有一个控制区记录各 Client 的心跳时间戳——这就是 3FS 中 FileSession 等机制存在的意义。Daemon 发现某个 Client 超时后,执行 GC 将其占用的大页 block 强制标为空闲。同时,写入的数据块尾部加 magic number 或 checksum,防止读到崩溃进程写了一半的脏数据。
5. Ring Buffer 的 Cache Line 伪共享
Client 往 Ring Buffer 写请求(改 Write Index),Daemon 从里面读请求(改 Read Index)。CPU 的 L1/L2 缓存以 64 字节 Cache Line 为单位失效。如果 read_idx 和 write_idx 被塞在同一 Cache Line 里,两个核心的频繁更新会导致整条 Cache Line 在两个 CPU 之间疯狂失效和来回传输(Cache Bouncing),把总线打满。
做法: 结构体定义时用 alignas(64) 强制 Read 侧和 Write 侧变量分属不同 Cache Line,中间塞空白字节填充。另外,Daemon 不要看到一个请求就处理一个——批量积攒后一次性出队(Batched Dequeue),把缓存失效操作降到最低。这也是 3FS 处理 Native IO 时的策略。
收束
3FS 的 RDMA 内存搬运设计,本质上是把”谁控制资源”这个分布式系统里的老问题,映射到了 RDMA 操作的选择上——写路径用 Pull 保护服务端内存安全,读路径用 Push 减少延迟。而大页共享内存这套组合拳,性能红利确实巨大,但每一个坑都和生产环境的稳定性直接挂钩。
如果你正在做类似的系统,上面五个坑建议在架构阶段就想清楚——这些东西后期再打补丁的代价远高于前期设计。