当前位置: 代码网 > 服务器>网站运营>运维 > 得物 Zookeeper SLA 也可以 99.99% | 得物技术

得物 Zookeeper SLA 也可以 99.99% | 得物技术

2024年08月04日 运维 我要评论
一、背景 ZooKeeper(ZK)是一个诞生于2007年的分布式应用程序协调服务。尽管出于一些特殊的历史原因,许多业务场景仍然不得不依赖它。比如,Kafka、任务调度等。特别是在 Flink 混合部署 ETCD 解耦 时,业务方曾要求绝对的稳定性,并强烈建议不要使用自建的 ZooKeeper。出于对稳定性的考量,采用了阿里的 MSE-ZK。自从 2022 年 9 月份开始使用至今,我们没有遇到任何稳定性问题,SLA 的可靠性确实达到了 99.99%。 在 2023 年,...

一、背景

zookeeper(zk)是一个诞生于2007年的分布式应用程序协调服务。尽管出于一些特殊的历史原因,许多业务场景仍然不得不依赖它。比如,kafka、任务调度等。特别是在 flink 混合部署 etcd 解耦 时,业务方曾要求绝对的稳定性,并强烈建议不要使用自建的 zookeeper。出于对稳定性的考量,采用了阿里的 mse-zk。自从 2022 年 9 月份开始使用至今,我们没有遇到任何稳定性问题,sla 的可靠性确实达到了 99.99%。

在 2023 年,部分业务使用了自建的 zookeeper(zk)集群,然后使用过程中 zk 出现了几次波动,随后得物 sre 开始接管部分自建集群,并进行了几轮稳定性加固的尝试。接管过程中我们发现zookeeper在运行一段时间后,内存占用率会不断增加,容易导致内存耗尽(oom)的问题。我们对这一现象非常好奇,因此也参与了解决这个问题的探索过程。

二、探索分析

确定方向

在排查问题时,我们非常幸运地发现了一个测试环境的故障现场,该集群中的两个节点恰好处于oom的边缘状态。

 

有了故障现场,那么一般情况下距离成功终点只剩下50%。

内存偏高,按以往的经验来看,要么是非堆,要么是堆内有问题。从火焰图和jstat 都能证实:是堆内的问题。

如图所示:说明 jvm 堆内存在某种资源占用了大量的内存,并且fgc都无法释放。

内存分析

为了探究 jvm 堆中内存占用分布,我们立即做了一个jvm堆dump。分析发现 jvm 内存被 childwatches 和 datawatches 大量占用。

datawatches:跟踪 znode 节点数据的变化。

childwatches:跟踪 znode 节点结构(tree)的变化。

childwatches和datawatches同源于watchermanager。

经过资料排查,我们发现 watchermanager 主要负责管理 watcher。zookeeper(zk)客户端首先将 watcher 注册到 zookeeper 服务器上,然后由 zookeeper 服务器使用 watchermanager 来管理所有的 watcher。当某个 znode 的数据发生变更时,watchmanager 将触发相应的 watcher,并通过与订阅该 znode 的 zookeeper 客户端的 socket 进行通信。随后,客户端的 watch 管理器将触发相关的 watcher 回调,以执行相应的处理逻辑,从而完成整个数据发布/订阅流程。

进一步分析watchmanager,成员变量 watch2path、watchtables 内存占比高达 (18.88+9.47)/31.82 = 90%。

而 watchtables、watch2path 存储的是 znode 与 watcher 正反映射关系,存储结构图所示:

watchtables【正向查询表】

hashmap<znode, hashset<watcher>>

场景:某个znode发生变化,订阅该znode的watcher会收到通知。

逻辑:用该znode,通过 watchtables 找到对应的所有 watcher 列表,然后逐个发通知。

watch2paths【逆向查询表】

hashmap<watcher, hashset>

场景:统计某个 watcher 到底订阅了哪些znode

逻辑:用该watcher,通过 watch2paths 找到对应的所有 znode 列表

watcher 本质是 nioservercnxn,可以理解成一个连接会话。

如果znode、和 watcher 的数量都比较多,并且客户端订阅 znode 也比较多,甚至全量订阅。这两张hash表记录的关系就会呈指数增长,最终会是一个天量!

当全订阅时,如图演示:

当 znode数量:3,watcher 数量:2   watchtables 和 watch2paths 会各有 6 条关系

当 znode数量:4,watcher 数量:3   watchtables 和 watch2paths 会各有 12 条关系

通过监控我们发现,异常的zk-node。znode数量大概有20w,watcher数量是5000。而watcher与znode的关系条数达到了1亿。

如果存储每条关系的需要1个 hashmap&node(32byte),由于是两个关系表,double一下。那么其它都不要计算,光是这个"壳",就需要 2*10000^2*32/1024^3 = 5.9gb 的无效内存开销。

分析到这里,大家应该明白了。为什么我们的zk内存总是在走“钢丝”,经常oom。

意外发现

既然已经确定了问题的原因,接下来我们应该考虑如何解决它。

通过上面的分析可以得知,我们需要避免客户端出现对所有 znode 进行全面订阅的情况。然而,实际情况是,许多业务代码确实存在这样的逻辑,从 ztree 的根节点开始遍历所有 znode,并对它们进行全面订阅。

我们或许能够说服一部分业务方进行改进,但无法强制约束所有业务方的使用方式。因此,我们解决这个问题的思路在于监控和预防。然而,遗憾的是,zk 本身并不支持这样的功能,这就需要对 zk 源码进行修改。

通过对源码的跟踪和分析,我们发现问题的根源又指向了 watchmanager,并且我们仔细研究了这个类的逻辑细节。经过深入理解后,我们发现这段代码的质量似乎像是由应届毕业生编写的,存在大量线程和锁的不恰当使用问题。通过查看 git 记录,我们发现这个问题可以追溯到 2007 年。然而,令人振奋的是,在这一段时间内,出现了 watchmanageroptimized(2018),通过搜索 zk 社区的资料,我们发现了 [zookeeper-1177],即在 2011 年,zk 社区就已经意识到了大量 watch 导致的内存占用问题,并最终在 2018 年提供了解决方案。正是这个watchmanageroptimized 的功劳,看来zk社区早就进行了优化。

有趣的是,zk默认情况下并未启用这个类,即使在最新的 3.9.x 版本中,默认仍然使用 watchmanager。也许是因为 zk 年代久远,渐渐地人们对其关注度降低了。通过询问阿里的同事,我们确认了 mse-zk 也启用了 watchmanageroptimized,这进一步证实了我们关注的方向是正确的。因此,我们认为有必要深入挖掘一下这个类的潜力。

优化探索

锁的优化

在默认版本中,使用的 hashset 是线程不安全的。在这个版本中,相关操作方法如 addwatch、removewatcher 和 triggerwatch 都是通过在方法上添加了 synchronized 重型锁来实现的。而在优化版中,我们采用了 concurrenthashmap 和 readwritelock 的组合,以更精细化地使用锁机制。这样一来,在添加 watch 和触发 watch 的过程中能够实现更高效的操作。

存储优化

这是我们关注的重点。从 watchmanager 的分析可以看出,使用 watchtables 和 watch2paths 存储效率并不高。如果 znode 的订阅关系较多,将会额外消耗大量无效的内存。

令我们感到惊喜的是,watchmanageroptimized 在这里使用了“黑科技” -> 位图。

利用位图将关系存储进行了大量的压缩,实现了降维优化。

java bitset 主要特点:

  • 空间高效:bitset 使用位数组存储数据,比标准的布尔数组需要更少的空间。

  • 处理快速:进行位操作(如 and、or、xor、翻转)通常比相应的布尔逻辑操作更快。

  • 动态扩展:bitset 的大小可以根据需要动态增长,以容纳更多的位。

bitset 使用一个 long[] words 来存储数据,long 类型占 8 字节,64位。数组中每个元素可以存储 64 个数据,数组中数据的存储顺序从左到右,从低位到高位

比如下图中的 bitset 的 words 容量为 4,words[0]从低位到高位分别表示数据 0~63是否存在,words[1] 的低位到高位分别表示数据 64~127 是否存在,以此类推。其中 words[1] = 8,对应的二进制第 8 位为 1,说明此时 bitset 中存储了一个数据 {67}。

watchmanageroptimized 使用 bitmap来存储所有的 watcher。这样即便是存在1w的 watcher。位图的内存消耗也只有8byte*1w/64/1024=1.2kb。如果换成 hashset ,则至少需要 32byte*10000/1024=305kb,存储效率相差近300倍。

watchmanager.java:private final map<string, set<watcher>> watchtable = new hashmap<>();private final map<watcher, set<string>> watch2paths = new hashmap<>();
watchmanageroptimized.java:private final concurrenthashmap<string, bithashset> pathwatches = new concurrenthashmap<string, bithashset>();private final bitmap<watcher> watcherbitidmap = new bitmap<watcher>();

znode到 watcher 的映射存储,由 map<string, set> 换成了 concurrenthashmap<string, bithashset>。也就是说不再存储 set,而是用位图来存储位图索引值。

我们用 1w的znode,1w的watcher,极端点走全订阅(所有的watcher订阅所有的znode),做存储效率pk:

可以看到 11.7mb pk 5.9gb,内存的存储效率相差:516倍

逻辑优化

  • 添加监视器:两个版本都能够在常数时间内完成操作,但是优化版通过使用concurrenthashmap提供了更好的并发性能。

  • 删除监视器:默认版可能需要遍历整个监视器集合来找到并删除监视器,导致时间复杂度为o(n)。而优化版利用bitsetconcurrenthashmap,在大多数情况下能够快速定位和删除监视器,o(1)。

  • 触发监视器:默认版的复杂度较高,因为它需要对每个路径上的每个监视器进行操作。优化版通过更高效的数据结构和减少锁的使用范围,优化了触发监视器的性能。

三、性能压测

jmh 微基准测试

zookeeper 3.6.4 源码编译, jmh micor 压测 watchbench。

pathcount:表示测试中使用的znode路径数目。

watchmanagerclass:表示测试中使用的 watchmanager 实现类。

watchercount:表示测试中使用的观察者(watcher)数目。

mode:表示测试的模式,这里是 avgt,表示平均运行时间。

cnt:表示测试运行的次数。

score:表示测试的得分,即平均运行时间。

error:表示得分的误差范围。

units:表示得分的单位,这里是毫秒/操作(ms/op)。

  • znode与watcher 100 万条订阅关系,默认版本使用 50mb,优化版只需要 0.2mb,而且不会线性增加。

  • 添加watch,优化版(0.406 ms/op)比 默认版(2.669 ms/op)提升 6.5 倍。

  • 大量触发watch ,优化版(17.833 ms/op)比 默认版(84.455 ms/op)提升 5 倍。

性能压测

接下来我们在一台机器(32c 60g) 搭建一套 3节点 zookeeper 3.6.4 使用优化版与默认版进行容量压测对比。

场景一:20w znode 短路径

znode 短路径: /demo/znode1 

场景二:20w znode 长路径

znode 长路径: /sentinel-cluster/dev/xx-admin-interfaces/lock/_c_bb0832d5-67a5-48ab-8fe0-040b9ddea-lock/12

  • watch 内存占用跟 znode 的 path 长度有关。

  • watch 的数量在默认版是线性上涨,在优化版中表现非常好,这对内存占用优化来说改善非常明显。

灰度测试

基于前面的基准测试和容量测试,优化版在大量 watch 场景内存优化明显,接下来我们开始对测试环境的 zk 集群进行灰度升级测试观察。

第一套 zookeeper 集群 & 收益

默认版

优化版

效果收益:

  • election_time (选举耗时):降低 60%

  • fsync_time (事务同步耗时):降低 75%

  • 内存占用:降低 91%

第二套 zookeeper 集群 & 收益

 

 

效果收益:

  • 内存:变更前 jvm attach 响应无法响应,采集数据失败。

  • election_time(选举耗时):降低 64%。

  • max_latency(读延迟):降低 53%。

  • proposal_latency(选举处理提案延迟):1400000 ms --> 43 ms。

  • propagation_latency(数据的传播延迟):1400000 ms --> 43 ms。

第三套 zookeeper 集群 & 收益

默认版

优化版

效果收益:

  • 内存:节省 89%

  • election_time(选举耗时):降低42%

  • max_latency(读延迟):降低 95%

  • proposal_latency(选举处理提案延迟):679999 ms --> 0.3 ms

  • propagation_latency(数据的传播延迟):928000  ms--> 5 ms

四、总结

通过之前的基准测试、性能压测以及灰度测试,我们发现了 zookeeper 的 watchmanageroptimized。这项优化不仅节省了内存,还通过锁的优化显著提高了节点之间的选举和数据同步等指标,从而增强了 zookeeper 的一致性。我们还与阿里mse的同学进行了深度交流,各自在极端场景模拟压测,并达成了一致的看法:watchmanageroptimized 对 zookeeper 的稳定性提升显著。总体而言,这项优化使得 zookeeper 的 sla 提升了一个数量级。

zookeeper有许多配置选项,但大部分情况下不需要调整。为提升系统稳定性,我们建议进行以下配置优化:

  • 将 datadir(数据目录)和 datalogdir(事务日志目录)分别挂载到不同的磁盘上,并使用高性能的块存储。

  • 对于 zookeeper 3.8 版本,建议使用 jdk 17 并启用 zgc 垃圾回收器;而对于 3.5 和 3.6 版本,可使用 jdk 8 并启用 g1 垃圾回收器。针对这些版本,只需要简单配置 -xms 和 -xmx 即可。

  • 将 snapshotcount 参数默认值 100,000 调整为 500,000,这样可以在高频率 znode 变动时显著降低磁盘压力。

  • 使用优化版的 watch 管理器 watchmanageroptimized。

 

ref:

https://issues.apache.org/jira/browse/zookeeper-1177

https://github.com/apache/zookeeper/pull/590

 

 *文/bruce

本文属得物技术原创,更多精彩文章请看:得物技术官网

未经得物技术许可严禁转载,否则依法追究法律责任!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com