Skip to main content

分布式缓存

JuiceFS 客户端的缓存默认是单机独享的,但当大量客户端需要反复访问同一个数据集时,可以开启「分布式缓存」功能,让大量客户端共享同一批缓存数据,有效提升性能。分布式缓存非常适合使用 GPU 集群进行深度学习模型训练的场景,通过把训练数据集缓存到集群所有节点的内存或 SSD 中,让数据访问能力不再成为 GPU 训练的瓶颈。

架构

启用了分布式缓存后,多个客户端会构成一个「分布式缓存组」,每一个缓存组实际上都是一个一致性哈希环,缓存块则根据一致性哈希算法分布在组内各个成员节点,并在组内共享。缓存组内的每个成员,其实都是正常挂载的客户端,它们通过 --cache-group 设置组名。这样一来,具有相同组名的成员就构成了一个缓存组,成员节点间的通信也只会发生在组内:任何一个成员在读取文件时,会根据目前缓存组拓扑结构计算出当前数据块所在的节点,并向该节点发起请求,如果数据尚未存在于该节点,也会由该节点从对象存储下载数据并存储在缓存盘中。

分布式缓存架构

以上图为例,节点 D 读取文件 a(由 4 个数据块组成,a1-a4)时,假设根据一致性哈希算法,得出 a1-a4 分别坐落于节点 A-D,因此每个成员节点都会访问对象存储,下载所需的数据块。下载完毕后,节点 A-C 再通过内网通信,将数据块返回给节点 D,完成文件读取。

网络环境和数据安全

目前而言,JuiceFS 分布式缓存服务假定部署在高性能、安全的内网环境。如果需要在公网部署分布式缓存服务,需要重点考虑的事项有:

  • 公网的网络质量通常远逊于对象存储服务,需要提前对网络质量进行验证。如果延迟和吞吐太差,则考虑放弃使用缓存组、直接从对象存储下载;
  • 缓存组的监听服务本身不设有身份认证,直接对公网服务存在数据安全风险,需要妥善管理访问策略。

服务发现

分布式缓存会以元数据服务作为服务发现:客户端会把自身监听的 IP 和端口汇报给元数据服务,也会从元数据服务获取同一个缓存组内的其他成员的连接方式,进而发现其它客户端,并通过节点间通信来共享缓存。

缓存组成员读写数据时会通过节点间通信来完成,在这个过程中如果面临任何通信失败,则会在此次请求中放弃使用缓存组,改为本地执行。仍旧以上方架构图来举例说明,D 读取文件时,如果与 A 节点通信失败(比如请求超时),无法获取数据块 a1,那么 D 将会改为直接从对象存储下载 a1,来完成此次读请求。

如果和某个缓存节点通信多次仍然失败,则会认为这个节点无法连接,将它从缓存组中移除。

信息

面对缓存组异常时,JuiceFS 客户端会优先保证自身的正常服务,这也意味着,如果客户端与缓存节点的通信出现问题,则可能出现大量请求穿透到对象存储、影响性能。因此为了保证缓存组正常运行,需要关注:

扩容和数据均衡

缓存组内如果发生了成员节点增删,数据会向哈希环的临近节点做迁移(为了防止波动,实际会等待成员变更后约 10 分钟,方执行迁移操作)。可见缓存组的成员变更,只影响到部分数据块的缓存命中率。在缓存组一致性哈希环的实现中,也采用了虚拟节点(virtual node)的概念,确保数据分布均衡,避免因数据迁移产生访问热点,影响缓存组性能。关于一致性哈希、虚拟节点的架构,可以阅读这篇文章了解更多。

启用分布式缓存

分布式缓存组就是由一个个 JuiceFS 客户端组成的,在内网互通的多个节点上像这样运行挂载命令,就能组建分布式缓存:

# 将 $VOL_NAME 替换为文件系统名,--cache-group 是用户自定义的缓存组名称
juicefs mount $VOL_NAME /jfs --cache-group=mygroup

集群网络往往很复杂,每个节点都绑定了多块性能不同的网卡,为了让缓存组达到最高性能,推荐将性能最好的网卡分配给缓存组,用于节点间通信:

# --group-ip 就是缓存组绑定的网卡,IP 不需要写完整,指定前缀即可,实现绑定网段的效果
# 比方说该节点对应的网卡 IP 是 10.6.6.1,对应的 CIDR 是 10.6.0.0/16
# 那么填写 --group-ip=10.6.0.0 就能智能选中该网卡
juicefs mount $VOL_NAME /jfs --cache-group=mygroup --group-ip=10.6.0.0

挂载完毕以后,可以查看客户端日志,或者直接查看 TCP 连接,来确认缓存组已经启用:

# 在日志中查看「peer」字样,就能看到缓存组相关日志
# 例如 <INFO>: Peer listen at 172.16.255.181:36985 [peer.go:790]
grep peer /var/log/juicefs.log

# 直接打印客户端发起的 TCP 连接,和正在监听的分布式缓存端口,确认缓存组已经在工作
ss -4atnp | grep jfsmount

缓存组搭建完毕,但组内成员节点还没有缓存任何数据,为了提升应用首次访问的速度,常常会事先运行预热命令,将所有需要用到的数据提前缓存下来:

# 在任意缓存组成员节点运行预热命令,效果相同
# 如果规模庞大,使用 -c 增加并发度,加速预热
juicefs warmup /jfs/train-data -c 80

特别地,还可以通过 --fill-group-cache(默认关闭),让客户端在写入数据的同时,将上传到对象存储的数据块同时发给缓存组,让写操作也能参与缓存集群的建设。需要特别注意,发送数据块给缓存组时有可能失败,因此不保证一定能被缓存。

构建分布式缓存,对节点规格有如下要求(同样适用于下方「独立缓存集群」一节):

  • 推荐用同构机型(至少缓存盘大小相同)来构建缓存组,JuiceFS 缓存组目前的一致性哈希算法并不会考虑到不同节点的可用空间不一致,从而调整各个节点的权重,因此如果缓存组节点的磁盘大小不一致,将会在均匀分配的算法下产生空间浪费。
  • 缓存组内节点至少以万兆网络相互连接,如果节点安装了多块网卡,需要使用 --group-ip 指定大带宽的网卡,确保组内成员通信顺畅。
  • 缓存组内节点需要保证能高速访问对象存储服务,如果下载速度太慢,在缓存预热和实际使用时容易发生下载超时,影响使用体验。

独立缓存集群

在分布式缓存方案中,每个客户端均参与缓存组的建立。但如果遇到客户端不是常驻的情况,比如 Kubernetes 集群中,客户端不断被销毁、重建,可能导致缓存利用率很低。对于此类动态创建伸缩的计算集群,可以将文件系统挂载在固定机器(或容器)上,组成「独立缓存集群」来给对象存储加速。

独立缓存集群架构

应用侧的客户端会加入缓存组、从缓存组获取数据,但这些客户端会启用 --no-sharing 挂载参数,它的意思也很直白:加入缓存组,但却不分享自己的缓存数据,只会向缓存集群索取数据,避免转瞬即逝的应用侧客户端频繁变动,影响缓存集群的服务能力。

独立缓存集群本质上就是一个分布式缓存组,只不过缓存的提供方和使用方是不同的 JuiceFS 客户端。因此在该场景下,我们常用「缓存集群」来指代这个常驻、稳定的缓存组,用「客户端」来指代应用侧的 JuiceFS 客户端——即使本质上他们都是 JuiceFS 客户端,只是使用了不同的挂载参数。

建设独立缓存集群,首先要搭建一个缓存组,在 Kubernetes 集群中常常用 DaemonSet 或 StatefulSet 来部署缓存集群(详见我们的示范),此处简单起见,直接以上一小节里已经搭建好的缓存组作为独立集群进行示范,那么客户端可以使用下方命令来连接这个缓存集群:

# 应用侧客户端添加 --no-sharing 参数,虽然加入缓存组,但不向其他成员提供分布式缓存服务
juicefs mount $VOL_NAME /jfs --cache-group=mygroup --no-sharing

# 挂载完毕以后,如果有需要,也可以直接在 --no-sharing 挂载点上执行预热,同样能将数据预热到整个缓存组
juicefs warmup /jfs/train-data -c 80

挂载完毕后,这些客户端就会以「只索取,不分享」的方式来使用缓存集群的数据了,注意即便对于这些客户端,多级缓存的设计依然生效:从缓存组获取到的数据,依然会缓存到本地。如果你希望客户端完全不保留任何本地缓存,所有文件访问都通过缓存集群来完成,那么可以通过 --cache-size=0 选项禁用客户端的本地缓存:

# 应用侧客户端禁用本地缓存,让所有读请求都走缓存集群
juicefs mount $VOL_NAME /jfs --cache-group=mygroup --no-sharing --cache-size=0

禁用客户端本地缓存,能够最大程度利用独立缓存集群的缓存盘,节约客户端节点磁盘空间。如果客户端的磁盘吞吐甚至比不上内网带宽,也可以使用这个方法获得更好的性能。

启用了 --no-sharing 的客户端,还有以下注意事项:

  • 客户端节点如果没有命中缓存,会由独立缓存集群负责下载并缓存数据,再提供给客户端。除非节点间通信出现问题导致超时失败,否则客户端自身不会直接从对象存储下载文件。
  • 多个客户端同时访问同一个未缓存的文件,也只会发生一次缓存穿透,对应的缓存组成员会下载好并返回给客户端,不会造成多次请求对象存储。

多级缓存架构

先前在单机缓存介绍过的多级缓存架构,在分布式缓存下同样生效,也就是单节点自身的多级缓存之上,还有来自缓存组节点的多级缓存:

独立缓存集群多级缓存架构

需要注意的是,分布式缓存下的多级缓存,并非单机客户端的多级缓存的简单叠加:客户端通过缓存组读取数据时,由于缓存组一侧并未通过 FUSE 读取数据,而是读取磁盘缓存数据、直接发送给对端成员,因此会在单机文件系统层面建立内核页缓存。而客户端一侧是通过 FUSE 读取数据,因此会通过 FUSE 建立内核页缓存。

如果客户端侧设置了 --cache-size=0,希望最大程度利用缓存集群空间、节约本地空间,这时 JuiceFS 客户端内存会预留 100MiB 的空间作为缓存空间,加上在进程内存之上同样会建立内核页缓存,反复读取的文件一样能获得极致性能。

提示

从 5.0 开始,JuiceFS 客户端删除文件后,如果他加入了缓存组(即便是 --no-sharing),那么其所在的缓存组,也会主动清理本地缓存。

混合部署缓存组

上一小节提到禁用本地缓存的实践,适用于客户端本地盘性能较差的场景,如果情况相反,希望最大程度利用客户端本地缓存盘的 I/O 能力,减少节点间的网络通信,可以考虑在所有节点混合部署两个 JuiceFS 客户端:一个是缓存集群挂载点,另一个则是业务侧实际使用的挂载点。

混和部署缓存组架构

这样的混合部署策略的好处是:

  • 高性能缓存盘同时用作分布式缓存和本地缓存,更充分利用本地高性能 I/O;
  • 所有节点都加入了同一个缓存组,读任何文件,都只需要通过缓存组请求一次。这样一来,对象存储请求被合并到了缓存集群,相比没有分布式缓存的孤立部署方案,大大减少了对象存储请求量。

混合部署需要在同一批节点上分别挂载缓存集群,以及 --no-sharing 客户端。假设缓存盘大小共 1TB(即 1000000MB),挂载命令示范如下:

# 缓存集群挂载点
# --cache-size=500000 表示最多使用 0.5TB 缓存盘空间
# --free-space-ratio=0.1 表示最多使用 SSD 盘 90% 的空间
juicefs mount $VOL_NAME /distributed-cache --cache-group=mygroup --cache-dir=/data/distributed-cache --cache-size=500000 --free-space-ratio=0.1

# 应用挂载点
# --cache-size=1000000 表示最多可以用满 1TB 缓存盘空间
# --free-space-ratio 0.01 表示最多使用 SSD 盘 99% 的空间
juicefs mount $VOL_NAME /jfs --cache-group=mygroup --cache-dir=/data/local-cache --cache-size=1000000 --free-space-ratio=0.01

虽然二者共用同一块缓存盘,但分别管理自己的缓存数据,因此缓存目录(--cache-dir)并不相同,不能混用(否则会产生冲突)。可想而知,这样的规划会产生冗余,随着文件读取,数据会被分别缓存在缓存集群,以及客户端本地,这样的多级冗余也是提升性能的必要开销。不过上方的命令中已经对参数进行了调优,让整体架构更偏好本地 I/O:本地客户端挂载点设置更小的 --free-space-ratio 和更大的 --cache-size,这样就能保证当缓存盘写满时,能够先释放分布式缓存数据。

混合部署方案可以根据集群规格和场景需要灵活调整,比方说节点有大量富余内存,也可以直接使用内存作为缓存盘,获得更快 I/O:

# 缓存集群挂载点
# 根据富余情况调整 --cache-size,避免占用过多内存
juicefs mount $VOL_NAME /distributed-cache --cache-size=4096 --cache-dir=/dev/shm --cache-group=mygroup

# 应用挂载点
# 为了最大化本地缓存盘的利用率,将 --free-space-ratio 调小
juicefs mount $VOL_NAME /jfs --cache-size=102400 --cache-dir=/data --cache-group=video-render --no-sharing --free-space-ratio=0.01

运维

成员节点变更

本小节以重启节点为例,介绍缓存组成员节点变更操作时,应该如何评估对缓存组的影响。节点重启操作一般不会超过 10 分钟,因此重启并不会引发数据迁移(参考架构)。但由于节点下线期间,对应的缓存数据无法访问,因此重启一定会引发缓存命中率波动,为了对影响进行量化,需要根据控制台中文件系统「监控」页面的数据进行预估。

假设需要重启的成员是节点 A,那么打开文件系统的监控页,首先关注缓存组的整体用量,可以直接在「分布式缓存」板块看到:

分布式缓存整体监控指标

从上图中的「Number of Distributed Cache Requests」可以看出,缓存组当前整体请求量约为每分钟 4300 次。

接下来查看节点 A 在缓存组中的请求负载,在页面右上角的「所有客户端」下拉框中过滤出节点 A,然后查看节点 A 的本地缓存访问情况:

分布式缓存成员节点监控指标

从上图中的「Average Size of Cached Blocks」可以看出,节点 A 有共计 13TiB 左右的缓存数据(由于已经过滤出了单个节点,这个数值就是该节点的缓存数据量),当前请求量(Cache Hits)约为每分钟 130 次,因此下线该节点后,受影响的请求占比为 130 / 4300 = 3%。也就意味着,在节点 A 重启期间,会有 3% 的读请求穿透到对象存储上,由一致性哈希环上的临近节点代为请求。节点 A 重启以后重新加入缓存组,由于操作时间不满 10 分钟,缓存组的拓扑结构没有任何变化,因此不会发生数据迁移(少量数据会重新在临近节点建立缓存)。

对于缓存组而言,重启节点可以视为「退出缓存组,然后迅速重新加入」。如果你需要从缓存组中永久驱逐某个节点,那么评估方法也是类似的,直接用上述流程计算该节点的请求量占比、本地缓存数据量,就能得出删除节点对集群的影响。

如果缓存组总容量不足(阅读下方「观测与监控」小节了解如何判断当前用量、判断是否需要扩容),决定增加节点,那么当新节点加入集群满 10 分钟以后,集群会开始重新均衡缓存数据,来最大化利用空闲缓存空间和 I/O 能力。在迁移过程中,客户端如果访问受影响的数据,同样会发生穿透。

假设缓存集群总数据大小为 500TiB,共计 40 个缓存节点,需要扩容到 50 个节点,根据不同的节点网络带宽,数据均衡所需的时间如下:

提示

「理论数据均衡时间」在计算时假设能够利用到全部网络带宽,这在真实场景下是不可能的,因此计算结果仅供参考。

缓存集群数据总量当前节点数扩容后节点数网络带宽理论数据均衡时间
500TiB405010Gbps2h26m
500TiB405025Gbps58m

具体计算方法:

  1. 扩容前每个缓存节点的数据量约为 500 / 40 = 12.5TiB,扩容并均衡数据后的数据量约为 500 / 50 = 10TiB,也就是说每个节点需要迁移 12.5 - 10 = 2.5TiB 数据,总共需要迁移 40 * 2.5 = 100TiB 数据。
  2. 由于存在虚拟节点,虽然只新增了 10 个物理节点,但实际上在一致性哈希环中会新增成百上千个「虚拟节点」。原集群的 40 个节点会将这 100TiB 数据均匀地传输给新增的这 10 个节点。
  3. 假设节点间网络带宽为 10Gbps,这个节点总共需要接收 10TiB 的数据,那么传输时间为 10TiB / 10Gbps = 2h26m

观测与监控

我们的 Grafana Dashboard 里已经包含了分布式缓存的相关监控面板:

  • Remote Cache Requests(分布式缓存请求量)
  • Remote Cache Throughput(分布式缓存吞吐)
  • Remote Cache Latency(分布式缓存请求时延)

缓存组的命中率无法直接在 dashboard 上查看,但你可以通过缓存组成员节点的「单机缓存指标」来对其进行观测。归根结底,缓存组都是一个个普通的 JuiceFS 客户端构成的,如果希望查看分布式缓存的整体命中情况,只需要关注成员节点的单机缓存指标,也就是 dashboard 中「Block Cache」相关监控面板。

分布式缓存架构中,由于数据块分布在组内成员节点中,因此单机的缓存命中率可能会偏低,但如果将所有缓存组成员一起计算命中率,预期就会很高了(假设已经提前预热)。具体而言,计算节点的本地缓存如果不命中,会从缓存组获取数据,这种情况会在计算节点记为缓存不命中,而在缓存组成员节点记为命中。如果要计算整个缓存组的命中率,则需要在整个集群层面计算 hits / (hits + object_get),其中 hits 代表缓存组成员节点的本地缓存命中,object_get 代表穿透到对象存储的请求数。

列举一些常见的缓存集群监控操作:

  • 可以检查成员节点的「Block Cache Hit Ratio」以及「Object Requests」的指标来判断缓存组是否发生穿透,如果存在大量穿透,检查「Block Cache Size」来判断当前已用缓存空间,以及是否需要扩容。
  • 缓存集群空间不足时会发生块换出(eviction),可以通过 juicefs_blockcache_evict 等相关指标来监控。详细的监控指标列表见监控项说明
  • 检查缓存组服务端的请求延迟,也就是「Remote Cache Latency (server)」,如果存在部分节点延迟异常高,则说明缓存组可能存在「害群之马」,可能需要检查问题节点的挂载方式,加上 --no-sharing 参数来阻止低性能节点参与缓存组服务。

问题排查

如果在使用分布式缓存时发现性能不佳,比如大量请求穿透到对象存储,那么在开始排查前,我们推荐:

  • 如果方便的话,为所有缓存组成员节点启用 DEBUG 日志,同时注意日志中的 peer 字样——缓存组成员变动时,日志里往往含有这个单词。
  • 在监控页面或 Grafana 查看「Remote cache」相关监控面板。

为什么预热后仍然会访问对象存储?

warmup 命令会将文件用到的所有数据块载入缓存(单机缓存或者分布式缓存),有以下几种情况可能导致之后的访问缓存不命中:

  1. 文件或者数据本身发生变化,有新数据写入,或者存在碎片合并操作产生了新的数据块。
  2. 因为缓存空间不够导致已经缓存的数据块被换出。如果缓存空间已满,达到设定的数据量(--cache-size 指定)或者缓存盘的剩余空间达到设定的阈值(--free-space-ratio),已有缓存数据会被 2-random 算法随机换出,可能包括当前预热命令刚刚载入的数据块。可以通过查看监控面板中缓存集群的缓存淘汰相关指标(「Block Cache Eviction Rate」)来确认。

cache eviction monitor

  1. 预热命令在访问部分数据块时失败了,可以在执行预热命令的客户端日志文件中看到相应的失败日志。可以通过参数 --max-failure 来指定最大允许的失败次数,当有更多数据块预热失败时,它会以非 0 退出码结束。

juicefs warmup 命令作用于当前挂载点上,因此对于该挂载点,增加 --verbose 参数,开启打印 DEBUG 日志,阅读报错日志。比方说:

<ERROR>: xxx could be corrupted: chunk x:x:xxx is not available: read header: EOF

这代表着缓存组成员在下载对象存储块时发生了失败,可以继续查看缓存组成员的客户端日志来进一步明确失败原因。

缓存组成员节点,是否有多块网卡?绑定网卡是否正确?

如果缓存集群成员节点有多块网卡,所属的网络不互通,或者带宽受限,不适合缓存集群使用,这时需要确定合适的网卡,你可以用类似下方的命令,来确认网络互通:

# 进入缓存集群成员节点或容器
# 对于缓存组成员,JuiceFS 客户端会随机监听本地端口,用于组内成员通信
# 默认 pprof 会监听 6060 与 6070,分布式缓存服务的监听端口通常是最大的那一个
lsof -PiTCP -sTCP:LISTEN | grep jfs

# 进入客户端节点或容器,尝试连接缓存集群端口,确认网络连通性
telnet [member-ip] [port]

如果排查发现确实需要更换缓存集群的监听网卡,那么需要在挂载命令用 --group-ip 指定对应的网卡 IP。注意,--group-ip 还可以指定 CIDR 前缀,比方说需要监听的网卡 IP 段为 172.16.0.0/16,那么可以直接指定 --group-ip=172.16.0.0,这样一来,缓存集群节点就可以使用统一的挂载参数,方便管理。

缓存组成员内网带宽是否受限?

组建 JuiceFS 分布式缓存的节点建议至少以万兆网互联,如果在低带宽的网络下构建缓存组,比方说千兆网络,则可能发生如下报错:

# 内网带宽太差,或者网络不互通
<INFO>: remove peer 10.8.88.242:40010 after 31 failure in a row [peer.go:532]

# 如果内网互通,但只是网络质量太差,成员被移除出缓存组后,还可能迅速被加回缓存组
<INFO>: add peer 10.8.88.242:40010 back after 829.247µs [peer.go:538]

# 从组内成员获取数据块失败
<WARNING>: failed to get chunks/6C/4/4020588_14_4194304 for 10.6.6.241:38282: timeout after 1m0s [peer.go:667]
<ERROR>: /fio_test/read4M-seq.2.0 could be corrupted: chunk 1:0:4020660 is not available: read header: read tcp 10.8.88.241:34526->10.8.88.242:40010: i/o timeout [fill.go:235]

# 下载数据块太慢
<INFO>: slow request: GET chunks/6E/4/4020590_0_4194304 (%!s(<nil>), 105.068s)

确认组内成员网络互通,并且使用万兆网络。如不确定网络质量,可以使用 iperf3 等工具进行测速。

缓存组是否存在「害群之马」?

缓存组能够高性能运作的前提是:各个节点的网络性能、可用磁盘空间相等或接近。如果某一个成员性能或者网络状况很差,则会拖累整个缓存组:

cache group black sheep

如图所示,假设缓存数据均匀分布在三节点的缓存集群上,但有一个节点网络极慢(可能是 --group-ip 不恰当,导致绑定了错误的网卡,也可能是将一台其他网络环境的节点错误地加入了缓存组),那么可想而知,1/3 的请求将会由慢节点来服务,缓存组的性能会系统性下降。

在更大规模缓存集群中,害群之马问题对整体的性能的影响可能会十分难以排查,比方说 200 节点的缓存组中有一个低性能节点,那么总体受影响的请求占比会非常低,容易被忽视。此时需要检查「Remote Cache Latency (server)」,如果存在部分节点延迟明显高于其他节点,那么他们可能正是缓存组性能问题的罪魁祸首:

cache group black sheep monitor

如上图所示,缓存组中有一个成员延迟特别高,就有可能恶化所有客户端的请求延迟。在「Remote Cache Latency (server)」也就是缓存组服务端的监控面板中,对延迟进行排序,如果「Send」一列存在某一个明显异常高的节点,那么需要检查问题节点的挂载方式,加上 --no-sharing 参数来阻止低性能节点参与缓存组服务。

缓存组能否高速访问对象存储服务?

如果缓存组成员无法快速从对象存储上下载数据,现象与报错类似于上一小节中内网带宽受限的情况。可以降低下载并发度来尝试获得更平稳的预热体验,比如 juicefs warmup --concurrent=1

客户端负载是否过高?

客户端负载过高,在特定情况下也能引起分布式缓存命中率低。比方说客户端启用了 --fill-group-cache 选项,将数据写入文件系统的同时,还会将数据贡献给分布式缓存集群。如果这些参与写入的客户端负载过高,虽然仍能够正常写入 JuiceFS 文件系统,但分布式缓存的写入则可能失败(缓存写入不会进行重试),而一旦失败,读取这些文件就会产生穿透到对象存储的请求,造成缓存命中率低。