传统的关系型数据库一般使用B树作为索引结构,而在大数据场景下,比较多的存储引擎使用LSM-tree这种数据结构,比如hbase、kudu等,本篇文章介绍下HBase中LSM-tree的具体应用以及针对读性能的具体优化机制(compaction、应用Bloom Filter以及BlockCache),
我们知道LSM树的存储结构由2部分组成,一部分是内存部分,一部分是磁盘部分。
在hbase实现中,内存部分采用跳跃表来维护一个有序的KeyValue集合memstore,数据首先写入到memstore中,为了避免memstore内存数据丢失这里引入了WAL机制,会先将数据写入到所属regionServer的WAL日志中,也即HLog。memstore的数据达到某个级别的阈值之后,会进行写盘操作,形成一个有序的数据文件是(文件内部KeyValue有序),存储在磁盘上,磁盘部分则是对应的HFile。HFile就是一个小的树,因为hbase一般是部署在hdfs上,hdfs不支持对文件的update操作,而且最终随着磁盘文件越来越多,对读的影响很大。所以内存flush到磁盘上的小树,定期也会合并成一个大树。来增强读操作的性能,整体上hbase就是用了lsm tree的思路,如下图所示。
KeyValue结构:
LSM树中存储的是多个keyValue组成的集合。每一个KeyValue由一个字节数组来表示。如下图所示
HBase中对LSM树的实现,是在内存中用一个ConcurrentSkipListMap保存数据,即MemStore。ConcurrentSkipListMap是利用跳跃表(SkipList)的数据结构实现的。
为什么采用跳跃表这种数据结构呢?我们来分析下:
跳跃表是一种能高效实现插入、删除、查找的内存数据结构,这些操作的期望复杂度都是O(logN)。跳跃表的优势在于:1. 比红黑树或其他的二分查找树的实现简单很多 2.并发场景下加锁粒度更小,提高并发性能。因此诸Redis、LevelDB、HBase等KV数据库,都把跳跃表作为一种维护有序数据集合的核心数据结构。
跳跃表可以看作是一种特殊的有序链表。跳跃表是由多层有序链表组成。最底一层的链表保存了所有的数据,为了提高链表的查询效率,通过每向上的一层链表依次保存下一层链表的部分数据作为索引,采用空间换取时间等方式提高效率。相邻的两层链表中元素相同的节点之间存在引用关系,一般是上层节点中存在一个指向下层节点的引用。
跳跃表的目的在于提高了查询效率,同时也牺牲了一定的存储空间,在跳跃表中查找一个指定元素的流程比较简单。如上图所示,以左上角元素作为起点:如果发现当前节点的后继节点的值小于等于待查询值,则沿着这条链表向后查询,否则,切换到当前节点的下一层链表。继续查询,直到找到待查询值为止(或者当前节点为空节点)为止。
跳跃表的构建稍微复杂一点,首先,需要按照上述查找流程找到待插入元素的前驱和后继;然后,按照如下随机算法生成一个高度值:
// p是一个(0,1)之间的常数,一般取p=1/4或者1/2
public void randomHeight(doubule p) {
int height = 0;
while(random.newtDouble < p) {
height++;
}
return height + 1;
}
最后,将待插入节点按照randomHeight生成一个垂直节点的位置(这个节点的层数位置正好等于高度值),之后插入到跳跃表的多条链表中去。假设height=randomHeight§,这里需要分两种情况讨论:如果height大于跳跃表的高度,那么跳跃表的高度被提升为height,同时需要更新头部节点和尾部节点的指针指向。如果height小于等于跳跃表的高度,那么需要更新待插入元素前驱和后继的指针指向。
随着写入的增加,内存数据会不断地刷新到磁盘上。最终磁盘上的数据文件会越来越多。所以LSM树的实现实际上是将写入操作全部转化为了磁盘的顺序写入,提高了写入性能。但是,这种设计是以牺牲一定的读操作性能为代价的,因为读取的时候,需要归并多个文件来获取满足条件的KV,非常消耗磁盘IO,这样读性能会很差,所以hbase内部采用多路合并思想异步地进行compaction把多个小文件合并成一个大文件,以提高读数据性能。
多路归并思想
回顾一下多路归并的算法思路,假设现在有K个文件,其中第i个文件内部存储有Ni个正整数(这些整数在文件内按照从小到大的顺序存储),如何将K个有序文件合并成一个大的有序文件?
可以使用多路归并算法进行实现。对每个文件设计一个指针,取出K个指针中数值最小的一个,然后把最小的那个指针后移,接着继续找K个指针中数值最小的一个,继续后移指针……直到N个文件全部读完为止。如下图示例所示:
具体实现上,可以用一个最小堆来维护K个指针,每次从堆中取最小值,开销为logK,最多从堆中取sum(Ni)次元素。
为了优化读取操作的性能,hbase会进行两种类型的compact。
minor compact:即选中少数几个Hfile,将他们多路归并成一个文件。这种方式的有点是可以进行局部的Compact,通过少量的IO减少文件个数,提高读取操作的性能。适合较高频率的执行。但它的缺点是只合并了局部的数据,对于那些全局删除操作,无法在合并过程中完全删除。
major compact:将所有的HFile一次性多路归并成一个文件。这种方式的好处是,合并之后只有一个文件,这样读取的性能肯定是最高的。但它的问题是合并所有的文件可能需要很长的时间并消耗大量的IO走远,因此major compact不宜使用太频繁,可以选择周期性的执行,或者在集群空闲时手动执行。
布隆过滤器(Bloom Filter)是由布隆(Burton Howard Bloom)在1970年提出的。它实际上是由一个很长的二进制向量和一系列随机映射函数组成,布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率(假正例False positives,即Bloom Filter报告某一元素存在于某集合中,但是实际上该元素并不在集合中)和删除困难,但是没有识别错误的情形(即假反例False negatives,如果某个元素确实没有在该集合中,那么Bloom Filter 是不会报告该元素存在于集合中的,所以不会漏报)。
由于布隆过滤器只需占用极小的空间,便可给出 “可能存在” 和 “肯定不存在”的存在性判断。因此可以提前过滤掉很多不必要的数据块,从而节省了大量的磁盘IO。hbase的get操作就是通过运用低成本高效率的布隆过滤器来过滤大量无效数据块的,从而节省了大量磁盘IO。
如果在表中设置了Bloomfilter,那么HBase会在生成StoreFile时包含一份bloomfilter结构的数据,称其为MetaBlock;MetaBlock与DataBlock(真实的KeyValue数据)一起由LRUBlockCache维护。所以,开启bloomfilter会有一定的存储及内存cache开销。
hbase中布隆过滤器的3中类型:
none :关闭布隆过滤器功能
row 按照rowkey计算布隆过滤器的二进制串并存储。get查询时,必须带rowkey.
rowcol 按照rowkey+family+qualifier这3个字段拼出byte[]来计算布隆过滤器值并存储。如果查询时,get可以指定到这3个字段,则肯定可以通过布隆过滤器提高性能。
任何类型的get(基于rowkey或row+col)Bloom Filter的优化都能生效,关键是get的类型要匹配Bloom Filter的类型。基于row的scan是没办法走Bloom Filter的,因为Bloom Filter是需要事先知道过滤项的,对于顺序scan是没有事先办法知道owkey的,而get是指明了rowkey所以可以用Bloom Filter。
在使用布隆过滤器时,需要注意两个问题:
什么时候应该使用布隆过滤器?根据上面的描述,布隆过滤器的主要作用,是帮助HBase跳过那些显然不包括所查找数据的底层文件。那么,当所查找的数据均匀分布在所有文件中(当用户定期更新所有行时,就可能导致这种情况),布隆过滤器的作用就微乎其微,反而浪费了存储空间。相反,如果我们查找的数据只包含在少部分的文件中,就应该果断使用布隆过滤器。
应该选择行级还是行加列级布隆过滤器?很显然,行加列级因为粒度更细,占用的存储空间也就越多。因此,如果用户总是读取整行的数据,行级布隆过滤器就够用了。在可以选择的情形下,尽可能使用行级布隆过滤器,因为它在额外的空间开销和利用过滤存储文件提升性能之间取得了更好的平衡。
除了应用compaction机制和布隆过滤器之外,BlockCache也是hbase读性能优化的一个重要手段之一。客户端读取某个Block,首先会检查该Block是否存在于Block Cache,如果存在就直接加载出来,如果不存在则去Hfile文件中加载,加载出来之后放到BlockCache中。后续同一请求或者邻近数据查找请求可以直接去内存中获取,以避免昂贵的IO操作。
BlockCache是regionServer级别的,BlockCache主要用来缓存Block(Block是Hbase中最小的数据读取单元),本质上是使用一个ConcurrentHashMap来管理BlockKey到Block的映射关系。一个RegionServer只有一个BlockCache.在Region启动的时候完成BlockCache的初始化工作。目前为止,针对BlockCache有3种实现方案:
LRUBlockCache是最早的实现方案,也是默认的实现方案。
SlabCache.在hbase0.98版本之后,已经不建议使用该方案。
BucketCache 基于SlabCache设计的一种高效缓存方案。
下面分别介绍下这三种方案:
1. LRUBlockCache
HBase默认的BlockCache实现方案,Block数据块都存储在 JVM heap内,由JVM进行垃圾回收管理。它将内存从逻辑上分为了三块:single-access区、mutil-access区、in-memory区,分别占到整个BlockCache大小的25%、50%、25%。一次随机读中,一个Block块从HDFS中加载出来之后首先放入signle区,后续如果有多次请求访问到这块数据的话,就会将这块数据移到mutil-access区。而in-memory区表示数据可以常驻内存,一般用来存放访问频繁、数据量小的数据,比如元数据,用户也可以在建表的时候通过设置列族属性IN-MEMORY= true将此列族放入in-memory区。很显然,这种设计策略类似于JVM中young区、old区以及perm区。无论哪个区,系统都会采用严格的Least-Recently-Used算法,当BlockCache总量达到一定阈值之后就会启动淘汰机制,最少使用的Block会被置换出来,为新加载的Block预留空间。
2. SlabCache
为了解决LRUBlockCache方案中因为JVM垃圾回收导致的服务中断,SlabCache方案使用Java NIO DirectByteBuffer技术实现了堆外内存存储,不再由JVM管理数据内存。默认情况下,系统在初始化的时候会分配两个缓存区,分别占整个BlockCache大小的80%和20%,每个缓存区分别存储固定大小的Block块,其中前者主要存储小于等于64K大小的Block,后者存储小于等于128K Block,如果一个Block太大就会导致两个区都无法缓存。和LRUBlockCache相同,SlabCache也使用Least-Recently-Used算法对过期Block进行淘汰。和LRUBlockCache不同的是,SlabCache淘汰Block的时候只需要将对应的bufferbyte标记为空闲,后续cache对其上的内存直接进行覆盖即可。
线上集群环境中,不同表不同列族设置的BlockSize都可能不同,很显然,默认只能存储两种固定大小Block的SlabCache方案不能满足部分用户场景,比如用户设置BlockSize = 256K,简单使用SlabCache方案就不能达到这部分Block缓存的目的。因此HBase实际实现中将SlabCache和LRUBlockCache搭配使用,称为DoubleBlockCache。一次随机读中,一个Block块从HDFS中加载出来之后会在两个Cache中分别存储一份;缓存读时首先在LRUBlockCache中查找,如果Cache Miss再在SlabCache中查找,此时如果命中再将该Block放入LRUBlockCache中。
经过实际测试,DoubleBlockCache方案有很多弊端。比如SlabCache设计中固定大小内存设置会导致实际内存使用率比较低,而且使用LRUBlockCache缓存Block依然会因为JVM GC产生大量内存碎片。因此在HBase 0.98版本之后,该方案已经被不建议使用。
3. BucketCache
SlabCache方案在实际应用中并没有很大程度改善原有LRUBlockCache方案的GC弊端,还额外引入了诸如堆外内存使用率低的缺陷。然而它的设计并不是一无是处,至少在使用堆外内存这个方面给予了阿里大牛们很多启发。站在SlabCache的肩膀上,他们开发了BucketCache缓存方案并贡献给了社区。
BucketCache通过配置可以工作在三种模式下:heap,offheap和file。无论工作在那种模式下,BucketCache都会申请许多带有固定大小标签的Bucket,和SlabCache一样,一种Bucket存储一种指定BlockSize的数据块,但和SlabCache不同的是,BucketCache会在初始化的时候申请14个不同大小的Bucket,而且即使在某一种Bucket空间不足的情况下,系统也会从其他Bucket空间借用内存使用,不会出现内存使用率低的情况。接下来再来看看不同工作模式,heap模式表示这些Bucket是从JVM Heap中申请,offheap模式使用DirectByteBuffer技术实现堆外内存存储管理,而file模式使用类似SSD的高速缓存文件存储数据块。
实际实现中,HBase将BucketCache和LRUBlockCache搭配使用,称为CombinedBlockCache。和DoubleBlockCache不同,系统在LRUBlockCache中主要存储Index Block和Bloom Block,而将Data Block存储在BucketCache中。因此一次随机读需要首先在LRUBlockCache中查到对应的Index Block,然后再到BucketCache查找对应数据块。BucketCache通过更加合理的设计修正了SlabCache的弊端,极大降低了JVM GC对业务请求的实际影响,但也存在一些问题,比如使用堆外内存会存在拷贝内存的问题,一定程度上会影响读写性能。当然,在后来的版本中这个问题也得到了解决,见https://issues.apache.org/jira/browse/HBASE-11425。