技术架构
JuiceFS 文件系统由三个部分组成:
JuiceFS 客户端(Client)
JuiceFS 采用所谓的「富客户端」设计,因此客户端是文件系统最重要的部分:所有文件读写,包括碎片合并、回收站文件过期删除等后台任务,均在客户端中发生。服务端(也就是下面将会介绍的「元数据引擎」)不会也不能直接修改任何文件系统数据。可想而知,客户端需要同时与对象存储和元数据存储服务打交道。
客户端支持众多接入方式,包括:
- FUSE 挂载:使用 JuiceFS 客户端将文件系统挂载到本地,在服务器直接访问海量云端存储
- Hadoop Java SDK:直接替代 HDFS,为 Hadoop 提供低成本的海量存储
- Kubernetes CSI 驱动:接入 Kubernetes 集群,为容器提供弹性存储
- S3 网关:对外暴露 S3 API,已有的支持 S3 应用可直接无缝接入
数据存储(Data Storage)
文件将会切分上传保存在对象存储服务(关于这一点,在下方的「JuiceFS 如何存储文件」一节详细介绍)。JuiceFS 支持几乎所有的公有云对象存储,同时也支持 OpenStack Swift、Ceph、MinIO 等私有化的对象存储,这些都在「设置对象存储」中详细介绍。
在 JuiceFS 中,只有客户端会直接与存储打交道,服务端(元数据引擎)与对象存储完全解耦,不关心也不会访问对象存储服务,所有实际的文件数据都坐落在你自己选择的对象存储上。
元数据引擎(Metadata Engine)
Juicedata 自研的高性能元数据服务,用于存储文件元数据(metadata),包含以下内容:
- 常规文件系统的元数据:文件名、文件大小、权限信息、创建修改时间、目录结构、文件属性、符号链接、文件锁等。
- JuiceFS 独有的元数据:文件的 chunk 及 slice 映射关系、客户端 session 等。
Juicedata 已经在大多数公有云都部署了元数据服务,供云服务用户开箱即用。客户端默认通过公网直接访问同区域的元数据服务,但在对网络延迟有着更高要求的大规模场景下,也可以通过内网打通的方式来进一步优化元数据服务的访问延迟,详情请咨询 Juicedata 团队。
元数据服务是一个基于 Raft 协议的高可用集群,所有元数据操作均以变更日志(changelog)形式进行追加(这也让类似「实时数据保护」这样的高级数据安全功能成为可能)。一个 Raft 组由 3 个节点组成,包含 Leader 和 Follower 两种角色,通过 Raft 共识算法进行数据复制,确保元数据的强一致性和服务的高可用。
一个 Raft 组构成一个 JuiceFS 元数据分区。单分区元数据服务适用于文件数不超过 2 亿的场景,如需支持更大量级可以使用多分区元数据服务(目前仅在私有部署提供),通过增加分区来实现元数据服务的水平拓展。
多分区模式下,能在单一命名空间支撑百亿级文件存储,每个分区内的架构与单分区相同。不同分区的节点可以混部在相同的机器上,分区数支持动态调整,分区间的元数据也支持动态迁移(自动或者手动负载均衡),来满足高负载业务访问场景,有效避免访问热点而引发的性能问题。这一系列功能均可通过 Web 控制台进行管理和监控,满足企业运维需求。而对于 JuiceFS 客户端,访问多分区元数据服务也与单分区无异,能同时读写不同分区的数据,当分区拓扑发生变化时自动感知。
JuiceFS 如何存储文件
与传统文件系统只能使用本地磁盘存储数据和对应的元数据的模式不同,JuiceFS 会将数据格式化以后存储在对象存储,同时会将文件的元数据存储在元数据引擎。在这个过程中,Chunk、Slice、Block 是三个重要的概念:
对于 JuiceFS,每一个文件都由 1 或多个「Chunk」组成,每个 Chunk 最大 64MB。不论文件有多大,所有的读写都会根据其偏移量(也就是产生读写操作的文件位置)来定位到对应的 Chunk。正是这种分而治之的设计,让 JuiceFS 面对大文件也有优秀的性能。只要文件总长度没有变化,不论经历多少修改写入,文件的 Chunk 切分都是固定的。
Chunk 的存在是为了优化查找定位,实际的文件写入则在「Slice」上进行。在 JuiceFS 中,一个 Slice 代表一次连续写入,隶属于某个 Chunk,并且不能跨越 Chunk 边界,因此 Slice 长度也不会超 64MB。
举例说明,如果一个文件是由一次连贯的顺序写生成,那么每个 Chunk 中只将会仅包含一个 Slice。上方的示意图就属于这种情况:顺序写入一个 160MB 文件,最终会产生 3 个 Chunk,而每个 Chunk 仅包含一个 Slice。
文件写入会产生 Slice,而调用 flush
则会将这些 Slice 持久化。flush
可以被用户显式调用,就算不调用,JuiceFS 客户端也会自动在恰当的时机进行 flush
,防止缓冲区被写满。持久化到对象存储 时,为了能够尽快写入,会对 Slice 进行进一步拆分成一个个「Block」(默认最大 4MB),多线程并发写入以提升写性能。上边介绍的 Chunk、Slice,其实都是逻辑数据结构,Block 则是最终的物理存储形式,是对象存储和磁盘缓存的最小存储单元。
因此,文件写入 JuiceFS 后,你不会在对象存储中找到原始文件,存储桶中只有一个 chunks
目录和一堆数字编号的目录和文件,让人不禁疑惑「我的文件到底去了哪儿?」。但事实上,这些数字编号的对象存储文件正是经过 JuiceFS 拆分存储的 Block,而这些 Block 与 Chunk、Slice 的对应关系,以及其他元数据信息(比如文件名、大小等属性)则存储在元数据引擎中,这样的分离设计,让 JuiceFS 文件系统得以高性能运作。
回到逻辑数据结构的话题,如果文件并不是由连贯的顺序写生成,而是多次追加写,每次追加均调用 flush
触发写入上传,就会产生多个 Slice。如果每次追加写入的数据量不足 4MB,那么最终存入对象存储的数据块,也会是一个个小于 4MB 的 Block。
取决于写入模式,Slice 的排列模式可以是多种多样的:
- 如果文件在相同区域被反复修改,Slice 之间会发生重叠。
- 如果在互不重合的区域进行 写入,Slice 中间会有间隔。
但不论 Slice 的排列有多复杂,当读取文件时,对于每一处文件位置,都会读到该位置最新写入的 Slice,用下图可以更加直观地理解:Slice 虽然会相互堆叠,但读文件一定是「从上往下看」,因此一定会看到该文件的最新状态。
正是由于 Slice 会相互覆盖,JuiceFS 在 Chunk 与 Slice 的引用关系中,标记了各个 Slice 的有效数据偏移范围(内部实现可以参考社区版文档,商业版的设计类似),用这种方式告诉文件系统,每一个 Slice 中的哪些部分是有效的数据。
但也不难想象,读取文件需要查找「当前读取范围内最新写入的 Slice」,在上图所示的大量堆叠 Slice 的情况下,这样的反复查找将会显著影响读性能,我们称之为文件「碎片化」。碎片化不仅影响读性能,还会在各个层面(对象存储、元数据)增加空间占用。因此每当写入发生时,客户端都会判断文件的碎片化情况,并在后台任务中运行碎片合并,将同一个 Chunk 内的所有 Slice 合并为一。
JuiceFS 的存储设计,还有着以下技术特点: