HBase 学习:Flush 和 Compaction

本文最后更新于:2021年6月10日 下午

当数据已经被写入 WAL 与 MemStore,就可以说数据已经被成功写到 HBase 中了。随着数据的不断写入,MemStore 中存储的数据会越来越多,系统会将 MemStore 中的数据进行 Flush 操作写入文件形成 HFile。而随着 Flush 产生的 HFile 文件越来越多,系统还会对 HFile 文件进行 Compaction 操作。本文主要介绍数据写入后 Flush & Compaction 的流程和策略。

FLush & Compaction 概述

MemStore 中的数据,达到一定的阈值,会被 Flush 成 HDFS 中的 HFile 文件。但随着 Flush 次数的不断增多,HFile 的文件数量也会不断增多。从多个 HFile 文件中读取记录,将导致更多的 IOPS,这会使得读取时延不断增大。

image-20201016111939976

Compaction 可以将一些 HFile 文件合并成较大的 HFile 文件,也可以把所有的 HFile 文件合并成一个大的 HFile 文件,这个过程可以理解为:将多个 HFile 的“交错无序状态”,变成单个 HFile 的“有序状态”,降低读取时延。

小范围的 HFile 文件合并,称之为 Minor Compaction,一个列族中将所有的 HFile 文件合并,称之为 Major Compaction。除了文件合并范围的不同之外,Major Compaction 还会清理一些 TTL 过期/版本过旧以及被标记删除的数据。

image-20201016112314778

Flush

触发条件

HBase 会在以下几种情况下触发 flush 操作。需要注意的是最小的 flush 单元是 HRegion 而不是 MemStore。如果一个 HRegion 中 Memstore 过多,每次 flush 的开销必然会很大,因此在进行表设计的时应该候尽量减少 ColumnFamily 的个数。

  1. Memstore 级别限制:当 Region 中任意一个 MemStore 的大小达到了上限(hbase.hregion.memstore.flush.size,默认 128MB),会触发 Memstore 刷新。
  2. Region 级别限制:当 Region 中所有 Memstore 的大小总和达到了上限(hbase.hregion.memstore.block.multiplier hbase.hregion.memstore.flush.size,默认 2 128M = 256M),会触发 memstore 刷新。
  3. Region Server 级别限制:当一个 Region Server 中所有 Memstore 的大小总和达到了上限(hbase.regionserver.global.memstore.upperLimit * hbase_heapsize,默认 40%的 JVM 内存使用量),会触发部分 Memstore 刷新。Flush 顺序是按照 Memstore 由大到小执行,先 Flush Memstore 最大的 Region,再执行次大的,直至总体 Memstore 内存使用量低于阈值(hbase.regionserver.global.memstore.lowerLimit * hbase_heapsize,默认 38%的 JVM 内存使用量)。
  4. 当一个 Region Server 中 HLog 数量达到上限(可通过参数hbase.regionserver.maxlogs配置)时,系统会选取最早的一个 HLog 对应的一个或多个 Region 进行 flush
  5. 定期刷新 Memstore:默认周期为 1 小时,确保 Memstore 不会长时间没有持久化。为避免所有的 MemStore 在同一时间都进行 flush 导致的问题,定期的 flush 操作有 20000 左右的随机延时。
  6. 手动执行 flush:用户可以通过 shell 命令 flush ‘tablename’或者 flush ‘region name’分别对一个表或者一个 Region 进行 flush。

策略

在 HBase 1.1 之前,MemStore 刷写是 Region 级别的。就是说,如果要刷写某个 MemStore ,MemStore 所在的 Region 中其他 MemStore 也是会被一起刷写的。针对这个问题,HBase 引入列族级别的刷写。我们可以通过 hbase.regionserver.flush.policy 参数选择不同的刷写策略。

HBase 2.0 的刷写策略全部都是实现 FlushPolicy 抽象类的。并且自带三种刷写策略:FlushAllLargeStoresPolicyFlushNonSloppyStoresFirstPolicy 以及 FlushAllStoresPolicy

FlushAllStoresPolicy

这种刷写策略实现最简单,直接返回当前 Region 对应的所有 MemStore。也就是每次刷写都是对 Region 里面所有的 MemStore 进行的,这个行为和 HBase 1.1 之前是一样的。

FlushAllLargeStoresPolicy

在 HBase 2.0 之前版本是 FlushLargeStoresPolicy,后面被拆分成分 FlushAllLargeStoresPolicyFlushNonSloppyStoresFirstPolicyFlushAllLargeStoresPolicy 是 2.0 版本中的默认策略。

这种策略会先判断 Region 中每个 MemStore 的使用内存(OnHeap + OffHeap)是否大于某个阀值,大于这个阀值的 MemStore 将会被刷写。阀值的计算是由 hbase.hregion.percolumnfamilyflush.size.lower.boundhbase.hregion.percolumnfamilyflush.size.lower.bound.min 以及 hbase.hregion.memstore.flush.size 参数决定的。计算逻辑如下:

  • 如果设置了 hbase.hregion.percolumnfamilyflush.size.lower.boundflushSizeLowerBound = hbase.hregion.percolumnfamilyflush.size.lower.bound

  • 否则flushSizeLowerBound = max(region.getMemStoreFlushSize() / familyNumber, hbase.hregion.percolumnfamilyflush.size.lower.bound.min)

hbase.hregion.percolumnfamilyflush.size.lower.bound.min 默认值为 16MB,而 hbase.hregion.percolumnfamilyflush.size.lower.bound 没有默认值。

比如当前表有 3 个列族,其他用默认的值,那么 flushSizeLowerBound = max((long)128 / 3, 16) = 42

如果当前 Region 中没有 MemStore 的使用内存大于上面的阀值,FlushAllLargeStoresPolicy 策略就退化成 FlushAllStoresPolicy 策略了,也就是会对 Region 里面所有的 MemStore 进行 Flush。

FlushNonSloppyStoresFirstPolicy

HBase 2.0 引入了 in-memory compaction。如果我们对相关列族 hbase.hregion.compacting.memstore.type 参数的值不是 NONE,也就是启用 in-memory compaction 时,那么这个 MemStore 的 isSloppyMemStore 值就是 true,否则就是 false。

FlushNonSloppyStoresFirstPolicy 策略将 Region 中的 MemStore 按照 isSloppyMemStore 分到两个 HashSet 里面(sloppyStoresregularStores)。然后

  • 判断 regularStores 里面是否有 MemStore 内存占用大于相关阀值的 MemStore ,有的话就会对这些 MemStore 进行刷写,其他的不做处理,这个阀值计算和 FlushAllLargeStoresPolicy 的阀值计算逻辑一致。
  • 如果 regularStores 里面没有 MemStore 内存占用大于相关阀值的 MemStore,这时候就开始在 sloppyStores 里面寻找是否有 MemStore 内存占用大于相关阀值的 MemStore,有的话就会对这些 MemStore 进行刷写,其他的不做处理。
  • 如果上面 sloppyStoresregularStores 都没有满足条件的 MemStore 需要刷写,这时候就 FlushNonSloppyStoresFirstPolicy 策略久退化成 FlushAllStoresPolicy 策略了。

流程

为了减少 flush 过程对读写的影响,HBase 采用了类似于两阶段提交的方式,将整个 flush 过程分为三个阶段:

  1. prepare 阶段:遍历当前 Region 中的所有 Memstore,将 Memstore 中当前数据集 kvset 做一个快照 snapshot,然后再新建一个新的 kvset。后期的所有写入操作都会写入新的 kvset 中,而整个 flush 阶段读操作会首先分别遍历 kvset 和 snapshot,如果查找不到再会到 HFile 中查找。prepare 阶段需要加一把 updateLock 对写请求阻塞,结束之后会释放该锁。因为此阶段没有任何费时操作,因此持锁时间很短。
  2. flush 阶段:遍历所有 Memstore,将 prepare 阶段生成的 snapshot 持久化为临时文件,临时文件会统一放到目录.tmp 下。这个过程因为涉及到磁盘 IO 操作,因此相对比较耗时。
  3. commit 阶段:遍历所有的 Memstore,将 flush 阶段生成的临时文件移到指定的 ColumnFamily 目录下,针对 HFile 生成对应的 storefile 和 Reader,把 storefile 添加到 HStore 的 storefiles 列表中,最后再清空 prepare 阶段生成的 snapshot。

对业务读写的影响

正常情况下,大部分 Memstore Flush 操作都不会对业务读写产生太大影响,比如这几种场景:HBase 定期刷新 Memstore、手动执行 flush 操作、触发 Memstore 级别限制、触发 HLog 数量限制以及触发 Region 级别限制等,这几种场景只会阻塞对应 Region 上的写请求,阻塞时间很短,毫秒级别。

然而一旦触发 Region Server 级别限制导致 flush,就会对用户请求产生较大的影响。会阻塞所有落在该 Region Server 上的更新操作,阻塞时间很长,甚至可以达到分钟级别。

Compaction

作用 & 副作用

Compaction 主要有以下几个核心作用:

  • 合并小文件,减少文件数,稳定随机读延迟。
  • 提高数据的本地化率。本地化率越高,在 HDFS 上访问数据时延迟就越小;相反,本地化率越低,访问数据就可能大概率需要通过网络访问,延迟必然会比较大。
  • 清除无效数据,减少数据存储量。

同时 Compaction 也会带来以下几个副作用:

  • 读延迟毛刺:

    Compaction 操作重写文件会带来很大的带宽压力以及短时间 IO 压力。要将小文件的数据读出来需要 IO,很多小文件数据跨网络传输需要带宽,读出来之后又要写成一个大文件,因为是三副本写入,必然需要网络开销和写入 IO 开销。在稳定数据读取延迟的同时,也会产生读取延迟毛刺。

  • 写阻塞:

    Compaction 对写入也会有很大的影响。当写请求非常多,导致不断生成 HFile,但 compact 的速度远远跟不上 HFile 生成的速度时。这样就会使 HFile 的数量会越来越多,导致读性能急剧下降。为了避免这种情况,在 HFile 的数量过多的时候会限制写请求的速度:在每次执行 MemStore flush 的操作前,如果 HStore 的 HFile 数超过hbase.hstore.blockingStoreFiles (默认 7),则会阻塞 flush 操作hbase.hstore.blockingWaitTime时间,在这段时间内,如果 compact 操作使得 HStore 文件数下降到回这个值,则停止阻塞。另外阻塞超过时间后,也会恢复执行 flush 操作。这样做就可以有效地控制大量写请求的速度,但同时这也是影响写请求速度的主要原因之一。

可见,Compaction 会使得数据读取延迟一直比较平稳,但付出的代价是大量的读延迟毛刺和一定的写阻塞。

流程

  1. 触发 Compaction 后,HBase 会将该 Compaction 交由一个独立的线程处理。

  2. 该线程首先会从对应 store 中选择合适的 hfile 文件进行合并,这一步是整个 Compaction 的核心,选取文件需要遵循很多条件。实际实现中,HBase 提供了多个文件选取算法:RatioBasedCompactionPolicy、ExploringCompactionPolicy 和 StripeCompactionPolicy 等。用户也可以通过特定接口实现自己的 Compaction 算法。

  3. 选出待合并的文件后,HBase 会根据这些 hfile 文件总大小挑选对应的线程池处理。
  4. 最后对这些文件执行具体的合并操作。

image-20201016141440053

触发条件

最常见的触发 compaction 的因素有三种:Memstore Flush、后台线程周期性检查、手动触发。

  1. Memstore Flush: 应该说 compaction 操作的源头就来自 flush 操作,memstore flush 会产生 HFile 文件,文件越来越多就需要 compact。因此在每次执行完 Flush 操作之后,都会对当前 Store 中的文件数进行判断,都会对当前 Store 中的文件数进行判断,一旦 Store 中总文件数大于hbase.hstore.compactionThreshold,就会触发 compaction。需要说明的是,compaction 都是以 Store 为单位进行的,而在 Flush 触发条件下,整个 Region 的所有 Store 都会执行 compact,所以会在短时间内执行多次 compaction。

  2. 后台线程周期性检查:后台线程 CompactionChecker 定期触发检查是否需要执行 compaction,检查周期为:hbase.server.thread.wakefrequency * hbase.server.compactchecker.interval.multiplier。和 flush 不同的是,该线程优先检查 Store 中总文件数是否大于阈值hbase.hstore.compactionThreshold,一旦大于就会触发 compaction。如果不满足,它会接着检查是否满足 major compaction 条件。简单来说,如果当前 store 中 hfile 的最早更新时间早于某个值 mcTime,就会触发 major compaction,HBase 预想通过这种机制定期删除过期数据。上文 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。

  3. 手动触发:一般来讲,手动触发 compaction 通常是为了执行 major compaction,原因有三,其一是因为很多业务担心自动 major compaction 影响读写性能,因此会选择低峰期手动触发;其二也有可能是用户在执行完 alter 操作之后希望立刻生效,执行手动触发 major compaction;其三是 HBase 管理员发现硬盘容量不够的情况下手动触发 major compaction 删除大量过期数据;无论哪种触发动机,一旦手动触发,HBase 会不做很多自动化检查,直接执行合并。

HFile 选择

选择合适的文件进行合并是整个 compaction 的核心,因为合并文件的大小以及其当前承载的 IO 数直接决定了 compaction 的效果。

最理想的情况是,这些文件承载了大量 IO 请求但是大小很小,这样 compaction 本身不会消耗太多 IO,而且合并完成之后对读的性能会有显著提升。

无论什么策略,选择时都会首先对该 Store 中所有 HFile 逐一进行排查,排除不满足条件的部分文件:

  1. 排除当前正在执行 compact 的文件及其比这些文件更新的所有文件(SequenceId 更大)

  2. 排除某些过大的单个文件,如果文件大小大于 hbase.hzstore.compaction.max.size(默认 Long 最大值),则被排除,否则会产生大量 IO 消耗。经过排除的文件称为候选文件,HBase 接下来会再判断是否满足 major compaction 条件,如果满足,就会选择全部文件进行合并。判断条件有下面三条,只要满足其中一条就会执行 major compaction:

    • 用户强制执行 major compaction

    • 长时间没有进行 compact(CompactionChecker 的判断条件 2)且候选文件数小于 hbase.hstore.compaction.max(默认 10)

    • Store 中含有 Reference 文件,Reference 文件是 split region 产生的临时文件,只是简单的引用文件,一般必须在 compact 过程中删除

如果不满足 major compaction 条件,那么就是执行 minor compaction。HBase 提供了了两种最基本的策略,RatioBasedCompactionPolicy 和 ExploringCompactionPolicy,后者继承自前者,也是当前版本的默认策略。

RatioBasedCompactionPolicy

从老到新逐一扫描所有候选文件,满足其中条件之一便停止扫描:

  1. 当前文件大小 < 比它更新的所有文件大小总和 * ratio,其中 ratio 是一个可变的比例,在高峰期时 ratio 为 1.2,非高峰期为 5,也就是非高峰期允许 compact 更大的文件。用户可以配置参数hbase.offpeak.start.hourhbase.offpeak.end.hour来设置高峰期
  2. 当前所剩候选文件数 <= hbase.store.compaction.min(默认为 3)

停止扫描后,待合并文件就选择出来了,即为当前扫描文件+比它更新的所有文件

ExploringCompactionPolicy

该策略思路基本和 RatioBasedCompactionPolicy 相同,不同的是,Ratio 策略在找到一个合适的文件集合之后就停止扫描了,而 Exploring 策略会记录下所有合适的文件组合,并在这些文件组合中寻找最优解。优先选择文件组合文件数多的,当文件数一样多时选择文件数小的,此目的是为了尽可能合并更多的文件并且产生的 IO 越少越好。

挑选线程池

HBase 实现中有一个专门的线程 CompactSplitThead 负责接收 compact 请求以及 split 请求,而且为了能够独立处理这些请求,这个线程内部构造了多个线程池:largeCompactions、smallCompactions 以及 splits 等,其中 splits 线程池负责处理所有的 split 请求,largeCompactions 和 smallCompaction 负责处理所有的 compaction 请求,区别在于文件总大小。

  1. 上述设计目的是为了能够将请求独立处理,提供系统的处理性能。

  2. 分配原则:待 compact 的文件总大小如果大于值 throttlePoint(可以通过参数hbase.regionserver.thread.compaction.throttle配置,默认为 2.5G),分配给 largeCompactions 处理,否则分配给 smallCompactions 处理。

  3. largeCompactions 和 smallCompactions 默认都只有一个线程,用户可以通过参数hbase.regionserver.thread.compaction.largehbase.regionserver.thread.compaction.small进行配置

执行HFile文件合并

合并流程主要分为如下几步:

  1. 分别读出待合并 hfile 文件的 KV,并顺序写到位于./tmp 目录下的临时文件中

  2. 将临时文件移动到对应 region 的数据目录

  3. 将 compaction 的输入文件路径和输出文件路径封装为 KV 写入 WAL 日志,并打上 compaction 标记,最后强制执行 sync

  4. 将对应 region 数据目录下的 compaction 输入文件全部删除

上述四个步骤看起来简单,但实际是很严谨的,具有很强的容错性和完美的幂等性:

  1. 如果 RS 在步骤 2 之前发生异常,本次 compaction 会被认为失败,如果继续进行同样的 compaction,上次异常对接下来的 compaction 不会有任何影响,也不会对读写有任何影响。唯一的影响就是多了一份多余的数据。

  2. 如果 RS 在步骤 2 之后、步骤 3 之前发生异常,同样的,仅仅会多一份冗余数据。

  3. 如果在步骤 3 之后、步骤 4 之前发生异常,RS 在重新打开 region 之后首先会从 WAL 中看到标有 compaction 的日志,因为此时输入文件和输出文件已经持久化到 HDFS,因此只需要根据 WAL 移除掉 compaction 输入文件即可

高级策略

compaction 的策略,一方面需要保证 compaction 的基本效果,另一方面又不会带来严重的 IO 压力。然而,并没有一种设计策略能够适用于所有应用场景或所有数据集。

HBase 从 0.96 版本开始对架构进行了一定的调整:

  • 提供了 Compaction 插件接口,用户只需要实现这些特定的接口,就可以根据自己的应用场景以及数据集定制特定的 compaction 策略。
  • 支持 table/cf 粒度的策略设置,使得用户可以根据应用场景为不同表/列族选择不同的 compaction 策略。

HBase 也逐步新增了一些其他 Compaction 策略,策略有一些共同的优化方向,总结如下:

  1. 减少参与 compaction 的文件数:首先需要将文件根据 rowkey、version 或其他属性进行分割,再根据这些属性挑选部分重要的文件参与合并;另一方面,尽量不要合并那些大文件,减少参与合并的文件数。

  2. 不要合并那些不需要合并的文件:比如 OpenTSDB 应用场景下的老数据,这些数据基本不会查询到,因此不进行合并也不会影响查询性能。

  3. 更小的 region:更小的 region 只会生成少量文件,这些文件合并不会引起很大的 IO 放大。

FIFO Compaction

FIFO Compaction 策略主要参考了rocksdb的实现,它会选择那些过期的数据文件,即该文件内所有数据都已经过期。因此,对应业务的列族必须设置 TTL,否则肯定不适合该策略。需要注意的是,该策略只做这么一件事情:收集所有已经过期的文件并删除。这样的应用场景主要包括:

  1. 大量短时间存储的原始数据,比如推荐业务,上层业务只需要最近时间内用户的行为特征,利用这些行为特征进行聚合为用户进行推荐。再比如 Nginx 日志,用户只需要存储最近几天的日志,方便查询某个用户最近一段时间的操作行为等等

  2. 所有数据能够全部加载到 block cache(RAM/SSD),假如 HBase 有 1T 大小的 SSD 作为 block cache,理论上就完全不需要做合并,因为所有读操作都是内存操作。

因为 FIFO Compaction 只是收集所有过期的数据文件并删除,并没有真正执行重写(几个小文件合并成大文件),因此不会消耗任何 CPU 和 IO 资源,也不会从 block cache 中淘汰任何热点数据。所以,无论对于读还是写,该策略都会提升吞吐量、降低延迟。

Tier-Based Compaction

现实业务中,有很大比例的业务都存在明显的热点数据,而其中最常见的情况是:最近写入到的数据总是最有可能被访问到,而老数据被访问到的频率就相对比较低。按照之前的文件选择策略,并没有对新文件和老文件进行一定的区别对待,每次 compaction 都有可能会有很多老文件参与合并,这必然会影响 compaction 效率,却对降低读延迟没有太大的帮助。

针对这种情况,HBase 社区借鉴 Facebook HBase 分支的解决方案,引入了 Tier-Based Compaction。这种方案会根据候选文件的新老程度将其分为多个不同的等级,每个等级都有对应等级的参数,比如参数 Compation Ratio,表示该等级文件选择时的选择几率,Ratio 越大,该等级的文件越有可能被选中参与 Compaction。而等级数、每个等级参数都可以通过 CF 属性在线更新。

该方案的具体实现思路,HBase 更多地参考了 Cassandra 的实现方案:基于时间窗的时间概念。如下图所示,时间窗的大小可以进行配置,其中参数 base_time_seconds 代表初始化时间窗的大小,默认为 1h,表示最近一小时内 flush 的文件数据都会落入这个时间窗内,所有想读到最近一小时数据请求只需要读取这个时间窗内的文件即可。后面的时间窗窗口会越来越大,另一个参数 max_age_days 表示比其更老的文件不会参与 compaction。

image-20201016162742561

上图所示,时间窗随着时间推移朝右移动,图一中没有任何时间窗包含 4 个(可以通过参数 min_thresold 配置)文件,因此 compaction 不会被触发。随着时间推移来到图二所示状态,此时就有一个时间窗包含了 4 个 HFile 文件,compaction 就会被触发,这四个文件就会被合并为一个大文件。

对比上文说到的分级策略以及 Compaction Ratio 参数,Cassandra 的实现方案中通过设置多个时间窗来实现分级,时间窗的窗口大小类似于 Compaction Ratio 参数的作用,可以通过调整时间窗的大小来调整不同时间窗文件选择的优先级,比如可以将最右边的时间窗窗口调大,那新文件被选择参与 Compaction 的概率就会大大增加。然而,这个方案里面并没有类似于当前 HBase 中的 Major Compaction 策略来实现过期文件清理的功能,只能借助于 TTL 来主动清理过期的文件,比如这个文件中所有数据都过期了,就可以将这个文件清理掉。

因此,我们可以总结得到使用 Date Tierd Compaction Policy 需要遵守的原则:

  1. 特别适合使用的场景:时间序列数据,默认使用 TTL 删除。类似于“获取最近一小时/三小时/一天”场景,同时不会执行 delete 操作。最典型的例子就是基于 Open-TSDB 的监控系统。
  2. 比较适合的应用场景:时间序列数据,但是会有全局数据的更新操作以及少部分的删除操作。
  3. 不适合的应用场景:非时间序列数据,或者大量的更新数据更新操作和删除操作。
Stripe Compaction

通常情况下,major compaction 都是无法绕过的,很多业务都会执行 delete/update 操作,并设置 TTL 和 Version,这样就需要通过执行 major compaction 清理被删除的数据以及过期版本数据、过期 TTL 数据。然而,major compaction 是一个特别昂贵的操作,会消耗大量系统资源,而且执行一次可能会持续几个小时,严重影响业务应用。因此,一般线上都会选择关闭 major compaction 自动触发,而是选择在业务低峰期的时候手动触发。为了彻底消除 major compaction 所带来的影响,hbase 社区提出了 strip compaction 方案。

解决 major compaction 的最直接办法是减少 region 的大小,最好整个集群都是由很多小 region 组成,这样参与 compaction 的文件总大小就必然不会太大。可是,region 设置小会导致 region 数量很多,这一方面会导致 hbase 管理 region 的开销很大,另一方面,region 过多也要求 hbase 能够分配出来更多的内存作为 memstore 使用,否则有可能导致整个 regionserver 级别的 flush,进而引起长时间的写阻塞。因此单纯地通过将 region 大小设置过小并不能本质解决问题。

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 的 KVs,再根据 KV 的 key 定位到具体的 stripe,将该 KV 插入对应 stripe 的文件中即可,如下图所示。之前说过 stripe 就是一个个小的 region,所以在 stripe 内部,依然会像正常 region 一样执行 minor compaction 和 major compaction,可以预想到,stripe 内部的 major compaction 并不会太多消耗系统资源。另外,数据读取也很简单,系统可以根据对应的 Key 查找到对应的 stripe,然后在 stripe 内部执行查找,因为 stripe 内数据量相对很小,所以也会一定程度上提升数据查找性能。

image-20201016163300597

和任何一种 compaction 机制一样,stripe compaction 也有它特别擅长的业务场景,也有它并不擅长的业务场景。下面是两种 stripe compaction 比较擅长的业务场景:

  1. 大 Region:小 region 没有必要切分为 stripes,一旦切分,反而会带来额外的管理开销。一般默认如果 region 大小小于 2G,就不适合使用 stripe compaction。
  2. RowKey 具有统一格式:stripe compaction 要求所有数据按照 Key 进行切分,切分为多个 stripe。如果 rowkey 不具有统一格式的话,无法进行切分。
MOB Compaction

有的场景下,需要使用 HBase 来存储 MB 级别的 Blob(如图片之类的小文件)数据,如果像普通的结构化数据/半结构化数据一样,直接写到 HBase 中,数 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)。

在每月压缩策略的情况下,基于 MOB 配置的阈值,当前日历周中的文件按照天进行压缩,本月的之前日历周中文件是按照周进行压缩,过去几个月的文件基于月进行压缩。通过这种设计,MOB 文件在月策略的情况下最多只会压缩 3 次;在周策略的情况下最多只会压缩 2 次。

默认情况下,MOB 压缩分区策略为天级别。按天压缩可能导致产生过多的文件,超出文件数量限制。因此可以使用每周或每月的策略。我们需要使用 MOB 列族的MOB_COMPACT_PARTITION_POLICY 属性。用户可以在 HBase shell 创建表时设置此属性。

如果压缩策略从每天更改为每周或每月,或每周更改为每月,则下一个 MOB 压缩将重新压缩之前策略压缩过的 MOB 文件。如果策略从每月或每周更改为每天,或者每月更改为每周,则已经压缩过的 MOB 文件在新的压缩策略将不再被压缩。

吞吐量限制和带宽占用

Limit Compaction Speed

上述几种策略都是根据不同的业务场景设置对应的文件选择策略,核心都是减少参与 compaction 的文件数,缩短整个 compaction 执行的时间,间接降低 compaction 的 IO 放大效应,减少对业务读写的延迟影响。然而,如果不对 Compaction 执行阶段的读写吞吐量进行限制的话也会引起短时间大量系统资源消耗,影响用户业务延迟。

通过感知 Compaction 的压力情况自动调节系统的 Compaction 吞吐量,在压力大的时候降低合并吞吐量,压力小的时候增加合并吞吐量。基本原理为:

  1. 在正常情况下,用户需要设置吞吐量下限参数hbase.hstore.compaction.throughput.lower.bound(默认 10MB/sec)和上限参数hbase.hstore.compaction.throughput.higher.bound(默认 20MB/sec),而 hbase 实际会工作在吞吐量为 lower + (higer – lower) * ratio 的情况下,其中 ratio 是一个取值范围在 0 到 1 的小数,它由当前 store 中待参与 compation 的 file 数量决定,数量越多,ratio 越小,反之越大。
  2. 如果当前 store 中 hfile 的数量太多,并且超过了参数hbase.hstore.blockingStoreFiles,此时所有写请求就会阻塞等待 compaction 完成,这种场景下上述限制会自动失效。
Compaction BandWidth Limit

在某些情况下 Compaction 还会因为大量消耗带宽资源从而严重影响其他业务,主要有两点原因:

  1. 正常请求下,compaction 尤其是 major compaction 会将大量数据文件合并为一个大 HFile,读出所有数据文件的 KVs,然后重新排序之后写入另一个新建的文件。如果待合并文件都在本地,那么读就是本地读,不会出现跨网络的情况。但是因为数据文件都是三副本,因此写的时候就会跨网络执行,必然会消耗带宽资源。

  2. 在有些场景下待合并文件有可能并不全在本地,即本地化率没有达到 100%,比如执行过 balance 之后就会有很多文件并不在本地。这种情况下读文件的时候就会跨网络读,如果是 major compaction,必然也会大量消耗带宽资源。

可以看出来,跨网络读是可以通过一定优化避免的,而跨网络写却是不可能避免的。因此优化 Compaction 带宽消耗,一方面需要提升本地化率,减少跨网络读;另一方面,虽然跨网络写不可避免,但也可以通过控制手段使得资源消耗控制在一个限定范围。Facebook 在这方面做了一些优化。

原理其实和 Limit Compaction Speed 思路基本一致,它主要涉及两个参数:compactBwLimit 和 numOfFilesDisableCompactLimit,作用分别如下:

  1. compactBwLimit:一次 compaction 的最大带宽使用量,如果 compaction 所使用的带宽高于该值,就会强制令其 sleep 一段时间
  2. numOfFilesDisableCompactLimit:很显然,在写请求非常大的情况下,限制 compaction 带宽的使用量必然会导致 HFile 堆积,进而会影响到读请求响应延时。因此该值意义就很明显,一旦 store 中 hfile 数量超过该设定值,带宽限制就会失效。

注意事项

  • Compaction 对于查询毛刺的影响

    关于 Compaction 的参数调优,我们可能看到过这样的一些建议:尽可能的减少每一次 Compaction 的文件数量,目的是为了减短每一次 Compaction 的执行时间。但在实践中,这可能并不是一个合理的建议,例如,HBase 默认的触发 Minor Compaction 的最小文件数量为 3,但事实上,对于大多数场景而言,这可能是一个非常不合理的默认值,在我们的测试中,我们将最小文件数加大到 10 个,我们发现对于整体的吞吐量以及查询毛刺,都有极大的改进,所以,这里的建议为:Minor Compaction 的文件数量应该要结合实际业务场景设置合理的值。另外,在实践中,合理的限制 Compaction 资源的占用也是非常关键的,如 Compaction 的并发执行度,以及 Compaction 的吞吐量以及网络带宽占用等等。

  • Compaction 会影响 Block Cache

    HFile 文件发生合并以后,旧 HFile 文件所关联的被 Cache 的 Block 将会失效。这也会影响到读取时延。

In-memory Flush and Compaction

HBase 2.0 新增的特性,默认禁用。开启之后,MemStore 由一个可写的 Segment,以及一个或多个不可写的 Segments 构成。

image-20201016112734469

MemStore 中的数据先 Flush 成一个 Immutable 的 Segment,多个 Immutable Segments 可以在内存中进行 Compaction,当达到一定阈值以后才将内存中的数据持久化成 HDFS 中的 HFile 文件。

如果 MemStore 中的数据被直接 Flush 成 HFile,而多个 HFile 又被 Compaction 合并成了一个大 HFile,随着一次次 Compaction 发生以后,一条数据往往被重写了多次,这带来显著的 IO 放大问题,另外,频繁的 Compaction 对 IO 资源的抢占,其实也是导致 HBase 查询时延大毛刺的罪魁祸首之一。而 In-memory Flush and Compaction 特性可以有力改善这一问题。

默认 MemStore 使用 ConcurrentSkipListMap 索引数据,这种结构支持动态修改,但是其中存在大量小对象,内存浪费比较严重。In-memory Flush and Compaction 将 MemStore 分为 MutableSegment 和 ImmutableSegment。其中 MutableSegment 仍然使用 ConcurrentSkipListMap 实现,而对 ImmutableSegment 就可以使用更紧凑的数据结构来存储索引,减少内存使用。

在融入了 In-Memory Flush and Compaction 特性之后,Flush 与 Compaction 的整体流程演变为:

image-20201016113241464

参考资料

HBase原理与实践

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

HBase – Memstore Flush深度解析

HBase 入门之数据刷写(Memstore Flush)详细说明

HBase Compaction的前生今世-身世之旅

HBase Compaction的前生今世-改造之路

HBase Compaction-(2)ExploringCompactionPolicy以及RatioBasedCompactionPolicy

HBase Compaction算法之ExploringCompactionPolicy

HBase HFile Compact吞吐量参数控制优化剖析-OLAP商业环境实战

Apache HBase中等对象存储MOB压缩分区策略介绍

HBase实战之MOB使用指南


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!