HBase核心组件——RegionServer


发布于 2024-12-28 / 72 阅读 / 0 评论 /
详细介绍HBase中最核心组件——RegionServer

RegionServer主要负责用户数据写入、读取等基础操作。

RegionServer是HBase系统响应用户读写请求的工作节点组件。

1.RegionServer内部结构

RegionServer内部结构如下图所示:

一个RegionServer有一个(或多个)HLog、一个BlockCache、多个Region组成。HLog保证数据写入的可靠性。BlockCache提高数据读取性能。Region是HBase中数据表的数据分片。

一个Region由多个Store组成,每个Store存放对应列簇的数据,比如一个表有两个列簇,这个表的所有Region就都会包含两个store,每个store包含一个MemStore和多个HFile,用户数据写入时会将对应列簇数据写入相应的MemStore,一旦写入数据的内存大小超过设定阈值,系统就会将MemStore中的数据落盘成HFile文件。HFile存放在HDFS上,是一种定制化格式的数据存储文件,方便用户进行数据读取。

2.HLog

HBase中系统故障恢复以及主从复制都是基于HLog实现。

默认情况下,所有写入操作都先以append的形式写入HLog,再写入MemStore。

大多数情况下,HLog并不会被读取,但如果RegionServer再某些异常情况下发生宕机,此时已经写入MemStore中但尚未flush到磁盘的数据就会丢失,需要回放HLog补救丢失的数据。

HBase主从复制需要主集群将HLog日志发送给从集群,从集群在本地执行回放操作,完成集群之间的数据复制。

2.1.HLog文件结构

HLog文件基本结构如下图所示:

每个HLog是多个Region共享的。

HLog中,每个日志单元WALEntry(图中小方格)表示一次行级更新的最小append单元。由HLogKey和WALEdit两部分组成。HLogKey由table name、region name、sequenceid等字段组成。

在0.94版本之前,如果一个事务对1行的3列做了修改,那么HLog会有3个WALEntry。这种日志结构无法保证行级事务的原子性。假如RegionServer更新第二列之后发生宕机,那么一行记录只有部分数据写入成功。为了解决这样的问题,HBase将一个行级事务的写入操作表示为一个WALEntry。

2.2.HLog生命周期

HLog文件生成之后,并不会永久保存在系统中,它的使命完成之后,文件就会失效,最终被删除。

HLog整个证明周期如下图所示:

3.MemStore

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

3.1.MemStore底层数据结构

写入HBase的数据会先写入MemStore,再写入HFile。除此之外,MemStore还承担业务多线程并发访问的职责。那么一个很现实的问题就是——MemStore应该采用何种数据结构,才能保证高效的写入效率和高效的多线程读取效率?

实际实现方案中,HBase采用跳跃表数据结构。

HBase没有直接使用跳跃表,而是使用JDK自带的数据结构ConcurrentSkipListMap。

ConcurrentSkipListMap底层使用跳跃表保证数据的有序性,并保证数据的写入、查找、删除操作都可以在O(logN)的时间复杂度完成。

ConcurrentSkipListMap还有个重要特点是线程安全,底层采用CAS原子性操作,避免了多线程访问下昂贵的锁开销。

3.2.MemStore写入过程

MemStore由两个ConcurrentSkipListMap(简称A和B),写入操作会将数据写入A,当A数据量超过一定阈值后,创建B,并由B来接收用户新的写入请求。之前写满的A会执行异步flush操作落盘形成HFile。

3.3.MemStore的GC问题

RegionServer由多个Region组成,每个Region根据列簇的不同又包含多个MemStore,这些MemStore都是共享内存的。

不同的Region写入对应的MemStore,因为共享内存,在JVM看来,所有MemStore的数据都是混合在一起写入Heap的。随着MemStore不断地写入和Flush,整个JVM将会产生大量越来越小的内存碎片,最后甚至分配不出足够大的内存给写入的对象,此时会触发JVM执行Full GC合并这些内存碎片。

3.3.1.MSLAB内存管理方式

为了解决MemStore产生的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

这种管理方式,使得flush之后残留的内存碎片更加粗力度。

3.3.2.MemStore Chunk Pool

MSLAB还存在一些小问题。比如一旦一个Chunk写满之后,系统会重新申请一个Chunk,新建Chunk对象会在JVM新生代申请新内存,如果申请比较频繁,会导致JVM得Eden区满,触发Young GC。

假如这些Chunk能被循环利用,系统就不需要申请新的Chunk,这样就会使得Young GC频率降低,晋升到老年代的Chunk就会减少。这就是MemStore Chunk Pool的核心思想。

MemStore Chunk Pool实现步骤如下:

(1)HBase创建一个Chunk Pool来管理所有未被引用的Chunk,这些Chunk就不会再被JVM当作垃圾回收。

(2)如果一个Chunk没有再被利用,将其放入Chunk Pool

(3)如果当前Chunk Pool已经到容量最大值,就不会再接纳新的Chunk

(4)如果需要申请新的Chunk来存储KeyValue,首先从Chunk Pool中获取,如果能够获取就重复利用,否则就重新申请一个新的Chunk。

4.HFile

HFile物理结构如下图所示:

Scanned Block部分表示顺序扫描HFile时所有的数据块将会被读取。

Non-scanned Block部分表示在HFile顺序扫描的时候数据不会被读取。

Load-on-open部分数据会在RegionServer打开HFile时直接加载到内存。

Trailer Block部分主要记录了HFile的版本信息,其他各个部分的偏移值和寻址信息。

HFile中所有的Block都有相同的数据结构,HBase将所有的Block统一抽象为HFileBlock。

HFileBlock主要包含两部分:BlockHeader和BlockData。BlockHeader主要存储Block相关元数据,BlockData用来存储具体数据。

5.BlockCache

提升数据库读取性能的一个核心方法是——尽可能将热点数据存储到内存中,以避免昂贵的IO开销。

同样为了提升读取性能,HBase也实现了一种读缓存结构——BlockCache。

客户端读取某个Block,首先会检查该Block是否存在于Block Cache中,如果存在就直接加载出来,如果不存在就去HFile文件中加载。

BlockCache主要是用来缓存Block,Block是HBase中最小的数据读取单元。

一个RegionServer只有一个BlockCache,在RegisonServer启动时完成BlockCache的初始化。

目前为止,BlockCache先后实现了3中方案——LRUBlockCache、SlabCache、BucketCache。

5.1.LRUBlockCache

LRUBlockCache是最早的BlockCache实现方案,也是现在默认的实现方案。

5.1.1.LRUBlockCache实现方法

使用一个ConcurrentHashMap管理BlockKey到Block的映射关系。

采用LRU淘汰算法,当Map中的BlockKey映射数量达到一定阈值后,就会启动淘汰机制,将最少使用的BlockKey映射置换出来。

5.1.2.缓存分层策略

HBase采用了缓存分层设计,将整个BlockCache分为三个部分:

(1)single-access:占比25%,一次随机读中,一个Block从HDFS加载出来后,首先放入single-access区。

(2)multi-access:占比50%,后续如果single-access区中的Block有多次请求访问,就将Block移到multi-access区。

(3)in-memory:占比25%,表示数据可以常驻内存,一般用来存放访问频繁、量小的数据,比如元数据,用户可以在建表的时候设置列簇属性IN_MEMORY=true,设置之后该列簇的Block在从磁盘中加载之后会直接放入in-memory区。

in-memory区的Block仍遵守LRU淘汰算法管理。

HBase系统的元数据(hbase:meta、hbase:namespace等表)都存放在in-memory区,因此对于很多业务来说,设置IN_MEMORY=true需要非常谨慎,一定啊哟确保此列簇数据量很小且访问频繁,否则可能会将hbase:meta等元数据挤出in-memory区,严重影响业务性能。

5.1.3.LRU淘汰算法

如果BlockCache中的BlockKey映射数量达到阈值,唤醒淘汰线程对Map中的Block进行淘汰。

系统设置3个MinMaxPriorityQueue,分别对3个分层,每个队列中的元素按照最近最少被使用的规则排列,系统会优先去除最近最少使用的Block,将其对应的内存释放。

3个分层中的Block会分别执行LRU淘汰算法进行淘汰。

5.1.4.LRUBlockCache优缺点

LRUBlockCache使用HashMap管理缓存,简单有效。

但随着数据从single-access区晋升到multi-access区或长时间停留在single-access区,对应的内存对象会从young区晋升到old区,晋升到old区的Block被淘汰后会变为内存垃圾,最终由CMS回收(标记清除算法),CMS可能会带来大量的内存碎片,碎片空间累计就会产生臭名昭著的Full GC。尤其是在大内存条件下,一次Full GC可能会持续较长时间,甚至达到分钟级别。Full GC会将整个进程暂停,称为stop-the-world(STW)暂停,因此长时间Full GC必然会极大地影响业务的正常读写请求。

正因为LRUBlockCache有这种弊端,之后相继出现了SlabCache和BucketCache方案。

5.2.SlabCache

HBase0.92版本实现了SlabCache方案,可参考HBASE-4027。

5.2.1.SlabCache方案实现方法

为了解决LRUBlockCache可能出现的GC过程中服务中断的问题,SlabCache方案提出使用Java NIO DirectByteBuffer技术实现堆外内存存储。不再由JVM管理缓存数据。

默认情况下,系统在初始化的时候分配两个缓存区:

(1)缓存区一:占BlockCache的80%,存储不大于64KB的Block

(2)缓存区二:占BlockCache的20%,存储不大于128KB的Block

如果一个Block太大,两个缓存区都无法存储。

5.2.2.淘汰算法

和LRUBlockCache一样,SlabCache也使用LRU淘汰过期的Block。

不同的是,SlabCache淘汰Block时,只需要将对应的BufferByte标记为空闲,后续cache对其上的内存直接进行覆盖即可,相当于过期缓存的复用,无须清理。

5.2.3.DoubleBlockCache

线上客户可能为不同表设置不同的BlockSize,默认只存储不大于128KB的Block可能不能满足客户的场景。

因此,HBase在实际生产中将LRUBlockCache和SlabCache搭配使用,称为DoubleBlockCache。一次随机读中,一个Block从HDFS加载出来后,会在两个cache中分别存储一份。缓存读时,先在LRUBlockCache中查找,如果未命中,则在SlabCache中查找,如果命中则将该Block放入LRUBlockCache中。

5.2.4.SlabCache和DoubleBlockCache优缺点

经过实际测试,DoubleBlockCache方案由诸多弊端,SlabCache中固定大小内存设置会导致实际内存使用率比较低。而且使用LRUBlockCache缓存Block依然会有GC问题。

因此,HBase0.98版本之后,已经不建议使用该方案。

5.3.BucketCache

HBase0.96版本实现了BucketCache方案,可参考HBASE-7404。

SlabCache并没有改善LRUBlockCache方案的GC弊端,反而引入了堆外内存使用率低的问题。

SlabCache方案的贡献在于:使用堆外内存给予后续开发者更多的启发。站在SlabCache的肩膀上,社区工程师开发了一种非常高效的缓存方案——BucketCache。

5.3.1.BucketCache工作模式

BucketCache方案有三种工作模式:

(1)heap:Bucket从JVM Heap中申请。

(2)offheap:使用DirectByteBuffer技术实现堆外内存存储管理。

(3)file:使用类似SSD的存储介质来缓存Block。

无论工作在哪种模式,BucketCache都会申请许多带有固定大小标签的Bucket。

和SlabCache一样,一种Bucket只能存储指定BlockSize的Data Block。

但和SlabCache不一样的是,BucketCache在初始化时申请14中大小不同的Bucket,如果某种Bucket空间不足,系统会从其他Bucket空间借用内存使用,因此不会出现内存使用率低的问题。

5.3.2.CombinedBlockCache

实际生产中,BucketCache和LRUBlockCache搭配使用,称为CombinedBlockCache。

和DoubleBlockCache不同的是,系统在LRUBlockCache中主要存储Index Block和Bloom Block,而Data Block存储在BucketCache中。

5.3.3.Bucket缓存读取和写入过程

BucketCache中Bucket读取和写入过程如下图所示:

途中包含5个角色:

(1)RAMCache:存储BlockKey和Block映射关系的HashMap。

(2)Write Thread:整个Block写入的中心枢纽,负责异步地将Block写入内存空间。

(3)BucketAllocator:实现对Bucket的组织管理,为Block分配内存空间。

(4)IOEngine:具体的内存管理模块,将Block数据写入对应地址的内存空间。

(5)BackingMap:也是一个HashMap,用来存储BlockKey与对应物理内存偏移量的映射关系,并根据BlockKey定位到具体的Block。

写入过程包含6个步骤:w1~w6

(w1)将Block写入RAMCache,HBase设置了多个RAMCache,系统首先会根据BlockKey进行hash,根据hash结果将Block分配到对应的RAMCache。

(w2)WriteThread从RAMCache取出所有的Block。和RAMCache相同,HBase会同时启动多个WriteThread并发地执行异步写入,每个WriteThread对应一个RAMCache。

(w3)每个WriteThread会遍历RAMCache中所有Block,分别调用bucketAllocator为这些Block分配内存空间。

(w4)BucketAllocator会选择与Block大小对应的Bucket存放,并且返回对应的物理地址偏移量offset。

(w5)WriteThread将Block以及分配好的物理地址偏移量传给IOEngine模块,执行具体的内存写入操作。

(w6)写入成功后,将BlockKey与对应物理内存偏移量的映射关系写入BackingMap中。

读取过程包含3个步骤:r1~r3

(r1)首先从RAMCache中查找。对于没有来得及写入Bucket的缓存Block,一定存储在RAMCache中。

(r2)如果RAMCache中没有找到,再根据BlockKey在BackingMap中找到对应的物理地址偏移量offset。

(r3)根据物理偏移地址offset直接从内存中查找对应的Block数据。