2025 年 2 月 28 日,DeepSeek 在其开源周最后一天压轴发布了自研的并行文件系统 Fire-Flyer File System,简称 3FS。该系统支撑了 DeepSeek V3&R1 模型训练、推理的全流程,在数据预处理、数据集加载、CheckPoint、KVCache 等场景发挥了重要的作用。
项目一经发布,就获得了存储领域的广泛关注。大家都迫切地想一探究竟,看看 3FS 到底有哪些压箱底的独门秘籍。火山引擎文件存储团队阅读和分析了 3FS 的设计文档和源代码,总结出这篇文章,在介绍 3FS 关键设计的同时,尝试从存储专业的视角挖掘出 3FS 团队在这些设计背后的考量。
与业界很多分布式文件系统架构类似,3FS 整个系统由四个部分组成,分别是 Cluster Manager、Client、Meta Service、Storage Service。所有组件均接入 RDMA 网络实现高速互联,DeepSeek 内部实际使用的是 InfiniBand。
Cluster Manager 是整个集群的中控,承担节点管理的职责:
Client 提供两种客户端接入方案:
Meta Service 提供元数据服务,采用存算分离设计:
Storage Service 提供数据存储服务,采用存算一体设计:
一个 3FS 集群可以部署单个或多个管理服务节点 mgmtd。这些 mgmtd 中只有一个主节点,承接所有的集群管理响应诉求,其它均为备节点仅对外提供查询主的响应。其它角色节点都需要定期向主 mgmtd 汇报心跳保持在线状态才能提供服务。
每个节点启动后,需要向主 mgmtd 上报必要的信息建立租约。mgmtd 将收到的节点信息持久化到 FoundationDB 中,以保证切主后这些信息不会丢失。节点信息包括节点 ID、主机名、服务地址、节点类别、节点状态、最后心跳的时间戳、配置信息、标签、软件版本等。
租约建立之后,节点需要向主 mgmtd 周期发送心跳对租约进行续租。租约双方根据以下规则判断租约是否有效:
对于元数据节点和客户端,租约有效意味着服务是可用的。但对于存储服务节点,情况要复杂一些。一个存储节点上会有多个 CRAQ 的 Target,每个 Target 是否可服务的状态是不一致的,节点可服务不能代表一个 Target 可服务。因此,Target 的服务状态会进一步细分为以下几种:
状态可读可写说明serving是是服务在线,可以响应客户端请求syncing否是服务在线,正在进行数据恢复waiting否否服务在线,数据恢复尚未启动lastsrv否否服务下线,且是 CRAQ 最后一个可以提供服务的 Targetoffline否否服务下线或磁盘介质损坏
元数据和存储节点(包括其上的 Target)的信息,以及下文会描述的 CRAQ 复制链表信息,共同组成了集群的路由信息(RoutingInfo)。路由信息由主 mgmtd 广播到所有的节点,每个节点在需要的时候通过它找到其它节点。
mgmtd 的选主机制基于租约和 FoundationDB 读写事务实现。租约信息 LeaseInfo 记录在 FoundationDB 中,包括节点 ID、租约失效时间、软件版本信息。如果租约有效,节点 ID 记录的节点即是当前的主。每个 mgmtd 每 10s 执行一次 FoundationDB 读写事务进行租约检查,具体流程如下图所示。
上述流程通过以下几点保证了选主机制的正确性:
3FS 提供了两种形态的客户端,FUSE 客户端 hf3fs_fuse 和原生客户端 USRBIO:
FUSE 客户端基于 libfuse lowlevel api 实现,要求 libfuse 3.16.1 及以上版本。和其它业界实现相比,最大的特色是使用了 C++20 协程,其它方面大同小异。本文仅列举一些实现上值得注意的点:
分类实现分析POSIX 兼容性不支持文件锁、xattr,其它主流操作均支持。文件锁功能对于有多机强一致性要求的业务(例如数据库)比较重要。目录遍历实现了 readdirplus 版本的接口。一次完整的目录遍历会包含一次 opendir、一次 closedir,和多次 readdirplus 调用。第一次调用 readdirplus 时,3FS 分批从 Meta Service 获取到完整的文件列表(不包含文件属性),缓存到内存中。这个缓存会在 closedir 的时候释放。每次 readdirplus 时,从缓存中拿到本次要返回的文件列表,向 Meta Service 发送一次 RPC 批量获取到这些文件的属性。这个实现对文件属性的获取有较多优化:- readdirplus 版本相比 readdir 版本会将文件属性信息一并带回给内核缓存,可以有效减少接下来一段时间的 stat 调用
基于共享内存 RingBuffer 的通信机制被广泛应用在高性能存储、网络领域,在 DPDK、io_uring 中均有相关实现,一般采用无锁、零拷贝设计,相比其它通信的机制有明显的性能提升。3FS 借鉴了这个思路实现了 USRBIO,和原有的 FUSE 实现相比,有以下特点:
USRBIO 的使用说明可以参考 3FS 代码库 USRBIO API Reference 文档: https://github.com/deepseek-ai/3FS/blob/main/src/lib/api/UsrbIo.md
在实现上,USRBIO 使用了很多共享内存文件:
Iov、Ior 共享内存文件通过 symlink 注册给 FUSE Daemon,这也是 3FS FUSE 实现上有意思的一个点,下一章节还会有进一步的描述。
通常一个文件系统如果想实现一些非标能力,在 ioctl 接口上集成是一个相对标准的做法。 3FS 里除了使用了这种方式外,对于 USRBIO、递归删除目录、禁用回收站的 rename、修改 conf 等功能,采用了集成到 symlink 接口的非常规做法。
3FS 采用这种做法可能基于两个原因:
symlink 的完整处理逻辑如下:
3FS 没有对小文件做调优,直接存取大量小文件性能会比较差。为了弥补这个短板,3FS 专门设计了 FFRecord (Fire Flyer Record)文件格式来充分发挥系统的大 IO 读写能力。
FFRecord 文件格式具有以下特点:
以下是 FFRecord 文件格式的存储 layout:
在 FFRecord 文件格式中,每一条样本的数据会做序列化按顺序写入,同时文件头部包含了每一条样本在文件中的偏移量和 crc32 校验和,方便做随机读取和数据校验。
3FS 面向高吞吐能力而设计,系统吞吐能力跟随 SSD 和网络带宽线性扩展,即使发生个别 SSD 介质故障,也能依然提供很高的吞吐能力。3FS 采用分摊查询的链式复制 CRAQ 来保证数据可靠性,CRAQ 的 write-all-read-any 特性对重读场景非常友好。
每个数据节点通过 Ext4 或者 XFS 文件系统管理其上的多块 NVME DISK,对内部模块提供标准的 POSIX 文件接口。数据节点包含几个关键模块:Chunk Engine 提供 chunk 分配管理;MetaStore 负责记录分配管理信息,并持久化到 RocksDB 中;主 IO handle 提供正常的读写操作。各个数据节点间组成不同的链式复制组,节点之间有复制链间写 IO、数据恢复 sync 写 IO。
链式复制是将多个数据节点组成一条链 chain,写从链首开始,传播到链尾,链尾写完后,逐级向前发送确认信息。标准 CRAQ 的读全部由链尾处理,因为尾部才是完全写完的数据。
多条链组成 chain table,存放在元数据节点,Client 和数据节点通过心跳,从元数据节点获取 chain table 并缓存。一个集群可有多个 chain table,用于隔离故障域,以及隔离不同类型(例如离线或在线)的任务。
3FS 的写采用全链路 RDMA,链的后继节点采用单边 RDMA 从前序节点读取数据,相比前序节点通过 RDMA 发送数据,少了数据切包等操作,性能更高。而 3FS 的读,可以向多个数据节点同时发送读请求,数据节点通过比较 commit version 和 update version 来读取已经提交数据,多节点的读相比标准 CRAQ 的尾节点读,显著提高吞吐。
传统的链式复制以固定的节点形成 chain table。如图所示节点 NodeA 只与 NodeB、C 节点形成 chain。若NodeA 故障,只能 NodeB 和 C 分担读压力。
3FS 采用了分摊式的打散方法,一个 Node 承担多个 chain,多个 chain 的数据在集群内多个节点进行数据均摊。如图所示,节点 NodeA 可与 Node B-F 节点组成多个chain。若 NodeA 产生故障,NodeB-F 更多节点分担读压力,从而可以避免 NodeA 节点故障的情况下,产生节点读瓶颈。
写数据流程:
读数据流程:
一个文件在创建时,会按照父目录配置的 layout 规则,包括 chain table 以及 stripe size,从对应的 chain table中选择多个 chain 来存储和并行写入文件数据。chain range 的信息会记录到 inode 元数据中,包括起始 chain id 以及 seed 信息(用来做随机打散)等。在这个基础之上,文件数据被进一步按照父目录 layout 中配置的 chunk size 均分成固定大小的 chunk(官方推荐 64KB、512KB、4MB 3 个设置,默认 512KB),每个 chunk 根据 index 被分配到文件的一个 chain上,chunk id 由 inode id + track + chunk index 构成。当前 track 始终为 0,猜测是预留给未来实现 chain 动态扩展用的。
访问数据时用户只需要访问 Meta Service 一次获得 chain 信息和文件长度,之后根据读写的字节范围就可以计算出由哪些 chain 进行处理。
假设一个文件的 chunk size 是 512KB,stripe size 是200,对应的会从 chain table 里分配 200 个 chain 用来存储这个文件的所有 chunk。在文件写满 100MB(512KB * 200)之前,其实并不是所有的 chain 都会有 chunk 存储。在一些需要和 Storage Service 交互的操作中,比如计算文件长度(需要获得所有chain上最后一个chunk的长度)、或者 Trucate 操作,需要向所有潜在可能存放 chunk 的 Storage Service 发起请求。但是对不满 100MB(不满stripe size个chunk)的小文件来说,向 200 个 chain 的 Storage Service 都发起网络请求无疑带来无谓的延时增加。
为了优化这种场景,3FS 引入了 Dynamic Stripe Size 的机制。这个的作用就是维护了一个可能存放有 chunk 的chain数量,这个值类似 C++ vector 的扩容策略,每次 x2 来扩容,在达到 stripe size 之后就不再扩了。这个值的作用是针对小文件,缩小存放有这个文件数据的 chain 范围,减少需要和 Storage Service 通信的数量。
通过固定切分 chunk 的方式,能够有效的规避数据读写过程中与 Meta Service 的交互次数,降低元数据服务的压力,但是也引入另外一个弊端,即对写容错不够友好,当前写入过程中,如果一个 chunk 写失败,是不支持切下一个 chunk 继续写入的,只能在失败的 chunk 上反复重试直到成功或者超时失败。
Chunk Engine 由 chunk data file、Allocator、LevelDB/RocksDB 组成。其中 chunk data file 为数据文件;Allocator 负责 chunk 分配;LevelDB/RocksDB 主要记录本地元数据信息,默认使用 LevelDB。
为确保查询性能高效,内存中全量保留一份元数据,同时提供线程级安全的访问机制,API 包括:
API 接口作用Open/Close初始化 Engine,从 RocksDB 中加载元数据Get通过 hash map cache 获取元数据引用计数Update采用 COW 的方式,以整块的方式进行更新,旧块可读,直到无引用才释放Commit批量提交的方式写入 LevelDB/RocksDB
Chunk
Chunk 大小范围 64KiB-64MiB,按照 2 的幂次递增,共 11 种,Allocator 会选择最接近实际空间大小的物理块进行分配。
对于每种物理块大小,以 256 个物理块组成一个 Resource Pool,通过 Bitmap 标识空间状态,为 0 代表空闲可回收状态,分配的时候优先分配空闲可回收的物理块。
写入流程
存储服务崩溃、重启、介质故障,对应的存储 Target 不参与数据写操作,会被移动到 chain 的末尾。当服务重新启动的时候,offline 节点上对应存储 Target的数据为老数据,需要与正常节点的数据进行补齐,才能保证数据一致性。offline 的节点周期性的从 cluster manager 拉取最新的 chain table 信息,直到该节点上所有的存储Target 在 chain table 中都被标记为 offline 以后,才开始发送心跳。这样可以保证该节点上的所有存储 Target 各自独立进入恢复流程。数据恢复采用了一种 full-chunk-replace 写的方式,支持边写边恢复,即上游节点发现下游的 offline 节点恢复,开始通过链式复制把写请求转发给下游节点,此时,哪怕 Client 只是写了部分数据,也会直接把完整的 chunk 复制给下游,实现 chunk 数据的恢复。
数据恢复过程整体分成为两个大步骤:Fetch Remote Meta、Sync Data。其中 Local node代表前继正常节点,Remote node为恢复节点。
业界基于分布式高性能 KV 存储系统,构建大规模文件系统元数据组件已成共识,如 Google Colossus、Microsoft ADLS 等。3FS 元数据服务使用相同设计思路,底层基于支持事务的分布式 KV 存储系统,上层元数据代理负责对外提供 POSIX 语义接口。总体来说,支持了大部分 POSIX 接口,并提供通用元数据处理能力:inode、dentry 元数据管理,支持按目录继承 chain 策略、后台数据 GC 等特性。
3FS 选择使用 FoundationDB 作为底层的 KV 存储系统。FoundationDB 是一个具有事务语义的分布式 KV 存储,提供了 NoSQL 的高扩展,高可用和灵活性,同时保证了serializable 的强 ACID 语义。该架构简化了元数据整体设计,将可靠性、扩展性等分布式系统通用能力下沉到分布式 KV 存储,Meta Service 节点只是充当文件存储元数据的 Proxy,负责语义解析。
利用 FoundationDB SSI 隔离级别的事务能力,目录树操作串行化,冲突处理、一致性问题等都交由 FoundationDB 解决。Meta Service 只用在事务内实现元数据操作语义到 KV 操作的转换,降低了语义实现复杂度。
存算分离架构下,各 MetaData Service 节点无状态,Client 请求可打到任意节点。但 Metadata Service 内部有通过 inode id hash,保证同目录下创建、同一文件更新等请求转发到固定元数据节点上攒 Batch,以减少事务冲突,提升吞吐。计算、存储具备独立 scale-out 能力。
Metadata Service 采用 inode 和 dentry 分离的设计思路,两种数据结构有不同的 schema 定义。具体实现时,采用了“将主键编码成 key,并添加不同前缀”的方式模拟出两张逻辑表,除主键外的其它的字段存放到 value 中。
在定义好的 inode、entry 结构之上,如何通过 FoundationDB 的读写事务正确实现各类 POSIX 元数据操作,是 Meta Service 中最重要的问题。但 POSIX 元数据操作有很多种,一一穷举说明会导致文章篇幅过长。本章节我们从这些操作中抽取了几种比较有代表性的常见操作来展开说明。
本文带着读者深入到了 3FS 系统内部去了解其各个组成部分的关键设计。在这个过程中,我们可以看到 3FS 的很多设计都经过了深思熟虑,不可否认这是一个设计优秀的作品。但是,我们也注意到这些设计和目前文件存储领域的一些主流做法存在差异。
本文是系列文章的上篇,在下篇文章中我们将进一步将 3FS 和业界的一些知名的文件系统进行对比,希望能够从整个文件存储领域的角度为读者分析清楚 3FS 的优点和局限性,并总结出我们从 3FS 得到的启示,以及我们是如何看待这些启示的。