HBASE 技术细节 读取与写入 Region Split与合并介绍

2020-07-31 11:08:25 浏览数 (2)

toc

1. Hbase的Region介绍

Hbase Rowkey CF 架构 概述 预分区及Rowkey设计 学习笔记介绍了Region类似于数据库的分片和分区的概念,每个Region负责一小部分Rowkey范围的数据的读写和维护,Region包含了对应的起始行到结束行的所有信息。master将对应的region分配给不同的RergionServer,由RegionSever来提供Region的读写服务和相关的管理工作。

这部分主要介绍Region实例以及Region的寻找路径:

1.1 region实例

imgimg

上图模拟了一个Hbase的表是如何拆分成region,以及分配到不同的RegionServer中去。上面是1个Userinfo表,里面有7条记录,其中rowkey为0001到0002的记录被分配到了Region1上,Rowkey为0003到0004的记录被分配到了Region2上,而rowkey为0005、0006和0007的记录则被分配到了Region3上。region1和region2被master分配给了RegionServer1(RS1),Region3被master配分给了RegionServer2(RS2)

备注:这里只是为了更容易的说明拆分的规则,其实真实的场景并不会几条记录拆分到不通的Region上,而是到一定的数据量才会拆分,具体的在Region的拆分那部分再具体的介绍。

1.2 Region的寻址

既然读写都在RegionServer上发生,我们前面有讲到,每个RegionSever为一定数量的region服务,那么client要对某一行数据做读写的时候如何能知道具体要去访问哪个RegionServer呢?那就是接下来我们要讨论的问题

1.2.1 老的Region寻址方式

在Hbase 0.96版本以前,Hbase有两个特殊的表,分别是-ROOT-表和.META.表,其中-ROOT-的位置存储在ZooKeeper中,-ROOT-本身存储了 .META. Table的RegionInfo信息,并且-ROOT-不会分裂,只有一个region。而.META.表可以被切分成多个region。读取的流程如下图所示:

imgimg
  • 第1步:client请求ZK获得-ROOT-所在的RegionServer地址
  • 第2步:client请求-ROOT-所在的RS地址,获取.META.表的地址,client会将-ROOT-的相关信息cache下来,以便下一次快速访问
  • 第3步:client请求 .META.表的RS地址,获取访问数据所在RegionServer的地址,client会将.META.的相关信息cache下来,以便下一次快速访问
  • 第4步:client请求访问数据所在RegionServer的地址,获取对应的数据

从上面的路径我们可以看出,用户需要3次请求才能直到用户Table真正的位置,这在一定程序带来了性能的下降。在0.96之前使用3层设计的主要原因是考虑到元数据可能需要很大。但是真正集群运行,元数据的大小其实很容易计算出来。在BigTable的论文中,每行METADATA数据存储大小为1KB左右,如果按照一个Region为128M的计算,3层设计可以支持的Region个数为2^34个,采用2层设计可以支持2^17(131072)。那么2层设计的情况下一个 集群可以存储4P的数据。这仅仅是一个Region只有128M的情况下。如果是10G呢? 因此,通过计算,其实2层设计就可以满足集群的需求。因此在0.96版本以后就去掉了-ROOT-表了。

1.2.2 新的Region寻址方式

如上面的计算,2层结构其实完全能满足业务的需求,因此0.96版本以后将-ROOT-表去掉了。如下图所示:

imgimg

访问路径变成了3步:

  • 第1步:Client请求ZK获取.META.所在的RegionServer的地址。
  • 第2步:Client请求.META.所在的RegionServer获取访问数据所在的RegionServer地址,client会将.META.的相关信息cache下来,以便下一次快速访问。
  • 第3步:Client请求数据所在的RegionServer,获取所需要的数据。

总结去掉-ROOT-的原因有如下2点:

其一:提高性能

其二:2层结构已经足以满足集群的需求

这里还有一个问题需要说明,那就是Client会缓存.META.的数据,用来加快访问,既然有缓存,那它什么时候更新?如果.META.更新了,比如Region1不在RerverServer2上了,被转移到了RerverServer3上。client的缓存没有更新会有什么情况? 其实,Client的元数据缓存不更新,当.META.的数据发生更新。如上面的例子,由于Region1的位置发生了变化,Client再次根据缓存去访问的时候,会出现错误,当出现异常达到重试次数后就会去.META.所在的RegionServer获取最新的数据,如果.META.所在的RegionServer也变了,Client就会去ZK上获取.META.所在的RegionServer的最新地址。

2. Hbase的写逻辑

Hbase的写逻辑涉及到写内存、写log、刷盘等操作,看起来简单,其实里面又有很多的逻辑,下面就来做详细的介绍。

2.1 Hbase写入逻辑

Hbase的写入流程如下图所示:

imgimg

从上图可以看出分为3步骤:

第1步:Client获取数据写入的Region所在的RegionServer

第2步:请求写Hlog

第3步:请求写MemStore

只有当写Hlog和写MemStore都成功了才算请求写入完成。MemStore后续会逐渐刷到HDFS中。

备注:Hlog存储在HDFS,当RegionServer出现异常,需要使用Hlog来恢复数据。

2.2 MemStore刷盘

为了提高Hbase的写入性能,当写请求写入MemStore后,不会立即刷盘。而是会等到一定的时候进行刷盘的操作。具体是哪些场景会触发刷盘的操作呢?总结成如下的几个场景:

2.2.1 全局内存控制

这个全局的参数是控制内存整体的使用情况,当所有memstore占整个heap的最大比例的时候,会触发刷盘的操作。这个参数是hbase.regionserver.global.memstore.upperLimit,默认为整个heap内存的40%。但这并不意味着全局内存触发的刷盘操作会将所有的MemStore都进行输盘,而是通过另外一个参数hbase.regionserver.global.memstore.lowerLimit来控制,默认是整个heap内存的35%。当flush到所有memstore占整个heap内存的比率为35%的时候,就停止刷盘。这么做主要是为了减少刷盘对业务带来的影响,实现平滑系统负载的目的。

2.2.2 MemStore达到上限

当MemStore的大小达到hbase.hregion.memstore.flush.size大小的时候会触发刷盘,默认128M大小

2.2.3 RegionServer的Hlog数量达到上限

前面说到Hlog为了保证Hbase数据的一致性,那么如果Hlog太多的话,会导致故障恢复的时间太长,因此Hbase会对Hlog的最大个数做限制。当达到Hlog的最大个数的时候,会强制刷盘。这个参数是hase.regionserver.max.logs,默认是32个。

2.2.4 手工触发

可以通过hbase shell或者java api手工触发flush的操作。

2.2.5 关闭RegionServer触发

在正常关闭RegionServer会触发刷盘的操作,全部数据刷盘后就不需要再使用Hlog恢复数据。

2.2.6 Region使用HLOG恢复完数据后触发

当RegionServer出现故障的时候,其上面的Region会迁移到其他正常的RegionServer上,在恢复完Region的数据后,会触发刷盘,当刷盘完成后才会提供给业务访问。

2.3 Hlog

2.3.1 Hlog简介

Hlog是Hbase实现WAL(Write ahead log)方式产生的日志信息,内部是一个简单的顺序日志。每个RegionServer对应1个Hlog(备注:1.x版本的可以开启MultiWAL功能,允许多个Hlog),所有对于该RegionServer的写入都被记录到Hlog中。Hlog实现的功能是保证数据安全。当RegionServer出现问题的时候,能跟进Hlog来做数据恢复。此外为了保证恢复的效率,Hbase会限制最大保存的Hlog数量,如果达到Hlog的最大个数(hbase.regionserver.max.logs参数控制)的时候,就会触发强制刷盘操作。对于已经刷盘的数据,其对应的Hlog会有一个过期的概念,Hlog过期后,会被监控线程移动到 .oldlogs,然后会被自动删除掉。

Hbase是如何判断Hlog过期的呢?要找到这个答案,我们就必须了解Hlog的详细结构。

2.3.2 Hlog结构

下图是Hlog的详细结构(图片来源 http://hbasefly.com/ ):

imgimg

从上图我们可以看出都个Region共享一个Hlog文件,单个Region在Hlog中是按照时间顺序存储的,但是多个Region可能并不是完全按照时间顺序。

每个Hlog最小单元由Hlogkey和WALEdit两部分组成。Hlogky由sequenceid、timestamp、cluster ids、regionname以及tablename等组成,WALEdit是由一系列的KeyValue组成,对一行上所有列(即所有KeyValue)的更新操作,都包含在同一个WALEdit对象中,这主要是为了实现写入一行多个列时的原子性。

注意,图中有个sequenceid。sequenceid是一个store级别的自增序列号,非常重要,region的数据恢复和Hlog过期清除都要依赖这个。下面就来简单描述一下sequenceid的相关逻辑。

  • Memstore在达到一定的条件会触发刷盘的操作,刷盘的时候会获取刷新到最新的一个sequenceid的下一个sequenceid,并将新的sequenceid赋给oldestUnflushedSequenceId,并刷到Ffile中。有点绕,举个例子来说明:比如对于某一个store,开始的时候oldestUnflushedSequenceId为NULL,此时,如果触发flush的操作,假设初始刷盘到sequenceid为10,那么hbase会在10的基础上append一个空的Entry到HLog,最新的sequenceid为11,然后将sequenceid为11的号赋给oldestUnflushedSequenceId,并将oldestUnflushedSequenceId的值刷到Hfile文件中进行持久化。
  • Hlog文件对应所有Region的store中最大的sequenceid如果已经刷盘,就认为Hlog文件已经过期,就会移动到.oldlogs,等待被移除。
  • 当RegionServer出现故障的时候,需要对Hlog进行回放来恢复数据。回放的时候会读取Hfile的oldestUnflushedSequenceId中的sequenceid和Hlog中的sequenceid进行比较,小于sequenceid的就直接忽略,但与或者等于的就进行重做。回放完成后,就完成了数据的恢复工作。
2.3.3 Hlog的生命周期

Hlog从产生到最后删除需要经历如下几个过程:

  • 产生 所有涉及到数据的变更都会先写Hlog,除非是你关闭了Hlog
  • 滚动 Hlog的大小通过参数hbase.regionserver.logroll.period控制,默认是1个小时,时间达到hbase.regionserver.logroll.period 设置的时间,Hbase会创建一个新的Hlog文件。这就实现了Hlog滚动的目的。Hbase通过hbase.regionserver.maxlogs参数控制Hlog的个数。滚动的目的,为了控制单个Hlog文件过大的情况,方便后续的过期和删除。
  • 过期 前面我们有讲到sequenceid这个东东,Hlog的过期依赖于对sequenceid的判断。Hbase会将Hlog的sequenceid和Hfile最大的sequenceid(刷新到的最新位置)进行比较,如果该Hlog文件中的sequenceid比刷新的最新位置的sequenceid都要小,那么这个Hlog就过期了,过期了以后,对应Hlog会被移动到.oldlogs目录。 这里有个问题,为什么要将过期的Hlog移动到.oldlogs目录,而不是直接删除呢? 答案是因为Hbase还有一个主从同步的功能,这个依赖Hlog来同步Hbase的变更,有一种情况不能删除Hlog,那就是Hlog虽然过期,但是对应的Hlog并没有同步完成,因此比较好的做好是移动到别的目录。再增加对应的检查和保留时间。
  • 删除 如果Hbase开启了replication,当replication执行完一个Hlog的时候,会删除Zoopkeeper上的对应Hlog节点。在Hlog被移动到.oldlogs目录后,Hbase每隔hbase.master.cleaner.interval(默认60秒)时间会去检查.oldlogs目录下的所有Hlog,确认对应的Zookeeper的Hlog节点是否被删除,如果Zookeeper 上不存在对应的Hlog节点,那么就直接删除对应的Hlog。 hbase.master.logcleaner.ttl(默认10分钟)这个参数设置Hlog在.oldlogs目录保留的最长时间。

3. HBase读逻辑

客户端读取数据有两种方式, GetScan。 Get 是一种随机点查的方式,根据 rowkey 返回一行数据,也可以在构造 Get 对象的时候传入一个 rowkey 列表,这样一次 RPC 请求可以返回多条数据。Get 对象可以设置列与 filter,只获取特定 rowkey 下的指定列的数据、Scan 是范围查询,通过指定 Scan 对象的 startRow 与 endRow 来确定一次扫描的数据范围,获取该区间的所有数据。

一次由客户端发起的完成的读流程,可以分为两个阶段。第一个阶段是客户端如何将请求发送到正确的 RegionServer 上,第二阶段是 RegionServer 如何处理读取请求。

3.1 请求发送至Region Server

有赞HBase技术实践:读流程解析与优化有赞HBase技术实践:读流程解析与优化

我们以单条 rowkey 的 Get 请求为例,当用户初始化到 zookeeper 的连接之后,并发送一个 Get 请求时,需要先定位这条 rowkey 的 HRegion 地址。如果该地址不在缓存之中,就需要请求 zookeeper (箭头 1),询问 meta 表的地址。在获取到 meta 表地址之后去读取 meta 表的数据来根据 rowkey 定位到该 rowkey 属于的 HRegion 信息和 RegionServer 的地址 (箭头 2),缓存该地址并发 Get 请求点对点发送到对应的 RegionServer(箭头 3),至此,客户端定位发送请求的流程走通。

3.2 RegionServer 处理读请求

首先在 RegionServer 端,将 Get 请求当做特殊的一次 Scan 请求处理,其 startRow 和 StopRow 是一样的,所以介绍 Scan 请求的处理就可以明白 Get 请求的处理流程了。

3.2.1 数据组织

让我们回顾一下 HBase 数据的组织架构,首先 Table 横向切割为多个 HRegion ,按照一个列族的情况,每一个 HRegion 之中包含一个 MemStore 和多个 HFile 文件, HFile 文件设计比较复杂,这里不详细展开,用户需要知道给定一个 rowkey 可以根据索引结合二分查找可以迅速定位到对应的数据块即可。结合这些背景信息,我们可以把一个 Read 请求的处理转化下面的问题:如何从一个 MemStore,多个 HFile 中获取到用户需要的正确的数据(默认情况下是最新版本,非删除,没有过期的数据。同时用户可能会设定 filter ,指定返回条数等过滤条件)。

在 RegionServer 内部,会把读取可能涉及到的所有组件都初始化为对应的 scanner 对象,针对 Region 的读取,封装为一个 RegionScanner 对象,而一个列族对应一个 Store,对应封装为 StoreScanner,在 Store 内部,MemStore 则封装为 MemStoreScanner,每一个 HFile 都会封装为 StoreFileScanner 。最后数据的查询就会落在对 MemStoreScanner 和 StoreFileScanner 上的查询之上。

这些 scanner 首先根据 scan 的 TimeRange 和 Rowkey Range 会过滤掉一些,剩下的 scanner 在 RegionServer 内部组成一个最小堆 KeyValueHeap,该数据结构核心一个 PriorityQueue 优先级队列,队列里按照 Scanner 指向的 KeyValue 排序。

代码语言:txt复制
// 用来组织所有的 Scanner 
protected PriorityQueue<KeyValueScanner> heap = null; 
// PriorityQueue 当前排在最前面的 Scanner 
protected KeyValueScanner current = null;    
3.2.2 数据过滤

我们知道数据在内存以及 HDFS 文件中存储着,为了读取这些数据,RegionServer 构造了若干 Scanner 并组成了一个最小堆,那么如何遍历这个堆去过滤数据返回用户想要的值呢。

我们假设 HRegion 有 4 个 Hfile,1 个 MemStore,那么最小堆内有 4 个 scanner 对象,我们以 scannerA-D 来代替这些 scanner 对象,同时假设我们需要查询的 rowkey 为 rowA。每一个 scanner 内部有一个 current 指针,指向的是当前需要遍历的 KeyValue,所以这时堆顶部的 scanner 对象的 current 指针指向的就是 rowA(rowA:cf:colA) 这条数据。通过触发 next() 调用,移动 current 指针,来遍历所有 scanner 中的数据。scanner 组织逻辑视图如下图所示。

有赞HBase技术实践:读流程解析与优化有赞HBase技术实践:读流程解析与优化

第一次 next 请求,将会返回 ScannerA 中的 rowA:cf:colA,而后 ScannerA 的指针移动到下一个 KeyValue rowA:cf:colB,堆中的 Scanners 排序不变;

第二次 next 请求,返回 ScannerA 中的 rowA:cf:colB,ScannerA 的 current 指针移动到下一个 KeyValue rowB:cf:ColA,因为堆按照 KeyValue 排序可知 rowB 小于 rowA, 所以堆内部,scanner 顺序发生改变,改变之后如下图所示:

有赞HBase技术实践:读流程解析与优化有赞HBase技术实践:读流程解析与优化
  • keyValue 类型为 put
  • 列是 Scanner 指定的列
  • 满足 filter 过滤条件
  • 最新的版本
  • 未删除的数据

如果 scan 的参数更加复杂,条件也会发生变化,比如指定 scan 返回 Raw 数据的时候,打了删除标记的数据也要被返回

4. RegionServer的故障恢复

我们知道,RegionServer的相关信息保存在ZK中,在RegionServer启动的时候,会在Zookeeper中创建对应的临时节点。RegionServer通过Socket和Zookeeper建立session会话,RegionServer会周期性地向Zookeeper发送ping消息包,以此说明自己还处于存活状态。而Zookeeper收到ping包后,则会更新对应session的超时时间。

当Zookeeper超过session超时时间还未收到RegionServer的ping包,则Zookeeper会认为该RegionServer出现故障,ZK会将该RegionServer对应的临时节点删除,并通知Master,Master收到RegionServer挂掉的信息后就会启动数据恢复的流程。

Master启动数据恢复流程后,其实主要的流程如下:

RegionServer宕机---》ZK检测到RegionServer异常---》Master启动数据恢复---》Hlog切分---》Region重新分配---》Hlog重放---》恢复完成并提供服务

故障恢复有3中模式,下面就一一来介绍。

4.1 LogSplitting

在最开始的恢复流程中,Hlog的整个切分过程都由于Master来执行,如下图所示:

imgimg
  • a、将待切分的日志文件夹进行重命名,防止RegionServer未真的宕机而持续写入Hlog
  • b、Master启动读取线程读取Hlog的数据,并将不同RegionServer的日志写入到不通的内存buffer中
  • c、针对每个buffer,Master会启动对应的写线程将不同Region的buffer数据写入到HDFS中,对应的路径为/hbase/table_name/region/recoverd.edits/.tmp。
  • d、Master重新将宕机的RegionServer中的Rgion分配到正常的RegionServer中,对应的RegionServer读取Region的数据,会发现该region目录下的recoverd.edits目录以及相关的日志,然后RegionServer重放对应的Hlog日志,从而实现对应Region数据的恢复。 从上面的步骤中,我们可以看出Hlog的切分一直都是master在干活,效率比较低。设想,如果集群中有多台RegionServer在同一时间宕机,会是什么情况?串行修复,肯定异常慢,因为只有master一个人在干Hlog切分的活。因此,为了提高效率,开发了Distributed Log Splitting架构。

4.2 Distributed Log Splitting

顾名思义,Distributed Log Splitting是LogSplitting的分布式实现,分布式就不是master一个人在干活了,而是充分使用各个RegionServer上的资源,利用多个RegionServer来并行切分Hlog,提高切分的效率。如下图所示:

imgimg

上图的操作顺序如下:

  • a、Master将要切分的日志发布到Zookeeper节点上(/hbase/splitWAL),每个Hlog日志一个任务,任务的初始状态为TASK_UNASSIGNED
  • b、在Master发布Hlog任务后,RegionServer会采用竞争方式认领对应的任务(先查看任务的状态,如果是TASK_UNASSIGNED,就将该任务状态修改为TASK_OWNED)
  • c、RegionServer取得任务后会让对应的HLogSplitter线程处理Hlog的切分,切分的时候读取出Hlog的对,然后写入不通的Region buffer的内存中。
  • d、RegionServer启动对应写线程,将Region buffer的数据写入到HDFS中,路径为/hbase/table/region/seqenceid.temp,seqenceid是一个日志中该Region对应的最大sequenceid,如果日志切分成功,而RegionServer会将对应的ZK节点的任务修改为TASK_DONE,如果切分失败,则会将任务修改为TASK_ERR。
  • e、如果任务是TASK_ERR状态,则Master会重新发布该任务,继续由RegionServer竞争任务,并做切分处理。
  • f、Master重新将宕机的RegionServer中的Rgion分配到正常的RegionServer中,对应的RegionServer读取Region的数据,将该region目录下的一系列的seqenceid.temp进行从小到大进行重放,从而实现对应Region数据的恢复。

从上面的步骤中,我们可以看出Distributed Log Splitting采用分布式的方式,使用多台RegionServer做Hlog的切分工作,确实能提高效率。正常故障恢复可以降低到分钟级别。

但是这种方式有个弊端是会产生很多小文件(切分的Hlog数 宕机的RegionServer上的Region数)。比如一个RegionServer有20个Region,有50个Hlog,那么产生的小文件数量为2050=1000个。如果集群中有多台RegionServer宕机的情况,小文件更是会成倍增加,恢复的过程还是会比较慢。由次诞生了Distributed Log Replay模式。

4.3 Distributed Log Replay

Distributed Log Replay和Distributed Log Splitting的不同是先将宕机RegionServer上的Region分配给正常的RgionServer,并将该Region标记为recovering。再使用Distributed Log Splitting类似的方式进行Hlog切分,不同的是,RegionServer将Hlog切分到对应Region buffer后,并不写HDFS,而是直接进行重放。这样可以减少将大量的文件写入HDFS中,大大减少了HDFS的IO消耗。如下图所示:

imgimg

5. Region的拆分

5.1 Hbase Region的三种拆分策略

Hbase Region的拆分策略有比较多,比如除了3种默认过的策略,还有DelimitedKeyPrefixRegionSplitPolicy、KeyPrefixRegionSplitPolicy、DisableSplitPolicy等策略,这里只介绍3种默认的策略。分别是ConstantSizeRegionSplitPolicy策略、IncreasingToUpperBoundRegionSplitPolicy策略和SteppingSplitPolicy策略。

5.1.1 ConstantSizeRegionSplitPolicy

ConstantSizeRegionSplitPolicy策略是0.94版本之前的默认拆分策略,这个策略的拆分规则是:当region大小达到hbase.hregion.max.filesize(默认10G)后拆分。 这种拆分策略对于小表不太友好,按照默认的设置,如果1个表的Hfile小于10G就一直不会拆分。注意10G是压缩后的大小,如果使用了压缩的话。

如果1个表一直不拆分,访问量小也不会有问题,但是如果这个表访问量比较大的话,就比较容易出现性能问题。这个时候只能手工进行拆分。还是很不方便。

5.1.2 IncreasingToUpperBoundRegionSplitPolicy

IncreasingToUpperBoundRegionSplitPolicy策略是Hbase的0.94~2.0版本默认的拆分策略,这个策略相较于ConstantSizeRegionSplitPolicy策略做了一些优化,该策略的算法为:min(r^2*flushSize,maxFileSize ),最大为maxFileSize 。

从这个算是我们可以得出flushsize为128M、maxFileSize为10G的情况下,可以计算出Region的分裂情况如下: 第一次拆分大小为:min(10G,11128M)=128M 第二次拆分大小为:min(10G,3*3*128M)=1152M 第三次拆分大小为:min(10G,55128M)=3200M 第四次拆分大小为:min(10G,77128M)=6272M 第五次拆分大小为:min(10G,9*9*128M)=10G 第五次拆分大小为:min(10G,11*11*128M)=10G

从上面的计算我们可以看到这种策略能够自适应大表和小表,但是这种策略会导致小表产生比较多的小region,对于小表还是不是很完美。

5.1.3 SteppingSplitPolicy

SteppingSplitPolicy是在Hbase 2.0版本后的默认策略,,拆分规则为:If region=1 then: flush size * 2 else: MaxRegionFileSize。

还是以flushsize为128M、maxFileSize为10场景为列,计算出Region的分裂情况如下: 第一次拆分大小为:2*128M=256M 第二次拆分大小为:10G

从上面的计算我们可以看出,这种策略兼顾了ConstantSizeRegionSplitPolicy策略和IncreasingToUpperBoundRegionSplitPolicy策略,对于小表也肯呢个比较好的适配。

5.2 Hbase Region拆分的详细流程

Hbase的详细拆分流程图如下:

imgimg

备注图片来源( https://zh.hortonworks.com/blog/apache-hbase-region-splitting-and-merging/ )

从上图我们可以看出Region切分的详细流程如下:

  1. 会ZK的/hbase/region-in-transition/region-name下创建一个znode,并设置状态为SPLITTING
  2. master通过watch节点检测到Region状态的变化,并修改内存中Region状态的变化
  3. RegionServer在父Region的目录下创建一个名称为.splits的子目录
  4. RegionServer关闭父Region,强制将数据刷新到磁盘,并这个Region标记为offline的状态。此时,落到这个Region的请求都会返回NotServingRegionException这个错误
  5. RegionServer在.splits创建daughterA和daughterB,并在文件夹中创建对应的reference文件,指向父Region的Region文件
  6. RegionServer在HDFS中创建daughterA和daughterB的Region目录,并将reference文件移动到对应的Region目录中
  7. 在.META.表中设置父Region为offline状态,不再提供服务,并将父Region的daughterA和daughterB的Region添加到.META.表中,已表名父Region被拆分成了daughterA和daughterB两个Region
  8. RegionServer并行开启两个子Region,并正式提供对外写服务
  9. RegionSever将daughterA和daughterB添加到.META.表中,这样就可以从.META.找到子Region,并可以对子Region进行访问了
  10. RegionServr修改/hbase/region-in-transition/region-name的znode的状态为SPLIT

备注:为了减少对业务的影响,Region的拆分并不涉及到数据迁移的操作,而只是创建了对父Region的指向。只有在做大合并的时候,才会将数据进行迁移。

那么通过reference文件如何才能查找到对应的数据呢?如下图所示:

imgimg
  • 根据文件名来判断是否是reference文件
  • 由于reference文件的命名规则为前半部分为父Region对应的File的文件名,后半部分是父Region的名称,因此读取的时候也根据前半部分和后半部分来识别
  • 根据reference文件的内容来确定扫描的范围,reference的内容包含两部分,一部分是切分点splitkey,另一部分是boolean类型的变量(true或者false)。如果为true则扫描文件的上半部分,false则扫描文件的下半部分
  • 接下来确定了扫描的文件,以及文件的扫描范围,那就按照正常的文件检索了

6. Region的合并

Region的合并分为小合并和大合并,下面就分别来做介绍:

6.1 小合并(MinorCompaction)

由前面的刷盘部分的介绍,我们知道当MemStore达到hbase.hregion.memstore.flush.size大小的时候会将数据刷到磁盘,生产StoreFile,因此势必产生很多的小问题,对于Hbase的读取,如果要扫描大量的小文件,会导致性能很差,因此需要将这些小文件合并成大一点的文件。因此所谓的小合并,就是把多个小的StoreFile组合在一起,形成一个较大的StoreFile,通常是累积到3个Store File后执行。通过参数hbase.hstore,compactionThreadhold配置。小合并的大致步骤为:

  • 分别读取出待合并的StoreFile文件的KeyValues,并顺序地写入到位于./tmp目录下的临时文件中
  • 将临时文件移动到对应的Region目录中
  • 将合并的输入文件路径和输出路径封装成KeyValues写入WAL日志,并打上compaction标记,最后强制自行sync
  • 将对应region数据目录下的合并的输入文件全部删除,合并完成

这种小合并一般速度很快,对业务的影响也比较小。本质上,小合并就是使用短时间的IO消耗以及带宽消耗换取后续查询的低延迟。

6.2 大合并(MajorCompaction)

所谓的大合并,就是将一个Region下的所有StoreFile合并成一个StoreFile文件,在大合并的过程中,之前删除的行和过期的版本都会被删除,拆分的母Region的数据也会迁移到拆分后的子Region上。大合并一般一周做一次,控制参数为hbase.hregion.majorcompaction。大合并的影响一般比较大,尽量避免统一时间多个Region进行合并,因此Hbase通过一些参数来进行控制,用于防止多个Region同时进行大合并。该参数为: hbase.hregion.majorcompaction.jitter 具体算法为:

hbase.hregion.majorcompaction参数的值乘于一个随机分数,这个随机分数不能超过hbase.hregion.majorcompaction.jitter的值。hbase.hregion.majorcompaction.jitter的值默认为0.5。 通过hbase.hregion.majorcompaction参数的值加上或减去hbase.hregion.majorcompaction参数的值乘于一个随机分数的值就确定下一次大合并的时间区间。

用户如果想禁用major compaction,只需要将参数hbase.hregion.majorcompaction设为0。建议禁用。

7. HBase相关优化

在介绍读流程之后,我们再结合有赞业务上的实践来介绍如何优化读请求,既然谈到优化,就要先知道哪些点可会影响读请求的性能,我们依旧从客户端和服务端两个方面来深入了解优化的方法。

7.1 客户端优化

7.1.1 读优化

HBase 读数据共有两种方式,Get 与 Scan。

在通用层面,在客户端与服务端建连需要与 zookeeper 通信,再通过 meta 表定位到 region 信息,所以在初次读取 HBase 的时候 rt 都会比较高,避免这个情况就需要客户端针对表来做预热,简单的预热可以通过获取 table 所有的 region 信息,再对每一个 region 发送一个 Scan 或者 Get 请求,这样就会缓存 region 的地址;

rowkey 是否存在读写热点,若出现热点则失去分布式系统带来的优势,所有请求都只落到一个或几个 HRegion 上,那么请求效率一定不会高;

读写占比是如何的。如果写重读轻,浏览服务端 RegionServer 日志发现很多 MVCC STUCK 这样的字样,那么会因为 MVCC 机制因为写 Sync 到 WAL 不及时而阻塞读。

7.1.1.1 Get 请求优化
  • 将 Get 请求批量化,减少 rpc 次数,但如果一批次的 Get 数量过大,如果遇到磁盘毛刺或者 Split 毛刺,则 Get 会全部失败(不会返回部分成功的结果),抛出异常。
  • 指定列族,标识符。这样可以服务端过滤掉很多无用的 scanner,减少 IO 次数,提高效率,该方法同样适用于 Scan。
7.1.1.2 scan 请求优化
  • 设定合理的 startRow 与 stopRow 。如果 scan 请求不设置这两个值,而只设置 filter,则会做全表扫描。
  • 设置合理的 caching 数目, scan.setCaching(100)。 因为 Scan 潜在会扫描大量数据,因此客户端发起一次 Scan 请求,实际并不会一次就将所有数据加载到本地,而是分成多次 RPC 请求进行加载。默认值是 100。用户如果确实需要扫描海量数据,同时不做逻辑分页处理,那么可以将缓存值设置到 1000,减少 rpc 次数,提升处理效率。如果用户需要快速,迭代地获取数据,那么将 caching 设置为 50 或者 100 就合理。
7.1.2 写优化

7.2 服务端优化

7.2.1 COMPRESSION

配置数据的压缩算法,这里的压缩是HFile中block级别的压缩。对于可以压缩的数据,配置压缩算法可以有效减少磁盘的IO,从而达到提高性能的目的。但是并不是所有数据都可以进行有效压缩,如图片,因为图片一般是已经压缩后的数据,所以压缩效果有限。常用的压缩算法是SNAPPY,因为它有较好的压缩和解压速度和可以接受的压缩率。

7.2.2 IN_MEMORY

配置表的数据优先缓存在内存中,这样可以有效提升读取的性能。适合小表,而且需要频繁进行读取操作的。

7.2.3 预分区

在HBase中数据是分布在各个Region中的,每个Region都负责一个起始RowKey和结束Rowkey的范围,在向HBase中写数据的时候,会根据RowKey请求到对应的Region上,如果写请求都集中在某一个Region或某几个Region上的时候,性能肯定不如写请求均匀分布在各个Region上好。默认情况下,创建的HBase的只有一个Region分区,会随着数据量的变大,进行split,拆分成多个Region,最开始的性能肯定会很不好

建议在设计HBase的的时候,进行预分区,并设计一个良好的Rowkey生成规则(关于RowKey设计,可以参考之前的博客,尽量将数据分散到各个Region上,那样在进行HBase的读写的时候,对性能会有很好的改善。

7.2.4 合理设置WAL存储级别

数据在写入HBase的时候,先写WAL,再写入缓存。通常情况下写缓存延迟很低,WAL机制一方面是为了确保数据即使写入缓存后数据丢失也可以通过WAL恢复,另一方面是为了集群之间的复制。默认WAL机制是开启的,并且使用的是同步机制写WAL。

如果业务不特别关心异常情况下部分数据的丢失,而更关心数据写入吞吐量,可考虑关闭WAL写,这样可以提升2~3倍数据写入的吞吐量。

如果业务不能接受不写WAL,但是可以接受WAL异步写入,这样可以带了1~2倍性能提升。

HBase中可以通过设置WAL的持久化等级决定是否开启WAL机制、以及HLog的落盘方式。

WAL的持久化等级分为如下四个等级:

  1. SKIP_WAL:只写缓存,不写HLog日志。这种方式因为只写内存,因此可以极大的提升写入性能,但是数据有丢失的风险。在实际应用过程中并不建议设置此等级,除非确认不要求数据的可靠性。
  2. ASYNC_WAL:异步将数据写入HLog日志中。
  3. SYNC_WAL:同步将数据写入日志文件中,需要注意的是数据只是被写入文件系统中,并没有真正落盘,默认。
  4. FSYNC_WAL:同步将数据写入日志文件并强制落盘。最严格的日志写入等级,可以保证数据不会丢失,但是性能相对比较差。

同样,除了在创建表的时候直接设置WAL存储级别,也可以通过客户端设置WAL持久化等级,代码:

代码语言:txt复制
put.setDurability(Durability.SYNC_WAL);
7.2.5 BLOCKSIZE

配置HFile中block块的大小,不同的block大小,可以影响HBase读写数据的效率。越大的block块,配置压缩算法,压缩的效率就越好;但是由于HBase的读取数据时以block块为单位的,所以越大的block块,对于随机读的情况,性能可能会比较差,如果要提升写入的性能,一般扩大到128kb或者256kb,可以提升写数据的效率,也不会影响太大的随机读性能。

7.2.6 DATA_BLOCK_ENCODING

配置HFile中block块的编码方法。当一行数据中存在多个列时,一般可以配置为"FAST_DIFF",可以有效的节省数据存储的空间,从而提升性能。

7.2.7 BloomFilter

优化原理:BloomFilter主要用来过滤不存在待检索RowKey或者Row-Col的HFile文件,避免无用的IO操作。它会告诉你在这个HFile文件中是否可能存在待检索的KeyValue,如果不存在,就可以不用小号IO打开文件进行seek。通过设置BloomFilter可以提升读写的性能。

BloomFilter是一个列族级别的配置属性,如果列族设置了BloomFilter,那么HBase会在生成StoreFile时包含一份BloomFilter的结构的数据,称为MetaBlock(一旦写入就无法更新)。MetaBlock和DataBlock(真实的KeyValue数据)一起由LRUBlockCache维护,所以开启了BloomFilter会有一定的存储即内存cache开销。

HBase利用BloomFilter可以节省必须读磁盘过程,可以提高随机读(get)的性能,但是对于顺序读(scan)而言,设置BloomFilter是没有作用的(0.92版本以后,如果设置了BloomFilter为ROWCOL,对于执行了qualifier的scan有一定的优化)

BloomFilter取值有两个,ROW和ROWCOL,需要根据业务来确定具体使用哪种。

  • 如果业务大多数随机查询仅仅使用row作为查询条件,BloomFilter一定要设置为ROW。
  • 如果大多数随机查询使用row col作为查询条件,BloomFilter需要设置为ROWCOL。
  • 如果不确定业务查询类型,设置为ROW。‘
7.2.8 GC_OPTS

HBase是利用内存完成读写操作。提高HBase内存可以有效提高HBase性能。GC_OPTS主要需要调整HeapSize和NewSize的大小。调整HeapSize大小的时候,建议将Xms和Xmx设置成相同的值,这样可以避免JVM动态调整HeapSize大小的时候影响性能。调整NewSize大小的时候,建议把其设置为HeapSize大小的1/9。

当HBase集群规模越大,Region数量越多时,可以适当调大HMaster的GC_OPTS参数

RegionServer需要比HMaster更大的内存,在内存充足的情况下,HeapSize可以相对设置大一些。

HMaster的HeapSize为4G的时候,HBase集群可以支持100000个Region的规模。根据经验值,单个RegionServer的HeapSize不建议超过20GB。

代码语言:txt复制
# HMaster、RegionServer GC_OPTS配置如下:HMaster: -Xms2G -Xmx2G -XX:NewSize=256M -XX:MaxNewSize=256M RegionServer: -Xms4G -Xmx4G -XX:NewSize=512M -XX:MaxNewSize=512M复制代码
7.2.9 RegionServer并发请求处理数量

hbase.regionserver.handler.count表示RegionServer在同一时刻能够并发处理多少请求。如果设置过高会导致激烈的线程竞争,如果设置过小,请求将会在RegionServer长时间等待,降低处理能力。应该根据资源情况,适当增加处理线程数。

建议根据CPU的使用情况,可以设置为100至300之间的值。

7.2.10 控制MemStore的大小

hbase.hregion.memstore.flush.size默认值128M,单位字节,一旦有MemStore超过该值将被flush,如果regionserver的jvm内存比较充足(16G以上),可以调整为256M。在内存足够put负载大情况下可以调整增大。

7.2.11 BlockCache优化

BlockCache作为读缓存,合理设置对于提高读性能非常重要。默认情况下,BlockCache和MemStore的配置各占40%,可以根据集群业务进行修正,比如读多写少业务可以将BlockCache占比调大。另外BlockCache的策略也很重要,不同策略对读性能来说影响并不大,但是对GC的影响 却很显著。

HBase缓存区大小,主要影响查询性能。根据查询模式以及查询记录分布情况来决定缓存区的大小。如果采用随机查询使得缓存区的命中率较低,可以适当降低缓存大小。

代码语言:txt复制
hfile.block.cache.size,默认0.4,用来提高读性能hbase.regionserver.global.memstore.size,默认0.4,用来提高写性能复制代码
7.2.12 控制HFile个数

MemStore在flush之前,会进行StoreFile的文件数量校验(通过hbase.hstore.blockingStoreFiles参数配置),如果大于设定值,系统将会强制执行Compaction操作进行文件合并,在合并的过程中会阻塞MemStore的数据写入,等待其他线程将StoreFile进行合并。通常情况下发生在数据写入很快的情况下。

hbase.hstore.compactionThreshold表示启动Compaction的最低阈值,该值不能太大,否则会积累太多文件,一般建议设置为5~8左右。

hbase.hstore.blockingStoreFiles默认设置为7,可以适当调大一些。

7.2.13 Split优化

hbase.hregion.max.filesize表示HBase中Region的文件总大小的最大值。当Region中的文件大于该参数时,将会导致Region分裂。

  • 如果该参数设置过小时,可能会导致Split操作频繁
  • 如果该参数设置过大时,会导致Compaction操作需要处理的文件个数增大,影响Compaction执行效率
7.2.14 Compaction优化

hbase.hstore.compaction.min当一个Store中文件超过该值时,会进行Compaction,适当增大该值,可以减少文件被重复执行Compaction。但是如果过大,会导致Store中文件数过多而影响读取的性能。

hbase.hstore.compaction.max控制一次Compaction操作时的文件数据量的最大值。

hbase.hstore.compaction.max.size如果一个HFile文件的大小大于该值,那么在Minor Compaction操作中不会选择这个文件进行Compaction操作,除非进行Major Compaction操作。这个值可以防止较大的HFile参与Compaction操作。在禁止Major Compaction后,一个Store中可能存在几个HFile,而不会合并成为一个HFile,这样不会对数据读取造成太大的性能影响。

原则是:尽量要减小Compaction的次数和Compaction的执行时间

7.3 架构优化

7.3.1 异构存储

HBase 资源隔离 异构存储。SATA 磁盘的随机 iops 能力,单次访问的 RT,读写吞吐上都远远不如 SSD,那么对 RT 极其敏感业务来说,SATA 盘并不能胜任,所以我们需要 HBase 有支持 SSD 存储介质的能力。

为了 HBase 可以支持异构存储,首先在 HDFS 层面就需要做响应的支持,在 HDFS 2.6.x 以及之后的版本,提供了对 SSD 上存储文件的能力,换句话说在一个 HDFS 集群上可以有 SSD 和 SATA 磁盘并存,对应到 HDFS 存储格式为 ssd 与 disk。然而 HBase 1.2.6 上并不能对表的列族和 RegionServer 的 WAL 上设置其存储格式为 ssd, 该功能在社区 HBase 2.0 版本之后才开放出来,所以我们从社区 backport 了对应的 patch ,打到了我们有赞自己的 HBase 版本之上。支持 ssd 的 社区 issue 如下:

https://issues.apache.org/jira/browse/HBASE-14061?jql=text ~ "storage policy" 。

添加 SSD 磁盘之后,HDFS 集群存储架构示意图如图 5 所示:

理想的混合机型集群异构部署,对于 HBase 层面来看,文件存储可选三种策略:HOT, ONE_SSD, ALL_SSD, 其中 ONE_SSD 存储策略既可以把三个副本中的两个存储到便宜的 SATA 磁盘介质之上来减少 SSD 磁盘存储成本的开销,同时在数据读取访问本地 SSD 磁盘上的数据可以获得理想的 RT ,是一个十分理想的存储策略。HOT 存储策略与不引入异构存储时的存储情况没有区别,而 ALL_SSD 将所有副本都存储到 SSD 磁盘上。 在有赞我们目前没有这样的理想混合机型,只有纯 SATA 与 纯 SSD 两种大数据机型,这样的机型对应的架构与之前会有所区别,存储架构示意图如图 6 所示。

基于这样的场景,我们做了如下规划:

  1. 将 SSD 机器规划成独立的组,分组的 RegionServer 配置 hbase.wal.storage.policy=ONE_SSD, 保证 wal 本身的本地化率;
  2. 将 SSD 分组内的表配置成 ONE_SSD 或者 ALL_SSD;
  3. 非 SSD 分组内的表存储策略使用默认的 HOT

具体的配置策略如下:在 hdfs-site.xml 中修改

代码语言:txt复制
 <property>      
     <name>dfs.datanode.data.dir</name>      
     <value>[SSD]file:/path/to/dfs/dn1</value> 
 </property>

在 SSD 机型 的 RegionServer 中的 hbase-site.xml 中修改

代码语言:txt复制
<property>      
    <name>hbase.wal.storage.policy</name>      
    <value>ONE_SSD</value> 
</property>

其中 ONE_SSD 也可以替代为 ALL_SSD。 SATA 机型的 RegionServer 则不需要修改或者改为 HOT。

7.3.2 HDFS 短路读

开启 HDFS 的短路读模式。该特性由 HDFS-2246 引入。我们集群的 RegionServer 与 DataNode 混布,这样的好处是数据有本地化率的保证,数据第一个副本会优先写本地的 Datanode。在不开启短路读的时候,即使读取本地的 DataNode 节点上的数据,也需要发送 RPC 请求,经过层层处理最后返回数据,而短路读的实现原理是客户端向 DataNode 请求数据时,DataNode 会打开文件和校验和文件,将两个文件的描述符直接传递给客户端,而不是将路径传递给客户端。客户端收到两个文件的描述符之后,直接打开文件读取数据,该特性是通过 UNIX Domain Socket 进程间通信方式实现

该特性内部实现比较复杂,设计到共享内存段通过 slot 放置副本的状态与计数,这里不再详细展开。

开启短路读需要修改 hdfs-site.xml 文件:

代码语言:txt复制
    <property>        
        <name>dfs.client.read.shortcircuit</name>        
        <value>true</value>    
    </property>    
    <property>        
        <name>dfs.domain.socket.path</name>         
        value>/var/run/hadoop/dn.socket</value>    
    </property>
7.3.3 HDFS Hedged Read

开启 Hedged Read 模式。当我们通过短路读读取本地数据因为磁盘抖动或其他原因读取数据一段时间内没有返回,去向其他 DataNode 发送相同的数据请求,先返回的数据为准,后到的数据抛弃,这也可以减少磁盘毛刺带来的影响。默认该功能关闭,在 HBase 中使用此功能需要修改 hbase-site.xml

代码语言:txt复制
   <property>       
       <name>dfs.client.hedged.read.threadpool.size</name>       
       <value>50</value>     
   </property>    
   <property>       
       <name>dfs.client.hedged.read.threshold.millis</name>       
       <value>100</value>    
   </property>

线程池大小可以与读 handler 的数目相同,而超时阈值不适宜调整的太小,否则会对集群和客户端都增加压力。同时可以通过 Hadoop 监控查看 hedgedReadOps 与 hedgedReadOps 两个指标项,查看启用 Hedged read 的效果,前者表示发生了 Hedged read 的次数,后者表示 Hedged read 比原生读要快的次数。

7.3.4 高可用读

HBase 是一个 CP 系统,同一个 region 同一时刻只有一个 regionserver 提供读写服务,这保证了数据的一致性,即不存在多副本同步的问题。但是如果一台 regionserver 发声宕机的时候,系统需要一定的故障恢复时间 deltaT, 这个 deltaT 时间内,region 是不提供服务的。这个 deltaT 时间主要由宕机恢复中需要回放的 log 的数目决定。

HBase 提供了 HBase Replication 机制,用来实现集群间单方向的异步数据复制我们线上部署了双集群,备集群 SSD 分组和主集群 SSD 分组有相同的配置。当主集群因为磁盘,网络,或者其他业务突发流量影响导致某些 RegionServer 甚至集群不可用的时候,就需要提供备集群继续提供服务,备集群的数据可能会因为 HBase Replication 机制的延迟,相比主集群的数据是滞后的,按照集群目前的规模统计,平均延迟在 100ms 以内。所以为了达到高可用,放弃了强一致性,选择了最终一致性和高可用性,在第一版采用的方案如下:

在客户端层面,他们只希望一个 Put 或者 Get 请求正常送达且返回预期的数据即可,那么就需要高可用客户端封装一层降级,熔断处理的逻辑,这里我们采用 Hystrix 做为底层熔断处理引擎,在引擎之上封装了 HBase 的基本 API,用户只需要配置主备机房的 ZK 地址即可,所有的降级熔断逻辑最终封装到 ha-hbase-client 中,原理类似上图。

Ref

  1. https://cloud.tencent.com/developer/article/1006043
  2. https://cloud.tencent.com/developer/article/1006044
  3. https://www.infoq.cn/article/IEHFj_IRZKiuepM6UDZA
  4. https://juejin.im/post/6844903873795063822#heading-32

0 人点赞