一条数据的 HBase 之旅,简明 HBase 入门教程 -Flush 与 Compaction
Flush与Compaction其实属于Write流程的继续,所以本文应该称之为"Write后传"。在2.0版本中,最主要的变化就是新增了In-memory Flush/Compaction,而DateTieredCompaction并不算2.0新加入的特性,2.0版本在Compaction核心算法方面并没有什么新的突破。本文将带你探讨Compaction的一些本质问题。
“
前文回顾
”
前文《一条数据的HBase之旅,简明HBase入门教程:Write全流程》主要讲了如下内容:
1. 介绍HBase写数据可选接口以及接口定义
2. 通过一个样例,介绍了RowKey定义以及列定义的一些方法,以及如何组装Put对象
3. 数据路由,数据分发、打包,以及Client通过RPC发送写数据请求至RegionServer
4. RegionServer接收数据以后,将数据写到每一个Region中。写数据流程先写WAL再写MemStore,这里展开了一些技术细节
5. 简单介绍了HBase权限控制模型
至此,数据已经被写入WAL与MemStore,可以说数据已经被成功写到HBase中了。事实上,上篇文章讲到的Flush流程是"简化"后的流程,在2.0版本中,这里已经变的更加复杂。
“
本文思路
”
1. 回顾Flush&Compaction旧流程
2. 介绍2.0版本的In-memory Flush & Compaction特性
3. 介绍Compaction要解决的本质问题是什么
4. 在选择合理的Compaction策略之前,用户应该从哪些维度调研自己的业务模型
5. HBase现有的Compaction策略有哪些,各自的适用场景是什么
6. Compaction如何选择待合并的文件
7. 关于Compaction更多的一些思考
8. 本计划列出所有的价值问题单,但篇幅文字受限。可以点击"阅读原文"链接参考文末References信息部分
“
Flush&Compaction
”
这是1.X系列版本以及更早版本中的Flush&Compaction行为:
MemStore中的数据,达到一定的阈值,被Flush成HDFS中的HFile文件。
但随着Flush次数的不断增多,HFile的文件数量也会不断增多,那这会带来什么影响?在HBaseCon 2013大会上,Hontonworks的名为《Compaction Improvements in Apache HBase》的演讲主题中,提到了他们测试过的随着HFile的数量的不断增多对读取时延带来的影响:
尽管关于Read的流程在后面文章中才会讲到,但下图可以帮助我们简单的理解这其中的原因:
图中说明了从一个文件中指定RowKey为“66660000431^201803011300”的记录,以及从两个文件中读取该行记录的区别,明显,从两个文件中读取,将导致更多的IOPS。这就是HBase Compaction存在的一大初衷,Compaction可以将一些HFile文件合并成较大的HFile文件,也可以把所有的HFile文件合并成一个大的HFile文件,这个过程可以理解为:将多个HFile的“交错无序状态”,变成单个HFile的“有序状态”,降低读取时延。小范围的HFile文件合并,称之为Minor Compaction,一个列族中将所有的HFile文件合并,称之为Major Compaction。除了文件合并范围的不同之外,Major Compaction还会清理一些TTL过期/版本过旧以及被标记删除的数据。下图直观描述了旧版本中的Flush与Compaction流程:
“
Flush
”
在2.0版本中,Flush的行为发生了变化,默认的Flush,仅仅是将正在写的MemStore中的数据归档成一个不可变的Segment,而这个Segment依然处于内存中,这就是2.0的新特性:In-memory Flush and Compaction ,而且该特性在2.0版本中已被默认启用(系统表除外)。
上文中简单提到了MemStore的核心是一个CellSet,但到了这里,我们可以发现,MemStore的结构其实更加复杂:MemStore由一个可写的Segment,以及一个或多个不可写的Segments构成。
MemStore中的数据先Flush成一个Immutable的Segment,多个Immutable Segments可以在内存中进行Compaction,当达到一定阈值以后才将内存中的数据持久化成HDFS中的HFile文件。
看到这里,可能会有这样的疑问:这样做的好处是什么?为何不直接调大MemStore的Flush Size?
如果MemStore中的数据被直接Flush成HFile,而多个HFile又被Compaction合并成了一个大HFile,随着一次次Compaction发生以后,一条数据往往被重写了多次,这带来显著的IO放大问题,另外,频繁的Compaction对IO资源的抢占,其实也是导致HBase查询时延大毛刺的罪魁祸首之一。而In-memory Flush and Compaction特性可以有力改善这一问题。
那为何不干脆调大MemStore的大小?这里的本质原因在于,ConcurrentSkipListMap在存储的数据量达到一定大小以后,写入性能将会出现显著的恶化。
在融入了In-Memory Flush and Compaction特性之后,Flush与Compaction的整体流程演变为:
关于Flush执行的策略
一个Region中是否执行Flush,原来的默认行为是通过计算Region中所有Column Family的整体大小,如果超过了一个阈值,则这个Region中所有的Column Family都会被执行Flush。
2.0版本中合入了0.89-fb版本中的一个特性:HBASE-10201/HBASE-3149:Make flush decisions per column family。
2.0版本中默认启用的Flush策略为FlushAllLargeStoresPolicy,也就是说,这个策略使得每一次只Flush超出阈值大小的Column Family,如果都未超出大小,则所有的Column Family都会被Flush。
改动之前,一个Region中所有的Column Family的Flush都是同步的,虽然容易导致大量的小HFile,但也有好处,尤其是对于WAL文件的快速老化,避免导致过多的WAL文件。而如果这些Column Family的Flush不同步以后,可能会导致过多的WAL文件(过多的WAL文件会触发一些拥有老数据的Column Family执行Flush),这里需要结合业务场景实测一下。如果每一行数据的写入,多个Column Family的数据都是同步写入的,那么,这个默认策略可能并不合适。
“ Compaction ”
在这个章节,我们继续探讨HBase Compaction,主要是想理清这其中所涉及的一些"道"与"术":
"道":HBase Compaction要解决的本质问题是什么?
"术":针对HBase Compaction的问题本质,HBase有哪些具体的改进/优秀实践?
Compaction会导致写入放大
我们先来看看Facebook在Fast 14提交的论文《Analysis of HDFS Under HBase: A Facebook Messages Case Study》所提供的一些测试结论(在我之前写的一篇文章《从HBase中移除WAL?3D XPoint技术带来的变革》已经提到过):
在Facebook Messages业务系统中,业务读写比为99:1,而最终反映到磁盘中,读写比却变为了36:64。WAL,HDFS Replication,Compaction以及Caching,共同导致了磁盘写IO的显著放大。
虽然距离论文发表已经过去几年,但问题本质并未发生明显变化。尤其是在一个写多读少的应用中,因Compaction带来的**写放大(Write Amplification)**尤其明显,下图有助于你理解写放大的原因:
随着不断的执行Minor Compaction以及Major Compaction,可以看到,这条数据被反复读取/写入了多次,这是导致写放大的一个关键原因,这里的写放大,涉及到网络IO与磁盘IO,因为数据在HDFS中默认有三个副本。
Compaction的本质
我们先来思考一下,在集群中执行Compaction,本质是为了什么?容易想到如下两点原因:
-
减少HFile文件数量,减少文件句柄数量,降低读取时延
-
Major Compaction可以帮助清理集群中不再需要的数据(过期数据,被标记删除的数据,版本数溢出的数据)
很多HBase用户在集群中关闭了自动Major Compaction,为了降低Compaction对IO资源的抢占,但出于清理数据的需要,又不得不在一些非繁忙时段手动触发Major Compaction,这样既可以有效降低存储空间,也可以有效降低读取时延。
而关于如何合理的执行Compaction,我们需要结合业务数据特点,不断的权衡如下两点:
-
避免因文件数不断增多导致读取时延出现明显增大
-
合理控制写入放大
HFile文件数量一定会加大读取时延吗?也不一定,因为这里与RowKey的分布特点有关。我们通过列举几个典型场景来说明一下不同的RowKey分布,为了方便理解,我们先将一个Region的RowKey Range进一步划分成多个Sub-Range,来说明不同的RowKey分布是如何填充这些Sub-Ranges的:
下面的不同行代表不同的时间点(我们使用不断递增的时间点T1,T2,T3,T4,来描述随着时间演变而产生的RowKey分布变化):
分布A
在这个Region中,某一个时间段只填充与之有关的一个Sub-Range,RowKey随着时间的演进,整体呈现递增趋势。但在填充每一个Sub-Range的时候,又可能有如下两种情形(以Sub-Range1的填充为例,为了区别于T1T4,我们使用了另外4个时间点TaTd):
分布A-a
这种情形下,RowKey是严格递增的。
分布A-b
这种情形下,RowKey在Sub-Range1的范围内是完全随机的。
下面则是一种随机RowKey的场景,也就是说,每一个时间点产生的数据都是随机分布在所有的Sub-Range中的:
分布B
对于分布A-a来说,不同的HFile文件在RowKey Range(该HFile文件所涉及到的最小数据RowKey与最大数据RowKey构成的RowKey区间)上并不会产生重叠,如果要基于RowKey读取一行数据,只需要查看一个文件即可,而不需要查看所有的文件,这里完全可以通过优化读取逻辑来实现。即使不做Compaction,对于读取时延的影响并不明显(当然,从降低文件句柄数量,降低HDFS侧的小文件数量的维度来考虑,Compaction还是有意义的)。
对于分布B来说,如果有多个HFiles文件,如果想基于RowKey读取一行数据,则需要查看多个文件,因为不同的HFile文件的RowKey Range可能是重叠的,此时,Compaction对于降低读取时延是非常必要的。
调研自己的业务模型
在选择一个合理的Compaction策略之前,应该首先调研自己的业务模型,下面是一些参考维度:
1.写入数据类型/单条记录大小
是否是KB甚至小于KB级别的小记录?还是MB甚至更大的图片/小文件数据?
2.业务读写比例
3.随着时间的不断推移,RowKey的数据分布呈现什么特点?
4.数据在读写上是否有冷热特点?
是否只读取/改写最近产生的数据?
5.是否有频繁的更新与删除?
6.数据是否有TTL限制?
7.是否有较长时间段的业务高峰期和业务低谷期?
几种Compaction策略
HBase中有几种典型的Compaction策略,来应对几类典型的业务场景:
Stripe Compaction
它的设计初衷是,Major Compaction占用大量的IO资源,所以很多HBase用户关闭了自动触发的Major Compaction,改为手动触发,因为Major Compaction依然会被用来清理一些不再需要的数据。
随着时间的推移,Major Compaction要合并的文件总Size越来越大,但事实上,**真的有必要每一次都将所有的文件合并成一个大的HFile文件吗?**尤其是,不断的将一些较老的数据和最新的数据合并在一起,对于一些业务场景而言根本就是不必要的。
因此,它的设计思路为:
将一个Region划分为多个Stripes(可以理解为Sub-Regions),Compaction可以控制在Stripe(Sub-Region)层面发生,而不是整个Region级别,这样可以有效降低Compaction对IO资源的占用。
那为何不直接通过设置更多的Region数量来解决这个问题?更多的Region意味着会加大HBase集群的负担,尤其是加重Region Assignment流程的负担,另外,Region增多,MemStore占用的总体内存变大,而在实际内存无法变大的情况下,只会使得Flush更早被触发,Flush的质量变差。
新Flush产生的HFile文件,先放到一个称之为L0的区域,L0中Key Range是Region的完整Key Range,当对L0中的文件执行Compaction时,再将Compaction的结果输出到对应的Stripe中:
HBase Document中这么描述Stripe Compaction的适用场景:
Large regions. You can get the positive effects of smaller regions without additional overhead for MemStore and region management overhead.
Non-uniform keys, such as time dimension in a key. Only the stripes receiving the new keys will need to compact. Old data will not compact as often, if at all
在我们上面列举的几种RowKey分布的场景中,分布A(含分布A-a,分布A-b)就是特别适合Stripe Compaction的场景,因为仅仅新写入数据的Sub-Range合并即可,而对于老的Sub-Range中所关联的数据文件,根本没有必要再执行Compaction。
Stripe Compaction关于Sub-Region的划分,其实可以加速Region Split操作,因为有的情形下,直接将所有的Stripes分成两部分即可。
Date Tiered Compaction
我们假设有这样一种场景:
-
新数据的产生与时间有关,而且无更新、删除场景
-
读取时通常会指定时间范围,而且通常读取最近的数据
在这种情形下,如果将老数据与新数据合并在一起,那么,指定时间范围读取时,就需要扫描一些不必要的老数据:因为合并后,数据按RowKey排序,RowKey排序未必与按照数据产生的时间排序一致,这使得新老数据交叉存放,而扫描时老数据也会被读到。
这是Date Tiered Compaction的设计初衷,Date Tiered Compaction在选择文件执行合并的时候,会感知Date信息,使得Compaction时,不需要将新老数据合并在一起。这对于基于Time Range的Scan操作是非常有利的,因为与本次Scan不相关的文件可以直接忽略。
什么是Time Range Based Scan?
HBase的Scan查询,通常都是指定RowKey的Range的,但HBase也支持这样一类查询:通过指定一个起始的Timestamp,扫描出所有的落在此Timestamp Range中的所有数据,这就是Time Range Based Scan。
可以参考接口:Scan#setTimeRange(long minStamp, long maxStamp)
Time Range Based Scan可以用来实现针对HBase数据表的增量数据导出/备份能力。
容易想到,时序数据就是最典型的一个适用场景。但需要注意的是,如下场景并不适合使用Date Tiered Compaction:
-
读取时通常不指定时间范围
-
涉及频繁的更新与删除
-
写入时主动指定时间戳,而且可能会指定一个未来的时间戳
-
基于bulk load加载数据,而且加载的数据可能会在时间范围上重叠
**
**
MOB Compaction
能否使用HBase来存储MB级别的Blob(如图片之类的小文件)数据?
这是很多应用面临的一个基础问题,因为这些数据相比于普通的存储于HBase中的结构化/半结构化数据显得过大了,而如果将这些数据直接存储成HDFS中的独立文件,会加重HDFS的NameNode的负担,再者,如何索引这些小文件也是一个极大的痛点。
当然,也有人采用了这样的方式:将多个小文件合并成HDFS上的一个大文件,这样子可以减轻HDFS的NameNode的负担,但需要维护每一个小文件的索引信息(文件名以及每一个小文件的偏移信息)。
如果存这些这些小文件时,像普通的结构化数据/半结构化数据一样,直接写到HBase中,会有什么问题?这样子多条数据可以被合并在较大的HFile文件中,减轻了NameNode的负担,同时解决了快速索引的问题。但基于前面的内容,我们已经清楚知道了Compaction带来的写放大问题。试想一下,数MB级别的Blob数据,被反复多次合并以后,会带来什么样的影响?这对IO资源的抢占将会更加严重。
因此,HBase的MOB特性的设计思想为:将Blob数据与描述Blob的元数据分离存储,Blob元数据采用正常的HBase的数据存储方式,而Blob数据存储在额外的MOB文件中,但在Blob元数据行中,存储了这个MOB文件的路径信息。MOB文件本质还是一个HFile文件,但这种HFile文件不参与HBase正常的Compaction流程。仅仅合并Blob元数据信息,写IO放大的问题就得到了有效的缓解。
MOB Compaction也主要是针对MOB特性而存在的,这里涉及到数据在MOB文件与普通的HFile文件之间的一些流动,尤其是MOB的阈值大小发生变更的时候(即当一个列超过预设的配置值时,才被认定为MOB),本文暂不展开过多的细节。
在HBase社区中,MOB特性(HBASE-11339)一直在一个独立的特性分支中开发的,直到2.0版本才最终合入进来(华为的FusionInsight的HBase版本中,以及华为云的CloudTable的HBase版本中,都包含了完整的MOB特性)。
Default Compaction
就是默认的Compaction行为,2.0版本和旧版本中的行为没有什么明显变化。
所谓的Default Compaction,具有更广泛的适用场景,它在选择待合并的文件时是在整个Region级别进行选择的,所以往往意味着更高的写IO放大。
在实际应用中,应该结合自己的应用场景选择合适的Compaction策略,如果前几种策略能够匹配自己的应用场景,那么应该是优选的(这几个策略的质量状态如何尚不好判断,建议结合业务场景进行实测观察),否则应该选择Default Compaction。
如果上述几种Compaction策略都无法很好的满足业务需求的话,用户还可以自定义Compaction策略,因为HBase已经具备良好的Compaction插件化机制。
如何选择待合并的文件
无论哪种Compaction策略,都涉及一个至关重要的问题:
“如何选择待合并的文件列表”
Major Compaction是为了合并所有的文件,所以,不存在如何选择文件的问题。
选择文件时,应该考虑如下几个原则:
1. 选择合理的文件数量
如果一次选择了过多的文件: 对于读取时延的影响时间范围可能比较长,但Compaction产生的写IO总量较低。
如果一次选择了较少的文件: 可能导致过于频繁的合并,导致写IO被严重放大。
2. 选择的文件在时间产生顺序上应该是连续的,即应该遵循HBase的Sequence ID的顺序
这样子,HBase的读取时可以做一些针对性的优化,例如,如果在最新的文件中已经读到了一个RowKey的记录,那就没有必要再去看较老的文件。
在HBase中,有一种"历史悠久"的选择文件的策略,名为ExploringCompactionPolicy,即使在最新的版本中,该策略依然在协助发挥作用,它选择文件的原理如下(下面的图片和例子源自HBASE-6371):
将HFile文件从老到新的顺序排序,通常,旧文件较大,因为旧文件更可能是被合并过的文件。
每一次需要从文件队列中选取一个合适的开始文件位置,通过如下算法:
_ f[start].size <= ratio * (f[start+1].size + ….. + f[end - 1].size)_
找到一个满足条件的开始位置,以及满足条件的文件组合。
**举例:**假设ratio = 1.0:
1. 如果候选文件[1200, 500, 150, 80, 50, 25, 12, 10],则最终选择出来的需要合并的文件列表为[150, 80, 50, 25, 12, 10]
2. 如果候选文件[1200, 500, 150, 80, 25, 10],则选不出任何文件。
每一次Minor Compaction所涉及的文件数目都有上下限。如果超过了,会减去一些文件,如果小于下限,则忽略此次Compaction操作。待选择的文件也有大小限制,如果超出了预设大小,就不会参与Compaction。这里可以理解为:Minor Compaction的初衷只是为了合并较小的文件。另外,BulkLoad产生的文件,在Minor Compaction阶段会被忽略。
RatioBasedCompactionPolicy曾一度作为主力文件选择算法沿用了较长的时间,后来,出现了一种ExploringCompactionPolicy,它的设计初衷为:
RatioBasedCompactionPolicy虽然选择出来了一种文件组合,但其实这个文件组合并不是最优的,因此它期望在所有的候选组合中,选择一组性价比更高的组合,性价比更高的定义为:文件数相对较多,而整体大小却较小。这样,即可以有效降低HFiles数量,又可能有效控制Compaction所占用的IO总量。
也可以这么理解它们之间的一些不同:
RatioBasedCompactionPolicy仅仅是在自定义的规则之下找到第一个"可行解"即可,而ExploringCompactionPolicy却在尽可能的去寻求一种自定义评价标准中的"最优解"。
另外,需要说明的一点:ExploringCompactionPolicy在选择候选组合时,正是采用了RatioBasedCompactionPolicy中的文件选择算法。
更多的一些思考
Compaction会导致写入放大,前面的内容中已经反复提及了很多次。在实际应用中,你是否关注过Compaction对于查询毛刺的影响(查询时延总是会出现偶发性的陡增)?
关于Compaction的参数调优,我们可能看到过这样的一些建议:尽可能的减少每一次Compaction的文件数量,目的是为了减短每一次Compaction的执行时间。这就好比,采用Java GC算法中的CMS算法时,每一次回收少量的无引用对象,尽管GC被触发的频次会增大,但可以有效降低Full GC的发生次数和发生时间。
但在实践中,这可能并不是一个合理的建议,例如,HBase默认的触发Minor Compaction的最小文件数量为3,但事实上,对于大多数场景而言,这可能是一个非常不合理的默认值,在我们的测试中,将最小文件数加大到10个,我们发现对于整体的吞吐量以及查询毛刺,都有极大的改进,所以,这里的建议为:Minor Compaction的文件数量应该要结合实际业务场景设置合理的值。另外,在实践中,合理的限制Compaction资源的占用也是非常关键的,如Compaction的并发执行度,以及Compaction的吞吐量以及网络带宽占用等等。
另外,需要关注到的一点:Compaction会影响Block Cache,因为HFile文件发生合并以后,旧HFile文件所关联的被Cache的Block将会失效。这也会影响到读取时延。HBase社区有一个问题单(HBASE-20045),试图在Compaction时缓存一些最近的Blocks。
在Facebook的那篇论文中,还有一个比较有意思的实践:
他们将Compaction下推到存储层(HDFS)执行,这样,每一个DateNode在本地合并自己的文件,这样可以降低一半以上的网络IO请求,但本地磁盘IO请求会增大,这事实上是用磁盘IO资源来换取昂贵的网络IO资源。在我们自己的测试中也发现,将Compaction下推到HDFS侧执行,能够明显的优化读写时延毛刺问题。
“
总结
”
本文基于2.0版本阐述了Flush与Compaction流程,讲述了Compaction所面临的本质问题,介绍了HBase现有的几种Compaction策略以及各自的适用场景,更多是从原理层面展开的,并没有过多讲述如何调整参数的实际建议,唯一的建议为:请一定要结合实际的业务场景,选择合理的Compaction策略,通过不断的测试和观察,选择合理的配置,何谓合理?可以观察如下几点:
-
写入吞吐量能否满足要求。随着时间的推移,写入吞吐量是否会不断降低?
-
读取时延能否满足要求。随着时间的推移,读取时延是否出现明显的增大?
-
观察过程中,建议不断的统计分析Compaction产生的IO总量,以及随着时间的变化趋势。2.0版本中尽管增加了一些与Compaction相关的Metrics信息,但关于Compaction IO总量的统计依然是非常不充分的,这一点可以自己定制实现,如果你有兴趣,也完全可以贡献给社区。
本文Review人员:智伟、钟延辉、钟超强
封面:毕美杰