HBase GC

HBase GC优化

本文也大量参考博主的文章。

Arena Allocation

Arena Allocation是一种非传统的内存管理方法。它通过顺序化分配内存,内存数据分块等特性使内存碎片粗化,有效改善了内存碎片导致的Full GC问题。

它的原理:

  • 创建一个大小固定的bytes数组和一个偏移量,默认值为0。
  • 分配对象时,将新对象的data bytes复制到数组中,数组的起始位置是偏移量,复制完成后为偏移量自增data.length的长度,这样做是防止下次复制数据时不会覆盖掉老数据(append)。
  • 当一个数组被充满时,创建一个新的数组。
  • 清理时,只需要释放掉这些数组,即可得到固定的大块连续内存。

在Arena Allocation方案中,数组的大小影响空间连续性,越大内存连续性越好,但内存平均利用率会降低。

HBase GC优化目标

  1. 要尽量避免长时间的Full GC,避免影响用户的读写请求
  2. 尽量减少GC时间,提高读写性能

1. Thread-Local Allocation Buffer

最原始的HBase版本存在很严重的内存碎片,经常会导致长时间的Full GC,其中最核心的问题就出在MemStore这里。因为一个RegionServer由多个Region构成,不同Region的数据写入到对应Memstore,在JVM看来其实是混合在一起写入Heap的,此时假如Region1上对应的所有MemStore执行落盘操作,就会出现下图所示场景:

即使memstore flush后有许多空间,但是某个空间放不下新的memstore,所以会导致Full GC。

为了优化这种内存碎片可能导致的Full GC,HBase借鉴了Arena Allocation内存管理方式,它通过顺序化分配内存、内存数据分块等特性使得内存碎片更加粗粒度,有效改善Full GC情况;

具体实现原理如下:

  1. 每个MemStore会实例化出来一个MemStoreLAB
  2. MemStoreLAB会申请一个2M大小的Chunk数组和一个Chunk偏移量,初始值为0
  3. 当一个KeyValue值插入MemStore后,MemStoreLAB会首先通过KeyValue.getBuffer()取得data数组,并将data数组复制到Chunk数组中,之后再将Chunk偏移量往前移动data.length
  4. 如果当前Chunk满了之后,再调用new byte[ 2 * 1024 * 1024]申请一个新的Chunk

2. MemStore Chunk Pool

然而一旦一个Chunk写满之后,系统就会重新申请一个新的Chunk,这些Chunk大部分都会经过多次YGC之后晋升到老生代,如果某个Chunk再没有被引用就会被JVM垃圾回收。很显然,不断申请新的Chunk会导致YGC频率不断增多,YGC频率增加必然会导致晋升到老生代的Chunk增多,进而增加CMS GC发生的频率。如果这些Chunk能够被循环利用,系统就不需要申请新的Chunk,这样就会使得YGC频率降低,晋升到老生代的Chunk就会减少,CMS GC发生的频率就会降低。这就是MemStore Chunk Pool的核心思想,具体实现如下:

  1. 系统会创建一个Chunk Pool来管理所有未被引用的chunks,这些chunk就不会再被JVM当作垃圾回收掉了
  2. 如果一个Chunk没有再被引用,将其放入Chunk Pool
  3. 如果当前Chunk Pool已经达到了容量最大值,就不会再接纳新的Chunk
  4. 如果需要申请新的Chunk来存储KeyValue,首先从Chunk Pool中获取,如果能够获取得到就重复利用,如果为null就重新申请一个新的Chunk

HBase调参

通过代码方式设置:

Confiuration conf = HBaseConfiguration.create();
conf.setInt("hbase.rpc.timeout",20000);
conf.setInt("hbase.client.operation.timeout”,30000);
conf.setInt("hbase.client.scanner.timeout.period",20000);
HTable table = new HTable(conf,"tableName");

出现如下问题:

java.io.IOException: Connection reset by peer
        at sun.nio.ch.FileDispatcherImpl.read0(Native Method)   
        at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:39)
        at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223)
        at sun.nio.ch.IOUtil.read(IOUtil.java:197)
        at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:384)
        at org.apache.hadoop.hbase.ipc.RpcServer.channelRead(RpcServer.java:2246)
        at org.apache.hadoop.hbase.ipc.RpcServer$Connection.readAndProcess(RpcServer.java:1496)

透过现象看本质:

hbase客户端每次和regionserver交互的时候,都会在服务器端生成一个租约(Lease),租约的有效期由参数hbase.regionserver.lease.period确定。

客户端去regionserver取数据的时候,hbase中存得数据量很大并且很多region的时候的,客户端请求的region不在内存中,或是没有被cache住,需要从磁盘中加载,如果这时候加载需要的时间超过hbase.regionserver.lease.period所配置的时间,并且客户端没有和 regionserver报告其还活着,那么regionserver就会认为本次租约已经过期,并从LeaseQueue从删除掉本次租约,当 regionserver加载完成后,拿已经被删除的租约再去取数据的时候,就会出现如上的错误现象。

解决的办法:

  1. 适当的增大 hbase.regionserver.lease.period参数的值,默认是1分钟
  2. 增大regionserver的cache大小,即hfile.block.cache.size

本人此次做的项目也会报这个错,但是主要原因觉得并非如上文所提,而是客户端与regionserver断开时并没有向regionserver报告,故报上述错误,但错误并不影响程序。

HBase 内存规划 读多写少型+BucketCache

如图,整个RegionServer内存(Java进程内存)分为两部分:JVM内存和堆外内存。其中JVM内存中LRUBlockCache和堆外内存BucketCache一起构成了读缓存CombinedBlockCache,其中LRUBlockCache用于缓存元数据Block,BucketCache用于缓存实际用户数据Block;MemStore用于写流程,缓存用户写入KeyValue数据;还有部分用于RegionServer正常运行所必须的内存。

内存规划思路:

假设物理机内存是96G,不过业务类型为读多写少:70%读+30%写。因为BucketCache模式下内存分布图相对复杂,我们使用如下表格一步一步对内存规划进行解析:

序号 步骤 原理 计算公式 计算值 修正值
A 规划RS总内存 在系统内存允许且不影响其他服务的情况下,越多越好。设置为系统总内存的 2/3。 2/3 * 96G 64G 64G值
B 规划读缓存 CombinedBlockCache 整个RS内存分为三部分:读缓存、写缓存、其他。基本按照5 : 3 : 2的分配原则。读缓存设置为整个RS内存的50% A * 50% 32G 34G
B1 规划读缓存LRU部分 LRU部分主要缓存数据块元数据,数据量相对较小。设置为整个读缓存的10% B * 10% 3.2G 3G
B2 规划读缓存BucketCache部分 BucketCache部分主要缓存用户数据块,数据量相对较大。设置为整个读缓存的90% B * 90% 28.8G 30G
C 规划写缓存MemStore 整个RS内存分为三部分:读缓存、写缓存、其他。基本按照5:4:1的分配原则。写缓存设置为整个RS内存的40% A * 30% 19.2G 20G
D 设置JVM_HEAP RS总内存大小 – 堆外内存大小 A – B2 35.2G 30G

计算修正

HBase有一个硬规定么:LRUBlockCache + MemStore < 80%*JVMHEAP,否则RS无法启动。不错,HBase确实有这样一个规定,这个规定的本质是为了在内存规划的时候能够给除过写缓存和读缓存之外的其他对象留够至少20%的内存空间。那按照上述计算方式能不能满足这个硬规定呢,LRU + MemStore / JVMHEAP = 3.2G + 19.2G / 35.2G = 22.4G / 35.2G = 63.6% ,远小于80%。因此需要对计算值进行简单的修正,适量减少JVMHEAP值(减少至30G),增大Memstore到20G。因为JVMHEAP减少了,堆外内存就需要适量增大,因此将BucketCache增大到30G。

调整之后,LRU + MemStore / JVM_HEAP = 3.2G + 20G / 30G = 23.2G / 30G = 77%

hbase-site.xml中MemStore相关参数设置如下:

<property>
    <name>hbase.regionserver.global.memstore.upperLimit</name>
    <value>0.66</value>
</property>
<property>
    <name>hbase.regionserver.global.memstore.lowerLimit</name>
    <value>0.60</value>
</property>

根据upperLimit参数的定义,结合上述内存规划数据可计算出 upperLimit = 20G / 30G = 66%。因此upperLimit参数设置为0.66,lowerLimit设置为0.60

hbase-site.xml中CombinedBlockCache相关参数设置如下:

<property>
    <name>hbase.bucketcache.ioengine</name>
    <value>offheap</value>
</property>
<property>
    <name>hbase.bucketcache.size</name>
    <value>34816</value>
</property>
<property>
    <name>hbase.bucketcache.percentage.in.combinedcache</name>
    <value>0.90</value>
</property>

按照上述介绍设置之后,所有关于内存相关的配置基本就完成了。但是需要特别关注一个参数hfile.block.cache.size,这个参数在本案例中并不需要设置,没有任何意义。但是HBase的硬规定却是按照这个参数计算的,这个参数的值加上hbase.regionserver.global.memstore.upperLimit的值不能大于0.8,上文提到hbase.regionserver.global.memstore.upperLimit值设置为0.66,因此,hfile.block.cache.size必须设置为一个小于0.14的任意值。hbase.bucketcache.ioengine表示bucketcache设置为offheap模式;hbase.bucketcache.size表示所有读缓存占用内存大小,该值可以为内存真实值,单位为M,也可以为比例值,表示读缓存大小占JVM内存大小比例。如果为内存真实值,则为34G,即34816。hbase.bucketcache.percentage.in.combinedcache参数表示用于缓存用户数据块的内存(堆外内存)占所有读缓存的比例,设为0.90;

Share