JuiceFS 设计和实现
分布式文件系统的发展
Google GFS 的发表是分布式文件系统发展的里程碑,Meta + Data 的架构影响了后来的分布式系统设计。随后的 HDFS 是大数据时代最重要的存储系统之一,现在分布式文件系统最流行的2种接口协议是 POSIX 和 HDFS。
随着云原生和对象存储的发展,数据系统的设计趋势是把数据放到对象存储,比如 autoMQ 把 Kafka 的数据放到对象存储,AWS 刚发布的基于对象存储的向量数据库。
现在 LLM 训练对分布式文件系统的需求也有变化,和大数据场景不同,AI 训练需要存储大量小文件,所以文件系统的元数据更多。分布式文件系统还有一个热点是 GPU Direct FS。
分布式文件系统的挑战
文件系统的元数据组成一个 DAG,分布式文件系统的挑战之一是:
- 怎么存储这个 DAG?
- 怎么高并发读写这个 DAG?
常见思路:
- 整个 DAG 存储在单个节点(一般会用主备),缺点是单节点吞吐量和容量有限,可能会成为系统瓶颈
- 设计一个可扩展的存储 DAG 的系统,把 DAG 划分存储到多个节点,目前有相关的论文,但是没有这样做的开源项目(CubeFS 好像是?)
- 转化成 KV/Table 数据模型存储在 KV/RDB
- 不采用 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 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
- 把 local data cache 的 slice 存储到对象存储时,把一个 slice 作为一个对象去存储?为了提高 slice 写到对象存储的速度,把 slice 划分成多个 block (block size = 4M) 并发写入
所以对象存储的对象是一个个 <= 4M 的 block。
对象名:${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, info 和超时时间
- inode attr:type, size, nlink, uid, parent inode id, &c
- file inode data (chunks): (inode id, chunk index) -> []sliceMeta
- dir inode data:(parent inode id, name) -> (type, inode id),即(目录 inode id, 子 inode 名) -> (子 inode type, 子 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}
- session timeout:
- inode attr: string KV:
i${inode_id}-> 二进制编码的 attr struct - chunk:
c${inode_id}_{chunk_index}-> list {二进制编码的 sliceMeta struct} - dir:
d${inode_id}-> a hash table {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 timestampSI${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,元数据分成多个表存放,本质上差不多。
以读文件数据为例,流程:
- root inode id = 1, 按照文件路径,找到 inode_id, size(in attr)
- 计算要读的 chunk_index, 获取 sliceMeta list
- 计算要读的位置在哪些 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 + 预读策略