Published at 2024-10-27 | Last Update 2024-10-27
本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。
Fig. JuiceFS object key naming and the objects in MinIO.
水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处。
上一篇从功能的角度体验了下 JuiceFS,这一篇我们深入到背后,看看 JuiceFS 分别在数据和元数据上做了哪些设计,才给到用户和本地文件系统一样的体验的。
本篇以 MinIO 为例,来看 JuiceFS 写入到对象存储中的文件是怎样组织的。 其他云厂商的对象存储(AWS S3、阿里云 OSS 等)也都是类似的。
可以用上一篇介绍的 juicefs format
命令再创建两个 volume,方便观察它们在 bucket 中的组织关系,
Fig. MinIO bucket browser: volume list.
如上图所示,bucket 内的顶层“目录”就是 JuiceFS 的 volumes,
我们这里提到“目录”时加双引号,是因为对象存储是扁平的 key-value 存储,没有目录的概念, 前端展示时模拟出目录结构(key 前缀一样的,把这个前缀作为一个“目录”)是为了查看和理解方便。 简单起见,后文不再加双引号。
每个 volume 目录内的结构如下:
{volume_name}/
|-chunks/ # 数据目录,volume 中的所有用户数据都放在这里面
|-juicefs_uuid
|-meta/ # `juicefs mount --backup-meta ...` 产生的元数据备份存放的目录
juicefs_uuid
:JuiceFS volume 的唯一标识可以把这个文件下载下来查看内容,会发现里面存放的就是 juicefs format 输出里看到的那个 uuid, 也就是这个 volume 的唯一标识。
删除 volume 时需要用到这个 uuid。
meta/
:JuiceFS 元数据备份如果在 juicefs mount
时指定了 --backup-meta
,JuiceFS 就会定期把元数据(存在在 TiKV 中)备份到这个目录中,
用途:
详见 JuiceFS 元数据引擎五探:元数据备份与恢复(2024)。
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 中都是用数字命名和存放的,分为三个层级:
{slice_id}_{block_id}_{size_of_this_block}
,表示的是这个 chunk 的这个 slice 内的 block_id 和 block 的大小。不理解 chunk/slice/block 这几个概念没关系,我们马上将要介绍。
通过以上 bucket 页面,我们非常直观地看到了一个 JuiceFS volume 的所有数据在对象存储中是如何组织的。
接下来进入正题,了解一下 JuiceFS 的数据和元数据设计。
对于每个文件,JuiceFS 首先会按固定大小(64MB)切大块,
这些大块称为「Chunk
」。
Fig. JuiceFS: split each file into their respective chunks (with max chunk size 64MB).
结合上一节在对象存储中看到的目录结构,
{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 只是一个“框”,在这个框里面对应文件读写的,是 JuiceFS 称为「Slice」 的东西。
根据写入行为的不同,一个 Chunk 内可能会有多个 Slice,
flush
触发写入上传,就会产生多个 Slice。Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.
拿 chunk1 为例,
slice5
;slice6
;chunk2
和 slice7
;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 中
}
Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.
对 JuiceFS 用户来说,文件永远只有一个,但在 JuiceFS 内部,这个文件对应的 Chunk 可能会有多个重叠的 Slice,
因此,读文件时,需要查找「当前读取范围内最新写入的 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/
为了加速写到对象存储,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.
从上图的名字和大小其实可以看出分别对应我们哪个文件:
1_0_16
:对应我们的 file1_1KB
;
echo "hello" >> file1_1KB
并不是写入了 1_0_16
,
而是创建了一个新对象 7_0_16
,这个 object list 最后面,所以在截图中没显示出来;file1_1KB
虽然只有两行内容,但在 MinIO 中对应的却是两个 object,各包含一行。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))
}
最后,我们将 volume 的数据切分和组织方式对应到 MinIO 中的路径和 objects,
Fig. JuiceFS object key naming and the objects in MinIO.
至此,JuiceFS 解决了数据如何切分和存放的问题,这是一个正向的过程:用户创建一个文件,我们能按这个格式切分、命名、上传到对象存储。
对应的反向过程是:给定对象存储中的 objects,我们如何将其还原成用户的文件呢? 显然,光靠 objects 名字中包含的 slice/block ID 信息是不够的,例如,
解决这个反向过程,我们就需要文件的一些元数据作为辅助 —— 这些信息在文件切分和写入对象存储之前,已经记录到 JuiceFS 的元数据引擎中了。
JuiceFS 支持不同类型的元数据引擎,例如 Redis、MySQL、TiKV/etcd 等等,每种类型的元数据引擎都有自己的 key 命名规则。
本文讨论的是 JuiceFS 使用 transactional key-value
(TKV)类型的元数据引擎时的 key 命名规则。
更具体地,我们将拿 TiKV 作为元数据引擎来研究。
这里的 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]:
setting
是一个特殊的 key,对应的 value 就是这个 volume 的设置信息。
前面的 JuiceFS 元数据引擎系列文章中介绍过 [3],这里不再赘述。
其他的,每个 key 的首字母可以快速区分 key 的类型,
c
ounter,这里面又包含很多种类,例如 name
可以是:
a
ttributed
eleted inodesF
locksP
OSIX locks
ession relatedu
sage of data length, space and inodes in directoryd
irectory q
uota需要注意的是,这里是 JuiceFS 定义的 key 格式,在实际将 key/value 写入元数据引擎时, 元数据引擎可能会对 key 再次进行编码,例如 TiKV 就会在 key 中再插入一些自己的字符。 前面的 JuiceFS 元数据引擎系列文章中也介绍过,这里不再赘述。
TiKV 的 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
用 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 等等信息,这里我们暂时就不展开了。
这一篇我们深入到 JuiceFS 内部,从数据和元数据存储中的东西来 反观 JuiceFS 切分数据和记录元数据的设计。 站在这个层次看,已经跟前一篇的理解程度全然不同。
如果说第一篇是“见自己”(功能如所见),这第二篇就是“见天(元数据引擎)地(对象存储)”, 那必然还得有一篇“见众生”。