1

HBase系统中一张表会被水平切分成多个Region,每个Region负责自己区域的数据读写请求。水平切分意味着每个Region会包含所有的列簇数据,HBase将不同列簇的数据存储在不同的Store中,每个Store由一个MemStore和一系列HFile组成,如图所示。

image.png
Region结构组成

HBase基于LSM树模型实现,所有的数据写入操作首先会顺序写入日志HLog,再写入MemStore,当MemStore中数据大小超过阈值之后再将这些数据批量写入磁盘,生成一个新的HFile文件。LSM树架构有如下几个非常明显的优势:

•这种写入方式将一次随机IO写入转换成一个顺序IO写入(HLog顺序写入)加上一次内存写入(MemStore写入),使得写入性能得到极大提升。大数据领域中对写入性能有较高要求的数据库系统几乎都会采用这种写入模型,比如分布式列式存储系统Kudu、时间序列存储系统Druid等。

•HFile中KeyValue数据需要按照Key排序,排序之后可以在文件级别根据有序的Key建立索引树,极大提升数据读取效率。然而HDFS本身只允许顺序读写,不能更新,因此需要数据在落盘生成HFile之前就完成排序工作,MemStore就是KeyValue数据排序的实际执行者。

•MemStore作为一个缓存级的存储组件,总是缓存着最近写入的数据。对于很多业务来说,最新写入的数据被读取的概率会更大,最典型的比如时序数据,80%的请求都会落到最近一天的数据上。实际上对于某些场景,新写入的数据存储在MemStore对读取性能的提升至关重要。

•在数据写入HFile之前,可以在内存中对KeyValue数据进行很多更高级的优化。比如,如果业务数据保留版本仅设置为1,在业务更新比较频繁的场景下,MemStore中可能会存储某些数据的多个版本。这样,MemStore在将数据写入HFile之前实际上可以丢弃老版本数据,仅保留最新版本数据。

MemStore内部结构

上面讲到写入(包括更新删除操作)HBase中的数据都会首先写入MemStore,除此之外,MemStore还要承担业务多线程并发访问的职责。那么一个很现实的问题就是,MemStore应该采用什么样的数据结构,既能够保证高效的写入效率,又能够保证高效的多线程读取效率?

实际实现中,HBase采用了跳跃表这种数据结构,当然,HBase并没有直接使用原始跳跃表,而是使用了JDK自带的数据结构ConcurrentSkipListMap。ConcurrentSkipListMap底层使用跳跃表来保证数据的有序性,并保证数据的写入、查找、删除操作都可以在O(logN)的时间复杂度完成。除此之外,ConcurrentSkipListMap有个非常重要的特点是线程安全,它在底层采用了CAS原子性操作,避免了多线程访问条件下昂贵的锁开销,极大地提升了多线程访问场景下的读写性能。

MemStore由两个ConcurrentSkipListMap(称为A和B)实现,写入操作(包括更新删除操作)会将数据写入ConcurrentSkipListMap A,当ConcurrentSkipListMap A中数据量超过一定阈值之后会创建一个新的ConcurrentSkipListMap B来接收用户新的请求,之前已经写满的ConcurrentSkipListMap A会执行异步f lush操作落盘形成HFile。

MemStore的GC问题

MemStore从本质上来看就是一块缓存,可以称为写缓存。众所周知在Java系统中,大内存系统总会面临GC问题,MemStore本身会占用大量内存,因此GC的问题不可避免。不仅如此,HBase中MemStore工作模式的特殊性更会引起严重的内存碎片,存在大量内存碎片会导致系统看起来似乎还有很多空间,但实际上这些空间都是一些非常小的碎片,已经分配不出一块完整的可用内存,这时会触发长时间的Full GC。

为什么MemStore的工作模式会引起严重的内存碎片?这是因为一个RegionServer由多个Region构成,每个Region根据列簇的不同又包含多个MemStore,这些MemStore都是共享内存的。这样,不同Region的数据写入对应的MemStore,因为共享内存,在JVM看来所有MemStore的数据都是混合在一起写入Heap的。此时假如Region1上对应的所有MemStore执行落盘操作,就会出现图所示场景。

image.png

MemStore f lush产生内存条带

上图中不同Region由不同颜色表示,右边图为JVM中MemStore所占用的内存图,可见不同Region的数据在JVM Heap中是混合存储的,一旦深灰色条带表示的Region1的所有MemStore数据执行f lush操作,这些深灰色条带所占内存就会被释放,变成白色条带。这些白色条带会继续为写入MemStore的数据分配空间,进而会分割成更小的条带。从JVM全局的视角来看,随着MemStore中数据的不断写入并且f lush,整个JVM将会产生大量越来越小的内存条带,这些条带实际上就是内存碎片。随着内存碎片越来越小,最后甚至分配不出来足够大的内存给写入的对象,此时就会触发JVM执行Full GC合并这些内存碎片。

MSLAB内存管理方式

为了优化这种内存碎片可能导致的Full GC,HBase借鉴了线程本地分配缓存(Thread-Local Allocation Buffer,TLAB)的内存管理方式,通过顺序化分配内存、内存数据分块等特性使得内存碎片更加粗粒度,有效改善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。

这种内存管理方式称为MemStore本地分配缓存(MemStore-Local AllocationBuffer,MSLAB)。下图是针对MSLAB的一个简单示意图,右侧为JVM中MemStore所占用的内存图,和优化前不同的是,不同颜色的细条带会聚集在一起形成了2M大小的粗条带。这是因为MemStore会在将数据写入内存时首先申请2M的Chunk,再将实际数据写入申请的Chunk中。这种内存管理方式,使得f lush之后残留的内存碎片更加粗粒度,极大降低Full GC的触发频率。

image.png

MemStore Chunk Pool

经过MSLAB优化之后,系统因为MemStore内存碎片触发的Full GC次数会明显降低。然而这样的内存管理模式并不完美,还存在一些“小问题”。比如一旦一个Chunk写满之后,系统会重新申请一个新的Chunk,新建Chunk对象会在JVM新生代申请新内存,如果申请比较频繁会导致JVM新生代Eden区满掉,触发YGC。试想如果这些Chunk能够被循环利用,系统就不需要申请新的Chunk,这样就会使得YGC频率降低,晋升到老年代的Chunk就会减少,CMS GC发生的频率也会降低。这就是MemStore Chunk Pool的核心思想,具体实现步骤如下:

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

MSLAB相关配置

HBase中MSLAB功能默认是开启的,默认的ChunkSize是2M,也可以通过参数"hbase.hregion.memstore.mslab.chunksize"进行设置,建议保持默认值。Chunk Pool功能默认是关闭的,需要配置参数"hbase.hregion.memstore.chunkpool.maxsize"为大于0的值才能开启,该值默认是0。"hbase.hregion.memstore.chunkpool.maxsize"取值为[0, 1],表示整个MemStore分配给Chunk Pool的总大小为hbase.hregion.memstore.chunkpool. maxsize * Memstore Size。另一个相关参数"hbase.hregion.memstore.chunkpool.initialsize"取值为[0, 1],表示初始化时申请多少个Chunk放到Pool里面,默认是0,表示初始化时不申请内存。

文章基于《HBase原理与实践》一书


小明的数据脚印
145 声望41 粉丝