分布式缓存
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 客户端会优先保证自身的正常服务,这也意味着,如果客户端与缓存节点的通信出现问题,则可能出现大量请求穿透到对象存储、影响性能。因此为了保证缓存组正常运行,需要关注:
- 客户端日志,所有的缓存组访问失败都会记录在这里,如果日志量太大,可以搜索「peer」一词,过滤出缓存组相关日志。
- 分布式缓存监控,尤其是
juicefs_remotecache_errors
指标,它包含缓存组通信链路的所有错误。
扩容和数据均衡
缓存组内如果发生了成员节点增删,数据会向哈希环的临近节点做迁移(为了防止波动,实际会等待成员变更后约 10 分钟,方执行迁移操作)。可见缓存组的成员变更,只影响到部分数据块的缓存命中率。在缓存组一致性哈希环的实现中,也采用了虚拟节点(virtual node)的概念,确保数据分布均衡,避免因数据迁移产生访问热点,影响缓存组性能。关于一致性哈希、虚拟节点的架构,可以阅读这篇文章了解更多。
缓存副本
JuiceFS 的分布式缓存功能默认只将数据缓存一份,并不为其创建副本。在缓存组节点不发生频繁变更、内网通信稳定的前提下,这样便是最高效的安排。
但是在某些特殊场景下,可以为 JuiceFS 客户端设置缓存副本功能,来提升缓存服务的稳定性。因此从 JuiceFS 5.1 开始,客户端挂载时可以指定 --group-backup
参数来启用缓存副本,他的原理如下:
启用了 --group-backup
的缓存组客户端,在面临缓存请求未命中时,会根据缓存块键值(cache key)计算出该数据块对应的“缓存备份节点”,然后将请求转发给该成员节点,由他来代处理该请求。“缓存备份节点”则会根据本地是否已经缓存了该数据,来决定是直接返回数据,还是穿透到对象存储请求。相比没有启用副本的缓存组架构,缓存块途径了 2 个缓存组成员节点才最终到达客户端,因此理想情况下,缓存块也会坐落在在两个成员节点上。
在不过多考虑特殊情况的前提下,缓存副本的架构图如下:
在上图中:
- 客户端欲请求数据块
b1
,根据缓存组的一致性哈希算出数据块应位于节点 A。 - 节点 A 尝试服务该请求但本地尚未缓存
b1
,因此再次根据b1
的键值计算出其备份节点为 B,并转发请求给 B。 - 节点 B 收到请求后也是一样未命中,因此一路穿透到对象存储,才最终获得该数据。
如果在「客户端→A→B→对象存储」的任何一个中间环节发现缓存数据已经存在,则请求会提前返回,不会再穿透到下一个环节。也正因此,我们的「缓存副本」功能并不严格保证每一个数据块都建立 2 份本地缓存,如果发生缓存空间不足、数据被淘汰,或者提前命中,都有可能导致缓存数量不达两副本的预期。
缓存副本功能的目标场景是:
- 缓存成员节点频繁变动,希望每次变动的时候尽可能保证命中率稳定;
- 缓存组需要扩容,希望扩容的时候尽可能保证命中率稳定;
- 缓存组节点间网络质量差,波动大,频繁有节点掉线断连,希望尽可能提升命中率。
相应的,以下场景不适合开启缓存副本功能,或者至少不能持续开启:
- 缓存空间本就不足,考虑到开启副本会进一步增加空间占用,缓存服务能力只会进一步下降;
- 缓存副本功能不能用来提升吞吐,这也是用户面对该功能的常见误区,缓存组的吞吐上限和节点间通信带宽、缓存盘的能力有关,增加副本数没办法提升吞吐。另一方面,如果你担心访问热点问题,JuiceFS 原本就会将缓存数据打散分布在一致性哈希环上,来均衡访问压力,并不会因为因为副本数增加,而更加分散。
启用分布式缓存
分布式缓存组就是由一个个 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
的客户端,还有以下注意事项:
- 客户端节点如果没有命中缓存,会由独立缓存集群负责下载并缓存数据,再提供给客户端。除非节点间通信出现问题导致超时失败,否则客户端自身不会直接从对象存储下载文件。
- 多个客户端同时访问同一个未缓存的文件,也只会发生一次缓存穿透,对应的缓存组成员会下载好并返回给客户端,不会造成多次请求对象存储。
多级缓存架构
先前在单机缓存介绍过的多级缓存架构,在分布式缓存下同样生效,也就是单节点自身的多级缓存之上,还有来自缓存组节点的多级缓存: