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 文件,
但这个恢复过程不是本文重点。本文主要看几个相关的问题,以加深对 JuiceFS 数据/元数据 设计的理解。 更多信息见官方文档 [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 中看到的一致。
bucket 中的数据是 JuiceFS 写入的,还是其他应用写入的呢? 另外即使是 JuiceFS 写入的,也可能有一些数据是无效的,比如 size 为 0 的 block、超出所属 slice 范围的 block 等等。 我们来看看基于哪些规则,能对这些非法数据进行判断。
准备工作:
接下来,根据几条标准,判断 bucket 中 {volume}/chunks/
内的数据是否是合法的 JuiceFS 数据:
{volume}/chunks/{slice_id/1000/1000}/{slice_id/1000}/{slice_id}_{block_id}_{block_size}
,
那么这个 object 就不是 JuiceFS 写入的;这个过程是没问题的,但需要对所有 object 和所有元数据进行遍历和比对,效率比较低。 有没有更快的方法呢?
回忆上一篇,在元数据引擎中其实已经记录了待删除的 slice/block 信息, 这里“待删除”的意思是 JuiceFS 中已经把文件删掉了(用户看不到了,volume usage 统计也不显示了), 但还没有从对象存储中删掉,
D
开头的记录:d
eleted inodesD{8bit-inode}{8bit-length}
,这种记录是 JuiceFS 在从 object storage 删除文件之前插入到元数据引擎中的,
所以扫描所有 D
开头的记录,可以找到所有待删除的 slice/block 信息。
juicefs gc
结合 2.1 & 2.2,就可以快速判断 bucket 中的数据是否是 JuiceFS 合法数据,不是就删掉; 基于 juicefs 已有的代码库,就可以写一个工具 —— 但用不着自己写 —— JuiceFS 已经提供了。
完整代码见 pkg/cmd/gc.go。
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
}))
}
// Scan all objects to find leaked ones
blob = object.WithPrefix(blob, "chunks/")
objs := osync.ListAll(blob, "", "", "", true) // List {vol_name}/chunks/ 下面所有对象
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)
}
$ ./juicefs gc -h
NAME:
juicefs gc - Garbage collector of objects in data storage
USAGE:
juicefs gc [command options] META-URL
大致效果:
$ ./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]
代码里就是直接根据 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
|-}
引入 pending delete slices 主要是大批量删除场景的性能优化:
// pkg/meta/base.go
func (m *baseMeta) fileDeleted(opened, force bool, inode Ino, length uint64) {
if opened
m.removedFiles[inode] = true
else
m.tryDeleteFileData(inode, length, force)
}
func (m *baseMeta) tryDeleteFileData(inode Ino, length uint64, force bool) {
if force {
m.maxDeleting <- struct{}{}
} else {
select {
case m.maxDeleting <- struct{}{}: // maxDeleting 没满,直接删
default: // maxDeleting 满了之后走到这里,直接返回,靠后台任务删
return // will be cleanup later
}
}
go func() {
m.en.doDeleteFileData(inode, length)
<-m.maxDeleting
}()
}
这个 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
...
后台删除是 JuiceFS client 中的 background job 做的,这个 background job 的开关是可配置的,
$ ./juicefs mount --no-bgjob ... # 关闭 background job
这个开关的控制有点 tricky:
一种折中的做法:
从以上定义可以看到,理论上 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
)
这里说的“不能读取”,是指不能直接读出原文件给到用户,而不是说不能读取 objects。
看过本文应该很清楚了,JuiceFS 写入对象存储的文件是按照 Chunk、Slice、Block 进行切分的, 只有数据内容,且保护重复数据,还没有文件信息元信息(文件名等)。
所以,以对象的存储的方式只能读这些 objects,是无法恢复出原文件给到用户的。
Highlight:JuiceFS 不会文件进行合并写入对象存储, 这是为了避免读放大。
至此,我们对 JuiceFS 数据和元数据设计的探索学习就告一段落了。希望有了这些知识, 用户和工程师在日常的使用和维护 JuiceFS 过程中,看问题和解决问题能更加得心应手。