Aggregator
直观解读 JuiceFS 的数据和元数据设计(一):看山是山(2024)
本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。
Fig. MinIO bucket browser: one object was created ({volume}/juicefs_uuid) on a new juicefs volume creation.
水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处。
- 直观解读 JuiceFS 的数据和元数据设计(一):看山是山(2024)
- 直观解读 JuiceFS 的数据和元数据设计(二):看山不是山(2024)
- 直观解读 JuiceFS 的数据和元数据设计(三):看山还是山(2024)
- 1 JuiceFS 高层架构与组件
- 2 搭建极简 JuiceFS 集群
- 3 将 JuiceFS volume 挂载到本地路径
- 4 在 JuiceFS volume 挂载的本地路径内读写
- 5 总结
- 参考资料
本篇首先快速了解下 JuiceFS 架构和组件,然后将搭建一个极简 JuiceFS 集群, 并以 JuiceFS 用户的身份来体验下它的基本功能。
1 JuiceFS 高层架构与组件JuiceFS 的高层架构和组件,
Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.
三大组件:
- 元数据引擎:存储文件元数据,例如文件名、权限等。JuiceFS 支持多种元数据引擎,比如 TiKV、sqlite、redis 等。
- 对象存储:存储文件本身。JuiceFS 支持多种对象存储,比如 MinIO、AWS S3、阿里云 OSS 等。
- JuiceFS 客户端:将 JuiceFS volume 挂载到机器上,提供文件系统视图给用户。
更多架构信息,见 [1]。
2 搭建极简 JuiceFS 集群接下来搭建一个极简 JuiceFS 环境,方便我们做一些功能测试。 按上一节提到的,只需要搭建以下 3 个组件:
- 元数据引擎,这里我们用 TiKV;
- 对象存储,这里我们用 MinIO;
- JuiceFS 客户端。
对于功能测试来说,使用哪种元数据引擎都无所谓,比如最简单的 sqlite 或 redis。
不过,本系列第二篇会介绍 TiKV 相关的一些设计,所以本文用的 TiKV 集群作为元数据引擎, 相关的搭建步骤见社区文档。
本篇假设搭建的是三节点的 TiKV 集群,IP 地址分别是 192.168.1.{1,2,3}。
2.2 搭建对象存储(MinIO)这里我们用 MinIO 搭建一个对象存储服务,主要是空集群方便观察其中的文件变化。
2.2.1 启动 MinIO serverMinIO 是一个兼容 S3 接口的开源对象存储产品,部署非常简单,就一个可执行文件,下载执行就行了。
也可以用容器,一条命令启动:
$ sudo docker run -p 9000:9000 -p 8080:8080 \ quay.io/minio/minio server /data --console-address "0.0.0.0:8080"访问 http://localhost:8080/ 就能看到 MinIO 的管理界面了。默认账号密码都是 minioadmin。
2.2.2 创建 bucket通过 MinIO 管理界面创建一个 bucket,这里我们命名为 juicefs-bucket,
Fig. MinIO bucket list: an empty bucket.
可以看到现在里面一个对象也没有,已使用空间也是 0 字节。
2.3 下载 juicefs 客户端从 https://github.com/juicedata/juicefs/releases 下载一个可执行文件就行了,
$ wget https://github.com/juicedata/juicefs/releases/download/v1.2.1/juicefs-1.2.1-linux-amd64.tar.gz $ tar -xvf juicefs-1.2.1-linux-amd64.tar.gz $ chmod +x juicefs 2.4 创建 JuiceFS volume接下来就可以创建一个 JuiceFS volume 了,这里命名为 foo-dev。
2.4.1 创建/格式化 volume:juicefs format $ juicefs format --storage minio --bucket http://localhost:9000/juicefs-bucket \ --access-key minioadmin \ --secret-key minioadmin \ tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev \ foo-dev <INFO>: Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev [interface.go:504] <INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [format.go:528] <INFO>: Volume is formatted as { "Name": "foo-dev", "UUID": "3b4e509b-a7c8-456f-b726-cb8395cf8eb6", "Storage": "minio", "Bucket": "http://localhost:9000/juicefs-bucket", "AccessKey": "minioadmin", "SecretKey": "removed", "BlockSize": 4096, "UploadLimit": 0, "DownloadLimit": 0, ... } 2.4.2 查看 MinIO bucket:多了一个 juicefs_uuid 文件再查看 MinIO bucket,会发现多了一个 object,
Fig. MinIO bucket browser: one object was created on a new juicefs volume creation.
点进去,发现是一个叫 juicefs_uuid 的文件,
Fig. MinIO bucket browser: one object was created after juicefs format.
可以把这个文件下载下来,其内容就是上面 juicefs format 命令输出的 uuid 信息,也就是说 juicefs client 会把 volume 的 uuid 上传到对象存储中。
3 将 JuiceFS volume 挂载到本地路径这么我们将这个 volume 挂载到本地路径 /tmp/foo-dev,
$ ./juicefs mount --debug --backup-meta 0 \ tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev /tmp/foo-dev [INFO] [client.go:405] ["[pd] create pd client with endpoints"] [component=tikv] [pid=2881678] [pd-address="[192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379]"] [INFO] [base_client.go:378] ["[pd] switch leader"] [component=tikv] [pid=2881678] [new-leader=https://192.168.1.3:2379] [old-leader=] [INFO] [base_client.go:105] ["[pd] init cluster id"] [component=tikv] [pid=2881678] [cluster-id=7418858894192002550] [INFO] [client.go:698] ["[pd] tso dispatcher created"] [component=tikv] [pid=2881678] [dc-location=global] <INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [mount.go:650] ...进入目录:
$ cd /tmp/foo-dev $ ls -ahl -r-------- 1 root root 0 Oct 26 10:45 .accesslog -r-------- 1 root root 2.9K Oct 26 10:45 .config -r--r--r-- 1 root root 0 Oct 26 10:45 .stats dr-xr-xr-x 2 root root 0 Oct 26 10:45 .trash可以看到几个隐藏文件,
- 这些是 JuiceFS 的元数据文件,在 [1] 系列文章中有过详细介绍。
- 这些都是 volume 本地文件,不会上传到 MinIO。此时,MinIO juicefs-bucket 里面还是只有一个 uuid 文件。
接下来进行一些 POSIX 操作测试。
4.1 创建和写入文件创建三个文件,一个只有几十字节(但命名为 file1_1KB), 一个 5MB,一个 129MB,
$ cd /tmp/foo-dev $ echo "Hello, JuiceFS!" > file1_1KB $ dd if=/dev/zero of=file2_5MB bs=1M count=5 5+0 records in 5+0 records out 5242880 bytes (5.2 MB, 5.0 MiB) copied, 0.0461253 s, 114 MB/s $ dd if=/dev/zero of=file3_129MB bs=1M count=129 129+0 records in 129+0 records out 135266304 bytes (135 MB, 129 MiB) copied, 0.648757 s, 209 MB/s 4.2 查看文件属性 $ ls -ahl file* -rw-r----- 1 root root 16 file1_1KB -rw-r----- 1 root root 5.0M file2_5MB -rw-r----- 1 root root 129M file3_129MB $ file file2_5MB file2_5MB: data 4.3 读取和追加文件 $ cat file1_1KB Hello, JuiceFS! $ echo "Hello, JuiceFS!" >> file1_1KB $ cat file1_1KB Hello, JuiceFS! Hello, JuiceFS! 4.4 查找文件 $ find /tmp -name file1_1KB /tmp/foo-dev/file1_1KB 4.5 删除文件直接用 rm 删除就行了,不过这几个文件我们还有用,先不删。
4.6 目录操作目录的创建、移动、修改权限、删除等待也是一样的,大家可以自己试试,这里不再赘述。
4.7 小结根据以上测试,在 JuiceFS 挂载路径里创建/读写/查找/删除文件,都跟本地目录没什么区别 —— 这也正是「分布式“文件系统”」的意义所在 —— 兼容 POSIX 语义,用户无需关心数据存在哪, 当本地目录使用就行了(性能另当别论)。
5 总结本篇中,我们作为 JuiceFS 用户对它进行了一些最基本的功能测试,结论是和本地文件系统没什么区别。
对于普通用户来说,了解到这一层就够了; 但对于高阶用户以及 JuiceFS 的开发/运维来说,这只是表象,必有第二重境界等着他们。
参考资料直观解读 JuiceFS 的数据和元数据设计(二):看山不是山(2024)
本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。
Fig. JuiceFS object key naming and the objects in MinIO.
水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处。
- 直观解读 JuiceFS 的数据和元数据设计(一):看山是山(2024)
- 直观解读 JuiceFS 的数据和元数据设计(二):看山不是山(2024)
- 直观解读 JuiceFS 的数据和元数据设计(三):看山还是山(2024)
上一篇从功能的角度体验了下 JuiceFS,这一篇我们深入到背后,看看 JuiceFS 分别在数据和元数据上做了哪些设计,才给到用户和本地文件系统一样的体验的。
2 对象存储中 JuiceFS 写入的文件本篇以 MinIO 为例,来看 JuiceFS 写入到对象存储中的文件是怎样组织的。 其他云厂商的对象存储(AWS S3、阿里云 OSS 等)也都是类似的。
2.1 Bucket 内:每个 volume 一个“目录”可以用上一篇介绍的 juicefs format 命令再创建两个 volume,方便观察它们在 bucket 中的组织关系,
Fig. MinIO bucket browser: volume list.
如上图所示,bucket 内的顶层“目录”就是 JuiceFS 的 volumes,
我们这里提到“目录”时加双引号,是因为对象存储是扁平的 key-value 存储,没有目录的概念, 前端展示时模拟出目录结构(key 前缀一样的,把这个前缀作为一个“目录”)是为了查看和理解方便。 简单起见,后文不再加双引号。
2.2 每个 volume 的目录: {chunks/, juicefs_uuid, meta/, ...}每个 volume 目录内的结构如下:
{volume_name}/ |-chunks/ # 数据目录,volume 中的所有用户数据都放在这里面 |-juicefs_uuid |-meta/ # `juicefs mount --backup-meta ...` 产生的元数据备份存放的目录 2.2.1 juicefs_uuid:JuiceFS volume 的唯一标识可以把这个文件下载下来查看内容,会发现里面存放的就是 juicefs format 输出里看到的那个 uuid, 也就是这个 volume 的唯一标识。
删除 volume 时需要用到这个 uuid。
2.2.2 meta/:JuiceFS 元数据备份如果在 juicefs mount 时指定了 --backup-meta,JuiceFS 就会定期把元数据(存在在 TiKV 中)备份到这个目录中, 用途:
- 元数据引擎故障时,可以从这里恢复;
- 在不同元数据引擎之间迁移元数据。
详见 JuiceFS 元数据引擎五探:元数据备份与恢复(2024)。
2.2.3 chunks/Fig. MinIO bucket browser: files in a bucket.
chunks/ 内的目录结构如下,
{volume_name}/ |-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 这几个概念没关系,我们马上将要介绍。
2.3 小结通过以上 bucket 页面,我们非常直观地看到了一个 JuiceFS volume 的所有数据在对象存储中是如何组织的。
接下来进入正题,了解一下 JuiceFS 的数据和元数据设计。
3 JuiceFS 数据的设计 3.1 顶层切分:一切文件先切 chunk对于每个文件,JuiceFS 首先会按固定大小(64MB)切大块, 这些大块称为「Chunk」。
- 这是为了读或修改文件内容时,方便查找和定位。
- 不管是一个只有几字节的文本文件,还是一个几十 GB 的视频文件, 在 JuiceFS 中都是切分成 chunk,只是 chunk 的数量不同而已。
Fig. JuiceFS: split each file into their respective chunks (with max chunk size 64MB).
3.1.2 对象存储:不存在 chunk 实体结合上一节在对象存储中看到的目录结构,
{volume_name}/ |-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/- Chunk 在对象存储中 没有对应任何实际文件,也就是说在对象存储中没有一个个 64MB 的 chunks;
- 用 JuiceFS 的话来说,Chunk 是一个逻辑概念。暂时不理解没关系,接着往下看。
chunk 只是一个“框”,在这个框里面对应文件读写的,是 JuiceFS 称为「Slice」 的东西。
- chunk 内的一次连续写入,会创建一个 slice,对应这段连续写入的数据;
- 由于 slice 是 chunk 内的概念,因此它不能跨 Chunk 边界,长度也不会超 max chunk size 64M。
- slice ID 是全局唯一的;
根据写入行为的不同,一个 Chunk 内可能会有多个 Slice,
- 如果文件是由一次连贯的顺序写生成,那每个 Chunk 只包含一个 Slice。
- 如果文件是多次追加写,每次追加均调用 flush 触发写入上传,就会产生多个 Slice。
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 存在重叠,因此引入了几个字段标识它的有效数据范围,
// pkg/meta/slice.go type slice struct { id uint64 size uint32 off uint32 len uint32 pos uint32 left *slice // 这个字段不会存储到 TiKV 中 right *slice // 这个字段不会存储到 TiKV 中 } 3.2.2 读 chunk 数据时的多 slice 处理:碎片化和碎片合并Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.
对 JuiceFS 用户来说,文件永远只有一个,但在 JuiceFS 内部,这个文件对应的 Chunk 可能会有多个重叠的 Slice,
- 有重叠的部分,以最后一次写入的为准。
- 直观上来说,就是上图 chunk 中的 slices 从上往下看,被盖掉的部分都是无效的。
因此,读文件时,需要查找「当前读取范围内最新写入的 Slice」,
- 在大量重叠 Slice 的情况下,这会显著影响读性能,称为文件「碎片化」。
- 碎片化不仅影响读性能,还会在对象存储、元数据等层面增加空间占用。
- 每当写入发生时,客户端都会判断文件的碎片化情况,并异步地运行碎片合并,将一个 Chunk 内的所有 Slice 合并。
跟 chunk 类似,在对象存储中 slice 也没有 没有对应实际文件。
{volume_name}/ |-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/ 3.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.
Block 是 JuiceFS 数据切分设计中最后一个层级,也是 chunk/slice/block 三个层级中唯一能在 bucket 中看到对应文件的。
Fig. MinIO bucket browser: objects in a 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;
格式:{volume}/chunks/{id1}/{id2}/{slice_id}_{block_id}_{size_of_this_block},对应的代码,
// pkg/chunk/cached_store.go 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)) } 3.5 将 chunk/slice/block 对应到对象存储最后,我们将 volume 的数据切分和组织方式对应到 MinIO 中的路径和 objects,
Fig. JuiceFS object key naming and the objects in MinIO.
3.6 小结:光靠对象存储数据和 slice/block 信息无法还原文件至此,JuiceFS 解决了数据如何切分和存放的问题,这是一个正向的过程:用户创建一个文件,我们能按这个格式切分、命名、上传到对象存储。
对应的反向过程是:给定对象存储中的 objects,我们如何将其还原成用户的文件呢? 显然,光靠 objects 名字中包含的 slice/block ID 信息是不够的,例如,
- 最简单情况下,每个 chunk 都没有任何 slice 重叠问题,那我们能够根据 object 名字中的 slice_id/block_id/block_size 信息拼凑出一个文件, 但仍然无法知道这个文件的文件名、路径(父目录)、文件权限(rwx)等等信息;
- chunk 一旦存在 slice 重叠,光靠对象存储中的信息就无法还原文件了;
- 软链接、硬链接、文件属性等信息,更是无法从对象存储中还原。
解决这个反向过程,我们就需要文件的一些元数据作为辅助 —— 这些信息在文件切分和写入对象存储之前,已经记录到 JuiceFS 的元数据引擎中了。
4 JuiceFS 元数据的设计(TKV 版)JuiceFS 支持不同类型的元数据引擎,例如 Redis、MySQL、TiKV/etcd 等等,每种类型的元数据引擎都有自己的 key 命名规则。 本文讨论的是 JuiceFS 使用 transactional key-value(TKV)类型的元数据引擎时的 key 命名规则。
更具体地,我们将拿 TiKV 作为元数据引擎来研究。
4.1 TKV 类型 key 列表这里的 key 是 JuiceFS 定义元数据 key,key/value 写入元数据引擎; 请注意跟前面提到的对象存储 key 区别开,那个 key/value 是写入对象存储的。
key 是一个字符串,所有 key 的列表,
// pkg/meta/tkv.go 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 中,所有整数都以编码后的二进制形式存储 [2]:
- inode 和 counter value 占 8 个字节,使用小端编码
- SessionID、sliceID 和 timestamp 占 8 个字节,使用大端编码
setting 是一个特殊的 key,对应的 value 就是这个 volume 的设置信息。 前面的 JuiceFS 元数据引擎系列文章中介绍过 [3],这里不再赘述。
其他的,每个 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 元数据引擎系列文章中也介绍过,这里不再赘述。
4.2 元数据引擎中的 key/value 4.2.1 扫描相关的 TiKV keyTiKV 的 scan 操作类似 etcd 的 list prefix,这里扫描所有 foo-dev volume 相关的 key,
$ ./tikv-ctl.sh scan --from 'zfoo-dev' --to 'zfoo-dew' 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 4.2.2 解码成 JuiceFS metadata key用 tikv-ctl --decode <key> 可以解码出来,注意去掉最前面的 z,得到的就是 JuiceFS 的原始 key,看着会更清楚一点,
foo-dev\375A\000\000\000\020\377\377\377\177I 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 总结这一篇我们深入到 JuiceFS 内部,从数据和元数据存储中的东西来 反观 JuiceFS 切分数据和记录元数据的设计。 站在这个层次看,已经跟前一篇的理解程度全然不同。
如果说第一篇是“见自己”(功能如所见),这第二篇就是“见天(元数据引擎)地(对象存储)”, 那必然还得有一篇“见众生”。
参考资料- 官方文档:JuiceFS 如何存储文件, juicefs.com
- 官方文档:JuiceFS 开发:内部实现, juicefs.com
- JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)
直观解读 JuiceFS 的数据和元数据设计(三):看山还是山(2024)
本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。
Fig. JuiceFS object key naming and the objects in MinIO.
水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处。
- 直观解读 JuiceFS 的数据和元数据设计(一):看山是山(2024)
- 直观解读 JuiceFS 的数据和元数据设计(二):看山不是山(2024)
- 直观解读 JuiceFS 的数据和元数据设计(三):看山还是山(2024)
对于一个给定的 JuiceFS 文件,我们在上一篇中已经看到两个正向的过程:
- 文件本身被切分成 Chunk、Slice、Block,然后写入对象存储;
- 文件的元数据以 inode、slice、block 等信息组织,写入元数据引擎。
有了对正向过程的理解,我们反过来就能从对象存储和元数据引擎中恢复文件: 对于一个给定的 JuiceFS 文件,
- 首先扫描元数据引擎,通过文件名、inode、slice 等等信息,拼凑出文件的大小、位置、权限等等信息;
- 然后根据 slice_id/block_id/block_size 拼凑出对象存储中的 object key;
- 依次去对象存储中根据这些 keys 读取数据拼到一起,得到的就是这个文件,然后写到本地、设置文件权限等等。
但这个恢复过程不是本文重点。本文主要看几个相关的问题,以加深对 JuiceFS 数据/元数据 设计的理解。 更多信息见官方文档 [2]。
1.2 juicefs info 查看文件 chunk/slice/block 信息JuiceFS 已经提供了一个命令行选项,能直接查看文件的 chunk/slice/block 信息,例如:
$ ./juicefs info foo-dev/file2_5MB 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 中看到的一致。
2 如何判断 {volume}/chunks/ 中的数据是否是合法bucket 中的数据是 JuiceFS 写入的,还是其他应用写入的呢? 另外即使是 JuiceFS 写入的,也可能有一些数据是无效的,比如 size 为 0 的 block、超出所属 slice 范围的 block 等等。 我们来看看基于哪些规则,能对这些非法数据进行判断。
2.1 原理准备工作:
- 从 JuiceFS 的元数据引擎中读取所有 slice size,这对应的是元数据信息;
- 从 object storage 中读取所有 object key,这对应的数据信息。
接下来,根据几条标准,判断 bucket 中 {volume}/chunks/ 内的数据是否是合法的 JuiceFS 数据:
- 如果 object 不符合命名规范 {volume}/chunks/{slice_id/1000/1000}/{slice_id/1000}/{slice_id}_{block_id}_{block_size}, 那么这个 object 就不是 JuiceFS 写入的;
- 如果符合以上命名规范,,那么这个 object 就是 JuiceFS 写入的,接下来,
- 如果 object 大小为零,那可以清理掉,因为这种 object 留着没意义;
- 如果 object 大小不为零,根据元数据内记录的 slice/block 信息计算这个 block 应该是多大,
- 如果大小跟 object 一致,那这个 object 就是一个合法的 JuiceFS 数据(Block);
- 否则,说明这个 object 有问题。
这个过程是没问题的,但需要对所有 object 和所有元数据进行遍历和比对,效率比较低。 有没有更快的方法呢?
2.2 改进:pending delete slices回忆上一篇,在元数据引擎中其实已经记录了待删除的 slice/block 信息, 这里“待删除”的意思是 JuiceFS 中已经把文件删掉了(用户看不到了,volume usage 统计也不显示了), 但还没有从对象存储中删掉,
- D 开头的记录:deleted inodes
- 格式:D{8bit-inode}{8bit-length},
这种记录是 JuiceFS 在从 object storage 删除文件之前插入到元数据引擎中的, 所以扫描所有 D 开头的记录,可以找到所有待删除的 slice/block 信息。
2.3 工具:juicefs gc结合 2.1 & 2.2,就可以快速判断 bucket 中的数据是否是 JuiceFS 合法数据,不是就删掉; 基于 juicefs 已有的代码库,就可以写一个工具 —— 但用不着自己写 —— JuiceFS 已经提供了。
2.3.1 核心代码完整代码见 pkg/cmd/gc.go。
从元数据引擎 list 所有 slice 信息 func (m *kvMeta) ListSlices(ctx Context, slices map[Ino][]Slice, delete bool, showProgress func()) syscall.Errno { if delete m.doCleanupSlices() // 格式:A{8digit-inode}C{4digit-blockID} file chunks klen := 1 + 8 + 1 + 4 result := m.scanValues(m.fmtKey("A"), -1, func(k, v []byte) bool { return len(k) == klen && k[1+8] == 'C' }) for key, value := range result { inode := m.decodeInode([]byte(key)[1:9]) ss := readSliceBuf(value) // slice list for _, s := range ss if s.id > 0 slices[inode] = append(slices[inode], Slice{Id: s.id, Size: s.size}) } if m.getFormat().TrashDays == 0 return 0 return errno(m.scanTrashSlices(ctx, func(ss []Slice, _ int64) (bool, error) { slices[1] = append(slices[1], ss...) if showProgress != nil for range ss showProgress() return false, nil })) } 从对象存储 list 所有 objects 信息 // Scan all objects to find leaked ones blob = object.WithPrefix(blob, "chunks/") objs := osync.ListAll(blob, "", "", "", true) // List {vol_name}/chunks/ 下面所有对象 遍历所有 objects,跟元数据引擎中的 slice 信息比对 for obj := range objs { // key 格式:{slice_id/1000/1000}/{slice_id/1000}/{slice_id}_{index}_{size} parts := strings.Split(obj.Key(), "/") // len(parts) == 3 parts = strings.Split(parts[2], "_") // len(parts) == 3 sliceID, _ := strconv.Atoi(parts[0]) // slice id, JuiceFS globally unique blockID, _ := strconv.Atoi(parts[1]) // blockID in this slice blockSize, _ := strconv.Atoi(parts[2]) // block size, <= 4MB sliceSizeFromMetaEngine := sliceSizesFromMetaEngine[uint64(sliceID)] // tikv 中记录的 slice size var isEmptySize bool if sliceSizeFromMetaEngine == 0 { sliceSizeFromMetaEngine = sliceSizesFromTrash[uint64(sliceID)] isEmptySize = true } if sliceSizeFromMetaEngine == 0 { foundLeaked(obj) continue } if blockSize == chunkConf.BlockSize { // exactly 4MB if (blockID+1)*blockSize > sliceSizeFromMetaEngine foundLeaked(obj) } else { // < 4MB if blockID*chunkConf.BlockSize+blockSize != sliceSizeFromMetaEngine foundLeaked(obj) }- slice size 为 0,说明这个 slice 在元数据引擎中被 compact 过了;
- slice size 非零,
- block size == 4MB,可能是也可能不是最后一个 block;
- block size != 4MB,说明这个 block 是最后一个 block;
大致效果:
$ ./juicefs gc tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev <INFO>: TiKV gc interval is set to 3h0m0s [tkv_tikv.go:138] <INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [gc.go:101] Pending deleted files: 0 0.0/s Pending deleted data: 0.0 b (0 Bytes) 0.0 b/s Cleaned pending files: 0 0.0/s Cleaned pending data: 0.0 b (0 Bytes) 0.0 b/s Listed slices: 6 327.3/s Trash slices: 0 0.0/s Trash data: 0.0 b (0 Bytes) 0.0 b/s Cleaned trash slices: 0 0.0/s Cleaned trash data: 0.0 b (0 Bytes) 0.0 b/s Scanned objects: 37/37 [=================================] 8775.9/s used: 4.268971ms Valid objects: 37 11416.0/s Valid data: 134.0 MiB (140509216 Bytes) 41.0 GiB/s Compacted objects: 0 0.0/s Compacted data: 0.0 b (0 Bytes) 0.0 b/s Leaked objects: 0 0.0/s Leaked data: 0.0 b (0 Bytes) 0.0 b/s Skipped objects: 0 0.0/s Skipped data: 0.0 b (0 Bytes) 0.0 b/s <INFO>: scanned 37 objects, 37 valid, 0 compacted (0 bytes), 0 leaked (0 bytes), 0 delslices (0 bytes), 0 delfiles (0 bytes), 0 skipped (0 bytes) [gc.go:379] 3 问题讨论 3.1 chunk id 和 slice id 的分配- 每个文件都是从 chunk0 开始的;
- 实际上没有 chunk id 的概念,只是在查找文件的过程中动态使用,并没有存储到数据和元数据中;
代码里就是直接根据 64MB 计算下一个 chunk id,接下来的读写都是 slice 维度的, slice id 是全局唯一的,会存储到数据(object key)和元数据(tikv keys/values)中。
下一个可用的 sliceID 和 inodeID 记录在 global unique 变量中,初始化:
Register("tikv", newKVMeta) // pkg/meta/tkv_tikv.go |-newBaseMeta(addr, conf) // pkg/meta/tkv.go |-newBaseMeta(addr, conf) // pkg/meta/base.go |-.freeInodes // initialized as default value of type `freeID` |-.freeSlices // initialized as default value of type `freeID`然后,以写文件为例,调用栈:
Write(off uint64, data) |-if f.totalSlices() >= 1000 { | wait a while | } |-chunkID := uint32(off / meta.ChunkSize) // chunk index, or chunk id |-pos := uint32(off % meta.ChunkSize) // position inside the chunk for writing |-for len(data) > 0 { | |-writeChunk | |-c := f.findChunk(chunkID) | |-s := c.findWritableSlice(off, uint32(len(data))) | |-if no wriatable slice { | | s = &sliceWriter{chunk: c, off: off, } | | go s.prepareID(meta.Background, false) // pkg/vfs/writer.go | | |-NewSlice | | |-*id = m.freeSlices.next // globally unique ID | | | | c.slices = append(c.slices, s) | | if len(c.slices) == 1 { | | f.refs++ | | go c.commitThread() | | } | |-} | |-return s.write(ctx, off-s.off, data) | NewSlice // pkg/meta/base.go |-} 3.2 JuiceFS pending delete slices 和 background job 3.2.1 设计初衷引入 pending delete slices 主要是大批量删除场景的性能优化:
- 每个 JuiceFS 客户端只允许并发 100 的删除操作;
- 超过 100 时,自动放入后台队列,由 background job 异步删除;
这个 maxDeleting 初始为一个 100 的 buffered channel,每次删除文件时,会尝试往里面放一个元素,
// pkg/meta/base.go func newBaseMeta(addr string, conf *Config) *baseMeta { return &baseMeta{ sid: conf.Sid, removedFiles: make(map[Ino]bool), compacting: make(map[uint64]bool), maxDeleting: make(chan struct{}, 100), // 代码里写死了 100 ... 3.2.3 潜在的问题后台删除是 JuiceFS client 中的 background job 做的,这个 background job 的开关是可配置的,
$ ./juicefs mount --no-bgjob ... # 关闭 background job这个开关的控制有点 tricky:
- 打开:如果一个 volume 的客户端太多,大家都会去做后台清理,都获取文件锁,对元数据引擎的压力非常大;
- 关闭:没有客户端去做后台清理,导致这些文件一直存在于对象存在中,也可以称为文件泄露,使用成本上升。
一种折中的做法:
- 客户端不太多的 volumes:默认启用 bgjob;
- 客户端太多的 volumes,默认关闭 bgjob,然后指定特定的 client 开启 bgjob,代表这个 volume 的所有客户端执行清理操作。
从以上定义可以看到,理论上 JuiceFS 支持的单个文件大小是 maxSliceID (int64) * maxChunkSize, 以默认的 maxChunkSize=64MB(2^26 Byte)为例,
- 理论上限:2^63 * 2^26 = 2^(63+26) Byte。
- 实际上限:2^31 * 2^26 = 2^(31+26) Byte = 128PiB,这个数字来自官方文档。
实际上限是 128PiB 的原因也很简单,在代码里写死了,
// pkg/vfs/vfs.go const ( maxFileSize = meta.ChunkSize << 31 ) 3.4 为什么 JuiceFS 写入对象存储的文件,不能通过对象存储直接读取?这里说的“不能读取”,是指不能直接读出原文件给到用户,而不是说不能读取 objects。
看过本文应该很清楚了,JuiceFS 写入对象存储的文件是按照 Chunk、Slice、Block 进行切分的, 只有数据内容,且保护重复数据,还没有文件信息元信息(文件名等)。
所以,以对象的存储的方式只能读这些 objects,是无法恢复出原文件给到用户的。
3.5 JuiceFS 不会对文件进行合并Highlight:JuiceFS 不会文件进行合并写入对象存储, 这是为了避免读放大。
4 总结至此,我们对 JuiceFS 数据和元数据设计的探索学习就告一段落了。希望有了这些知识, 用户和工程师在日常的使用和维护 JuiceFS 过程中,看问题和解决问题能更加得心应手。
参考资料- 官方文档:JuiceFS 如何存储文件, juicefs.com
- 官方文档:文件数据格式, juicefs.com
NASA 研发能从轨道降落的火星直升机
攻防实战-fuzz上传接口到内网
OnlyFans 支付给歌手的钱超过了 Spotify
Daily Dose of Dark Web Informer - October 25th, 2024
宝塔面板降级版本教程(如9.0.0降版本为8.0.5)
【转载】以色列官宣对伊朗军事目标进行精准打击,美军大量兵力“保驾护航”
【资料】美国国防情报局(DIA)的岗位要求
Play
Play
Play
A Threat is Allegedly Selling 36K KYC Documents from BloFin
对抗零日漏洞的十年(2014~2024)
「轉發」: 世界真的變了。
2024 CCF-深信服“远望”科研基金(第二期)
Weekly Update 423
Firstly, my apologies for the minute and a bit of echo at the start of this video, OBS had somehow magically decided to start recording both the primary mic and the one built into my camera. Easy fix, moving on...
During the livestream, I was perplexed as to why the