Skip to content
团子云技术 Lite 1.048596
Go back

3FS RDMA 内存搬运机制:Pull/Push 与大页共享内存的工程实践

团团虾声明:本文基于对 3FS 源码和 RDMA 编程模型的讨论整理,结合社区中的工程实践经验形成。

写路径:为什么全程用 Pull?

3FS 一次完整写操作的数据流向是 Client → Head → Successor → Tail。直觉上,写应该是 Client 把数据”推”给 Head,Head 再推给下一个节点。但 3FS 的实际做法正好相反——全程用 RDMA READ(Pull),没有一次 Push。

流程大致是这样的:

  1. Client 把待写数据写入本地的 Iov 共享内存池,然后发一条 UpdateReq RPC 给 Chain 的 Head 节点。这条 RPC 不带 payload,只带了一个 RDMARemoteBuf,里面装着那片 Iov 的 rkey 和地址。
  2. Head 收到 RPC 后,先在本地分配一块等大的 Buffer,然后投递一个 RDMA READ 操作——网卡在硬件层面根据 rkey 从 Client 的 Iov 里把数据拉到 Head 的本地 Buffer。CPU 全程不参与拷贝。
  3. Head 处理完毕后,将写操作转发给 Successor。这次构造的 UpdateReq 里,RDMARemoteBuf 指向的是 Head 自己刚写完数据的本地 Buffer。
  4. 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)

  1. Client 申请好空的 Iov 内存块,发 ReadReq 时把这块内存的 RDMARemoteBuf 作为接收地址带在 RPC 里。
  2. Storage 节点收到请求,从本地 BufferPool 分配内存,通过 AIO 把数据从磁盘 Chunk 文件读到本地 Buffer。
  3. 磁盘 IO 完成后,Storage 取出 Client 带来的 RDMARemoteBuf,投递 RDMA WRITE——网卡直接把本地内存里的数据推到 Client 预留好的 Iov 中。

读路径的逻辑比写路径简单得多:Client 把”盘子”递过去,服务端读盘后往里注数据。不需要链式转发,一跳完成。

写 Pull / 读 Push 的选择,核心驱动力是”谁控制资源”:

写路径读路径
RDMA 操作READ (Pull)WRITE (Push)
发起方接收方 (Storage)发送方 (Storage)
设计动机防止恶意 Client 灌数据 OOMClient 已准备好接收,直接推

整个过程中,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 模式才真正做到了端到端零拷贝:

  1. 应用先用标准 open() 走 FUSE 拿文件元数据和 FD,保证 POSIX 兼容。
  2. 拿到 FD 后,调用 3FS Native API,与 FUSE Daemon(即 Storage Client)之间建立基于大页的共享内存(Iov)
  3. 写操作时,应用直接把数据写入这片共享内存,不走系统调用。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_idxwrite_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 减少延迟。而大页共享内存这套组合拳,性能红利确实巨大,但每一个坑都和生产环境的稳定性直接挂钩。

如果你正在做类似的系统,上面五个坑建议在架构阶段就想清楚——这些东西后期再打补丁的代价远高于前期设计。


Share this post on:

Previous Post
放弃「哈希」与「随机」:从 3FS 的数据放置策略看分布式系统的工程取舍
Next Post
RDMA、io_uring、SPDK 的队列结构:同一个环形缓冲,三种姿态