RaftKeeper 是一款高新能分布式共识服务,完全兼容 Zookeeper 但性能更出色,更多关于 RaftKeeer 参考 Github,我们将 RaftKeeper 大规模应用到 ClickHouse 场景中,用于解决 ZooKeeper 的性能瓶颈问题,同时 RaftKeeper 也可以用于其它大数据组件比如 HBase。
v2.1.0 作为 v2.0.0 后的重要版本,引入了一系列新特性,包括异步创建 snapshot。该版本的最大亮点在于性能优化:写请求性能提升 11%,读写混合场景更是大幅提升了 118% 。本文将从工程细节的角度深入解析新版本的改进与优化。
一、性能优化效果
在性能测试中,我们使用了 raftkeeper-bench工具,测试环境为三个节点组成的集群,每个节点配置为 16 核 CPU、32GB 内存和 100GB 存储空间。测试对象包括 RaftKeeper v2.1.0、RaftKeeper v2.0.4 和 ZooKeeper 3.7.1,均采用默认配置。
测试分为两组:
第一组测试纯 create 操作的性能,create 操作的 value 大小为 100 字节。结果显示,RaftKeeper v2.1.0 相较于 v2.0.4 性能提升了 11%,相较于 ZooKeeper 性能提升了 143%。
第二组请求比例为 create-1%、set-8%、get-45%、list-45%、delete-1%。其中,list 请求结果包含 100 个子节点,每个子节点大小为 50 字节;get、set、create 请求的节点 value 大小为 100 字节。结果显示,RaftKeeper v2.1.0 相较于 v2.0.4 性能提升了 118%,相较于 ZooKeeper 性能提升了 198%。
rk2.1.0 版本在测试中 avgRT 和 TP99 指标均优于 rk2.0.4,具体可以参考 测试报告。
二、性能优化
接下来从工程细节的角度,介绍一些 v2.1.0 的优化点。
1. 响应并行序列化
RaftKeeper 被我们广泛应用到 ClickHouse 中,下图是一个规模较大的 RaftKeeper 集群的火焰图,通过火焰图发现 ResponseThread 线程消耗不少 CPU 时间片,其中大概三分之一时间片用于序列化响应。
ResponseThread 负责序列化响应并且转发给 IO 线程,它是一个单线程,串行执行序列化会增大延迟。我们可以把响应的序列化交给 IO 线程来做,以并发的方式提高吞吐。
同时可以看到sdallocx_default
函数占用了不少时间片,该函数是 jemelloc 释放内存的函数,函数对于时间片的消耗没有问题,但是该操作在基于 mutex 的同步队列中执行会增加锁的时间。
复制代码
解决的方式是在 tryPop 方法前先释放 response_for_session 的内存空间。
下面的表格展示了优化前后的性能指标,测试共有四组每组使用不同的并发度,其中响应大小为 50bytes,当并发度为 10 的时候,TPS 增加 31%,AvgRT 降低 32%。
2. 优化 List 请求
依然是同一个 RaftKeeper 集群,通过火焰图发现,List 请求处理几乎消耗了 request-processor 线程所有的 CPU 时间片。在 RaftKeeper 的执行链路中 request-processor 负责处理用户的请求,它是一个单线程,所以比较容易成为瓶颈点。
通过火焰图可以发现两个瓶颈点:1.为字符串分配内存空间;2.插入 vector。
List 请求返回的结果是一个 std::vector<string>动态数组,其内存 layout 如下图所示,每个成员是一个字符串,每个字符串需要分配一块动态内存用于保存数据,所以当字符串多的时候需要大量的动态内存分配。
一个很直观的优化思路,可以设计一个 compact strings,数据采用紧凑的方式存储,在以下的设计中,采用两个连续内存空间,一个用于存储数据,一个用于存储 offset,具体参考: CompactStrings实现。
优化后从火焰图方面看 List 请求处理在 CPU 的占比从 5.46%下降到 3.37%,进行 List 请求的 benchmark 测试,TPS 从 45.8w/s 增长到 61.9w/s,同时 TP99 更低。
复制代码
3. 优化无用的系统调用
系统调用会引起用户态和内核态的上下文切换,往往系统调用函数会有比较大的开销,我们通过 bpftrace 对 RaftKeeper 进行了 profile
复制代码
发现大量的getsockname
和getsockopt
系统调用占用了不少开销。
复制代码
这些系统调用本不该存在,经过排查发现是在打印日志的时候错误的进行了调用。
复制代码
4. 线程池优化
下图是一次 benchmark(读写 4:6 的比例)RaftKeeper 的火焰图,进行性能瓶颈分析发现,发现 request-processor 线程的 CPU 时间片大部分时间(超过 60%)消耗在条件变量等待的调用。
在 RaftKeeper 的主执行链路中 request-processor 线程负责处理用户请求,它的主要流程可以简单抽象为:1. 对于写请求,单线程处理;2. 对于读请求,通过线程池并发处理,然后调用 request_thread->wait()阻塞等待所有读取请求完成。
复制代码
增加监控指标分别统计读和写请求的执行时间发现,在读请求和写请求数量几乎相同的情况下,读请求的处理延时是写请求的 3 倍。
因为每个请求的处理时间很短,到这里可以推测出,线程池任务调度的时间不可忽视,所以出现了性能下降。解决方式是去掉线程池,单线程处理读请求,以下 benchmark 是优化前后 benchmark 结果,TPS 提升 13%。
复制代码
三、Snapshot 优化
1. 异步 snapshot
在 RaftKeeper 整个请求处理链路中,创建 snapshot 是在主链路中进行处理的,当数据量大的时候会长时间阻塞用户请求,造成请求超时、leader 切换等引起服务不可用的问题,在我们线上场景中对于 6000w 的数据做 snapshot 需要 180s。
为了解决以上问题,新版本中支持了异步 snapshot,当需要创建 snapshot 的时候首先将整个 DataTree 拷贝一份,这一步在主线程中处理,然后在后台将拷贝的 DataTree 序列化到磁盘中。
采用这用方式 6000w 的数据做 snaphot 对用户的阻塞时间从 180s 降低到了 4.5s,但是这种方案也有一些负面效果,需要额外消耗大于 50%的内存。
为了进一步降低对用户的阻塞时间,对 DataTree 拷贝进行了进一步优化。DataTree 拷贝其实是一个计算密集型的任务,所以可以采用向量化的方式,同时会遍历 hashmap 可以适当进行 prefetch。
复制代码
上面的拷贝函数基于 SSE 指令集,优化后 DataTree 拷贝时间从 4.5s 降低到 3.5s。
2. Snapshot 加载速度优化
RaftKeeper 老版本中,启动服务之后 snapshot 加载速度比较慢,线上一个作为 ClickHouse metadata 存储的 Raftkeeper 有 6kw 的数据,在 NVMe 磁盘的服务器上加载 snapshot 需要 180s,导致服务启动速度很慢。
加载 snapshot 主要分两步,第一步读取磁盘上的数据,反序列化成节点;第二步遍历 DataTree 并构建父子关系,其中第一步是并行的,第二步是单线程的。
由于第二步是单线程执行,可以改成并行的方式,并行化改造的基础是 DataTree 是一个二层 HashMap 结构,改造后每个线程负责固定的 bucket,这样避免了并发问题。具体流程为首先从磁盘读取数据并按照 bucket 的粒度存储节点和父子关系,然后填充 DataTree 并构建父子关系。
优化后加载 snapshot 时间从 180s 降低到 99s,之后又通过锁优化、snapshot 格式优化、减少数据拷贝等手段将时间降低到 22s。
四、上线效果
我们选取线上一个对 ZooKeeper 请求量大的 ClickHouse 集群,在 ClickHouse 测的监控指标看 QPS 大概为 17w/s,其中绝大部分为 List 请求。依次将其从 ZooKeeper 升级到 RaftKeeper v2.0.4 和 v2.1.0,观察监控指标
可以看到 RaftKeeper v2.0.4 的表现不及 ZooKeeper(主要原因是该场景下绝大部分请求是 list,v2.0.4 对于 list 请求性能较差),但是 v2.1.0 有比较大幅的优势。