JuiceFS 简介
JuiceFS 是一款面向云原生设计的高性能分布式文件系统,在 Apache 2.0 开源协议下发布。提供完备的 POSIX 兼容性,可将几乎所有对象存储接入本地作为海量本地磁盘使用,亦可同时在跨平台、跨地区的不同主机上挂载读写。
JuiceFS 采用「数据」与「元数据」分离存储的架构,从而实现文件系统的分布式设计。
文件数据本身会被切分保存在对象存储(例如 Amazon S3),而元数据则可以保存在 Redis、MySQL、TiKV、SQLite 等多种数据库中,你可以根据场景与性能要求进行选择。
JuiceFS 提供了丰富的 API,适用于各种形式数据的管理、分析、归档、备份,可以在不修改代码的前提下无缝对接大数据、机器学习、人工智能等应用平台,为其提供海量、弹性、低价的高性能存储。运维人员不用再为可用性、灾难恢复、监控、扩容等工作烦恼,专注于业务开发,提升研发效率。同时运维细节的简化,对 DevOps 极其友好。
核心特性
POSIX 兼容:像本地文件系统一样使用,无缝对接已有应用,无业务侵入性;
HDFS 兼容:完整兼容 HDFS API,提供更强的元数据性能;
S3 兼容:提供 S3 网关 实现 S3 协议兼容的访问接口;
云原生:通过 Kubernetes CSI 驱动 轻松地在 Kubernetes 中使用 JuiceFS;
分布式设计:同一文件系统可在上千台服务器同时挂载,高性能并发读写,共享数据;
强一致性:确认的文件修改会在所有服务器上立即可见,保证强一致性;
强悍性能:毫秒级延迟,近乎无限的吞吐量(取决于对象存储规模),查看性能测试结果;
数据安全:支持传输中加密(encryption in transit)和静态加密(encryption at rest),查看详情;
文件锁:支持 BSD 锁(flock)和 POSIX 锁(fcntl);
数据压缩:支持 LZ4 和 Zstandard 压缩算法,节省存储空间。
应用场景
JuiceFS 为海量数据存储设计,可以作为很多分布式文件系统和网络文件系统的替代,特别是以下场景:
大数据分析:HDFS 兼容;与主流计算引擎(Spark、Presto、Hive 等)无缝衔接;无限扩展的存储空间;运维成本几乎为 0;性能远好于直接对接对象存储。
机器学习:POSIX 兼容,可以支持所有机器学习、深度学习框架;方便的文件共享还能提升团队管理、使用数据效率。
Kubernetes:JuiceFS 支持 Kubernetes CSI;为容器提供解耦的文件存储,令应用服务可以无状态化;方便地在容器间共享数据。
共享工作区:可以在任意主机挂载;没有客户端并发读写限制;POSIX 兼容已有的数据流和脚本操作。
数据备份:在无限平滑扩展的存储空间备份各种数据,结合共享挂载功能,可以将多主机数据汇总至一处,做统一备份。
数据隐私
JuiceFS 是开源软件,你可以在 GitHub 找到完整的源代码。
在使用 JuiceFS 存储数据时,数据会按照一定的规则被拆分成数据块并保存在你自己定义的对象存储或其它存储介质中,数据所对应的元数据则存储在你自己定义的数据库中。
技术架构
JuiceFS 文件系统由三个部分组成:
JuiceFS 客户端(Client):所有文件读写,以及碎片合并、回收站文件过期删除等后台任务,均在客户端中发生。客户端需要同时与对象存储和元数据引擎打交道。客户端支持多种接入方式:
通过 FUSE,JuiceFS 文件系统能够以 POSIX 兼容的方式挂载到服务器,将海量云端存储直接当做本地存储来使用。
通过 Hadoop Java SDK,JuiceFS 文件系统能够直接替代 HDFS,为 Hadoop 提供低成本的海量存储。
通过 Kubernetes CSI 驱动,JuiceFS 文件系统能够直接为 Kubernetes 提供海量存储。
通过 S3 网关,使用 S3 作为存储层的应用可直接接入,同时可使用 AWS CLI、s3cmd、MinIO client 等工具访问 JuiceFS 文件系统。
通过 WebDAV 服务,以 HTTP 协议,以类似 RESTful API 的方式接入 JuiceFS 并直接操作其中的文件。
数据存储(Data Storage):文件将会被切分上传至对象存储服务。JuiceFS 支持几乎所有的公有云对象存储,同时也支持 OpenStack Swift、Ceph、MinIO 等私有化 的对象存储。
元数据引擎(Metadata Engine):用于存储文件元数据(metadata),包含以下内容:
常规文件系统的元数据:文件名、文件大小、权限信息、创建修改时间、目录结构、文件属性、符号链接、文件锁等。
文件数据的索引:文件的数据分配和引用计数、客户端会话等。
JuiceFS 采用多引擎设计,目前已支持 Redis、TiKV、MySQL/MariaDB、PostgreSQL、SQLite 等作为元数据服务引擎,也将陆续实现更多元数据存储引擎。欢迎提交 Issue 反馈你的需求。
JuiceFS 如何存储文件
与传统文件系统只能使用本地磁盘存储数据和对应的元数据的模式不同,JuiceFS 会将数据格式化以后存储在对象存储,同时会将文件的元数据存储在元数据引擎。在这个过程中,Chunk、Slice、Block 是三个重要的概念:
对于 JuiceFS,每一个文件都由 1 或多个「Chunk」组成,每个 Chunk 最大 64M。
不论文件有多大,所有的读写都会根据其偏移量(也就是产生读写操作的文件位置)来定位到对应的 Chunk。
正是这种分而治之的设计,让 JuiceFS 面对大文件也有优秀的性能。只要文件总长度没有变化,不论经历多少修改写入,文件的 Chunk 切分都是固定的。
Chunk 的存在是为了优化查找定位,实际的文件写入则在「Slice」上进行。在 JuiceFS 中,一个 Slice 代表一次连续写入,隶属于某个 Chunk,并且不能跨越 Chunk 边界,因此 Slice 长度也不会超 64M。
举例说明,如果一个文件是由一次连贯的顺序写生成,那么每个 Chunk 中只将会仅包含一个 Slice。上方的示意图就属于这种情况:顺序写入一个 160M 文件,最终会产生 3 个 Chunk,而每个 Chunk 仅包含一个 Slice。
文件写入会产生 Slice,而调用 flush 则会将这些 Slice 持久化。flush 可以被用户显式调用,就算不调用,JuiceFS 客户端也会自动在恰当的时机进行 flush,防止缓冲区被写满。
持久化到对象存储时,为了能够尽快写入,会对 Slice 进行进一步拆分成一个个「Block」(默认最大 4M),多线程并发写入以提升写性能。上边介绍的 Chunk、Slice,其实都是逻辑数据结构,Block 则是最终的物理存储形式,是对象存储和磁盘缓存的最小存储单元。
因此,文件写入 JuiceFS 后,你不会在对象存储中找到原始文件,存储桶中只有一个 chunks 目录和一堆数字编号的目录和文件,让人不禁疑惑「我的文件到底去了哪儿」?
但事实上,这些数字编号的对象存储文件正是经过 JuiceFS 拆分存储的 Block,而这些 Block 与 Chunk、Slice 的对应关系,以及其他元数据信息(比如文件名、大小等属性)则存储在元数据引擎中,这样的分离设计,让 JuiceFS 文件系统得以高性能运作。
回到逻辑数据结构的话题,如果文件并不是由连贯的顺序写生成,而是多次追加写,每次追加均调用 flush 触发写入上传,就会产生多个 Slice。
如果每次追加写入的数据量不足 4M,那么最终存入对象存储的数据块,也会是一个个小于 4M 的 Block。
取决于写入模式,Slice 的排列模式可以是多种多样的:如果文件在相同区域被反复修改,Slice 之间会发生重叠。
如果在互不重合的区域进行写入,Slice 中间会有间隔。
但不论 Slice 的排列有多复杂,当读文件发生时,对于每一处文件位置,都会读到该位置最新写入的 Slice,用下图可以更加直观地理解:Slice 虽然会相互堆叠,但读文件一定是“从上往下看”,因此一定会看到该文件的最新状态。
正是由于 Slice 会相互覆盖,JuiceFS 在 Chunk 与 Slice 的引用关系中,标记了各个 Slice 的有效数据偏移范围,用这种方式告诉文件系统,每一个 Slice 中的哪些部分是有效的数据。
但也不难想象,读取文件需要查找「当前读取范围内最新写入的 Slice」,在上图所示的大量堆叠 Slice 的情况下,这样的反复查找将会显著影响读性能,我们称之为文件「碎片化」。碎片化不仅影响读性能,还会在各个层面(对象存储、元数据)增加空间占用。
因此每当写入发生时,客户端都会判断文件的碎片化情况,并异步地运行碎片合并,将同一个 Chunk 内的所有 Slice 合并为一。
最后,JuiceFS 的存储设计,还有着以下值得一提的技术特点:
对于任意大小的文件,JuiceFS 都不进行合并存储,这也是为了性能考虑,避免读放大。
提供强一致性保证,但也可以根据场景需要与缓存功能一起调优,比如通过设置出更激进的元数据缓存,牺牲一部分一致性,换取更好的性能。详见「元数据缓存」。
支持并默认开启「回收站」功能,删除文件后保留一段时间才彻底清理,最大程度避免误删文件导致事故。
读写请求处理流程
写入流程
JuiceFS 对大文件会做多级拆分(JuiceFS 如何存储文件),以提高读写效率。
在处理写请求时,JuiceFS 先将数据写入 Client 的内存缓冲区,并在其中按 Chunk/Slice 的形式进行管理。Chunk 是根据文件内 offset 按 64 MiB 大小拆分的连续逻辑单元,不同 Chunk 之间完全隔离。每个 Chunk 内会根据应用写请求的实际情况进一步拆分成 Slice;当新的写请求与已有的 Slice 连续或有重叠时,会直接在该 Slice 上进行更新,否则就创建新的 Slice。Slice 是启动数据持久化的逻辑单元,其在 flush 时会先将数据按照默认 4 MiB 大小拆分成一个或多个连续的 Block,并作为最小单元上传到对象存储;然后再更新一次元数据,写入新的 Slice 信息。
显然,在应用顺序写情况下,只需要一个不停增长的 Slice,最后仅 flush 一次即可;此时能最大化发挥出对象存储的写入性能。以一次简单的 JuiceFS 基准测试为例,使用 1 MiB IO 顺序写 1 GiB 文件,在不考虑压缩和加密的前提下,数据在各个组件中的形式如下图所示:
图中第 1 阶段:
对象存储写入的平均 IO 大小为 object.put / object.put_c = 4 MiB,等于 Block 的默认大小
元数据事务数与对象存储写入数比例大概为 meta.txn : object.put_c ~= 1 : 16,对应 Slice flush 需要的 1 次元数据修改和 16 次对象存储上传,同时也说明了每次 flush 写入的数据量为 4 MiB * 16 = 64 MiB,即 Chunk 的默认大小
FUSE 层的平均请求大小为约 fuse.write / fuse.ops ~= 128 KiB,与其默认的请求大小限制一致
小文件的写入通常是在文件关闭时被上传到对象存储,对应 IO 大小一般就是文件大小。指标图的第 3 阶段是创建 128 KiB 小文件,可以发现:
对象存储 PUT 的大小就是 128 KiB
元数据事务数大致是 PUT 计数的两倍,对应每个文件的一次 Create 和一次 Write
对于这种不足一个 Block Size 的对象,JuiceFS 在上传的同时还会尝试写入到本地缓存,来提升后续可能的读请求速度。因此从图中第 3 阶段也可以看到,创建小文件时,本地缓存(blockcache)与对象存储有着同等的写入带宽,而在读取时(第 4 阶段)大部分均在缓存命中,这使得小文件的读取速度看起来特别快。
由于写请求写入客户端内存缓冲区即可返回,因此通常来说 JuiceFS 的 Write 时延非常低(几十微秒级别),真正上传到对象存储的动作由内部自动触发,比如单个 Slice 过大,Slice 数量过多,或者仅仅是在缓冲区停留时间过长等,或应用主动触发,比如关闭文件、调用 fsync 等。
缓冲区中的数据只有在被持久化后才能释放,因此当写入并发较大时,如果缓冲区大小不足(默认 300MiB,通过 –buffer-size 调节),或者对象存储性能不佳,读写缓冲区将持续被占用而导致写阻塞。缓冲区大小可以在指标图的 usage.buf 一列中看到。当使用量超过阈值时,JuiceFS Client 会主动为 Write 添加约 10ms 等待时间以减缓写入速度;若已用量超过阈值两倍,则会导致写入暂停直至缓冲区得到释放。因此,在观察到 Write 时延上升以及 Buffer 长时间超过阈值时,通常需要尝试设置更大的 –buffer-size。另外,增大上传并发度(–max-uploads,默认 20)也能提升写入到对象存储的带宽,从而加快缓冲区的释放。
随机写
JuiceFS 支持随机写,包括通过 mmap 等进行的随机写。
要知道,Block 是一个不可变对象,这也是因为大部分对象存储服务并不支持修改对象,只能重新上传覆盖。
因此发生覆盖写、大文件随机写时,并不会将 Block 重新下载、修改、重新上传(这样会带来严重的读写放大!),而是在新分配或者已有 Slice 中进行写入,以新 Block 的形式上传至对象存储,然后修改对应文件的元数据,在 Chunk 的 Slice 列表中追加新 Slice。后续读取文件时,其实在读取通过合并 Slice 得到的视图。
因此相较于顺序写来说,大文件随机写的情况更复杂:每个 Chunk 内可能存在多个不连续的 Slice,使得一方面数据对象难以达到 4 MiB 大小,另一方面元数据需要多次更新。因此,JuiceFS 在大文件随机写有明显的性能下降。当一个 Chunk 内已写入的 Slice 过多时,会触发碎片清理(Compaction)来尝试合并与清理这些 Slice,来提升读性能。碎片清理以后台任务形式发生,除了系统自动运行,还能通过 juicefs gc 命令手动触发。
客户端写缓存
客户端写缓存,也称为「回写模式」。
如果对数据一致性和可靠性没有极致要求,可以在挂载时添加 –writeback 以进一步提写性能。
客户端缓存开启后,Slice flush 仅需写到本地缓存目录即可返回,数据由后台线程异步上传到对象存储。换个角度理解,此时本地目录就是对象存储的缓存层。
更详细的介绍请见「客户端写缓存」。
读取流程
JuiceFS 支持顺序读和随机读(包括基于 mmap 的随机读),在处理读请求时会通过对象存储的 GetObject 接口完整读取 Block 对应的对象,也有可能仅仅读取对象中一定范围的数据(比如通过 S3 API 的 Range 参数限定读取范围)。
与此同时异步地进行预读(通过 –prefetch 参数控制预读并发度),预读会将整个对象存储块下载到本地缓存目录,以备后用(如指标图中的第 2 阶段,blockcache 有很高的写入带宽)。显然,在顺序读时,这些提前获取的数据都会被后续的请求访问到,缓存命中率非常高,因此也能充分发挥出对象存储的读取性能。
数据流如下图所示:
但是对于大文件随机读场景,预读的用途可能不大,反而容易因为读放大和本地缓存的频繁写入与驱逐使得系统资源的实际利用率降低,此时可以考虑用 –prefetch=0 禁用预读。
考虑到此类场景下,一般的缓存策略很难有足够高的收益,可考虑尽可能提升缓存的整体容量,达到能几乎完全缓存所需数据的效果;或者直接禁用缓存(–cache-size=0),并尽可能提高对象存储的读取性能。
小文件的读取则比较简单,通常就是在一次请求里读取完整个文件。由于小文件写入时会直接被缓存起来,因此,之后的读性能非常可观。
参考资料
https://juicefs.com/docs/zh/community/internals/io_processing