cxljs

JuiceFS 设计和实现

分布式文件系统的发展

Google GFS 是分布式文件系统发展的里程碑,Meta 和 Data 分离的架构影响了后来的分布式系统设计。随后的 HDFS 是大数据时代最重要的存储系统之一,现在分布式文件系统最流行的2种接口协议是 POSIX 和 HDFS。

随着云原生和对象存储的发展,数据系统的设计趋势是把数据放到对象存储,比如 autoMQ 把 Kafka 数据放到对象存储,AWS 刚发布的基于对象存储的向量数据库。

现在 LLM 训练对分布式文件系统的需求也有变化,和大数据场景不同,LLM 训练过程需要读取大量小文件,所以文件系统的元数据更多。分布式文件系统还有一个热点是 GPU Direct FS。

分布式文件系统的挑战

文件系统的元数据组成一棵树结构,分布式文件系统的挑战之一是怎么存储和高并发读写树结构。

常见思路:

  1. 整个 Tree 存储在单个节点(主备模式做高可用),缺点是单节点吞吐量和容量有限,可能会成为系统瓶颈
  2. 设计一个可扩展的存储 Tree 的系统,把 Tree 划分存储到多个节点,有相关论文
  3. 转化成 KV/Table 数据模型存储在 KV/RDB
  4. 不采用 Meta + Data 架构,把 Meta 和 Data 混合存储

JuiceFS

JuiceFS 是经典的 client-meta-data 架构,采用上面的第3种思路存储元数据,支持 TiKV/MySQL/Etcd/Redis 等多种系统作为 Meta Service,数据放在对象存储,JuiceFS 本身是一个 client 层。

架构:

app        fuse lib  -->  juicefs --> meta service
 |            |             |
 |            |          data block cache --> object storage service
syscall    /dev/fuse
 |            |
vfs    -->   fuse

文件存储模型

如果一个文件作为一个对象去存储,修改文件会有严重的写放大,特别是大文件,所以需要把文件划分成多个对象去存储。

为此 JuiceFS 设计了 chunk-slice-block 模型:

  • 文件逻辑上划分成多个 chunk,chunk size = 64MB
  • 对 chunk 的每次写为一个 slice,slice = (chunk id, offset, size, data),data 存储在 local data cache,多次写可能有重叠,所以还要维护 slice 顺序,以最新的为准。跨 chunk 的写会分成多个 slice
  • 把 slice 存储到对象存储时,把 slice 划分成多个 block (block size = 4MB) 并发写入,所以对象存储里的对象是一个个 <= 4MB 的 block

block 为存储的单位 vs. slice 为存储单位:

  • pros.:
    • 方便并发上传,slice 为单位也可以用 multipart 方式上传,但是有些对象存储不支持 multipart
    • 方便 GC,juicefs 为了避免写放大,随机修改文件时,juicefs 不是“下载对象->修改->重传”,而是直接上传新对象,然后删除旧对象,如果以 slice 为单位,即使修改了一点数据,整个 slice 也无法删除,因为 slice 的部分数据是有效的,以 block 为单位,能更快删除旧数据
    • 本地 object cache 的实现更加简洁
  • cons.:
    • 更多元数据,对 meta service 压力更大

对象名:${fsname}/chunks/${hash}/${basename}:

  • fsname: 用户创建文件系统时定义的文件系统名
  • hash: hash_func(basename),为了做隔离
  • basename: 对象名,格式是 ${slice_id}_${block_index}_${block_size},block_index 是 block 在 slice 的序号,范围 [0,16)

多个写重叠会有过期 slice -> 有过期 block,所以需要有删除策略,还可以做碎片合并等设计。

上面说每次写是一个 slice,实际上由于有 local data cache,JuiceFS 会做一些优化,比如一次写后,slice 还没有上传到对象存储,此时有新的写操作和它重叠或连续,会直接修改它,不创建新 slice。

元数据存储模型

JuiceFS 需要维护的元数据:

  • 系统元数据
  • 各种计数值:下个可用的 inode id / slice id / session id / &c
  • session: client id, client info 和超时时间
  • inode attr:type, size, nlink, uid, parent inode id, &c
  • file inode data (chunks): (inode id, chunk index) -> []sliceMeta
  • dir inode data:(dir inode id, child inode name) -> (child inode type, child inode id)
  • plock: (inode, session id, owner) -> []plock record
  • …

JuiceFS 需要设计合理的数据模型,把元数据放到 TiKV/MySQL/Redis 等系统。

Redis:

  • 系统元数据:string KV :setting -> json string
  • 各种计数值:string KV :nextInode -> num, nextSliceId -> num, &c
  • session
    • session timeout: allSessions -> a sorted set {member: client id, score: timeout timestamp}
    • session info: sessionInfos -> a hash table {client id -> json string}
  • inode attr: string KV: i${inode_id} -> 二进制编码的 attr struct
  • chunk: c${inode_id}_{chunk_index} -> a list {二进制编码的 sliceMeta struct}
  • dir: d${inode_id} -> a hash table {child inode name -> 二进制编码的 (inode_type, inode_id)}
  • …

用只有 KV API 的系统作为 Meta Service 时,JuiceFS 的做法是 string KV 不变,hash table 等数据结构把 key 和 field 拼接成一个 key:

  • 系统元数据:setting -> json string
  • session
    • SE${client_id} -> timeout timestamp
    • SI${client_id} -> json string
  • inode attr: A${inode_id}I -> 二进制编码的 attr struct
  • chunk: A${inode_id}C${chunk_index} -> 二进制编码的 sliceMeta array
  • dir: A${inode_id}D${name} -> 二进制编码的 (inode_type, inode_id)
  • …

key 的具体拼接细节没有保持一致,但无伤大雅。

用数据库作为 Meta Service,元数据分成多个表存放。

以读文件数据为例,流程:

  1. root inode id = 1, 根据文件路径找到 inode_id, file size(in attr struct)
  2. 计算要读的 chunk_index, 获取 sliceMeta array
  3. 计算要读的位置在哪些 slices 以及在 slice 内的 index,${slice_id}_${block_index}_${block_size} 读取 block,拼接数据

架构

JuiceFS 3个主要模块:vfs, meta store 和 chunk store:

  • vfs: 从文件系统的视角,维护需要的元数据,做相关的检查,调用 meta store 和 chunk store
  • meta store: cache + meta service cli,获取元数据时,会先查看 cache,减少对远程 meta service 的压力
  • chunk store: data cache + oss cli + 预读策略