一条数据的 HBase 之旅,简明 HBase 入门教程 -Flush 与 Compaction



转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com

AIQ 机器学习大数据 知乎专栏 点击关注

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 人员智伟、钟延辉、钟超强

封面毕美杰


更多高质资源 尽在AIQ 机器学习大数据 知乎专栏 点击关注

转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com