乐趣区

关于go:40倍提升详解-JuiceFS-元数据备份恢复性能优化之路

JuiceFS 反对多种元数据存储引擎,且各引擎外部的数据管理格局各有不同。为了便于管理,JuiceFS 自 0.15.2 版本提供了 dump 命令容许将所有元数据以对立格局写入到 JSON 文件进行备份。同时,JuiceFS 也提供了 load 命令,容许将备份复原或迁徙到任意元数据存储引擎。命令的详细信息能够参考这里。根本用法:

$ juicefs dump redis://192.168.1.6:6379/1 meta.json
$ juicefs load redis://192.168.1.6:6379/2 meta.json

该性能自 0.15.2 版本公布后到当初 v1.0 RC2 经验了 3 次比拟大的优化,性能失去了几十倍的晋升 ,咱们次要在以下三个方向做了优化:

  1. 减小数据处理的的粒度:通过将大对象拆分为小对象解决,能够大幅缩小内存的占用。另外拆分还有利于做细粒度的并发解决。
  2. 缩小 io 的操作次数:应用 pipline 来批量发送申请缩小网络 io 的耗时。
  3. 剖析零碎中的耗时瓶颈:串行改为并行,进步 cpu 利用率。

这些优化思路比拟典型,对于相似网络申请比拟多的场景具备肯定的通用性,所以咱们心愿分享下咱们的具体实际,心愿能给大家肯定的启发。

元数据格式

在分享 dump load 性能之前,咱们先看下文件系统长什么样,如下图所示,文件系统是一个树形构造,顶层根目录,根目录下有子目录或者文件,子目录上面又有子目录或者文件。所以如果想要晓得文件系统外面的所有文件和文件夹,只须要遍历这颗树就行了。

理解了文件系统的特点后,咱们再看 JuiceFS 的元数据存储的特点,JuiceFS 元数据的存储次要是几张不同的 hash 表,每个 hash 表的 key 都是单个文件的 inode,而 inode 信息能够通过文件树的遍历失去。所以只须要遍历文件树拿到所有的 inode,再依据 inode 为索引就能够拿到所有的元数据了。另外为了浏览性更好,并且保留本来的文件系统的树形构造,咱们将导出的格局定为了 json。
将下面示例文件系统 dump 进去的 json 文件如下所示,其中 hardLink 为 file 的硬链接

json 内容:

Dump 优化流程

dump 如何实现?

首先从元数据的格局来看,所有的元数据都是以 inode 为局部变量的为 key, 也就是说咱们晓得了 inode 的具体值就能够通过 redis 获取到它的所有元数据信息。所以依据文件系统的特点,咱们能够构建一棵 FSTree,从根目录以深度优先遍历扫描填充这颗树,先扫描根目录(inode 为 1)下的所有 entry,顺次遍历,依据其 inode 获取其元数据信息,如果发现其是目录,就递归扫描,否则就别离申请 redis 拿其各个维度的元数据,拼装成一个 entry 的构造,作为父目录的 entry list 中的一员。当递归遍历实现后,这棵 FSTree 就曾经建设结束。咱们再加上 setting 等绝对动态的元数据作为一个对象,而后将其整个序列化为 json 字符串。最初将 json 字符串写入到文件中,整个 dump 就算实现了。

性能

咱们以蕴含 110 万文件元数据的 redis 为例进行测试,测试后果为 dump 过程耗时 7 分 47 秒,内存占用为 3.18G。(为了保障测试后果的可比性,本文的所有测试都是应用同一份元数据)

下图为执行中的内存占用变动。内存占用刚开始迟缓回升,此时是在将深度优先遍历的过程中每扫描到一个 entry 就会将其存入内存中,所以内存迟缓减少。当结构残缺个 FSTree 对象后开始进行 json 序列化,此时是 FSTree 对象大概 750M,将一个对象序列化为 json 字符串,过程大概须要 2 倍的对象大小,最初的 json 字符串大概等于一倍原始对象的大小,所以内存大概减少了 3 倍的 FSTree 对象的大小,急速攀升到 3.18G。最终内存占用峰值大概须要 4 倍的 FSTree 的大小。

下面的实现会什么问题?

依据下面的思路咱们能够看出咱们的外围是为了构建一个 FSTree 对象,因为 json 的序列化办法能够间接将一个对象序列化为 j son 格局的字符串。所以一旦咱们构建进去了 FSTree 对象,残余的事件就能够交给 json 包来做了,十分不便。可是对于一个文件系统来说,文件可能十分多,十分大,带来的是元数据十分大,而 FSTree 保留的就是整个整个零碎的 entry 的元数据信息,所以 dump 的过程占用内存就会比拟高,另外在将对象序列化为 json 字符串后,这个 json 字符串也会十分大,其实相当于 dump 过程须要至多 2 倍的元数据的大小。如果 dump 过程所在的客户端可能并没有这么大的内存能够应用,那么 dump 过程可能会被操作系统因为 OOM 杀掉。

如何优化内存占用过高?

FSTree 由 很多个 Entry 组成,十分大,咱们不能对其整个序列化,怎么办,咱们能够减小数据处理的的粒度,将大对象拆分为小对象解决,别离对组成 FSTree 的 entry 进行序列化,将失去的 json 字符串写入到 json 的文件开端。具体做法就是深度优先递归扫描 FSTree,而后如果是个 entry,就将其序列化并且写入到 json 文件内,如果是个文件夹,那么就递归进去。这样失去的 json 文件中的 FSTree 仍旧是与 FSTree 对象放弃一一对应的,entry 的树形构造与程序并没有被毁坏。这样咱们 dump 内存中就只保留了一倍元数据大小的对象——FSTree,相比最开始节俭了一半的内存,成果很显著。那剩下的这一倍内存能够省掉吗?答案是能够的,咱们回忆下 FSTree 是如何被构建的,是通过深度优先递归扫描根目录,所以 entry 是依照深度优先递归遍历的程序被创立,深度优先递归遍历的程序不也是咱们序列化 FSTree 中每个 entry 的程序吗?既然这两者程序统一,那咱们就能够在刚构建出 entry 的时候就将其序列化写入到 json 文件,这样遍历残缺个文件系统的时候,所有的 entry 也被序列化完了,也就没有必要构建保留整棵 FSTree 了,最终优化的后果就是 FSTree 对象咱们也不必构建了,每个 entry 只会被拜访一遍,序列化后就扔掉它。这样占用的内存就是更少了。

性能

通过内存优化后的测试后果为 dump 过程耗时 8 分钟,内存占用为 62M。 耗时相当,内存由 3.18G 升高到 62M,内存优化成果高达 5100%!
下图为内存变动占用状况

怎么优化 dump 耗时太长?

从下面的测试后果来看,一百万 dump 大概须要 8 分钟,如果 1 亿文件就是 13 个小时之久,可见如果数据量太大,耗时就十分长。这么长的工夫,生产上是不能被承受的。内存不够尚且能够通过钞能力解决,然而太耗时的话,钞能力也成果不大,所以根治还是要从外部程序来优化。咱们先剖析一下当初的消耗最多的环节是什么。

个别耗时分两个方面,大量的计算操作,大量的 io 操作,很显著咱们属于大量的网络 IO 操作,dump 过程每扫描到一个 entry 就须要申请其元数据信息,每次申请耗时由 RTT(Round Trip Time)+ 命令计算工夫组成,redis 基于内存操作计算工夫是十分快的,所以次要耗时是 RTT 上。N 个 entry 就是 N 个 RTT,耗时十分多。

如何缩小 RTT 的次数那?答案是应用 redis 的 pipline 技术,pipline 的基本原理就是将 N 个命令一次性发送过来,redis 计算完 N 个命令后将后果依照程序打包一次性返回给客户端,所以 N 个命令的耗时为 1 个 RTT 加 N 条命令计算工夫。从实际来看,pipline 的优化是十分可观的。顺着这个思路,咱们能够应用 pipline 将存在 redis 中的元数据全副拿到内存中存起来,相似在内存中做个 redis 的快照,代码上实现就是将其放入 map 外面,原逻辑须要申请 redis 的当初间接从 map 中拿到。这样即用了 pipline 批量拉取数据缩小了 RTT,本来的逻辑又不须要扭转太多,只须要把 redis 申请操作改为读 map 即可。

性能

通过“快照”形式优化后的 dump 性能测试后果: 耗时 35 秒,内存占用 700M,耗时从 8 分钟缩小到 35 秒,晋升高达 1270%,然而内存占用却因为咱们在内存中结构了元数据缓存而减少到了 700M,从下面的测试可知这大概是一倍的元数据大小,这也合乎预期。

低内存与低耗时是否兼得?

在内存中做 redis 的快照版本尽管速度快了很多,然而咱们相当于把 redis 的数据全副放到了内存中,这样内存占用又回到到了一倍的元数据大小。当元数据太大的时候,dump 占用内存十分高。所以针对耗时的优化是就义了内存为代价的。一倍的内存占用与耗时长对于生产都是不可承受的,所以咱们须要一个鱼和熊掌兼得的优化办法。咱们回忆之前的两次优化,针对内存占用高应用流式写入解决,针对耗时长通过应用 redis pipline 缩小 RTT 次数解决。这两个优化伎俩都是必须的,关键在于如何将两者联合起来一起应用。

咱们能够在针对优化内存占用过高做的流式写入这版上思考如何加上 pipline。流式写入版本其实能够看着是一个流水线解决,源端负责依照程序结构 entry,接收端负责依照程序序列化 entry,entry 的程序就是 FSTree 的深度优先遍历的程序。要应用 pipline,就必须走批量解决,那么咱们能够逻辑上将 entry 依照程序划分为多个批次,每个批次长度 100,将流水线的解决逻辑单元变成一个批次,这样流程变为:

  1. 当源端解决完 1 个批次后告诉接收端开始序列化这个批次
  2. 接收端序列化完这 1 个批次后再告诉源端结构下一个批次
  3. 以此重复到完结

每一个批次都通过 pipline 来减速获取后果,这样就做到了 pipline 与流式写入共存了。
对于内存的优化曾经完结了,那对于耗时还能再优化吗?咱们剖析当初的流水线的运行状况,当源端发送 pipline 申请元数据时,此时接收端在做什么?在无事可做,因为没有数据能够序列化,那么当接收端在序列化的时候源端在做什么,也是无事可做。所以其实流水线是走走停停的,这样的是串行计算。如果将这两者并行,进步 cpu 利用率,速度就能够进一步晋升。接下来咱们思考怎么能力让源端与序列化端并行?同一个批次数据产生与解决必定是无奈并行的,能并行的只能是未申请回来元数据的的批次与待序列化的批次。也就是说源端不必等等序列化端是否处理完毕了,源端只管开足马力拿数据就好了,拿到的数据依照程序放入到流水线上,序列化端依照程序序列化,如果发现某个批次还没拿到,就等源端通知本人这个批次 ready 了再解决。同时思考到结构批次的速度慢于序列化批次的工夫,所以咱们还能够给源端加上并发。源端同时序列化多个批次来缩小序列化端的等待时间。

咱们能够看着下图,模仿一下流程,假如咱们以后源端并发度为 2,那么首先 1 号协程 2 号协程会同时别离构建批次 1,批次 2,而序列化端与在期待批次 1 是否结构结束,一旦 1 号协程结构结束批次 1 就会告诉序列化端端开始顺次序列化批次 1。当批次 1 序列化结束时,序列化端会告诉 1 号协程结构批次 3(因为批次 2,批次 4 是该协程 2 解决的,每个协程依照肯定规定调配批次序列化端才能够依照规定反过来推算出该告诉哪个协程开始结构下一个批次),告诉完 1 协程后就会开始序列化批次 2(先查看批次 2 是否 ready,如果没 ready 就等协程 2 告诉 ready,一般来讲此时批次 2 曾经 ready 了),序列化完批次 2 就告诉协程 2 开始结构批次 4 以此类推。这样就做到了序列化端在序列化 entry 时源端在并行的解决 entry 以便跟上序列化的速度。

下面的逻辑步骤在树形的文件系统上执行的实在的过程如下图所示

性能

通过“鱼和熊掌”兼得的优化形式后测试性能,耗时为 19 秒,内存占用 75M,都达到了各自优化时的最佳成果。真正做到了“两个都要”。

Load 优化流程

load 如何做

与 dump 相比,load 逻辑绝对简略,最间接的办法,咱们将 json 文件内容全副读入内存,而后反序列化到 FSTree 的对象上,深度优先遍历 FSTree 树,而后把每个 entry 的各个维度的元数据别离插入到 redis 中。然而如果这么做就会存在一个问题,以下面的示例 json 文件内容的文件树为例,在 dump 这个文件系统的时候存在某种状况,此时 file1 曾经扫描到,redis 返回 file1 的 nlink 为 2(因为 hardLink 硬链接到了 file1),此时用户删除了 hardLink,file1 的 nlink 在 redis 中被批改为了 1,然而因为其在 dump 中曾经被扫描过了,所以最终 dump 进去的 json 文件中 nlink 仍旧为 2,导致 nlink 谬误,nlink 对于文件系统来说十分重要,其值的谬误会导致删不掉或者丢数据等问题,所以这种会导致 nlink 谬误的形式不太行。

为了解决这个问题,咱们须要在 load 的时候从新计算 nlink 值,这就须要咱们再 load 前记录下所有的 inode 信息,所以咱们在内存中构建了一个 map,key 为 inode,value 为 entry 的所有元数据,在遍历 entry 树的时候将所有扫描到的文件类型的 entry 放入 map 中而不是直接插入 redis,每次放入 map 前判断这个 inode 是否曾经存在,如果存在意味着是这是一个硬链接,须要将这个 inode 的 nlink++。同样的状况也可能呈现在子目录上,所以须要在遍历到子目录的时候将父目录的 nlink++。遍历完 entry 后 nlink 也就全副从新计算结束了。此时遍历 entry map,将所有的 entry 的元数据插入到 redis 中即可。当然为了放慢插入速度,咱们须要应用 pipline 的形式插入。

性能

依照下面的思路的代码测试后果如下,耗时 2 分 15 秒,内存占用 2.18G。

优化耗时

并不是用了 pipline 后,耗时就缩小到了极致,咱们仍旧能够通过其余办法进一步缩小工夫。家喻户晓 redis 是十分快的,即便是应用了 pipline,命令的处理速度依然远小于 RTT 工夫,而 load 过程结构 pipline 也是一个内存的操作,构建 pipline 的工夫也远小于 RTT 工夫。咱们能够通过一个举一个极其的例子剖析工夫到底节约到了哪里:假如如果构建 pipline 与 redis 解决 pipline 的工夫都是 10 ms,而 RTT 工夫是 80ms,这样就意味着 load 过程每破费 10ms 构建一个 pipline 给 redis 都要期待 90ms 能力构建下一个 pipline,所以其 cpu 利用率为 10%,redis 也同样如此,可见单方的 cpu 利用率之低。所以咱们能够通过并发 pipline 插入,进步单方 cpu 利用率来节省时间。

性能

通过增加并发优化后的测试后果, 耗时 1 分钟,内存占用 2.17G,内存根本持平,耗时优化成果 125%

优化内存

通过下面的测试应该明确了内存的优化次要在序列化上下功夫,首先读取整个 json 文件反序列化到构造体上,这个就动作就须要大概 2 倍元数据的内存,一倍的 json 字符串,一倍的构造体。可见整个读入的代价太高了,所以咱们要以流式读取的形式来解决,每次读取并反序列一个最小的 json 对象,这样内存占用就非常低了。load 的另一个问题是咱们把所有的 entry 存到了内存中来从新计算 nlink,这个也是导致内存占用十分高的起因之一。解决办法也非常简单,nlink 诚然是须要从新计算的,不过把 entry 的所有属性都记录下其实是没有必要的,咱们回忆从新计算的逻辑,每次将文件类型的 entry 放入 map 前依据 inode 判断 entry 是否存在,如果存在就意味着这是一个硬链接,将这个 inode 的 nlink++。所以将 map 的 value 类型改为 int64 即可,每次放入时 value 值 +1,这样比拟大的 map 也就不存在了,内存占用进一步缩小。

性能

通过了流式读取优化的测试后果如下, 耗时 40s,内存占用 518M。内存优化成果 330%

总结

以后 1.0-rc2 版本与最后版优化成果

  • Dump 耗时 7 分 47 秒,内存占用为 3.18G,优化为耗时 19 秒,内存占用 75M, 优化成果别离为 2300% 和 4200%
  • Load 耗时 2 分 15 秒,内存占用 2.18G,优化后为耗时 40 秒,内存占用 518M。 优化成果别离为 230% 和 330%

能够看到优化成果是非常明显的。

以上就是咱们的优化的思路与后果了,如果遇到相似的场景,心愿这些实践经验也能够帮忙大家拓展优化的思路,晋升零碎的性能!

如有帮忙的话欢送关注咱们我的项目 Juicedata/JuiceFS 哟!(0ᴗ0✿)

退出移动版