docs/zh_cn/development/internals.md
本文介绍 JuiceFS 的实现细节,用来为开发者了解和贡献开源代码作参考。其中内容对应的 JuiceFS 代码版本为 v1.0.0,元数据版本为 v1。
在深入学习源码前,我们还推荐阅读:
高层概念:
底层概念(详见 JuiceFS 读写请求处理流程):
JuiceFS 源码的大体结构如下:
cmd 是代码结构总入口,所有相关功能都能在此找到入口,如 juicefs format 命令对应着 cmd/format.go;pkg 是具体实现,核心逻辑都在其中:
pkg/fuse/fuse.go 是 FUSE 实现的入口,提供抽象 FUSE 接口;pkg/vfs 是具体的 FUSE 接口实现,元数据请求会调用 pkg/meta 中的实现,读请求会调用 pkg/vfs/reader.go,写请求会调用 pkg/vfs/writer.go;pkg/meta 目录中是所有元数据引擎的实现,其中:
pkg/meta/interface.go 是所有类型元数据引擎的接口定义pkg/meta/redis.go 是 Redis 数据库的接口实现pkg/meta/sql.go 是关系型数据库的接口定义及通用接口实现,特定数据库的实现在单独文件中(如 MySQL 的实现在 pkg/meta/sql_mysql.go)pkg/meta/tkv.go 是 KV 类数据库的接口定义及通用接口实现,特定数据库的实现在单独文件中(如 TiKV 的实现在 pkg/meta/tkv_tikv.go)pkg/object 是与各种对象存储对接的实现。sdk/java 是 Hadoop Java SDK 的实现,底层依赖 sdk/java/libjfs 这个库(通过 JNI 调用)。JuiceFS 基于 FUSE(Filesystem in Userspace)实现了一个用户态文件系统,FUSE 接口在 Linux 系统中的实现库 libfuse 提供两种 API:high-level API 和 low-level API,其中 high-level API 基于文件名和路径,low-level API 基于 inode。
JuiceFS 基于 low-level API 实现(事实上 JuiceFS 不依赖 libfuse,而是 go-fuse),这是因为内核的 VFS 跟 FUSE 库交互就使用 low-level API。如果使用 high-level API 的话,其实是在 libfuse 内部做了 VFS 树的模拟,然后对外暴露基于路径的 API,这种模式适合元数据本身是基于路径提供的 API 的系统,比如 HDFS 或者 S3 之类。而如果元数据本身也是基于 inode 的目录树,这种 inode → path → inode 的反复转换就会影响性能(所以 HDFS 的 FUSE 接口实现性能都不好)。JuiceFS 的元数据是按照 inode 组织的,也直接提供基于 inode 的 API,那么使用 FUSE 的 low-level API 就非常简单和自然,性能也很好。
文件系统通常组织成树型结构,其中节点代表文件,边代表目录的包含关系。文件无法悬空停留,其(根目录除外)必然属于某个目录;目录可以包含一个或多个子文件。JuiceFS 中一共有十多种元数据结构,其中大部分用来维护文件树的组织关系和各个节点的属性,其余的用来管理系统配置,客户端会话和异步任务等。以下具体介绍所有的元数据结构。
保存文件系统的格式化信息,在执行 juicefs format 命令时创建,后续可通过 juicefs config 命令修改其中的部分字段。结构具体如下:
type Format struct {
Name string
UUID string
Storage string
Bucket string
AccessKey string `json:",omitempty"`
SecretKey string `json:",omitempty"`
SessionToken string `json:",omitempty"`
BlockSize int
Compression string `json:",omitempty"`
Shards int `json:",omitempty"`
HashPrefix bool `json:",omitempty"`
Capacity uint64 `json:",omitempty"`
Inodes uint64 `json:",omitempty"`
EncryptKey string `json:",omitempty"`
KeyEncrypted bool `json:",omitempty"`
TrashDays int `json:",omitempty"`
MetaVersion int `json:",omitempty"`
MinClientVersion string `json:",omitempty"`
MaxClientVersion string `json:",omitempty"`
EnableACL bool
}
s3、oss 等此结构会序列化成 JSON 格式保存在元数据引擎中。
维护系统中的各个计数器值和一些后台任务的启动时间戳,具体有:
记录连接到此文件系统的客户端会话 ID 和其超时时间。每个客户端会定时发送心跳消息以更新超时时间,长时间未更新者会被其他客户端自动清理。
:::tip 注意 只读客户端无法写入元数据引擎,因此其会话不会被记录。 :::
记录客户端会话的具体元信息,使其可以通过 juicefs status 命令查看。具体为:
type SessionInfo struct {
Version string // JuiceFS 版本
HostName string // 主机名称
MountPoint string // 挂载点路径。S3 网关和 WebDAV 服务分别为 "s3gateway" 和 "webdav"
ProcessID int // 进程 ID
}
此结构会序列化成 JSON 格式保存在元数据引擎中。
记录每个文件的属性信息,具体为:
type Attr struct {
Flags uint8 // reserved flags
Typ uint8 // type of a node
Mode uint16 // permission mode
Uid uint32 // owner id
Gid uint32 // group id of owner
Rdev uint32 // device number
Atime int64 // last access time
Mtime int64 // last modified time
Ctime int64 // last change time for meta
Atimensec uint32 // nanosecond part of atime
Mtimensec uint32 // nanosecond part of mtime
Ctimensec uint32 // nanosecond part of ctime
Nlink uint32 // number of links (sub-directories or hardlinks)
Length uint64 // length of regular file
Parent Ino // inode of parent; 0 means tracked by parentKey (for hardlinks)
Full bool // the attributes are completed or not
KeepCache bool // whether to keep the cached page or not
AccessACL uint32 // access ACL id (identical ACL rules share the same access ACL ID.)
DefaultACL uint32 // default ACL id (default ACL and the access ACL share the same cache and store)
}
其中几个需要说明的字段:
--atime-mode此结构一般会编码成二进制格式保存在元数据引擎中。
记录文件树中每条边的信息,具体为:
parentInode, name -> type, inode
其中 parentInode 是父目录的 inode 号,其他分别为子文件的名称、类型和 inode 号。
记录部分文件的父目录。绝大部分文件的父目录记在其属性的 Parent 字段中;但对于创建过硬链接的文件,其父目录可能有多个,此时会将 Parent 字段置 0,同时独立记录其所有父目录 inodes,具体为:
inode -> parentInode, links
其中 links 是 parentInode 的计数,因为一个目录中可以创建多个硬链接,这些硬连接共享 inode。
记录每个 Chunk 的信息,具体为:
inode, index -> []Slices
其中 inode 是此 Chunk 所属文件的 inode 号,index 是其在这个文件所有 Chunks 中序号,从 0 开始。Chunk 值内容为一个 Slices 数组,每个 Slice 代表一段客户端写入的数据,并且按写入时间顺序 append 到这个数组中。当不同 Slices 之间有重叠时,以后加入的 Slice 为准。Slice 的具体结构为:
type Slice struct {
Pos uint32 // Slice 在 Chunk 中的偏移位置
ID uint64 // Slice 的 ID,全局唯一
Size uint32 // Slice 的总大小
Off uint32 // 有效数据在此 Slice 中的偏移位置
Len uint32 // 有效数据在此 Slice 中的大小
}
此结构会编码成二进制格式保存,占 24 个字节。
记录 Slice 的引用计数,具体为:
sliceId, size -> refs
由于绝大部分 Slice 的引用计数均为 1,为减少数据库中相关 entry 数量,在 Redis 和 TKV 中以实际值减 1 作为存储的计数值。这样,大部分的 Slice 对应 refs 值为 0,则不必在数据库中创建相关 entry。
记录软链接文件的指向位置,具体为:
inode -> target
记录文件相关的扩展属性(Key-Value 对),具体为:
inode, key -> value
记录文件相关的 BSD locks(flock),具体为:
inode, sid, owner -> ltype
其中 sid 为客户端会话 ID,owner 为一串数字,通常与进程相关联;ltype 为锁类型,可以为 'R' 或者 'W'。
记录文件相关的 POSIX record locks(fcntl),具体为:
inode, sid, owner -> []plockRecord
这里 plock 是一种更细粒度的锁,可以只锁定文件中的某一片段:
type plockRecord struct {
ltype uint32 // 锁类型
pid uint32 // 进程 ID
start uint64 // 锁起始位置
end uint64 // 锁结束位置
}
此结构会编码成二进制格式保存,占 24 个字节。
记录待清理的文件列表。由于文件的数据清理是一个异步且可能长耗时的操作,可能被其他因素中断,因此会由此列表进行跟踪:
inode, length -> expire
其中 length 为文件长度,expire 为文件被删除的时间。
记录延迟删除的 Slices。当回收站功能开启时,因 Slice Compaction 功能删除的旧 Slices 会被保留与回收站配置相同的时间,以被在必要时可用来恢复数据。其内容为:
sliceId, deleted -> []slice
其中 sliceId 为 compact 后新 Slice 的 ID,deleted 为 compact 完成的时间戳,映射值为被 compacted 的所有旧 slice 列表,每个 slice 仅编码了 ID 和 size 信息:
type slice struct {
ID uint64
Size uint32
}
此结构会编码成二进制格式保存,占 12 个字节。
记录会话中需临时保留的文件列表。当文件被删除时若其仍处于打开状态,则不能立即清理数据,而需要暂时保留直至其被关闭。
sid -> []inode
其中 sid 为会话 ID,映射值为暂时未删除的文件 inodes 列表。
Redis 中 Key 的通用格式为 ${prefix}${JFSKey},其中:
在 Redis 的 Keys 中,如无特殊说明整数(包括 inode 号)都以十进制字符串表示。
settingallSessionssessionInfosi${inode}d${inode}p${inode}c${inode}_${index}sliceRefk${sliceId}_${size}s${inode}x${inode}lockf${inode}${sid}_${owner},owner 以十六进制表示lockp${inode}${sid}_${owner},owner 以十六进制表示delfiles${inode}:${length}delSlices${sliceId}_${deleted}session${sid}元数据按类型存储在不同的表中,每张表命名时以 jfs_ 开头,跟上其具体的结构体名称组成表名,如 jfs_node。部分表中加入了 bigserial 类型的 Id 列作为主键,其仅用来确保每张表中都有主键,并不包含实际信息。
type setting struct {
Name string `xorm:"pk"`
Value string `xorm:"varchar(4096) notnull"`
}
固定只有一条 entry,Name 为 "format",Value 为 JSON 格式的文件系统格式化信息。
type counter struct {
Name string `xorm:"pk"`
Value int64 `xorm:"notnull"`
}
type session2 struct {
Sid uint64 `xorm:"pk"`
Expire int64 `xorm:"notnull"`
Info []byte `xorm:"blob"`
}
没有独立的表,而是记在 session2 的 Info 列中。
type node struct {
Inode Ino `xorm:"pk"`
Type uint8 `xorm:"notnull"`
Flags uint8 `xorm:"notnull"`
Mode uint16 `xorm:"notnull"`
Uid uint32 `xorm:"notnull"`
Gid uint32 `xorm:"notnull"`
Atime int64 `xorm:"notnull"`
Mtime int64 `xorm:"notnull"`
Ctime int64 `xorm:"notnull"`
Nlink uint32 `xorm:"notnull"`
Length uint64 `xorm:"notnull"`
Rdev uint32
Parent Ino
AccessACLId uint32 `xorm:"'access_acl_id'"`
DefaultACLId uint32 `xorm:"'default_acl_id'"`
}
大部分字段与 Attr 相同,但时间戳使用了较低精度,其中 Atime/Mtime/Ctime 的单位为微秒。
type edge struct {
Id int64 `xorm:"pk bigserial"`
Parent Ino `xorm:"unique(edge) notnull"`
Name []byte `xorm:"unique(edge) varbinary(255) notnull"`
Inode Ino `xorm:"index notnull"`
Type uint8 `xorm:"notnull"`
}
没有独立的表,而是根据 edge 中的 Inode 索引找到所有 Parent。
type chunk struct {
Id int64 `xorm:"pk bigserial"`
Inode Ino `xorm:"unique(chunk) notnull"`
Indx uint32 `xorm:"unique(chunk) notnull"`
Slices []byte `xorm:"blob notnull"`
}
Slices 是一段字节数组,每 24 字节对应一个 Slice。
type sliceRef struct {
Id uint64 `xorm:"pk chunkid"`
Size uint32 `xorm:"notnull"`
Refs int `xorm:"notnull"`
}
type symlink struct {
Inode Ino `xorm:"pk"`
Target []byte `xorm:"varbinary(4096) notnull"`
}
type xattr struct {
Id int64 `xorm:"pk bigserial"`
Inode Ino `xorm:"unique(name) notnull"`
Name string `xorm:"unique(name) notnull"`
Value []byte `xorm:"blob notnull"`
}
type flock struct {
Id int64 `xorm:"pk bigserial"`
Inode Ino `xorm:"notnull unique(flock)"`
Sid uint64 `xorm:"notnull unique(flock)"`
Owner int64 `xorm:"notnull unique(flock)"`
Ltype byte `xorm:"notnull"`
}
type plock struct {
Id int64 `xorm:"pk bigserial"`
Inode Ino `xorm:"notnull unique(plock)"`
Sid uint64 `xorm:"notnull unique(plock)"`
Owner int64 `xorm:"notnull unique(plock)"`
Records []byte `xorm:"blob notnull"`
}
Records 是一段字节数组,每 24 字节对应一个 plockRecord。
type delfile struct {
Inode Ino `xorm:"pk notnull"`
Length uint64 `xorm:"notnull"`
Expire int64 `xorm:"notnull"`
}
type delslices struct {
Id uint64 `xorm:"pk chunkid"`
Deleted int64 `xorm:"notnull"`
Slices []byte `xorm:"blob notnull"`
}
Slices 是一段字节数组,每 12 字节对应一个 slice。
type sustained struct {
Id int64 `xorm:"pk bigserial"`
Sid uint64 `xorm:"unique(sustained) notnull"`
Inode Ino `xorm:"unique(sustained) notnull"`
}
TKV(Transactional Key-Value Database)中 Key 的通用格式为 ${prefix}${JFSKey},其中:
${VolumeName}0xFD,其中的 0xFD 作为特殊字节用来处理不同文件系统名称间存在包含关系的情况。此外,对于无法公用的数据库(如 BadgerDB)则直接使用空字符串作前缀在 TKV 的 Keys 中,所有整数都以编码后的二进制形式存储:
setting -> JSON 格式的文件系统格式化信息
C${name} -> counter value
SE${sid} -> timestamp
SI${sid} -> JSON 格式的会话信息
A${inode}I -> encoded Attr
A${inode}D${name} -> encoded {type, inode}
A${inode}P${parentInode} -> counter value
A${inode}C${index} -> Slices
其中 index 占 4 个字节,使用大端编码。Slices 是一段字节数组,每 24 字节对应一个 Slice。
K${sliceId}${size} -> counter value
其中 size 占 4 个字节,使用大端编码。
A${inode}S -> target
A${inode}X${name} -> xattr value
F${inode} -> flocks
其中 flocks 是一段字节数组,每 17 字节对应一个 flock:
type flock struct {
sid uint64
owner uint64
ltype uint8
}
P${inode} -> plocks
其中 plocks 是一段字节数组,对应的 plock 是变长的:
type plock struct {
sid uint64
owner uint64
size uint32
records []byte
}
其中 size 是 records 数组的长度,records 中每 24 字节对应一个 plockRecord。
D${inode}${length} -> timestamp
其中 length 占 8 个字节,使用大端编码。
L${timestamp}${sliceId} -> slices
其中 slices 是一段字节数组,每 12 字节对应一个 slice。
SS${sid}${inode} -> 1
这里 Value 值仅用来占位。
根据 Edge 的设计,元数据引擎中只记录了每个目录的直接子节点。当应用提供一个路径来访问文件时,JuiceFS 需要逐级查找。现在假设应用想打开文件 /dir1/dir2/testfile,则需要:
在以上步骤中,任何一步搜寻失败都会导致该路径指向的文件未找到。
上一节中,我们已经可以根据文件的路径找到此文件,并获取到其属性。根据文件属性中的 inode 和 size 字段,即可找到��文件内容相关的元数据。现在假设有个文件的 inode 为 100,size 为 160 MiB,那么该文件一共有 (size-1) / 64 MiB + 1 = 3 个 Chunks,如下:
File: |_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _|_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _|_ _ _ _ _ _ _ _|
Chunk: |<--- Chunk 0 --->|<--- Chunk 1 --->|<-- Chunk 2 -->|
在单机 Redis 中,这意味着有 3 个 Chunk Keys,分别为 c100_0, c100_1 和 c100_2,每个 Key 对应一个 Slices 列表。这些 Slices 主要在数据写入时生成,可能互相之间有覆盖,也可能未完全填充满 Chunk。因此,在使用前需要顺序遍历这个 Slices 列表,并重新构建出最新版的数据分布,做到:
现假设 Chunk 0 中有 3 个 Slices,分别为:
Slice{pos: 10M, id: 10, size: 30M, off: 0, len: 30M}
Slice{pos: 20M, id: 11, size: 16M, off: 0, len: 16M}
Slice{pos: 16M, id: 12, size: 10M, off: 0, len: 10M}
图示如下(每个 '_' 表示 2 MiB):
Chunk: |_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _|
Slice 10: |_ _ _ _ _ _ _ _ _ _ _ _ _ _ _|
Slice 11: |_ _ _ _ _ _ _ _|
Slice 12: |_ _ _ _ _|
New List: |_ _ _ _ _|_ _ _|_ _ _ _ _|_ _ _ _ _|_ _|_ _ _ _ _ _ _ _ _ _ _ _|
0 10 12 11 10 0
重构后的新列表包含且仅包含了此 Chunk 的最新数据分布,具体如下:
Slice{pos: 0, id: 0, size: 10M, off: 0, len: 10M}
Slice{pos: 10M, id: 10, size: 30M, off: 0, len: 6M}
Slice{pos: 16M, id: 12, size: 10M, off: 0, len: 10M}
Slice{pos: 26M, id: 11, size: 16M, off: 6M, len: 10M}
Slice{pos: 36M, id: 10, size: 30M, off: 26M, len: 4M}
Slice{pos: 40M, id: 0, size: 24M, off: 0, len: 24M} // 实际这一段也会省去
Block 是 JuiceFS 管理数据的基本单元,其大小默认为 4 MiB,且可在文件系统格式化时配置,允许调整的区间范围为 [64 KiB, 16 MiB]。每个 Block 上传后即为对象存储中的一个对象,其命名格式为 ${fsname}/chunks/${hash}/${basename},其中:
${sliceId}_${index}_${size},其中:
目前使用的 hash 算法有两种,以 basename 中的 sliceId 为参数,根据文件系统格式化时的 HashPrefix 配置选择:
func hash(sliceId int) string {
if HashPrefix {
return fmt.Sprintf("%02X/%d", sliceId%256, sliceId/1000/1000)
}
return fmt.Sprintf("%d/%d", sliceId/1000/1000, sliceId/1000)
}
假设一个名为 jfstest 的文件系统中写入了一段连续的 10 MiB 数据,内部赋予的 SliceID 为 1,且未开启 HashPrefix,那么在对象存储中则会产生以下三个对象:
jfstest/chunks/0/0/1_0_4194304
jfstest/chunks/0/0/1_1_4194304
jfstest/chunks/0/0/1_2_2097152
类似地,现在以上一节的 64 MiB 的 Chunk 为例,它的实际数据分布如下:
0 ~ 10M: 补零
10 ~ 16M: 10_0_4194304, 10_1_4194304(0 ~ 2M)
16 ~ 26M: 12_0_4194304, 12_1_4194304, 12_2_2097152
26 ~ 36M: 11_1_4194304(2 ~ 4M), 11_2_4194304, 11_3_4194304
36 ~ 40M: 10_6_4194304(2 ~ 4M), 10_7_2097152
40 ~ 64M: 补零
据此,客户端可以快速找到应用所需数据。例如,在 offset 为 10MiB 位置读取 8MiB 数据,会涉及 3 个对象,具体为:
10_0_4194304 读取整个对象,对应读取数据的 0 ~ 4 MiB10_1_4194304 读取 0 ~ 2 MiB,对应读取数据的 4 ~ 6 MiB12_0_4194304 读取 0 ~ 2 MiB,对应读取数据的 6 ~ 8 MiB为方便直接查看文件内容对应的对象列表,JuiceFS 提供了 info 命令,如 juicefs info /mnt/jfs/test.tmp:
objects:
+------------+---------------------------------+----------+---------+----------+
| chunkIndex | objectName | size | offset | length |
+------------+---------------------------------+----------+---------+----------+
| 0 | | 10485760 | 0 | 10485760 |
| 0 | jfstest/chunks/0/0/10_0_4194304 | 4194304 | 0 | 4194304 |
| 0 | jfstest/chunks/0/0/10_1_4194304 | 4194304 | 0 | 2097152 |
| 0 | jfstest/chunks/0/0/12_0_4194304 | 4194304 | 0 | 4194304 |
| 0 | jfstest/chunks/0/0/12_1_4194304 | 4194304 | 0 | 4194304 |
| 0 | jfstest/chunks/0/0/12_2_2097152 | 2097152 | 0 | 2097152 |
| 0 | jfstest/chunks/0/0/11_1_4194304 | 4194304 | 2097152 | 2097152 |
| 0 | jfstest/chunks/0/0/11_2_4194304 | 4194304 | 0 | 4194304 |
| 0 | jfstest/chunks/0/0/11_3_4194304 | 4194304 | 0 | 4194304 |
| 0 | jfstest/chunks/0/0/10_6_4194304 | 4194304 | 2097152 | 2097152 |
| 0 | jfstest/chunks/0/0/10_7_2097152 | 2097152 | 0 | 2097152 |
| ... | ... | ... | ... | ... |
+------------+---------------------------------+----------+---------+----------+
表中空的 objectName 表示文件空洞,读取时均为 0。可以看到,输出结果与之前分析一致。
值得一提的是,这里的 size 是 Block 中原始数据的大小,而不是对象存储中实际对象的大小。默认情况下,原始数据拆分后直接写到对象存储,此时 size 与对象大小是相等的。但当开启了数据压缩或数据加密功能后,实际对象的大小会发生变化,此时其与 size 很可能不再相同。
在文件系统格式化时可以通过 --compress <value> 参数配置压缩算法(支持 LZ4 和 zstd),使得此文件系统的所有数据 Block 会经过压缩后再上传到对象存储。此时对象名称仍与默认配置相同,且内容为原始数据经压缩算法后的结果,不携带任何其它元信息。因此,文件文统格式化信息中的压缩算法不允许修改,否则会导致读取已有数据失败。
在文件系统格式化时可以通过 --encrypt-rsa-key <value> 参数配置 RSA 私钥以开启静态数据加密功能,使得此文件系统的所有数据 Block 会经过加密后再上传到对象存储。此时对象名称仍与默认配置相同,内容为一段 header 加上数据经加密算法后的结果。这段 header 里记录了用来解密的对称密钥以及随机种子,而对称密钥本身又经过 RSA 私钥加密。因此,文件文统格式化信息中的 RSA 私钥目前不允许修改,否则会导致读取已有数据失败。
:::note 备注 若同时开启压缩和加密,原始数据会先压缩再加密后上传到对象存储。 :::