HBase中的用户数据在LSM树体系架构中最终会形成一个一个小的HFile文件。我们知道,HFile小文件如果数量太多会导致读取低效。为了提高读取效率,LSM树体系架构设计了一个非常重要的模块——Compaction。Compaction核心功能是将小文件合并成大文件,提升读取效率。一般基于LSM树体系架构的系统都会设计Compaction,比如LevelDB、RocksDB以及Cassandra等。
Compaction是从一个Region的一个Store中选择部分HFile文件进行合并。合并原理是,先从这些待合并的数据文件中依次读出KeyValue,再由小到大排序后写入一个新的文件。之后,这个新生成的文件就会取代之前已合并的所有文件对外提供服务。
HBase根据合并规模将Compaction分为两类:Minor Compaction和Major Compaction。
(1)Minor Compaction是指选取部分小的、相邻的HFile,将它们合并成一个更大的HFile。
(2)Major Compaction是指将一个Store中所有的HFile合并成一个HFile,这个过程还会完全清理三类无意义数据:被删除的数据、TTL过期数据、版本号超过设定版本号的数据。
一般情况下,Major Compaction持续时间会比较长,整个过程会消耗大量系统资源,对上层业务有比较大的影响。因此线上部分数据量较大的业务通常推荐关闭自动触发Major Compaction功能,改为在业务低峰期手动触发(或设置策略自动在低峰期触发)。
在HBase的体系架构下,Compaction有以下核心作用:
(1)合并小文件,减少文件数,稳定随机读延迟。
(2)提高数据的本地化率。
(3)清除无效数据,减少数据存储量。
注意:
(1)随着HFile文件数不断增多,查询可能需要越来越多的IO操作,读取延迟必然会越来越大。
(2)执行Compaction会使文件个数基本稳定,进而读取IO的次数会比较稳定,延迟就会稳定在一定范围
(3)Compaction的另一个重要作用是提高数据的本地化率。本地化率越高,在HDFS上访问数据时延迟就越小;相反,本地化率越低,访问数据就可能大概率需要通过网络访问,延迟必然会比较大。
(4)Compaction合并小文件的同时会将落在远程DataNode上的数据读取出来重新写入大文件,合并后的大文件在当前DataNode节点上有一个副本,因此可以提高数据的本地化率。极端情况下,Major Compaction可以将当前Region的本地化率提高到100%。这也是最常用的一种提高数据本地化率的方法。
(5)Compaction在执行过程中有个比较明显的副作用:Compaction操作重写文件会带来很大的带宽压力以及短时间IO压力。这点比较容易理解,要将小文件的数据读出来需要IO,很多小文件数据跨网络传输需要带宽,读出来之后又要写成一个大文件,因为是三副本写入,必然需要网络开销,当然写入IO开销也避免不了。因此可以认为,Compaction就是使用短时间的IO消耗以及带宽消耗换取后续查询的低延迟。从图7-2上看,虽然数据读取延迟相比图一稳定了一些,但是读取响应时间有了很大的毛刺,这是因为Compaction在执行的时候占用系统资源导致业务读取性能受到一定波及。
总结:
Compaction操作是所有LSM树结构数据库所特有的一种操作,它的核心操作是批量将大量小文件合并成大文件用以提高读取性能。另外,Compaction是有副作用的,它在一定程度上消耗系统资源,进而影响上层业务的读取响应。因此Compaction通常会设计各种措施,并且针对不同场景设置不同的合并策略来尽量避免对上层业务的影响。
HBase中Compaction只有在特定的触发条件才会执行,比如部分flush操作完成之后、周期性的Compaction检查操作等。一旦触发,HBase会按照特定流程执行Compaction。
HBase会将该Compaction交由一个独立的线程处理,该线程首先会从对应Store中选择合适的HFile文件进行合并,这一步是整个Compaction的核心。选取文件需要遵循很多条件,比如待合并文件数不能太多也不能太少、文件大小不能太大等,最理想的情况是,选取那些IO负载重、文件小的文件集。实际实现中,HBase提供了多种文件选取算法,如RatioBasedCompactionPolicy、ExploringCompactionPolicy和StripeCompactionPolicy等,用户也可以通过特定接口实现自己的Compaction策略。选出待合并的文件后,HBase会根据这些HFile文件总大小挑选对应的线程池处理,最后对这些文件执行具体的合并操作。
HBase中触发Compaction的时机有很多,最常见的时机有如下三种:MemStore Flush、后台线程周期性检查以及手动触发。
应该说Compaction操作的源头来自flush操作,MemStore Flush会产生HFile文件,文件越来越多就需要compact执行合并。因此在每次执行完flush操作之后,都会对当前Store中的文件数进行判断,一旦Store中总文件数大于hbase.hstore.compactionThreshold,就会触发Compaction。需要说明的是,Compaction都是以Store为单位进行的,而在flush触发条件下,整个Region的所有Store都会执行compact检查,所以一个Region有可能会在短时间内执行多次Compaction。
RegionServer会在后台启动一个线程CompactionChecker,定期触发检查对应Store是否需要执行Compaction,检查周期为hbase.server.thread.wakefrequency*hbase.server.compactchecker.interval.multiplier。和flush不同的是,该线程优先检查Store中总文件数是否大于阈值hbase.hstore.compactionThreshold,一旦大于就会触发Compaction;如果不满足,接着检查是否满足Major Compaction条件。简单来说,如果当前Store中HFile的最早更新时间早于某个值mcTime,就会触发Major Compaction。mcTime是一个浮动值,浮动区间默认为[7-7×0.2,7+7×0.2],其中7为hbase.hregion.majorcompaction,0.2为hbase.hregion.majorcompaction.jitter,可见默认在7天左右就会执行一次Major Compaction。用户如果想禁用Major Compaction,需要将参数hbase.hregion.majorcompaction设为0。
一般来讲,手动触发Compaction大多是为了执行Major Compaction。使用手动触发Major Compaction的原因通常有三个——其一,因为很多业务担心自动Major Compaction影响读写性能,因此会选择低峰期手动触发;其二,用户在执行完alter操作之后希望立刻生效,手动触发Major Compaction;其三,HBase管理员发现硬盘容量不够时手动触发Major Compaction,删除大量过期数据。
选择合适的文件进行合并是整个Compaction的核心,因为合并文件的大小及其当前承载的IO数直接决定了Compaction的效果以及对整个系统其他业务的影响程度。理想的情况是,选择的待合并HFile文件集合承载了大量IO请求但是文件本身很小,这样Compaction本身不会消耗太多IO,而且合并完成之后对读的性能会有显著提升。然而现实中可能大部分HFile文件都不会这样,在HBase早期的版本中,分别提出了两种选择策略:RatioBasedCompactionPolicy以及ExploringCompactionPolicy,后者在前者的基础上做了进一步修正。
在选择策略时,都会首先对该Store中所有HFile逐一进行排查,排除不满足条件的部分文件:
(1)排除当前正在执行Compaction的文件以及比这些文件更新的所有文件。
(2)排除某些过大的文件,如果文件大于hbase.hstore.compaction.max.size(默认Long.MAX_VALUE),则被排除,否则会产生大量IO消耗。
经过排除后留下来的文件称为候选文件,接下来HBase再判断侯选文件是否满足Major Compaction条件,如果满足,就会选择全部文件进行合并。
判断条件,只要满足其中一条就会执行Major Compaction:
(1)用户强制执行Major Compaction。
(2)长时间没有进行Major Compaction(上次执行Major Compaction的时间早于当前时间减去hbase.hregion.majorcompaction)且候选文件数小于hbase.hstore.compaction.max(默认10)。
(3)Store中含有reference文件,reference文件是region分裂产生的临时文件,一般必须在Compaction过程中清理。
如果满足Major Compaction条件,文件选择这一步就结束了,待合并HFile文件就是Store中所有HFile文件。如果不满足Major Compaction条件,就必然为Minor Compaction,HBase主要有两种Minor Compaction文件选择策略,一种是RatioBasedCompactionPolicy,另一种是ExploringCompactionPolicy。
从老到新逐一扫描所有候选文件,满足其中条件之一便停止扫描:
(1)当前文件大小<比当前文件新的所有文件大小总和*ratio。其中ratio是一个可变的比例,在高峰期ratio为1.2,非高峰期ratio为5,也就是非高峰期允许compact更大的文件。HBase允许用户配置参数hbase.offpeak.start.hour和hbase.offpeak.end.hour设置高峰期时间段。
(2)当前所剩候选文件数<=hbase.store.compaction.min(默认为3)。
停止扫描后,待合并文件就选择出来了,即当前扫描文件以及比它更新的所有文件。
该策略思路基本和RatioBasedCompactionPolicy相同,不同的是,Ratio策略在找到一个合适的文件集合之后就停止扫描了,而Exploring策略会记录所有合适的文件集合,并在这些文件集合中寻找最优解。最优解可以理解为:待合并文件数最多或者待合并文件数相同的情况下文件较小,这样有利于减少Compaction带来的IO消耗。
HBase实现中有一个专门的类CompactSplitThead负责接收Compaction请求和split请求,而且为了能够独立处理这些请求,这个类内部构造了多个线程池:largeCompactions、smallCompactions以及splits等。splits线程池负责处理所有的split请求,largeCompactions用来处理大Compaction,smallCompaction负责处理小Compaction。
注意:
(1)上述设计目的是能够将请求独立处理,提高系统的处理性能。
(2)大Compaction并不是Major Compaction,小Compaction也并不是Minor Compaction。HBase定义了一个阈值hbase.regionserver.thread.compaction.throttle,如果Compaction合并的总文件大小超过这个阈值就认为是大Compaction,否则认为是小Compaction。大Compaction会分配给largeCompactions线程池处理,小Compaction会分配给small
(3)Compactions线程池处理。
(4)largeCompactions线程池和smallCompactions线程池默认都只有一个线程,用户可以通过参数hbase.regionserver.thread.compaction.large和hbase.regionserver.thread.compaction.small进行配置。
选出待合并的HFile集合,再选出合适的处理线程,接下来执行合并流程。合并流程主要分为如下几步:
(1)分别读出待合并HFile文件的KeyValue,进行归并排序处理,之后写到./tmp目录下的临时文件中。
(2)将临时文件移动到对应Store的数据目录。
(3)将Compaction的输入文件路径和输出文件路径封装为KV写入HLog日志,并打上Compaction标记,最后强制执行sync。
(4)将对应Store数据目录下的Compaction输入文件全部删除。
合并流程具有很强的容错性和幂等性:
(1)如果RegionServer在步骤2)之前发生异常,本次Compaction会被认定为失败,如果继续进行同样的Compaction,上次异常对接下来的Compaction不会有任何影响,也不会对读写有任何影响。唯一的影响就是多了一份多余的数据。
(2)如果RegionServer在步骤2)之后、步骤3)之前发生异常,同样,仅仅会多一份冗余数据。
(3)如果在步骤3)之后、步骤4)之前发生异常,RegionServer在重新打开Region之后首先会从HLog中看到标有Compaction的日志,因为此时输入文件和输出文件已经持久化到HDFS,因此只需要根据HLog移除Compaction输入文件即可。
对文件进行Compaction操作可以提升业务读取性能,然而,如果不对Compaction执行阶段的读写吞吐量进行限制,可能会引起短时间大量系统资源消耗,使得用户业务发生读写延迟抖动。HBase社区意识到了这个问题,并提出了一些优化方案,
该优化方案通过感知Compaction的压力情况自动调节系统的Compaction吞吐量,在压力大的时候降低合并吞吐量,压力小的时候增加合并吞吐量。
基本原理:
(1)在正常情况下,用户需要设置吞吐量下限参数"hbase.hstore.compaction.throughput.lower.bound"和上限参数"hbase.hstore.compaction.throughput.higher.bound",而HBase实际工作在吞吐量为lower+(higher-lower)*ratio的情况下,其中ratio是一个取值(0,1)的小数,它由当前Store中待参与Compaction的HFile数量决定,数量越多,ratio越小,反之越大。
(2)如果当前Store中HFile的数量太多,并且超过了参数blockingFileCount,此时所有写请求就会被阻塞以等待Compaction完成,这种场景下上述限制会自动失效。
Compaction除了带来严重的IO放大效应之外,在某些情况下还会因为大量消耗带宽资源而严重影响其他业务。
Compaction大量消耗带宽资源的两个原因:
(1)正常请求下,Compaction尤其是Major Compaction会将大量数据文件合并为一个大HFile,读出所有数据文件的KV,重新排序之后写入另一个新建的文件。如果待合并文件都在本地,那么读是本地读,不会出现跨网络的情况;如果待合并文件并不都在本地,则需要跨网络进行数据读取。一旦跨网络读取就会有带宽资源消耗。
(2)数据写入文件默认都是三副本写入,不同副本位于不同节点,因此写的时候会跨网络执行,必然会消耗带宽资源。
与Limit Compaction Speed思路基本一致,Compaction BandWidth Limit方案中主要涉及两个参数:compactBwLimit和numOfFilesDisableCompactLimit。
主要作用:
(1)compactBwLimit:一次Compaction的最大带宽使用量,如果Compaction所使用的带宽高于该值,就会强制其sleep一段时间。
(2)numOfFilesDisableCompactLimit:在写请求非常大的情况下,限制Compaction带宽的使用量必然会导致HFile堆积,进而会影响读请求响应延时。因此该值意义很明显,一旦Store中HFile数量超过该设定值,带宽限制就会失效。
Compaction合并小文件,一方面提高了数据的本地化率,降低了数据读取的响应延时,另一方面也会因为大量消耗系统资源带来不同程度的短时间读取响应毛刺。因此,HBase对Compaction的设计追求一个平衡点,既要保证Compaction的基本效果,又不会带来严重的IO压力。
并没有一种设计策略能够适用于所有应用场景或所有数据集。在意识到这样的问题之后,HBase希望提供一种机制,一方面可以在不同业务场景下针对不同设计策略进行测试,另一方面也可以让用户针对自己的业务场景选择合适的Compaction策略。因此,在0.96版本中HBase对架构进行了一定的调整,提供了Compaction插件接口,用户只需要实现特定的接口,就可以根据自己的应用场景以及数据集定制特定的Compaction策略。同时,在0.96版本之后Compaction可以支持表/列簇粒度的策略设置,使得用户可以根据应用场景为不同表/列簇选择不同的Compaction策略
FIFO Compaction策略主要参考了RocksDB的实现,它选择那些过期的数据文件,即该文件内所有数据都已经过期。因此,对应业务的列簇必须设置有TTL,否则不适合该策略。需要注意的是,该策略只做一件事:收集所有已经过期的文件并删除。这一策略的应用场景主要包括:大量短时间存储的原始数据,比如推荐业务,上层业务只需最近时间的用户行为特征。再比如Nginx日志,用户只需存储最近几天的日志,方便查询某个用户最近一段时间的操作行为等。
因为FIFO Compaction只是收集所有过期的数据文件并删除,并没有真正执行重写(几个小文件合并成大文件),因此不会消耗任何CPU和IO资源,也不会从BlockCache中淘汰任何热点数据。所以,无论对于读还是写,该策略都会提升吞吐量、降低延迟。
所有“文件选取策略”实际上都不够灵活,没有考虑到热点数据的情况。然而现实业务中,有很大比例的业务都存在明显的热点数据,其中最常见的情况是:最近写入的数据总是最有可能被访问到,而老数据被访问到的频率相对比较低。按照之前的文件选择策略,并没有对新文件和老文件进行“区别对待”,每次Compaction时可能会有很多老文件参与合并,这必然影响Compaction效率,且对降低读延迟没有太大的帮助。
针对这种情况,HBase社区借鉴Facebook HBase分支解决方案,引入了Tier-Based Compaction。这种方案会根据候选文件的新老程度将其分为不同的等级,每个等级都有对应的参数,比如参数Compaction Ratio,表示该等级文件的选择几率,Ratio越大,该等级的文件越有可能被选中参与Compaction。而等级数、等级参数都可以通过CF属性在线更新。
可见,通过引入时间等级和Compaction Ratio等概念,使得Tier-Based Compaction方案更加灵活,不同业务场景只需要调整参数就可以达到更好的Compaction效率。目前HBase计划在后续版本发布基于时间划分等级的实现方式——Date Tiered Compaction Policy。该方案的具体实现思路更多地参考了Cassandra的实现方案——基于时间窗的时间概念。
Cassandra通过设置多个时间窗来实现分级,时间窗的窗口大小类似于Compaction Ratio参数的作用,可以通过调整时间窗的大小来调整不同时间窗文件选择的优先级。比如,可以将最右边的时间窗窗口调大,那么新文件被选中参与Compaction的概率就会大大增加。然而,这个方案里并没有类似于当前HBase中的MajorCompaction策略来实现过期文件清理功能,只能借助TTL来主动清理过期的文件。比如,若这个文件中所有数据都过期了,就可以将这个文件清理掉。
使用Tier-Based Compaction策略需要遵循的原则:
(1)特别适合使用的场景:时间序列数据,默认使用TTL删除。类似于“获取最近1小时/3小时/1天”场景,同时不会执行delete操作。最典型的例子是基于OpenTSDB的监控系统。
(2)比较适合的应用场景:时间序列数据,但是有全局数据的更新操作以及少部分的删除操作。
(3)不适合的应用场景:非时间序列数据,或者包含大量的数据更新操作和删除操作。
Level Compaction设计思路是将Store中的所有数据划分为很多层,每一层都会有一部分数据:
(1)数据不再按照时间先后进行组织,而是按照KeyRange进行组织。每个KeyRange中包含多个文件,这些文件所有数据的Key必须分布在同一个范围。比如Key分布在Key0~KeyN之间的所有数据都会落在第一个KeyRange区间的文件中,Key分布在KeyN+1~KeyT之间的所有数据会落在第二个区间的文件中,以此类推。
(2)整个数据体系会被划分为很多层,最上层(Level 0)表示最新数据,最下层(Level 6)表示最旧数据。每一层都由大量KeyRange块组成(Level 0除外),KeyRange之间没有Key重合。而且层数越大,对应层的每个KeyRange块越大,下层KeyRange块大小是上一层的10倍。数据从MemStore中flush之后,会先落入Level 0,此时落入Level 0的数据可能包含所有可能的Key。此时如果需要执行Compaction,只需将Level 0中的KV逐个读出来,然后按照Key的分布分别插入Level 1中对应KeyRange块的文件中,如果此时刚好Level 1中的某个KeyRange块大小超过了阈值,就会继续往下一层合并。
(3)Level Compaction中依然存在Major Compaction的概念,发生Major Compaction只需将部分Range块内的文件执行合并就可以,而不需要合并整个Region内的数据文件。
在这种合并策略实现中,从上到下只需要部分文件参与,而不需要对所有文件执行Compaction操作。另外,Level Compaction还有另外一个好处,对于很多“只读最近写入数据”的业务来说,大部分读请求都会落到Level 0,这样可以使用SSD作为上层Level存储介质,进一步优化读。然而,Level Compaction因为层数太多导致合并的次数明显增多,经过测试发现,LevelCompaction对IO利用率并没有显著提升。
虽然原生的Level Compaction并不适用于HBase,但这种分层合并的思想却激发了HBase开发工程师的灵感,一种称为Stripe Compaction的新策略被创造出来。同Level Compaction相同,Stripe Compaction会将整个Store中的文件按照Key划分为多个range,在这里称为stripe。stripe的数量可以通过参数设定,相邻的stripe之间Key不会重合。实际上从概念上看stripe类似于sub-region,即将一个大Region切分成很多小的sub-region。
随着数据写入,MemStore执行flush形成HFile,这些HFile并不会马上写入对应的stripe,而是放到一个称为L0的地方,用户可以配置L0放置HFile的数量。一旦L0放置的文件数超过设定值,系统就会将这些HFile写入对应的stripe:首先读出HFile的KV,再根据KV的Key定位到具体的stripe,将该KV插入对应的stripe文件中,stripe就是一个个小的Region,所以在stripe内部,依然会像正常Region一样执行Minor Compaction和Major Compaction,可以预见,stripe内部的Major Compaction并不会消耗太多系统资源。另外,数据读取也很简单,系统可以根据对应的Key查找到对应的stripe,然后在stripe内部执行查找,因为stripe内数据量相对很小,所以也会在一定程度上提升数据查找性能。
Stripe Compaction比较擅长的业务场景:
(1)大Region。小Region没有必要切分为stripe,一旦切分,反而会带来额外的管理开销。一般默认Region小于2G,就不适合使用Stripe Compaction。
(2)Rowkey具有统一格式,Stripe Compaction要求所有数据按照Key进行切分,生成多个stripe。如果rowkey不具有统一格式,则无法进行切分。
文章来源:《HBase原理与实践》 作者:胡争;范欣欣
文章内容仅供学习交流,如有侵犯,联系删除哦!