03 JuiceFS 数据存储——分块、压缩与缓存

摘要

JuiceFS 的数据存储性能核心来自三个机制:数据分块(Chunk/Block 模型) 将文件切割为固定大小的对象写入对象存储、透明压缩 在上传前自动压缩数据块降低存储成本、多级本地缓存 通过内存页缓存和磁盘 Block Cache 降低对象存储访问延迟。本文深入剖析这三个机制的实现原理,重点讲解写缓冲(Write Buffer)如何将小 IO 合并为大对象上传、预读(Prefetch)如何提升顺序读性能,以及缓存设计对 AI 训练和大数据计算场景的关键意义。


第 1 章 数据写入路径——从 write() 到对象存储

1.1 写缓冲(Write Buffer)

当应用通过 POSIX write() 系统调用写入文件时,JuiceFS 客户端不会立即将每个 write 操作转化为对象存储的 PUT 请求。原因在于:

对象存储的 PUT 操作有固定开销(TCP 建连、HTTPS 握手、请求头、响应等),每次 PUT 的延迟通常在 10-100ms 之间。如果每次 write() 都直接触发 PUT,对于小块频繁写入(如追加写日志文件,每次写 4KB),性能会极差(每秒只能写几十到几百次)。

JuiceFS 的解决方案是写缓冲:将写入数据先积累在内存缓冲中,当满足以下条件之一时再上传到对象存储:

  1. 缓冲中的数据达到 Block 大小(默认 4MB)
  2. 文件被 close()fsync()(数据必须持久化)
  3. 缓冲中积累了超过 upload-delay(默认 0,即每个 Block 满立即上传)时间的数据
应用 write(fd, buf, 4096)  → 写入内存写缓冲(Page Cache + JuiceFS write buffer)
应用 write(fd, buf, 4096)  → 继续积累
... (重复 1024 次,积累约 4MB)
触发条件:缓冲满 4MB
    → 压缩(可选)
    → 上传到对象存储:PUT s3://bucket/jfs-vol/123456/0_4194304
    → 更新元数据:在 Redis 中记录此 Block 的 inode/chunk/slice 映射

写缓冲的配置参数:

# 挂载时配置写缓冲大小(默认 300MB)
juicefs mount redis://... /mnt/jfs \
    --buffer-size 1024  # 单位 MB,增大可提升并发写入吞吐
 
# 对于批量数据写入场景,可以增大 buffer-size
# 对于内存紧张的场景,应减小 buffer-size

1.2 数据上传的并发控制

JuiceFS 支持并发上传多个 Block,充分利用对象存储的多连接吞吐能力:

juicefs mount redis://... /mnt/jfs \
    --max-uploads 20    # 最多同时上传 20 个 Block(默认 20)
    --upload-delay 0    # Block 满立即上传(0 表示不等待)

对于高带宽网络和对象存储(如 100Gbps 网络 + AWS S3),增大 --max-uploads 可以线性提升写入吞吐。

1.3 写失败的处理

当 Block 上传到对象存储失败时(网络中断、对象存储限流等),JuiceFS 客户端会自动重试(默认最多 10 次,指数退避),并将尚未成功上传的 Block 缓存在本地磁盘的临时目录中(--cache-dir),防止数据丢失。只有所有 Block 成功上传后,相关元数据才会更新到元数据引擎,保证数据的一致性。


第 2 章 数据读取路径——多级缓存架构

2.1 为什么对象存储的读延迟是关键瓶颈

对象存储的首字节延迟(Time To First Byte, TTFB)通常在 50-200ms 之间(取决于地理位置和网络情况)。对于顺序读大文件(如读取 10GB 的训练数据集),延迟不是瓶颈——一旦建立连接,带宽可以达到 GB/s 级别。但对于随机小 IO(如随机读取大量小图片文件),每次读取都需要建立新的对象存储连接,TTFB 累加导致整体吞吐极低。

JuiceFS 通过多级缓存(Page Cache → Block Cache → 对象存储)将热点数据缓存在本地,大幅降低热点数据的访问延迟。

2.2 Linux Page Cache——第一级缓存

Linux 内核的 Page Cache(页面缓存)是 JuiceFS 读取路径的第一道防线。通过 FUSE 读取的文件数据自动被内核缓存在 Page Cache 中,下次访问相同数据直接从内存返回(延迟 < 1μs),无需任何 JuiceFS 代码参与。

Page Cache 的限制:受系统可用内存限制(通常是机器总内存的 60-80%),内存压力时数据被驱逐;机器重启后 Page Cache 全部清空。

2.3 Block Cache——第二级磁盘缓存

Block Cache 是 JuiceFS 在本地磁盘上维护的持久化缓存,将频繁访问的对象存储 Block(4MB)缓存在本地 SSD 上:

juicefs mount redis://... /mnt/jfs \
    --cache-dir /data/jfs-cache   # 缓存目录(建议使用 NVMe SSD)
    --cache-size 102400           # 缓存大小(单位 MB,此处为 100GB)
    --free-space-ratio 0.1        # 保留 10% 磁盘空间,防止缓存撑满磁盘

Block Cache 的工作机制:

  • 当读取某个 Block 时,若 Block Cache 未命中,从对象存储下载后同时写入 Block Cache
  • 下次读取相同 Block 时,直接从本地 SSD 读取(延迟 0.1-1ms,远小于对象存储的 50-200ms)
  • Block Cache 满时,按 LRU 策略驱逐最久未访问的 Block

Block Cache 在 AI 训练场景中价值极大:训练数据集通常需要多轮(Epoch)访问。第一轮训练时从对象存储下载数据(慢),从第二轮开始命中 Block Cache(接近本地磁盘速度),使多轮训练的平均吞吐接近本地 SSD 速度。

2.4 预读(Prefetch)——提升顺序读性能

顺序读取大文件时(如读取一个 100GB 的 Parquet 文件),JuiceFS 会提前预读后续 Block,与当前 IO 并行下载,隐藏对象存储的 TTFB 延迟:

juicefs mount redis://... /mnt/jfs \
    --prefetch 3    # 预读 3 个 Block(默认 3,即同时预取 3×4MB=12MB)
    --readahead 256 # 内核层面的预读大小(MB)

预读的效果:对于顺序读取 10GB 文件,无预读时每个 Block 需要等待对象存储 TTFB(50ms)后才开始传输;有预读时,下载当前 Block 的同时已经开始下载后续 Block,整体吞吐接近带宽上限。


第 3 章 透明压缩——降低存储成本

3.1 压缩的时机和位置

JuiceFS 支持在数据上传到对象存储之前在客户端进行压缩,压缩后的 Block 写入对象存储,读取时客户端自动解压。

# 创建 Volume 时指定压缩算法
juicefs format \
    --storage s3 \
    --bucket https://my-bucket.s3.amazonaws.com \
    --compress lz4    # 可选:lz4(快速)、zstd(高压缩率)、none(禁用)
    redis://redis-host:6379/1 \
    my-volume

LZ4:压缩/解压速度极快(GB/s 级别),压缩率中等(2-5x 典型),适合吞吐敏感、数据可压缩性中等的场景(日志、JSON、CSV)。

Zstandard(ZSTD):压缩率更高(5-20x 典型),速度稍慢(百 MB/s 级别),适合存储成本敏感、CPU 资源充裕的场景(冷数据归档)。

不建议压缩的数据类型:

  • 已压缩格式:JPEG、PNG、MP4、Parquet(内部已压缩)、ORC
  • 二进制模型文件(PyTorch .pt、ONNX 模型):随机性高,压缩效果差

3.2 压缩对性能的影响

压缩的引入在 CPU 和 IO 之间做了权衡:

  • 写入:数据先在内存中压缩(消耗 CPU),然后上传压缩后的数据(减少网络带宽和对象存储 PUT 费用)
  • 读取:从对象存储下载压缩数据(减少带宽),然后在内存中解压(消耗 CPU)

对于 CPU 充裕、网络带宽受限的场景(如跨 AZ 访问对象存储),启用压缩可以显著提升吞吐并降低费用。


第 4 章 缓存预热与数据预加载

4.1 warmup 命令——提前加载 Block Cache

对于已知的训练数据集或频繁访问的目录,可以提前预热 Block Cache,避免第一次训练时的冷启动延迟:

# 预热整个目录(将所有文件的 Block 下载到本地缓存)
juicefs warmup /mnt/jfs/train_data/ --threads 20
 
# 预热特定文件列表
find /mnt/jfs/train_data -name "*.parquet" | \
    juicefs warmup --file -  # 从 stdin 读取文件列表
 
# 检查缓存命中率(挂载后的统计)
cat /proc/$(pgrep juicefs)/net/dev  # 查看网络 IO 是否减少

4.2 本地缓存的并发读取优化

Block Cache 支持多个读取线程并发访问同一缓存文件,内部通过文件锁和 mmap 实现高并发读取。对于 AI 训练中数据加载器(DataLoader)的多进程并发读取场景,Block Cache 能够显著降低 GPU 等待数据的时间。


第 5 章 小结

JuiceFS 的数据存储性能优化围绕一个核心矛盾展开:对象存储的高延迟 vs 应用对低延迟文件 IO 的需求

三个机制共同解决这个矛盾:

  1. 写缓冲:将小 write 合并为 4MB Block 批量上传,将对象存储的 PUT 开销分摊到大批量数据上
  2. 多级缓存(Page Cache + Block Cache):热点数据服务于本地内存/SSD,消除对象存储延迟
  3. 预读:顺序读取时并行预取后续 Block,隐藏 TTFB 延迟,接近带宽上限的顺序读吞吐

这三个机制使 JuiceFS 在大文件顺序读写(AI 训练、大数据处理)场景下,能够达到接近本地 SSD 的性能(热数据命中 Block Cache 时),同时保持对象存储的低成本和无限扩展能力。


延伸阅读


思考题

  1. JuiceFS 将文件分为 64MB 的 Chunk,每个 Chunk 进一步分为多个 Block(默认 4MB)上传到对象存储。小文件(<4MB)作为一个 Block 存储。在海量小文件场景(如日志文件、代码仓库)中,每个小文件产生一个对象存储请求——对象存储的请求费用和延迟如何影响成本和性能?JuiceFS 的 compact 机制如何优化小文件?
  2. 对象存储的写入是’最终一致’的(如 S3 在 2020 年前对覆盖写不保证 read-after-write 一致性)。JuiceFS 如何在最终一致的对象存储上实现强一致的文件系统语义?元数据引擎在这里扮演什么角色?
  3. JuiceFS 支持数据加密(客户端加密,密钥由用户管理)和压缩(LZ4/Zstandard)。加密和压缩在写入时执行,读取时解密解压。在 CPU 受限的边缘环境中,加密和压缩的 CPU 开销是否会成为瓶颈?你如何评估是否开启这些特性?