公司的hbase集群早先是基于社区1.2.4版本进行搭建的,在时延表现方面起初并不十分理想,受GC尖刺的影响非常严重,针对P99响应时延也只能给业务提供不高于100毫秒的SLA承诺,因此在公司层面接入hbase的业务普遍还是面向近线或者离线场景,而针对时延响应要求比较高的在线业务则没有办法提供能力支持。
近期随着社区补丁的陆续合入,以及公司自研补丁的不断集成,hbase在吞吐能力表现方面已经得到了非常巨大的改善,图计算场景下针对多跳查询已经可以达到3~7倍的能力提升,以下主要是在整个吞吐能力建设过程中,我们所做的一些改进与尝试。
HBase原生提供了三种类型的缓存支持,分别是LruBlockCache,BucketCache以及MemcachedBlockCache。其中MemcachedBlockCache主要是借助外部缓存系统来处理相应的块缓存操作,而在hbase内部采用比较多的还是通过组合LruBlockCache和BucketCache来形成一种复合型的缓存模型,即CombinedBlockCache的实现。其中LruBlockCache主要用来缓存索引块和布隆数据块,其数据内容需要保存在堆内;而BucketCache主要用来保存数据块以及LruBlockCache中淘汰的块。不同于LruBlockCache,BucketCache是可以支持多种存储媒介的,比如我们可以将数据保存在堆外,也可以将数据保存到硬盘或者PMEM设备上。即便是将数据保存到硬盘,其对应的访问效率也是要优于HDFS的,因为一方面我们可以利用操作系统的零拷贝功能,另一方面可以避免RPC远程调用以及DN协议带来的开销。所以理想情况下HDFS可以只拿来做容灾备份处理,而数据的访问可以从cache层全部命中,因此需要提供一种大容量的缓存能力支持。
但是缓存容量大了以后有可能会带来以下问题。以公司常用的机器配置模版为例,通常每台机器会挂载12块盘,每块盘提供5T存储,因此每台机器可对外提供约60TB的存储容量。而如果以每个HFileBlock默认采用64KB存储来估算的话,60TB的存储大概需要有近百G的索引块和布隆数据块。由于LruBlockCache是基于堆内进行管理的,如果索引块全部缓存到堆内,将极大增加堆内存的使用开销。另一方面LruBlockCache所管理的缓存数据是需要通过GC来进行回收的,如果空间分配量过小,那么缓存的驱逐频率会更加频繁,随之而来的GC压力也会变得更加明显,尤其在启用cacheOnWrite或者prefetchOnOpen特性时。
既然大数据容量场景下采用LruBlockCache不太能满足我们的需求,那么我们自然会想到能否采用堆外BucketCache来做替换处理,形成一种新的复合型BlockCache,如下图所示:
在此模式下,L1层的BucketCache主要通过堆外内存进行管理,而L2层的BucketCache可通过SSD或PMEM进行管理,以此来解决大容量的缓存需求,同时也意味着我们需要针对BucketCache提供分层存储的能力支持。在功能实现上,分层的BucketCache主要是通过CompositeBucketCache来进行封装的,其延用了原生CombinedBlockCache的处理逻辑,只不过将L1缓存从FirstLevelBlockCache替换成了BucketCache。因此在类结构上我们只需将CombinedBlockCache的代码上移到超类(即CompositeBlockCache),然后将CompositeBucketCache和CombinedBlockCache分别继承该超类即可(目前代码已提交社区,详细可参考HBASE-23296)。
有了大容量的缓存能力支撑之后,我们希望把所有的索引块和布隆数据块全部缓存下来,以减少数据在检索过程中对磁盘的seek操作。因为在缓存不命中的情况下,对HFile的读取有可能需要经过3次seek才能定位到目标想要的数据,这将极大降低读取效率。
为此我们针对数据写入开启了cacheOnWrite以及prefetchOnOpen特性,并调整了部分缓存的预热逻辑,其中包括:
针对时延响应要求比较高的java系统,GC往往是最为头疼的问题,如果读写链路有大量的临时对象创建,YGC的执行频率将变得异常频繁。而如果对象的使用空间管理不当,还很容易引发碎片问题,进而增加fullgc的触发频率。所有这些操作都将换来STW,进而影响整个读写链路的吞吐时延。
针对GC问题,一种比较好的改善方式是将占用空间比较大或者使用频率比较高的对象,采用池化的机制来进行管理,然后基于覆写的方式将逻辑上已被释放的空间进行再度利用,从而避免GC层面对象空间的不断申请与释放行为。比如BucketCache针对block的缓存管理方式。
RS启动过程中会预先分配出block可以使用的内存空间,后续这部分空间将常驻于内存,不参与GC回收。当某个不使用的block被驱逐后,我们可以在逻辑上将其标识为可覆写的状态,这样有后续的block缓存进来时便可以复用这部分空间,而无需在GC层面将其释放回收掉。
在GC能力改善方面,社区在2.0之后的版本已经提供了一些非常优秀的补丁,比如:
以上补丁已经全部backport回我们自己的版本,补丁启用后堆内存空间的使用情况得到了极大的改善,临时对象的申请与释放频率不再那么频繁,YGC的触发频率得到了显著的下降。然而通过对RS进程进行profile发现,整个读写链路的GC优化其实还不够彻底,在很多功能链路上还是遗漏了一些细节,比如:
为此,针对这部分内存申请,我们延用了HBASE-21879的处理方式,采用池化机制来对其进行管理,功能启用后内存申请操作由80%下降到了5%,gc时延方面得到了近1倍的改良(改善后的火焰图可参考HBASE-22802)。
以上便是有关GC链路的一些优化处理,核心思想主要是采用池化管理机制来降低临时对象的空间申请与释放行为,代码层面主要是通过ByteBuffer池来进行空间管理并配合Unsafe的使用来跳过一些边界检查行为。
在实际应用中,为了提升与服务端的交互能力,我们通常会将多个请求先汇总成一个批次,然后在统一发送到服务端去进行处理,通过降低与服务端的RPC交互频率来换取对应的吞吐能力。典型的应用场景比如图数据库Janusgraph在查询目标顶点的邻接表信息时,便是向服务端发送一个multiget请求。
然而针对该类型的请求(multiget),服务端并没有提供与之相对应的并发处理模型,请求到达服务端之后针对每个multiget将会采用单一的handler线程来串行处理其中的每一个get,如图所示。
因此,针对批处理请求数量较小但是请求批次很大的场景,服务端资源并不能得到有效充分的利用。为此我们可以针对multiget请求引入一个新的线程池模型,将批次中的每一个get请求分发到对应的线程池中去做处理,以此来增加multiget请求在服务端的并发处理粒度。启用该功能以后,multiget的请求时延可以达到将近40%的性能提升,目前补丁已经提交至社区,相关的代码逻辑可参考HBASE-23063。
为了衡量HBase的吞吐能力效果,我们采用了统一基准测试YCSB对集群进行了压测,测试环境如下。
测试过程主要针对multiget请求以及随机get点读两种场景来进行,其中针对multiget请求我们对YCSB做了相应的定制处理,对应的测试结果如下。
单客户端开启40个线程并发执行1亿次get,测试结果如下。
multiget批量读测试
单客户端开启30个线程并发执行1000万次multiget请求,每个multiget返回50行数据,测试结果如下:
(1)客户端视角的端到端监控如下
从端到端的监控结果来看,P999时延可以稳定控制在50ms之内,由于每个multiget请求会返回50行数据,因此单行数据(每行10个KV,数据总量1KB)平均下来可达1ms。
(2) 服务端视角的监控如下
从服务端视角来看,单机get吞吐量达到6万时,每秒GC时间平均可控制在6.5毫秒上下,且GC的整体表现非常平稳,P999时延不在受到GC尖刺的影响。
本文作者 陈旭,感谢来稿及对HBase社区做出的卓越贡献。原文发表于chenxu的博客,文章链接https://chenxu14.github.io/2020/04/13/hbase-perfomance-improve.html(点击阅读原文进入)