代码级解析:JuiceFS 元数据、数据存储设计原理

2024-11-25
Arthur

为了提升性能,JuiceFS 在数据存储过程中实施了分块策略,chunk、slice 、block 等概念以及他们的工作原理对于新用户来说,并不容易理解。本文转载自社区用户 Arthur ,他将结合代码示例深入解析 JuiceFS 的设计原理,包括数据存储及元数据存储。本文为其系列长文中的部分内容。原文请点此查看

1 对象存储中 JuiceFS 写入的文件

以 MinIO 为例,来看 JuiceFS 写入到对象存储中的文件是怎样组织的。其他云厂商的对象存储(AWS S3、阿里云 OSS 等)也都是类似的。

1.1 Bucket 内:每个 volume 一个“目录”

可以用 juicefs format 命令再创建两个 volume,方便观察它们在 bucket 中的组织关系,

如上图所示,bucket 内的顶层“目录”就是 JuiceFS 的 volumes, 我们这里提到“目录”时加双引号,是因为对象存储是扁平的 key-value 存储,没有目录的概念, 前端展示时模拟出目录结构(key 前缀一样的,把这个前缀作为一个“目录”)是为了查看和理解方便。简单起见,后文不再加双引号。

1.2 每个 volume 的目录:{chunks/, juicefs_uuid, meta/, ...}

每个 volume 目录内的结构如下:

 |-chunks/         # 数据目录,volume 中的所有用户数据都放在这里面
  |-juicefs_uuid    
  |-meta/           # `juicefs mount --backup-meta ...` 产生的元数据备份存放的目录

1.2.1 juicefs_uuid:JuiceFS volume 的唯一标识

可以把这个文件下载下来查看内容,会发现里面存放的就是 juicefs format 输出里看到的那个 uuid, 也就是这个 volume 的唯一标识。

删除 volume 时需要用到这个 uuid。

1.2.2 meta/:JuiceFS 元数据备份

如果在 juicefs mount 时指定了 --backup-meta,JuiceFS 就会定期把元数据(存在在 TiKV 中)备份到这个目录中, 用途:

  1. 元数据引擎故障时,可以从这里恢复;
  2. 在不同元数据引擎之间迁移元数据。详见JuiceFS 元数据引擎五探:元数据备份与恢复(2024)。

1.2.3 chunks/

Fig. MinIO bucket browser: files in a bucket.
Fig. MinIO bucket browser: files in a bucket.

chunks/ 内的目录结构如下,

  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/
如上,所有的文件在 bucket 中都是用数字命名和存放的,分为三个层级:
  • 第一层级:纯数字,是 sliceID 除以 100 万得到的;
  • 第二层级:纯数字,是 sliceID 除以 1000 得到的;
  • 第三层级:纯数字加下划线,{slice_id}{block_id}{size_of_this_block},表示的是这个 chunk 的这个 slice 内的 block_id 和 block 的大小。不理解 chunk/slice/block 这几个概念没关系,我们马上将要介绍。

1.3 小结

通过以上 bucket 页面,我们非常直观地看到了一个 JuiceFS volume 的所有数据在对象存储中是如何组织的。

接下来进入正题,了解一下 JuiceFS 的数据和元数据设计。

2 JuiceFS 数据的设计

2.1 顶层切分:一切文件先切 chunk

对于每个文件,JuiceFS 首先会按固定大小(64MB)切大块, 这些大块称为「Chunk」。

  • 这是为了读或修改文件内容时,方便查找和定位。
  • 不管是一个只有几字节的文本文件,还是一个几十 GB 的视频文件, 在 JuiceFS 中都是切分成 chunk,只是 chunk 的数量不同而已。

2.1.1 示意图

Fig. JuiceFS: split each file into their respective chunks (with max chunk size 64MB).
Fig. JuiceFS: split each file into their respective chunks (with max chunk size 64MB).

2.1.2 对象存储:不存在 chunk 实体

结合上一节在对象存储中看到的目录结构,

  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/
  1. Chunk 在对象存储中 没有对应任何实际文件,也就是说在对象存储中没有一个个 64MB 的 chunks;
  2. 用 JuiceFS 的话来说,Chunk 是一个逻辑概念。暂时不理解没关系,接着往下看。

2.2 Chunk 内的一次连续写入:Slice

chunk 只是一个“框”,在这个框里面对应文件读写的,是 JuiceFS 称为「Slice」 的东西。chunk 内的一次连续写入,会创建一个 slice,对应这段连续写入的数据;由于 slice 是 chunk 内的概念,因此它不能跨 Chunk 边界,长度也不会超 max chunk size 64M。slice ID 是全局唯一的;

2.2.1 Slice 的重叠问题

根据写入行为的不同,一个 Chunk 内可能会有多个 Slice, 如果文件是由一次连贯的顺序写生成,那每个 Chunk 只包含一个 Slice。如果文件是多次追加写,每次追加均调用 flush 触发写入上传,就会产生多个 Slice。

Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.
Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.

以 chunk1 为例,

  • 用户先写了一段 ~30MB 数据,产生 slice5;
  • 过了一会,从 ~20MB 的地方重新开始写 45MB(删掉了原文件的最后一小部分,然后开始追加写),
    • chunk1 内的部分产生 slice6;
    • 超出 chunk1 的部分,因为 slice 不能跨 chunk 边界,因此产生 chunk2 和 slice7;
  • 过了一会,从 chunk1 ~10MB 的地方开始修改(覆盖写),产生 slice8。由于 Slice 存在重叠,因此引入了几个字段标识它的有效数据范围,
type slice struct {
    id    uint64
    size  uint32
    off   uint32
    len   uint32
    pos   uint32
    left  *slice // 这个字段不会存储到 TiKV 中
    right *slice // 这个字段不会存储到 TiKV 中
}

2.2.2 读 chunk 数据时的多 slice 处理:碎片化和碎片合并

Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continuous write operation.
Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continuous write operation.

对 JuiceFS 用户来说,文件永远只有一个,但在 JuiceFS 内部,这个文件对应的 Chunk 可能会有多个重叠的 Slice,

  • 有重叠的部分,以最后一次写入的为准。
  • 直观上来说,就是上图 chunk 中的 slices 从上往下看,被盖掉的部分都是无效的。

因此,读文件时,需要查找「当前读取范围内最新写入的 Slice」,

  • 在大量重叠 Slice 的情况下,这会显著影响读性能,称为文件「碎片化」。
  • 碎片化不仅影响读性能,还会在对象存储、元数据等层面增加空间占用。
  • 每当写入发生时,客户端都会判断文件的碎片化情况,并异步地运行碎片合并,将一个 Chunk 内的所有 Slice 合并。

2.2.3 对象存储:不存在 slice 实体

跟 chunk 类似,在对象存储中 slice 也没有 没有对应实际文件。

  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/

2.3 Slice 切分成固定大小 Block(e.g. 4MB):并发读写对象存储

为了加速写到对象存储,JuiceFS 将 Slice 进一步拆分成一个个「Block」(默认 4MB),多线程并发写入。

Fig. JuiceFS: slices are composed of blocks (4MB by default), each block is an object in object storage.
Fig. JuiceFS: slices are composed of blocks (4MB by default), each block is an object in object storage.

Block 是 JuiceFS 数据切分设计中最后一个层级,也是 chunk/slice/block 三个层级中唯一能在 bucket 中看到对应文件的。

  • 连续写:前面 Block 默认都是 4MB,最后一个 Block 剩多少是多少。
  • 追加写:数据不足 4MB 时,最终存入对象存储的也会是一个小于 4M 的 Block。

从上图的名字和大小其实可以看出分别对应我们哪个文件:

  • 1_0_16:对应我们的 file1_1KB
    • 我们上一篇的的追加写 echo "hello" >> file1_1KB 并不是写入了 1_0_16, 而是创建了一个新对象 7_0_16,这个 object list 最后面,所以在截图中没显示出来;
    • 换句话说,我们的 file1_1KB 虽然只有两行内容,但在 MinIO 中对应的却是两个 object,各包含一行。
    • 通过这个例子,大家可以体会到 JuiceFS 中连续写和追加写的巨大区别。
  • 3_0_4194304 + 3_1_1048576:总共 5MB,对应我们的 file2_5MB
  • 4_*:对应我们的 file3_129MB

2.4 object key 命名格式(及代码)

格式:{volume}/chunks/{id1}/{id2}/{slice_id}_{block_id}_{size_of_this_block},对应的代码,

func (s *rSlice) key(blockID int) string {
   if s.store.conf.HashPrefix  // false by default
       return fmt.Sprintf("chunks/%02X/%v/%v_%v_%v", s.id%256, s.id/1000/1000, s.id, blockID, s.blockSize(blockID))

   return fmt.Sprintf("chunks/%v/%v/%v_%v_%v", s.id/1000/1000, s.id/1000, s.id, blockID, s.blockSize(blockID))
} 

2.5 将 chunk/slice/block 对应到对象存储

最后,我们将 volume 的数据切分和组织方式对应到 MinIO 中的路径和 objects,

2.6 小结:光靠对象存储数据和 slice/block 信息无法还原文件

至此,JuiceFS 解决了数据如何切分和存放的问题,这是一个正向的过程:用户创建一个文件,我们能按这个格式切分、命名、上传到对象存储。对应的反向过程是:给定对象存储中的 objects,我们如何将其还原成用户的文件呢?显然,光靠 objects 名字中包含的 slice/block ID 信息是不够的,例如,

  1. 最简单情况下,每个 chunk 都没有任何 slice 重叠问题,那我们能够根据 object 名字中的 slice_id/block_id/block_size 信息拼凑出一个文件, 但仍然无法知道这个文件的文件名、路径(父目录)、文件权限(rwx)等等信息;
  2. chunk 一旦存在 slice 重叠,光靠对象存储中的信息就无法还原文件了;
  3. 软链接、硬链接、文件属性等信息,更是无法从对象存储中还原。

解决这个反向过程,我们就需要文件的一些元数据作为辅助 —— 这些信息在文件切分和写入对象存储之前,已经记录到 JuiceFS 的元数据引擎中了。

3 JuiceFS 元数据的设计(TKV 版)

JuiceFS 支持不同类型的元数据引擎,例如 Redis、MySQL、TiKV/etcd 等等,每种类型的元数据引擎都有自己的 key 命名规则。本文讨论的是 JuiceFS 使用 transactional key-value(TKV)类型的元数据引擎时的 key 命名规则。更具体地,我们将拿 TiKV 作为元数据引擎来研究。

3.1 TKV 类型 key 列表

这里的 key 是 JuiceFS 定义元数据 key,key/value 写入元数据引擎;请注意跟前面提到的对象存储 key 区别开,那个 key/value 是写入对象存储的。key 是一个字符串,所有 key 的列表,

 setting                           format
  C{name}                           counter
  A{8byte-inode}I                   inode attribute
  A{8byte-inode}D{name}             dentry
  A{8byte-inode}P{8byte-inode}      parents // for hard links
  A{8byte-inode}C{4byte-blockID}    file chunks
  A{8byte-inode}S                   symlink target
  A{8byte-inode}X{name}             extented attribute
  D{8byte-inode}{8byte-length}      deleted inodes
  F{8byte-inode}                    Flocks
  P{8byte-inode}                    POSIX locks
  K{8byte-sliceID}{8byte-blockID}   slice refs
  Ltttttttt{8byte-sliceID}          delayed slices
  SE{8byte-sessionID}               session expire time
  SH{8byte-sessionID}               session heartbeat // for legacy client
  SI{8byte-sessionID}               session info
  SS{8byte-sessionID}{8byte-inode}  sustained inode
  U{8byte-inode}                    usage of data length, space and inodes in directory
  N{8byte-inode}                    detached inde
  QD{8byte-inode}                   directory quota
  R{4byte-aclID}                    POSIX acl

在 TKV 的 Keys 中,所有整数都以编码后的二进制形式存储:

  • inode 和 counter value 占 8 个字节,使用小端编码
  • SessionID、sliceID 和 timestamp 占 8 个字节,使用大端编码 setting 是一个特殊的 key,对应的 value 就是这个 volume 的设置信息。前面的 JuiceFS 元数据引擎系列文章中介绍过,这里不再赘述。

其他的,每个 key 的首字母可以快速区分 key 的类型,

  • C:counter,这里面又包含很多种类,例如 name 可以是:
    • nextChunk
    • nextInode
    • nextSession
  • A:inode attribute
  • D:deleted inodes
  • F:Flocks
  • P:POSIX lock
  • S:session related
  • K:slice ref
  • L: delayed (to be deleted?) slices
  • U:usage of data length, space and inodes in directory
  • N:detached inode
  • QD:directory quota
  • R:POSIX acl

需要注意的是,这里是 JuiceFS 定义的 key 格式,在实际将 key/value 写入元数据引擎时, 元数据引擎可能会对 key 再次进行编码,例如 TiKV 就会在 key 中再插入一些自己的字符。前面的 JuiceFS 元数据引擎系列文章中也介绍过,这里不再赘述。

3.2 元数据引擎中的 key/value

3.2.1 扫描相关的 TiKV key

TiKV 的 scan 操作类似 etcd 的 list prefix,这里扫描所有 foo-dev volume 相关的 key,

key: zfoo-dev\375\377A\000\000\000\020\377\377\377\377\177I\000\000\000\000\000\000\371
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1_\3771KB\000\000\000\000\000\372
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile2_\3775MB\000\000\000\000\000\372
...
key: zfoo-dev\375\377SI\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371
        default cf value: start_ts: 453485726123950084 value: 7B225665727369...33537387D
key: zfoo-dev\375\377U\001\000\000\000\000\000\000\377\000\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 453485722598113282 value: 7B0A224E616D65223A202266...0A7D

3.2.2 解码成 JuiceFS metadata key

用 tikv-ctl --decode <key>可以解码出来,注意去掉最前面的 z,得到的就是 JuiceFS 的原始 key,看着会更清楚一点,

foo-dev\375A\001\000\000\000\000\000\000\000Dfile1_1KB
foo-dev\375A\001\000\000\000\000\000\000\000Dfile2_5MB
foo-dev\375A\001\000\000\000\000\000\000\000Dfile3_129MB
foo-dev\375A\001\000\000\000\000\000\000\000I
foo-dev\375A\002\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\002\000\000\000\000\000\000\000I
foo-dev\375A\003\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\003\000\000\000\000\000\000\000I
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\001
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\002
foo-dev\375A\004\000\000\000\000\000\000\000I
foo-dev\375ClastCleanupFiles
foo-dev\375ClastCleanupSessions
foo-dev\375ClastCleanupTrash
foo-dev\375CnextChunk
foo-dev\375CnextCleanupSlices
foo-dev\375CnextInode
foo-dev\375CnextSession
foo-dev\375CtotalInodes
foo-dev\375CusedSpace
foo-dev\375SE\000\000\000\000\000\000\000\001
foo-dev\375SI\000\000\000\000\000\000\000\001
foo-dev\375U\001\000\000\000\000\000\000\000
foo-dev\375setting

从上面的 keys,可以看到我们创建的三个文件的元信息了, 这里面是用 slice_id 等信息关联的,所以能和对象存储里的数据 block 关联上。

可以基于上一节的 key 编码规则进一步解码,得到更具体的 sliceID/inode 等等信息,这里我们暂时就不展开了。

5 这个设计下产生的常见问题

5.1 如何从数据和元数据中恢复文件

5.1.2 理论步骤

对于一个给定的 JuiceFS 文件,我们在上一篇中已经看到两个正向的过程: 1. 文件本身被切分成 Chunk、Slice、Block,然后写入对象存储; 2. 文件的元数据以 inode、slice、block 等信息组织,写入元数据引擎。

有了对正向过程的理解,我们反过来就能从对象存储和元数据引擎中恢复文件:对于一个给定的 JuiceFS 文件,

  1. 首先扫描元数据引擎,通过文件名、inode、slice 等等信息,拼凑出文件的大小、位置、权限等等信息;
  2. 然后根据 slice_id/block_id/block_size 拼凑出对象存储中的 object key;
  3. 依次去对象存储中根据这些 keys 读取数据拼到一起,得到的就是这个文件,然后写到本地、设置文件权限等等。

5.1.3 juicefs info 查看文件 chunk/slice/block 信息

JuiceFS 已经提供了一个命令行选项,能直接查看文件的 chunk/slice/block 信息,例如:

foo-dev/file2_5MB :
  inode: 3
  files: 1
   dirs: 0
 length: 5.00 MiB (5242880 Bytes)
   size: 5.00 MiB (5242880 Bytes)
   path: /file2_5MB
 objects:
+------------+--------------------------------+---------+--------+---------+
| chunkIndex |           objectName           |   size  | offset |  length |
+------------+--------------------------------+---------+--------+---------+
|          0 | foo-dev/chunks/0/0/3_0_4194304 | 4194304 |      0 | 4194304 |
|          0 | foo-dev/chunks/0/0/3_1_1048576 | 1048576 |      0 | 1048576 |
+------------+--------------------------------+---------+--------+---------+

和我们在 MinIO 中看到的一致。

5.2 为什么 JuiceFS 写入对象存储的文件,不能通过对象存储直接读取?

这里说的“不能读取”,是指不能直接读出原文件给到用户,而不是说不能读取 objects。

看过本文应该很清楚了,JuiceFS 写入对象存储的文件是按照 Chunk、Slice、Block 进行切分的, 只有数据内容,且保护重复数据,还没有文件信息元信息(文件名等)。

所以,以对象的存储的方式只能读这些 objects,是无法恢复出原文件给到用户的。

Author

Arthur
JuiceFS 用户

最新博客

从 CephFS 到 JuiceFS:同程旅游亿级文件存储平台构建之路

2024-12-13
同程旅游已在 JuiceFS 上构建了一个企业级存储平台,平台规模涵盖了超过 20 个文件系统和 2000 多个客户端挂载点,能够高效管理亿级文件和百 TiB 级别的数据量。

Conda + JuiceFS :增强 AI 开发环境共享能力

2024-12-04
本文简要分享了在多机环境下,如何利用 JuiceFS 复用 Conda 虚拟环境的具体方法与注意事项。希望这篇文章中的技巧和经验能够为读者的日常开发和团队协作提供帮助,并提升工作效率。我们鼓励感兴趣…

代码级解析:JuiceFS 元数据、数据存储设计原理

2024-11-25
为了提升性能,JuiceFS 在数据存储过程中实施了分块策略,chunk、slice 、block 等概念以及他们的工作原理对于新用户来说,并不容易理解。本文转载自社区用户 Arthur ,他将结合…

使用 JuiceFS 快照功能实现数据库发布与端到端测试

2024-11-15
JuiceFS 云服务用户 Jerry,他们通过使用 JuiceFS snapshot 功能,创新性地实现了数据的版本控制。Jerry,是一家位于北美的科技公司,利用人工智能和机器学习技术,简化用户…