本系列文章分为五篇
- 分布式存储的战争(一)大数据的基石-HDFS的崛起
- 分布式存储的战争(二)对象存储的挑战 MinIO/SeaweedFS/Ceph
- 分布式存储的战争(三)存算分离的必然性-Alluxio/JuiceFS数据编排层
- 分布式存储的战争(四)AI的咆哮-GPFS/Deepseek 3FS 并行文件系统
- 创作中
本文为第三篇,书接上回
1. 一波未平,一波又起:数据编排层的诞生
在大数据存储与分析、云原生场景、AI/ML 模型训练场景下,HDFS 和对象存储都无法解决一些问题:
- 存算分离下的大数据分析性能差:虽然云原生倡导的存储与计算分离让存储系统具备近乎无限的扩展能力和低成本优势,但是在大数据计算场景下却丢失了数据本地性,每次计算都需通过网络拉取数据,不仅速度慢,而且也架不住频繁读取的网络带宽消耗。这一矛盾在 AI/ML 模型训练中尤为突出,因为训练过程通常需要反复读取同一数据集(多个 epoch),若每次都要从远程对象存储加载,引入的高延迟会导致昂贵的 GPU 资源因等待 I/O 而空转,实践中没人这么做。
- 各存储自身接口语义的适配成本高:比如大多数 AI/ML 框架(如 PyTorch、TensorFlow)默认使用 open() / read() 等 POSIX 接口,对象存储的 HTTP/S3 API 无法直接满足;或者想迁移 Spark on HDFS 至 Spark on S3,需要修改大量代码。
- 存储系统分散:企业数据常分散在 HDFS、对象存储等多种系统中,应用需适配不同 API,运维复杂。
计算机软件的世界里,大多数场景都是通过加一层来解决问题。数据编排层(数据缓存层)也是这么做的,它通过如下几点解决了上述问题:
- 在计算侧引入分层缓存:自动将热点数据(如训练集)缓存在靠近计算节点的高速介质中,重建“逻辑上的数据本地性”,显著提升 I/O 吞吐,避免计算资源闲置。比如在 AI 训练中,可将 S3 上的数据集缓存在 Alluxio Worker 的内存中,GPU 直接读取,吞吐提升 10 倍以上。
- 多协议统一接入:通过 FUSE、HDFS 兼容接口或,将对象存储的语义透明转换为 POSIX 或 HDFS 文件系统语义,使现有 AI/大数据应用无需修改即可运行。
- 统一命名空间:无论数据物理上分散在 S3、HDFS 还是其他存储系统中,数据编排层都能将其挂载到一个全局一致的目录树下,应用程序只需访问该层,即可无缝读取所有数据。
一般大家认为这不就是个数据缓存层,怎么还起了个「数据编排层」这么高大上的名字,这是要和 k8s 叫板?在某种程度上,它确实对得起数据编排层这个名字。它不仅仅提供了数据缓存这一单一功能,并涵盖了数据的生命周期管理、位置调度、跨存储统一视图、访问协议抽象等能力。
典型的数据编排层系统组件有两个:Alluixo 和 JuiceFS,但它两在架构实现上和应用场景上却大有区别,下面我们具体分析。
2. 浅析 Alluxio
Alluxio(/əˈluːksiəʊ/ 音似“呃卢克西奥”)是在任意底层存储(如 S3、HDFS)之上,为上层计算框架(如 Spark、TensorFlow)提供高性能、低延迟、统一且 POSIX 兼容的数据访问层。它的角色是计算层和存储层的桥梁。
整体架构
吐槽下Alluxio这个组件命名真随意,Master/Job Master/Worker/Job Worker傻傻分不清楚,就不能换个名字。
通常 Alluxio 会和计算/训练节点部署在同一个集群上,作为一个伴生服务(如图中的 Application)。Alluxio 是主从架构,有两种角色:
- Alluxio Masters:这里它有两种 Master
- Master:通过 RocksDB&内存来管理整个 Alluxio 集群的两种元数据;一种是文件元数据,比如文件路径、权限、块信息、副本位置等;另一种是集群状态数据,比如 Worker 注册信息。
- Job Master:专门用于管理和调度异步数据操作任务,这些作业通常是耗时较长、资源密集型的操作。比如将 Alluxio 中的临时数据异步持久化到底层存储 UFS、在多个 Worker 之间复制数据块以提高容错或局部性。将这些重的操作从核心元数据中剥离至 Job Master,使 Master 更轻量、更稳定。
- Master 的单点问题解决也是类似于 HDFS 或者 Ceph 中的 Monitor,设置热备节点,通过 Zookeeper 或 Raft 协议来选举 Leader 和维护一致性。
- Alluxio Workers:它分布在各个计算节点上,也有两种 Worker(这名字也是醉了)。
- Worker,存储实际的数据块(block),执行数据读写、缓存、复制、淘汰策略,同时向 Master 报告自身状态(内存使用、磁盘空间、负载等)。
- Job Worker:可选组件,用于执行 Job Master 下发的异步任务(如预加载、清理、数据写回存储等)
POSIX 追加写和随机写
Alluxio 的初衷是做一个底层存储系统的 proxy,存储通常是 HDFS、S3 等等,这些存储部分支持追加写,部分不支持追加写,而且几乎都不支持随机写,所以 Alluxio 最开始的写入模型也是 HDFS 最开始的写入模型设想,即一次写入多次读取(Write Once, Read Many),不支持追加写和随机写。
这个模型在大数据层面还算可用,比如 Spark on Alluxio,但到了云原生环境或者 AI 模型训练场景,就显得捉襟见肘了。比如模型训练中分 batch 预估文本的 Embedding,每个 batch 预估完成之后需要将结果追加写入,不支持追加写的情况下,会产生很多的小文件。
从 Alluxio 2.6+ 开始,实验性支持追加写(append)和随机写,但这需要显式启用。
- 追加写为了避免写冲突,会在 Master 上加一个内部锁,确保同一时间只有一个 append 操作进行。由于大多数对象存储不支持追加写,Alluxio 通过攒多次写至一次写和写时复制的策略来模拟了追加写。
- 随机写则是直接对底层存储文件进行 Copy On Write,是一个比较低效率的操作。
虽然 Alluxio 支持了追加写和随机写,但可以看到这是一个有性能代价(锁、写时复制)的折衷方案。
一致性保障
由于 Alluxio 主要提供了数据缓存的能力,引入缓存必然会引入「数据一致性」的问题,即 Cache 数据文件和底层存储数据文件的一致性。
Alluxio 提供了不同的一致性选择,主要是出于期望在性能和一致性之间取得平衡
- MUST_CACHE:仅写入 Alluxio,不写底层(适用于中间计算结果,如 Spark)。这种情况下要求缓存和文件系统的强一致是没有必要的。
- CACHE_THROUGH:强一致性;同时写 Alluxio 和底层存储(强一致性写)
- THROUGH:弱一致性;仅写底层,不缓存。
- ASYNC_THROUGH:弱一致性;先写 Alluxio,异步写底层(高性能,但存在短暂不一致窗口)。
这种一致性模型,能够让 Alluxio 不论在大数据场景、云原生、或者模型训练场景都高效且安全。
但对于 CACHE_THROUGH 这种语义来说,可能也会存在一致性问题。在多个节点都缓存了同一份文件的情况下,比如 A、B 节点均存储了文件。当 A 节点修改文件后,B 节点的数据会有不一致问题。
这种情况下会通过 Master 和 Worker 之间的心跳信息交互来保证最终一致性。实现方式是 Master 在收到 Worker 的心跳后,Master 会向 Worker 发出删除哪些 block,移动哪些 block 的命令,以清除这些缓存。从这里可以看出 Alluxio 的设计会有两个小问题:
- 这种只是保障了最终一致,并非强一致性。要达到强一致性,就需要使用 Push 模型,即当有文件修改时,Master 主动向所有 Worker 发布消息,让 Worker 上的相关缓存失效。
- 在 Worker 节点到达一定数量时,Master 会有单点瓶颈问题,过多的心跳会成为 Master 的负担。
一些缺点
- Master 单点瓶颈:不可水平扩展,这是最大的痛点,无法支撑模型训练下的海量小文件场景。
- 写性能差:在强一致语义下,写性能大概是 JuiceFS 的 1/10,原因主要在于 Alluxio 严重依赖底层存储的写性能。
- 对 POSIX 支持较差:除了上述所说的随机写、追加写、还有 mmap,hard link 都不行。
- 一致性模型有限:不保证跨客户端的 close-to-open 语义,即 Client A 写完并关闭文件后,Client B 立即打开该文件,可能读到旧数据。同时Alluxio 和 UnderFileSystem 维护了两套元数据,需要额外操作保证两个元数据一致。
当前 Alluxio 在企业版已经发布了 DORA 版本,它是一个去中心化的架构,旨在解决单点瓶颈问题。
3. JuiceFS 浅析
JuiceFS 可以简单理解为在弹性与低成本的对象存储上提供了 POSIX 兼容的带缓存的存储系统。
JuiceFS 在设计哲学上有点类似于 Hive,对外暴露的是一个存储系统,但内部的功能却外包给其他“专业人士”,Hive 将 Metadata 外包给 MySQL,将存储外包给 HDFS;而 JuiceFS 是将 Metadata 外包给 MySQL/Redis/TiKV 等数据库,将存储外包给 S3/MinIO/Ceph RGW 等。
那就有人要问了,我干嘛不直接用底层存储,非要给它包一层?这就体现了计算机软件界中「中间层」的意义了。JuiceFS 包了这一层之后,你可以实现利用对象存储的低成本来提供 POSIX 文件系统语义,并且可以利用它的缓存功能来加速。典型的场景有 Kubernetes 中为 AI 训练提供“虚拟共享文件系统”,自动缓存热数据到本地盘。你可以理解为你有了一个更具扩展性和成本更低的 HDFS,或者你有了一个操作更方便的对象存储。这就是中间层的意义。
整体架构
JuiceFS 如图中的红框的模块所示,主要由三个部分构成:
- Metadata Engine:依赖第三方 kv 存储或者关系型数据库,维护文件系统的元数据,比如文件名、文件大小等;以及文件数据的索引:文件的数据分配和引用计数、客户端会话等。这保证了目录列表、文件查找等操作极快,解决了对象存储元数据操作慢的问题。
- Data Storage:依赖第三方对象存储,支持绝大多数对象存储,是数据的实际存储底座。数据在存储时会被切片存放至对象存储。
- JuiceFS 客户端:所有文件读写,以及碎片合并、回收站文件过期删除等后台任务,均在客户端中发生。客户端需要同时与对象存储和元数据引擎打交道。客户端以独立进程部署在计算集群中(如 Yarn,Kubernetes 等)
可以看到与 Alluxio 不同的是,JuiceFS 不是主从架构,没有主节点,所有客户端节点都是平等的。
无状态客户端
JuiceFS 客户端是无状态的,不会向元数据服务“注册”或“心跳上报”。客户端退出对 JuiceFS 无影响。这种设计使其部署极其简单,非常适合云原生、Serverless、AI 训练等动态扩缩容场景。
文件组织
- Chunk:逻辑概念;相当于一级索引,作用是加速查找。一个 chunk 最大 64MB,如果设置过小会导致元数据过大。
- Block:物理概念;真实物理存储的文件块。一个 Block 最大 4MB。切分到这么细是为了优化文件读写性能,实现并发读写,用满网络带宽。
- Slice:逻辑概念;代表一次连续的写,类似于一段数据的一个版本。一个 Chunk 只包含一个 Slice,Slice 不会跨 Chunk。通过 Slice 就可以实现随机写,下文详述。
POSIX 追加写和随机写
JuiceFS 原生支持追加写和随机写,这个功能依赖上面提到的文件分块特性来实现的。
- 追加写:直接在文件末端写入 Slice。
- 随机写:在原有文件的 Slice 上再写一层 Slice。注意这里并没有写时复制(Copy On Write),而是直接叠加写(如下左图)。随着随机写越来越多,一段数据的“Slice 版本”也就越来越多,这样会影响读取性能并增加空间占用。这里就需要进行 Compaction 整理,将多个 Slice 数据版本进行合并(如下右图)
JuiceFS 的 POSIX 兼容性测试在 pjd-fstest 中,通过率达到 99%+。
一致性保障
JuiceFS 的一致性保障主要在于缓存文件和底层数据文件的一致性。
分布式有 CAP 理论,即一致性 Consistency 总是和可用性 Availability 之间进行取舍。为了提高可用性 Availability,客户端会缓存元数据,但这会牺牲一致性。由于缓存了元数据,而元数据决定文件数据,所以 JuiceFS 的一致性保障本质是客户端元数据和 Metadata Engine 的一致性。
JuiceFS 提供的是 Close-to-Open 的一致性模型,即如果客户端 A 在关闭(close)一个文件后,客户端 B 重新打开(open)该文件,那么它将看到之前所有已完成的写入。当客户端调用 open 方法时,会强制走 metadata engine;调用 open 指的是当需要与文件发生交互时,都需要首先调用一次 open,从而确保一致性。而不调用 open 的场景比如 ls -l,此时如果没有 cache,文件量大或者文件层级深的话,会给 metadata engine 过大的压力。
并发写支持
对于文件并发写,JuiceFS 遵循 POSIX 协议,并发写入会相互覆盖。写入过程无锁,通过元数据引擎的事务性来保证。但如果你想更精确的控制写入并发,你可以使用 JuiceFS 支持 BSD 锁(flock)和 POSIX 锁(fcntl)。其锁是通过 metadata engine 和类似 HDFS 的租约机制实现的。
一个缺点
- 写入带宽受限:在 AI 模型训练场景下,Checkpoint 一般比较大,此时就要求大带宽写入。而 JuiceFS 会受限于底层对象存储的带宽限制影响 Checkpoint 写入速度。虽然 Juice 提供了写入本地后异步写入存储,但由于客户端是无状态的,节点掉线后数据就被销毁了。
4. 总结对比
虽然 JuiceFS 和 Alluxio 同样都是作为数据缓存和编排,但他们两个的初衷就不同。两者都没有在底层存储造轮子,都是借助于第三方存储底座。Alluxio 是把自己看作中间层,它是计算系统和存储系统的桥梁,而 JuiceFS 则是一个完整的 POSIX 兼容文件系统,它向用户屏蔽了底层存储底座,存储底座是它的内部组件,意味着你不需要感知如何按照什么结构、文件大小来存储数据,这一切 JuiceFS 会按照自己的设计组织。
这种初衷就会导致一些设计上的区别:
- 追加写和随机写支持:由于 Alluxio 扮演的是中间层的角色,那么它就要去协调各种存储来进行功能支持,比如对于追加写,HDFS 支持而对象存储不支持,那 Alluxio 要么干脆不支持,要么就要在对象存储上模拟出追加写。而 JuiceFS 则是直接接管了存储,它不用协调各类存储,而是直接通过自己设计来支持追加写和随机写。
- 一致性保障:JuiceFS 内部一切都在它的掌控范围内,而 Alluxio 则需要通过额外手段维护自己的元数据和存储系统元数据的一致性。
- POSIX 兼容性:JuiceFS 还是自己执掌,可以方便适配,尤其在对接对象存储下性能也可以很好。但是 Alluxio 则需要按照底层存储来进行适配,无法像 JuiceFS 那样可以灵活进行文件分片。
从应用场景上看,Alluxio 适合大数据计算场景,比如 Spark on Object Storage,这种场景下文件数量不多,而且 Alluxio 提供的 MUST_CACHE 语义很方便存储一些 Spark 运行过程中的中间文件。JuiceFS 则由于其 POSIX 兼容性极佳且对海量小文件友好,适用于云原生场景和部分模型训练场景。
然而,在当前百模大战的场景下,JuiceFS 还是存在一些问题,最显著的就是它的写入吞吐仍然不能够达到部分场景大模型的要求(如保存模型的 checkpoint)。
下一篇,我们来看看并行文件系统 Parallel File System 是如何解决这个问题的。