JuiceFS调研(基于开源版本代码)




本文作者: 胡 遥 (https://my.oschina.net/u/2257799 、 https://github.com/baijiaruo )

使用场景

JuiceFS 为海量数据存储设计,可以作为很多分布式文件系统和网络文件系统的替代,特别是以下场景:

  1. 大数据分析:HDFS 兼容,没有任何特殊 API 侵入业务;与主流计算框架(Spark, Hadoop, Hive等)无缝衔接;无限扩展的存储空间;运维成本几乎为 0;完善的缓存机制,高于对象存储性能数倍。
  2. 机器学习:POSIX 兼容,可以支持所有机器学习、深度学习框架;共享能力提升团队管理、使用数据效率。
  3. 容器集群中的持久卷:Kubernetes CSI 支持;持久存储并与容器生存期独立;强一致性保证数据正确;接管数据存储需求,保证服务的无状态化。
  4. 共享工作区:没有 VPC 限制,可以在任意主机挂载;没有客户端并发读写限制;POSIX 兼容已有的数据流和脚本操作。
  5. 数据备份:POSIX 是运维工程师最友好的接口;无限平滑扩展的存储空间;跨云跨区自动复制;挂载不受 VPC 限制,方便所有主机访问;快照(snapshot)可用于快速恢复和数据验证。

整体架构

arch

整体架构很简单,就是一个client core,负责实现fuse接口,将数据写到s3,元数据写到redis。client core的内部模块从代码目录中已经能够很清晰的体现。

  • fuse模块:负责和go-fuse对接,同时调用vfs模块接口
  • vfs模块:负责整个posix语义实现,数据操作会进行chunk粒度的拆分,调用chunk模块接口;元数据操作调用meta模块接口
  • chunk模块:负责数据上传下载,同时通过object模块适配不同的厂商。
  • meta模块:负责和redis交互。

设计及流程

数据映射s3
format

文件会先按照chunk进行拆分,每个chunk 64MB,每个chunk可以由1到多个slice组成,而slice大小是变长,考虑到覆盖写的支持,slice之间是可以有数据逻辑地址重叠的情况。每个slice又有若干个block组成,每个block大小为4MB,最终数据是已block为粒度写到S3上

元数据

juiceFS元数据主要分为3类:一类是i+inode number的用于存放文件的基本属性,value类型为string;一类是已c+inode number + chunk index的用于存放数据信息,value类型为list;一类是d+inode number用于存放目录信息,value类型为hash。

posix接口流程

create流程

主要调用了redis模块的create接口,redis模块的create流程中封装了一个事务,主要包含以下几个操作:

  1. 获取父目录的属性,即i+parent inode的key的值。
  2. 获取父目录中是否有该文件,即d+inode的name作为key去查询。
  3. 在父目录中写入该文件name,即d+inode中插入一个key-value,key为文件name,value为文件类型+文件inode number。
  4. 修改父目录属性,更新父目录信息,即插入i+parent inode key的值。
  5. 新增文件元数据,即插入一个i+inode的key。

lookup流程

主要调用了redis模块的Lookup接口,先在父目录中查找该文件name是否存在,即d+parent inode中查找name,通过value解析出inode;如果存在获取到inode,然后根据inode在获取文件key,拿到文件属性。

写流程

写流程主要是根据offset,len等计算当前io落在哪个chunk index以及是否有缓存slice。如果没有则创建slice,数据只写到slice中的page字段缓存中就返回了。
数据下刷主要依赖commitThread和flushData2个协程的配合:

  1. 在write流程中,一个chunk的slice会在被第一次生成的时候产生一个协程commitThread,用于清理已经done的slice,将slice的信息记录到redis中。如果当前的slice没有done,则创建flushData协程。
  2. flushData携程通过redis获取一个chunk id,并将slice中的数据flush到s3中,然后标记为done。所以每个slice会对应有一个唯一的chunkid。
  3. 当数据flush到s3后commitThread会对redis中c+inode number + index的key插入一条slice的信息,进行元数据更新。
  4. 对于可能存在的读缓存使失效。
    • 对于覆盖写每次都会新启用一个chunk id来进行写,而对于追加写按照上面的逻辑,如果slice已落盘则会新启用一个chunk,而slice没落盘则会复用老的slice进行写。

举例说明:

  • 场景1 :追加写 dd=/dev/uradom of=/mnt/test/test1 bs=1M count=9
    • 数据:63_0_4194304,63_1_4194304,63_2_1048576
    • 元数据:slice1{chunkid:63;offset:0;len:9M}
  • 场景2: 在上面的基础上覆盖写0~1M。
    • 数据:63_0_4194304,63_1_4194304,63_2_1048576 64_0_1048576
    • 元数据:slice1{chunkid:63;offset:0,;len:9M};slice2{chunkid:64;offset:0;len:1M}

读流程

  1. 根据offset,len和缓存slice(offset,len,data)将原有的offset len划分为多个block。
  2. 划分后的block,必然有的在缓存中,有的不在缓存中。不在缓存中的blcok在重新生成slice。
  3. 新的slice调用run协程去s3读取对应的数据。这里有一个问题,对于覆盖写场景,如写流程里的例子,0~1M是有新老2份数据,如何知道哪份是新的数据?由于chunkId是递增的,所以chunkid越大的肯定是最新的,同时元数据的slice采用list的管理,在读取chunk所有的slice的时候,会对将overlap的slice进行切分,并且用后面的元数据覆盖前面的。
  4. 等待所有slice数据都获取到(这里通过slice的一个状态来判断是否从s3中已读取到数据,如果没有读取到则一直等待),进行数据拼接,并返回给client。

其他特性

  1. 当一个chunk的slice过多的时候,会进行compactChunk操作
  2. 本地磁盘进行读写缓存加速

这里暂时未深入调研。