如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统

前言

本文致力于从架构原理、集群部署、性能优化与使用技巧等方面,阐述在如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统 。

一、架构原理

1.1 基本架构

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第1张图片
从上层往下可以看到HBase架构中的角色分配为:

Client——>Zookeeper——>HMaster——>RegionServer——>HDFS

Client

Client是执行查询、写入等对HBase表数据进行增删改查的使用方,可以是使用HBase Client API编写的程序,也可以是其他开发好的HBase客户端应用。

Zookeeper

Zookeeper同HDFS一样,HBase使用Zookeeper作为集群协调与管理系统

在HBase中其主要的功能与职责为:

  • 存储整个集群HMasterRegionServer的运行状态
  • 实现HMaster的故障恢复自动切换
  • 为Client提供元数据表存储信息
  1. HMaster、RegionServer启动之后,将会在Zookeeper上注册并创建节点(/hbasae/master/hbase/rs/*),同时 Zookeeper 通过Heartbeat的心跳机制维护与监控节点状态,一旦节点丢失心跳,则认为该节点宕机或者下线,将清除该节点在Zookeeper中的注册信息。
  2. 当Zookeeper中任一RegionServer节点状态发生变化时,HMaster都会收到通知,并作出相应处理,例如RegionServer宕机,HMaster重新分配Regions至其他RegionServer,以保证集群整体可用性
  3. 当HMaster宕机时(Zookeeper监测到心跳超时),Zookeeper中的 /hbasae/master 节点将会消失,同时Zookeeper通知其他备用HMaster节点重新创建 /hbasae/master 并转化为active master。

协调过程示意图如下:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第2张图片
除了作为集群中的协调者,Zookeeper还为Client提供了 hbase:meta 表的存储信息。

客户端要访问HBase中的数据,只需要知道Zookeeper集群的连接信息,访问步骤如下:

  1. 客户端将从Zookeeper(/hbase/meta-region-server)获得 hbase:meta 表存储在哪个RegionServer缓存该位置信息
  2. 查询该RegionServer上的 hbase:meta 表数据,查找要操作的 rowkey所在的Region存储在哪个RegionServer中,缓存该位置信息
  3. 在具体的RegionServer上,根据rowkey检索该Region数据

可以看到,客户端操作数据过程并不需要HMaster的参与,通过Zookeeper间接访问RegionServer来操作数据

第一次请求将会产生3次RPC,之后使用相同的rowkey时,客户端将直接使用缓存下来的位置信息,直接访问RegionServer,直至缓存失效(Region失效迁移等原因)。

通过Zookeeper的读写流程如下:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第3张图片
hbase:meta 表存储了集群中所有Region位置信息。
表结构如下:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第4张图片

rowkey规则:${表名},${起始键},${region时间戳}.${encode编码}.

列簇:info

       state:Region状态,正常情况下为 OPEN
       serverstartcode:RegionServer启动的13位时间戳
       server:所在RegionServer 地址和端口,如cdh85-47:16020
       sn:server和serverstartcode组成,如cdh85-47:16020,1549491783878
       seqnumDuringOpen:Region在线时长的二进制串
       regioninfo:region的详细信息,如:ENCODED、NAME、STARTKEY、ENDKEY等
              ENCODED:基于${表名},${起始键},${region时间戳}生成的32位md5字符串,
                                   region数据存储在hdfs上时使用的唯一编号,可以从meta表中根据该值定位到hdfs中的具体路径。
                                   rowkey中最后的${encode编码}就是 ENCODED 的值,其是rowkey组成的一部分。
            NAME:与ROWKEY值相同
            STARTKEY:该region的起始键
            ENDKEY:该region的结束键

简单总结Zookeeper在HBase集群中的作用如下:

  • 对于服务端,是实现集群协调控制的重要依赖。
  • 对于客户端,是查询操作数据必不可少的一部分。

HMaster

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第5张图片

  1. HBase整体架构中HMaster的功能与职责如下:
  2. 管理RegionServer,监听其状态,保证集群负载均衡且高可用。
  3. 管理Region,如新Region的分配RegionServer宕机时该节点Region的分配与迁移
  4. 接收客户端的DDL操作,如创建与删除表、列簇等信息
  5. 权限控制

如我们前面所说的,HMaster 通过 Zookeeper 实现对集群中,各个 RegionServer 的监控与管理,在RegionServer 发生故障时,可以发现节点宕机,并转移 Region 至其他节点以保证服务的可用性。

但是HBase的故障转移并不是无感知的,相反故障转移过程中,可能会直接影响到线上请求的稳定性,造成段时间内的大量延迟

在分布式系统的 CAP定理中(Consistency一致性、Availability可用性、Partition tolerance分区容错性),分布式数据库基本特性都会实现P,但是不同的数据库对于A和C各有取舍。

如HBase选择了C而通过Zookeeper这种方式来辅助实现A(虽然会有一定缺陷),而Cassandra选择了A,通过其他辅助措施实现了C,各有优劣。

对于HBase集群来说,HMaster是一个内部管理者,除了DDL操作并不对外(客户端)开放,因而HMaster的负载是比较低的。

造成HMaster压力大的情况,可能是集群中存在多个(两个或者三个以上)HMaster,备用的Master会定期与Active Master通信,以获取最新的状态信息,以保证故障切换时自身的数据状态是最新的,因而Active Master可能会收到大量来自备用Master的数据请求

RegionServer

RegionServer在HBase集群中的功能与职责:

  • 根据HMaster的region分配请求,存放和管理Region
  • 接受客户端的读写请求,检索与写入数据,产生大量IO
  • 一个RegionServer中存储并管理者多个Region,是HBase集群中真正 存储数据、接受读写请求 的地方,是HBase架构中最核心、同时也是最复杂的部分。

RegionServer内部结构图如下:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第6张图片
BlockCache

BlockCache为RegionServer中的读缓存一个RegionServer共用一个BlockCache。

RegionServer处理客户端读请求的过程:

  1. 在BlockCache中查询是否命中缓存
  2. 缓存未命中,则定位到存储该数据的Region
  3. 检索Region Memstore中,是否有所需要的数据
  4. Memstore中未查得,则检索Hfiles
  5. 任一过程查询成功,则将数据返回给客户端,并缓存至BlockCache。

BlockCache有两种实现方式,有不同的应用场景,各有优劣

  • On-Heap的LRUBlockCache
  1. 优点:直接从Java堆内内存获取,响应速度快。
  2. 缺陷:容易受GC影响,响应延迟不稳定,特别是在堆内存巨大的情况下
  3. 适用于:写多读少型、小内存等场景
  • Off-Heap的BucketCache
  1. 优点:无GC影响延迟稳定
  2. 缺陷:从堆外内存获取数据性能略差于堆内内存
  3. 适用于:读多写少型、大内存等场景

我们将在「性能优化」一节中具体讨论如何判断应该使用哪种内存模式。

WAL

全称 Write Ahead Log ,是 RegionServer 中的预写日志。

所有写入数据,默认情况下,都会先写入WAL中,以保证RegionServer宕机重启之后,可以通过WAL来恢复数据,一个RegionServer中共用一个WAL。

RegionServer的写流程如下:

  1. 将数据写入WAL中
  2. 根据TableName、Rowkey和ColumnFamily将数据写入对应的Memstore
  3. Memstore通过特定算法内存中的数据刷写成Storefile写入磁盘,并标记WAL sequence值
  4. Storefile定期合小文件

WAL会通过日志滚动的操作,定期对日志文件进行清理已写入HFile中的数据可以清除),对应HDFS上的存储路径为 /hbase/WALs/${HRegionServer_Name} 。

Region

一个Table由一个或者多个Region组成,一个Region中可以看成是Table按行切分有序的数据块,每个Region都有自身的StartKey、EndKey。

一个Region由一个或者多个Store组成,每个Store存储该Table对应Region中一个列簇的数据,相同列簇的列,存储在同一个Store中。

同一个Table的Region,会分布在集群中不同的RegionServer上,以实现读写请求的负载均衡。故,一个RegionServer中,将会存储来自不同Table的N多个Region

Store、Region与Table的关系可以表述如下:多个Store(列簇)组成Region多个Region(行数据块)组成完整的Table

其中,Store由Memstore(内存)StoreFile(磁盘)两部分组成

在RegionServer中,Memstore可以看成指定Table、Region、Store的写缓存(正如BlockCache小节中所述,Memstore还承载了一些读缓存的功能),以RowKey、Column Family、Column、Timestamp进行排序。如下图所示:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第7张图片
写请求到RegionServer之后,并没有立刻写入磁盘中,而是先写入内存中的Memstore(内存中数据丢失问题,可以通过回放WAL解决)以提升写入性能。

Region中的Memstore,会根据特定算法,将内存中的数据,将会刷写到磁盘,形成Storefile文件,因为数据在Memstore中为已排序,顺序写入磁盘性能高、速度快。

在这种 Log-Structured Merge Tree架构模式下,随机写入HBase拥有相当高的性能。

Memstore刷磁盘形成的StoreFile,以HFile格式,存储HBase的KV数据于HDFS之上。

HDFS

HDFS为HBase提供底层存储系统,通过HDFS的高可用、高可靠等特性,保障了HBase的数据安全容灾与备份

1.2写数据 与 Memstore Flush

对于客户端来说,将请求发送到需要写入的RegionServer,等待RegionServer写入WAL、Memstore之后,即返回写入成功的ack信号。
对于RegionServer来说,写入的数据,还需要经过一系列的处理步骤。

首先我们知道Memstore是在内存中的,将数据放在内存中,可以得到优异的读写性能,但是同样也会带来麻烦:

  1. 内存中的数据如何防止断电丢失
  2. 数据存储于内存中的代价是高昂的,空间总是有限的

对于第一个问题,虽然可以通过WAL机制在重启的时候,进行数据回放,但是对于第二个问题,则必须将内存中的数据持久化到磁盘中

在不同情况下,RegionServer通过不同级别的刷写策略,对Memstore中的数据进行持久化,根据触发刷写动作的时机,以及影响范围,可以分为不同的几个级别:

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

Memstore刷写时,会阻塞线上的请求响应,由此可以看到,不同级别的刷写,对线上的请求,会造成不同程度影响的延迟:

  1. 对于MemstoreRegion级别的刷写,速度是比较快的,并不会对线上造成太大影响
  2. 对于RegionServer级别的刷写,将会阻塞发送到该RegionServer上的所有请求,直至Memstore刷写完毕,会产生较大影响

所以在Memstore的刷写方面,需要尽量避免出现RegionServer级别的刷写动作。

数据在经过Memstore刷写到磁盘时,对应的会写入WAL sequence的相关信息,已经持久化到磁盘的数据,就没有必要通过WAL记录的必要。

RegionServer会根据这个sequence值,对WAL日志进行滚动清理,防止WAL日志数量太多,RegionServer启动时,加载太多数据信息
同样,在Memstore的刷写策略中,可以看到,为了防止WAL日志数量太多,达到指定阈值之后,将会选择WAL记录中,最早的一个或者多个Region进行刷写。

1.3读数据 与 Bloom Filter

经过前文的了解,我们现在可以知道HBase中一条数据完整的读取操作流程中Client会和Zookeeper、RegionServer等发生多次交互请求。

基于HBase的架构,一条数据可能存在RegionServer中的三个不同位置:

  1. 对于刚读取过的数据将会被缓存到BlockCache中
  2. 对于刚写入的数据,其存在Memstore中
  3. 对于之前已经从Memstore刷写磁盘的,其存在于HFiles中

RegionServer接收到一条数据查询请求,只需要从以上三个地方,检索到数据即可。

在HBase中的检索顺序依次是:BlockCache -> Memstore -> HFiles

其中,BlockCache、Memstore都是直接在内存中进行高性能的数据检索。

HFiles则是真正存储在HDFS上的数据

  1. 检索HFiles时会产生真实磁盘的IO操作
  2. Memstore不停刷写的过程中,将会产生大量的HFile

如何在大量的HFile中快速找到所需要的数据呢?

为了提高检索HFiles的性能,HBase支持使用 Bloom FliterHFiles进行快读定位

Bloom Filter(布隆过滤器)是一种数据结构,常用于大规模数据查询场景,其能够快速判断一个元素一定不在集合中,或者可能在集合中。
Bloom Filter由 一个长度为m位数组 k个哈希函数 组成。

其工作原理如下:

  • 原始集合写入一个元素时,Bloom Filter同时将该元素 经过k个哈希函数映射成k个数字,并以这些数字为下标,将 位数组 中对应下标的元素标记为1
  • 当需要判断一个元素是否存在于原始集合中,只需要将该元素经过同样的 k个哈希函数得到k个数字
  1. 位数组 中对应下标的元素,如果都为1,则表示元素可能存在
  2. 如果存在其中一个元素为0,则该元素不可能存在于原始集合中
  • 因为哈希碰撞问题不同的元素经过相同的哈希函数之后可能得到相同的值
  1. 对于集合外的一个元素,如果经过 k个函数得到的k个数字,对应位数组中的元素都为1,可能是该元素存在于集合中
  2. 也有可能是集合中的其他元素”碰巧“让这些下标对应的元素标记为1,所以只能说其可能存在
  • 对于集合中的不同元素,如果 经过k个函数得到的k个数字中,任意一个重复
  1. 位数组 中对应下标的元素会被覆盖,此时该下标的元素不能被删除即归零
  2. 删除可能会导致其他多个元素在Bloom Filter表示不「存在」

由此可见,Bloom Filter中:

  • 位数组的长度m越大,误差率越小,而存储代价越大
  • 哈希函数的个数k越多,误差率越小,而性能越低

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第8张图片

HBase中支持使用以下两种Bloom Filter:

  1. ROW:基于 Rowkey 创建的Bloom Filter
  2. ROWCOL基于 Rowkey+Column 创建的Bloom Filter

两者的区别仅仅是:是否使用列信息作为Bloom Filter的条件。

  1. 使用ROWCOL时,可以让指定列的查询更快,因为其通过Rowkey列信息来过滤不存在数据的HFile,但是相应的,产生的Bloom Filter数据会更加庞大。
  2. 而只通过Rowkey进行检索的查询,即使指定了ROWCOL也不会有其他效果,因为没有携带列信息
  3. 通过Bloom Filter(如果有的话)快速定位当前的Rowkey数据存储于哪个HFile之后(或者不存在直接返回),通过HFile携带的 Data Block Index 等元数据信息,可快速定位到具体的数据块起始位置,读取并返回(加载到缓存中)。

这就是Bloom Filter在HBase检索数据的应用场景:

  1. 高效判断key是否存在
  2. 高效定位key所在的HFile

当然,如果没有指定创建Bloom Filter,RegionServer将会花费比较多的力气,一个个检索HFile,来判断数据是否存在。

1.4 HFile存储格式

通过Bloom Filter快速定位需要检索的数据,所在的HFile之后的操作,自然是从HFile中读出数据,并返回。

据我们所知,HFile是HDFS上的文件(或大或小都有可能),现在HBase面临的一个问题,就是如何在HFile中 快速检索获得指定数据?

HBase随机查询的高性能,很大程度上取决于底层HFile的存储格式,所以这个问题可以转化为 HFile的存储格式,该如何设计,才能满足HBase 快速检索 的需求。

生成一个HFile

Memstore内存中的数据,在刷写到磁盘时,将会进行以下操作:

  1. 会先现在内存中创建 空的Data Block数据块 包含 预留的Header空间。而后,将Memstore中的KVs一个个顺序写满该Block一般默认大小为64KB)。
  2. 如果指定了压缩或者加密算法Block数据写满之后,将会对整个数据区相应的压缩或者加密处理。
  3. 随后在预留的Header区,写入该Block的元数据信息,如 压缩前后大小上一个block的offsetchecksum 等。
  4. 内存中的准备工作完成之后,通过HFile Writer输出流将数据写入到HDFS中,形成磁盘中的Data Block
  5. 为输出的Data Block生成一条索引数据,包括 { startkeyoffsetsize} 信息,该索引数据会被暂时记录在内存中的Block Index Chunk中。

        至此,已经完成了第一个   Data Block 的写入工作,Memstore中的 KVs 数据,将会按照这个过程,不断进行     写入内存    中的Data Block ——> 输出到HDFS——> 生成索引数据保存到内存中Block Index Chunk 流程

值得一提的是,如果启用了Bloom Filter,那么 Bloom Filter Data(位图数据Bloom元数据(哈希函数与个数等) 将会和 KVs 数据一样被处理:写入内存中的Block  ——> 输出到HDFS Bloom Data Block  ——>生成索引数据保存到相对应的内存区域中。

由此我们可以知道,HFile写入过程中,Data BlockBloom Data Block交叉存在的

随着输出的Data Block越来越多,内存中的索引数据Block Index Chunk也会越来越大。

达到一定大小之后(默认128KB)将会经过类似Data Block的输出流程,写入到HDFS中,形成 Leaf Index Block (和Data Block一样,Leaf Index Block也有对应的Header区,保留该Block的元数据信息)。

同样的,也会生成一条该 Leaf Index Block 对应的索引记录,保存在内存中的 Root Block Index Chunk

Root Index ——> Leaf Data Block ——> Data Block 的索引关系,类似 B+树 的结构。得益于多层索引,HBase可以在不读取整个文件的情况下查找数据。

随着内存中最后一个 Data Block、Leaf Index Block 写入到HDFS,形成 HFile 的 Scanned Block Section。

Root Block Index Chunk 也会从内存中写入HDFS,形成 HFile 的 Load-On-Open Section 的一部分。
至此,一个完整的HFile已经生成,如下图所示:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第9张图片
检索HFile

生成HFile之后该如何使用呢?

HFile的索引数据(包括 Bloom Filter索引数据索引信息)会在 Region Open 的时候被加载到读缓存中,之后数据检索经过以下过程:

  1. 所有的读请求,如果读缓存Memstore中不存在,那么将会检索HFile索引
  2. 通过Bloom Filter索引(如果有设置Bloom Filter的话)检索Bloom Data快速定位HFile是否存在 所需数据
  3. 定位到数据可能存在HFile之后,读取该HFile的 三层索引数据,检索数据是否存在
  4. 存在,则根据索引中的 元数据 ,找到具体的 Data Block 读入内存,取出所需的KV数据

可以看到,在HFile的数据检索过程中,一次读请求,只有 真正确认数据存在,需要读取硬盘数据的时候,才会 执行硬盘查询操作
同时,得益于 分层索引分块存储,在Region Open加载索引数据的时候,再也不必和老版本(0.9甚至更早,HFile只有一层数据索引并且统一存储)一样加载所有索引数据到内存中,导致启动缓慢甚至卡机等问题

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第10张图片

1.5 HFile Compaction

Bloom Filter解决了如何在大量的HFile中快速定位数据,所在的HFile文件,虽然有了Bloom Filter的帮助,大大提升了检索效率,但是对于RegionServer来说,要检索的HFile数量并没有减少。

为了再次提高HFile的检索效率,同时避免大量小文件的产生,造成性能低下,RegionServer会通过Compaction机制,HFile进行合并操作。

常见的Compaction触发方式有:

  1. Memstore Flush检测条件执行
  2. RegionServer定期检查执行
  3. 用户手动触发执行

Minor Compaction

Minor Compaction 只执行简单的文件合并操作,选取较小的HFiles,将其中的数据顺序写入新的HFile后替换老的HFiles

但是如何在众多HFiles中,选择本次Minor Compaction,要合并的文件却有不少讲究:

  1. 首先排除掉文件大小 大于 hbase.hstore.compaction.max.size 值的HFile
  2. 将HFiles按照文件年龄排序(older to younger),并从older file开始选择
  3. 如果该文件大小 小于 hbase.hstore.compaction.min ,则加入Minor Compaction中
  4. 如果该文件大小 小于 后续hbase.hstore.compaction.max HFile大小之和 * hbase.hstore.compaction.ratio,则将该文件加入Minor Compaction
  5. 扫描过程中,如果需要合并的HFile文件数 达到 hbase.hstore.compaction.max(默认为10)开始合并过程
  6. 扫描结束后,如果需要合并的HFile的文件数 大于 hbase.hstore.compaction.min(默认为3) 则开始合并过程
  7. 通过 hbase.offpeak.start.hourhbase.offpeak.end.hour 设置高峰、非高峰时期,使 hbase.hstore.compaction.ratio的值在不同时期灵活变化高峰值1.2非高峰值5

可以看到,Minor Compaction不会合并过大的HFile合并的HFile数量也有严格的限制,以避免产生太大的IO操作,Minor Compaction经常在Memstore Flush后触发,但不会线上读写请求造成太大延迟影响

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第11张图片

Major Compaction

相对于Minor Compaction 只合并选择的一部分HFile合并合并时只简单合并数据文件的特点Major Compaction则将会把Store中的所有HFile合并成一个大文件,将会产生较大的IO操作

同时将会清理三类无意义数据被删除的数据TTL过期数据版本号超过设定版本号的数据,Region Split过程中产生的Reference文件也会在此时被清理
Major Compaction定期执行的条件由以下两个参数控制:

  • hbase.hregion.majorcompaction:默认7天
  • hbase.hregion.majorcompaction.jitter:默认为0.2

集群中各个RegionServer将会在 hbase.hregion.majorcompaction +- hbase.hregion.majorcompaction * hbase.hregion.majorcompaction.jitter 区间浮动进行Major Compaction,以避免过多RegionServer同时进行,造成较大影响。

Major Compaction 执行时机触发之后,简单来说,如果当前Store中HFile的最早更新时间,早于某个时间值,就会执行Major Compaction,该时间值为 hbase.hregion.majorcompaction * hbase.hregion.majorcompaction.jitter

手动触发的情况下将会直接执行Compaction。

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第12张图片

Compaction的优缺点

HBase通过Compaction机制,使底层HFile文件数,保持在一个稳定的范围,减少一次请求产生的IO次数文件Seek次数,确保HFiles文件检索效率,从而实现高效处理线上请求。

如果没有Compaction机制,随着Memstore刷写数据越来越多,HFile文件数量将会持续上涨一次读请求生产的IO操作Seek文件的次数将会越来越多,反馈到线上,就是请求延迟越来越大

然而,在Compaction执行过程中,不可避免的仍然会对线上造成影响

  1. 对于Major Compaction来说,合并过程将会占用大量带宽IO资源,此时线上的读延迟将会增大。
  2. 对于Minor Compaction来说,如果Memstore写入的数据量太多刷写越来越频繁超出了HFile合并的速度。
  • 即使不停地在合并,但是HFile文件仍然越来越多读延迟也会越来越大
  • HBase通过 hbase.hstore.blockingStoreFiles(默认7)控制Store中的HFile数量
  • 超过配置值时,将会堵塞Memstore Flush阻塞flush操作 ,阻塞超时时间为 hbase.hstore.blockingWaitTime
  • 阻塞Memstore Flush操作将会使Memstore的内存占用率越来越高,可能导致完全无法写入

简而言之,Compaction机制保证了HBase的读请求一直保持低延迟状态,但付出的代价是Compaction执行期间大量的读延迟毛刺和一定的写阻塞(写入量巨大的情况下)。

1.6 Region Split

HBase通过 LSM-Tree架构提供了高性能的随机写,通过缓存、Bloom FilterHFile与Compaction等机制提供了高性能的随机读

至此,HBase已经具备了作为一个高性能读写数据库的基本条件。如果HBase仅仅到此为止的话,那么其也只是个在架构上传统数据库有所区别的数据库而已,作为一个高性能读写分布式数据库来说,其拥有近乎可以无限扩展的特性

支持HBase进行自动扩展负载均衡的是Region Split机制。

Split策略与触发条件

在HBase中,提供了多种Split策略不同的策略触发条件各不相同。

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第13张图片
如上图所示,不同版本使用的默认策略在变化。

ConstantSizeRegionSplitPolicy
固定值策略:阈值默认大小 hbase.hregion.max.filesize
优点:简单实现
缺陷:考虑片面,小表不切分、大表切分成很多Region,线上使用弊端多
IncreasingToUpperBoundRegionSplitPolicy
非固定阈值:计算公式 min(R^2 * memstore.flush.size, region.split.size)
                     
R为Region所在的Table,在当前RegionServer上Region的个数
                       最大大小 hbase.hregion.max.filesize
优点:自动适应大小表,对于Region个数多阈值大,Region个数少阈值小
缺陷:对于小表来说会产生很多小region
SteppingSplitPolicy:
非固定阈值:如果Region个数为1,则阈值为 memstore.flush.size * 2
                      否则为 region.split.size
优点:对大小表更加友好,小表不会一直产生小Region
缺点:控制力度比较粗

可以看到,不同的切分策略其实只是在寻找切分Region时的阈值不同的策略阈值有不同的定义

切分点

切分阈值确认完之后,首先要做的是寻找切分Region切分点

HBase对Region的切分点定义如下:

  1. Region中最大的Store中,最大的HFile中心block中首个Rowkey。
  2. 如果最大的HFile只有一个block,那么不切分没有middle key)。

得到切分点之后,核心的切分流程分为 prepare - execute - rollback 三个阶段。

  • prepare阶段

在内存中,初始化两个子RegionHRegionInfo对象),准备进行切分操作

  • execute阶段

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第14张图片

execute阶段执行流程较为复杂,具体实施步骤为:

1 RegionServer在Zookeeper上的 /hbase/region-in-transition 节点中,标记该Region状态为SPLITTING
2 HMaster监听到Zookeeper节点发生变化,在内存中,修改此Region状态为RIT
3 在该Region的存储路径下创建临时文件夹 .split
4 父Region close,flush所有数据到磁盘中,停止所有写入请求。
5 在父Region的 .split文件夹中,生成两个子Region文件夹,并写入reference文件
1.reference是一个特殊的文件,体现
在其文件名文件内容
2.文件名组成:{
父Region}
3.文件内容:[splitkey]
切分点rowkey,[top?]true/false,true为top上半部分false为bottom下半部分
4.根据reference文件名,可以快速找到对应的父Region、其中的HFile文件HFile切分点从而确认该子Region的数据范围
5.数据范围确认完毕之后,进行正常的数据检索流程(此时仍然检索父Region的数据
6 子Region的目录拷贝到HBase根目录下,形成新的Region
7 父Regin通知修改 hbase:meta 表后下线不再提供服务
1.此时并没有删除父Region数据,仅在表中标记split列offline列为true,并记录两个子region
8 两个子Region上线服务
9 通知 hbase:meta 表标记两个子Region正式提供服务
  • rollback阶段

如果execute阶段出现异常,则执行rollback操作,保证Region切分整个过程,是具备事务性原子性的要么切分成功要么回到未切分的状态

region切分是一个复杂的过程,涉及到父region切分子region生成region下线与上线zk状态修改元数据状态修改master内存状态修改 等多个子步骤,回滚程序,会根据当前进展到哪个子阶段,清理对应的垃圾数据

为了实现事务性,HBase设计了使用状态机SplitTransaction类),来保存切分过程中的每个子步骤状态。这样一来,一旦出现异常,系统可以根据当前所处的状态,决定是否回滚以及如何回滚

但是目前实现中,中间状态存储在内存中,因此一旦在切分过程中,RegionServer宕机或者关闭,重启之后,将无法恢复切分前的状态。即Region切分处于中间状态的情况,也就是RIT

由于Region切分子阶段很多,不同阶段,解决RIT的处理方式也不一样,需要通过hbck工具进行,具体查看,并分析解决方案

好消息是,HBase2.0之后提出了,新的分布式事务框架Procedure V2,将会使用HLog存储事务中间状态,从而保证事务处理中,宕机重启后,可以进行回滚或者继续处理,从而减少RIT问题产生。

  • 父Region清理

从以上过程中我们可以看到,Region的切分过程,并不会父Region的数据子Region中只是子Region中创建了reference文件,故Region切分过程是很快的。

只有进行Major Compaction时,才会真正(顺便)将数据切分到子Region中,将HFile中的kv顺序读出写入新的HFile文件

RegionServer将会定期检查 hbase:meta 表中的splitoffline为true的Region,对应的子Region,是否存在reference文件,如果不存在则删除父Region数据。

  • 负载均衡

Region切分完毕之后RegionServer将会存在更多的Region块,为了避免RegionServer热点,使请求负载均衡集群各个节点上,HMaster将会把一个或者多个子Region移动其他RegionServer上。

移动过程中,如果当前RegionServer繁忙HMaster只会修改Region元数据信息,其他节点,而Region数据,仍然保留在当前节点中,直至下一次Major Compaction时进行数据移动

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第15张图片

至此,我们已经揭开了HBase架构与原理的大部分神秘面纱,在后续做集群规划性能优化实际应用中,为什么这么调整,以及为什么这么操作,都将一一映射到HBase的实现原理上。

如果你希望了解HBase的更多细节,可以参考《HBase权威指南》。

二、集群部署

经过冗长的理论初步了解过HBase架构与工作原理之后,搭建HBase集群是使用HBase的第一个步骤。
需要注意的是,HBase集群一旦部署使用,再想对其作出调整需要付出惨痛代价(线上环境中),所以如何部署HBase集群是使用的第一个关键步骤。

2.1 集群物理架构

硬件混合型+软件混合型集群:

  1. 硬件混合型 :指的是该集群机器配置参差不齐混搭结构
  2. 软件混合型 :指的是该集群部署了一套类似CDH全家桶套餐

如以下的软件混合型集群状况:

  1. 集群规模:30
  2. 部署服务:HBase、Spark、Hive、Impala、Kafka、Zookeeper、Flume、HDFS、Yarn等
  3. 硬件情况:内存、CPU、磁盘等参差不齐,有高配有低配,混搭结构

这个集群不管是规模、还是服务部署方式相信都是很多都有公司的「标准」配置。

那么这样的集群有什么问题呢?

如果仅仅HBase是一个非「线上」的系统,或者充当一个历史冷数据存储的大数据库,这样的集群其实一点问题也没有因为对其没有任何苛刻的性能要求。

但是如果希望HBase作为一个线上能够承载海量并发实时响应的系统,这个集群随着使用时间的增加很快就会崩溃。

从 硬件混合型 来说,一直以来Hadoop都是以宣称能够用低廉、老旧的机器撑起一片天。

这确实是Hadoop的一个大优势,然而前提是作为离线系统使用。

离线系统的定义,即跑批的系统,如:Spark、Hive、MapReduce等,没有很强的时间要求,显著的吞吐量大,延迟高。

因为没有实时性要求,几台拖拉机跑着也没有问题,只要最后能出结果并且结果正确就OK。

那么在我们现在的场景中,对HBase的定义已经不是一个离线系统,而是一个实时系统

对于一个硬性要求很高的实时系统来说,如果其中几台老机器拖了后腿也会引起线上响应的延迟。

统一高配硬件+软件混合型集群

既然硬件拖后腿,那么硬件升级自然是水到渠成。
现在我们有全新的高配硬件可以使用,参考如下:

  1. 集群规模:30
  2. 部署服务:HBase、Spark、Hive、Impala、Kafka、Zookeeper、Flume、HDFS、Yarn等
  3. 硬件情况:内存、CPU、磁盘统一高配置

这样的集群可能还会存在什么问题呢?

软件混合型 来说,离线任务最大的特点就是吞吐量特别高,瞬间读写的数据量,可以把IO直接撑到10G/s,最主要的影响因素,就是大型离线任务,带动高IO,将会影响HBase的响应性能

如果仅止步于此,那么线上的表现仅仅为短暂延迟,真正令人窒息的操作是,如果离线任务再把CPU撑爆RegionServer节点可能会直接宕机,造成严重的生产影响。

存在的另外一种情况是,离线任务大量读写磁盘读写HDFS导致HBase IO连接异常,也会造成RegionServer异常HBase日志反应HDFS connection timeout,HDFS日志反应IO Exception),造成线上故障。

根据观测,集群磁盘IO到4G以上集群网络IO 8G以上HDFS IO 5G以上任意符合一个条件,线上将会有延迟反应

因为离线任务运行太过强势,导致RegionServer宕机,无法解决,那么能采取的策略,只能是重新调整离线任务的执行,使用资源执行顺序等限制离线计算能力来满足线上的需求。同时还要限制集群的CPU的使用率,可能出现,某台机器CPU打满后,整个机器假死,致服务异常,造成线上故障

软、硬件独立的HBase集群

简而言之,无论是硬件混合型还是软件混合型集群,其可能因为各种原因带来的延迟影响,对于一个高性能要求的HBase来说,都是无法忍受的。
所以在集群规划初始就应该考虑到种种情况,最好使用独立的集群部署HBase

参考如下一组集群规模配置:

  1. 集群规模:15+5(RS+ZK
  2. 部署服务:HBase、HDFS(另5台虚拟Zookeeper
  3. 硬件情况:除虚拟机外,物理机统一高配置

虽然从可用节点上来看,比之前的参考配置少了一半,但是从集群部署模式上看,最大程度保证HBase的稳定性,从根本上,分离了软硬件对HBase所带来的影响,将会拥有比之前两组集群配置 更稳定的响应和更高的性能

其他硬件推荐

  • 网卡:网卡是容易产生瓶颈的地方,有条件建议使用双万兆网卡
  • 磁盘:没有特殊要求,空间越大越好转速越高越好
  • 内存:不需要大容量内存,建议32-128G(详见下文)
  • CPU:CPU核数越多越好,HBase本身压缩数据合并HFile等都需要CPU资源
  • 电源:建议双电源冗余

另外值得注意的是,Zookeeper节点建议设置5个节点5个节点能保证Leader快速选举并且最多可以允许2个节点宕机的情况下正常使用。

硬件上可以选择使用虚拟机,因为zk节点本身消耗资源并不大不需要高配机器。但是5个虚拟节点不能在一个物理机上防止物理机宕机影响所有zk节点。

2.2 安装与部署

以CDH集群为例安装HBase。
使用ansible自动化脚本工具进行安装操作:

# 获取安装脚本,上传相关安装软件包至服务器(JDK、MySQL、CM、CDH等)
yum install -y git
git clone https://github.com/chubbyjiang/cdh-deploy-robot.git
cd cdh-deploy-robot
# 编辑节点主机名
vi hosts
# 修改安装配置项
vi deploy-robot.cnf
# 执行
sh deploy-robot.sh install_all

安装脚本,将会执行 配置SSH免密登录、安装软件、操作系统优化、Java等开发环境初始化、MySQL安装、CM服务安装、操作系统性能测试等过程。
脚本操作说明见:CDH集群自动化部署工具 。
等待cloudera-scm-server进程起来后,在浏览器输入 ip:7180 进入CM管理界面部署HDFS、HBase组件即可。

三、性能优化

HBase集群部署完毕运行起来之后,看起来一切顺利,但是所有东西都处于「初始状态」中。

我们需要根据软硬件环境,针对性地对HBase进行 调优设置,以确保其能够以最完美的状态运行,在当前集群环境中,尽可能发挥硬件的优势。

为了方便后续配置项计算说明,假设我们可用的集群硬件状况如下:

  • 总内存:256G
  • 总硬盘:1.8T * 12 = 21.6T
  • 可分配内存:256 * 0.75 = 192G
  • HBase可用内存空间:192 * 0.8 = 153G(20%留给HDFS等其他进程)
  • 可用硬盘空间:21.6T * 0.85 = 18.36T

3.1 Region规划

对于Region的大小,HBase官方文档推荐单个在10G-30G之间,单台RegionServer的数量,控制在20-300之间(当然,这仅仅是参考值)。
Region过大过小都会有不良影响:

的Region
优点:迁移速度快、减少总RPC请求
缺点:compaction的时候资源消耗非常大、可能会有数据分散不均衡的问题
的Region
优点:集群负载平衡、HFile比较少,compaction影响小
缺点:迁移或者balance效率低、频繁flush导致频繁的compaction、维护开销大

规划Region的大小与数量时可以参考以下算法:

0. 计算HBase可用磁盘空间(单台RegionServer)
1. 设置region最大与最小阈值,region的大小在此区间选择,如10-30G
2. 设置最佳region数(这是一个经验值),如单台RegionServer 200个
3. 从region最小值开始,计算 HBase可用磁盘空间 / (region_size * hdfs副本数) = region个数
4. 得到的region个数如果 > 200,则增大region_size(step可设置为5G),
   继续计算直至找到region个数最接近200的region_size大小
5. region大小建议不小于10G

例如:

当前可用磁盘空间为18T,选择的region大小范围为10-30G,最佳region个数为300
那么最接近 最佳Region个数300的 region_size 值为30G

得到以下配置项:

  • hbase.hregion.max.filesize=30G
  • 单节点最多可存储的Region个数约为300

3.2 内存规划

我们知道RegionServer中的BlockCache有两种实现方式:

  • LRUBlockCache:On-Heap
  • BucketCache:Off-Heap

这两种模式的详细说明可以参考 CDH官方文档。

为HBase选择合适的 内存模式 以及根据 内存模式 计算相关配置项是调优中的重要步骤

首先我们可以根据可用内存大小判断使用哪种内存模式

先看 超小内存(假设8G以下) 和 超大内存(假设128G以上) 两种极端情况:

  • 对于超小内存来说,即使可以使用BucketCache来利用堆外内存,但是使用堆外内存的主要目的,是避免GC时不稳定的影响,堆外内存的效率是要比堆内内存的。由于内存总体较小,即使读写缓存都在堆内内存中,GC时也不会造成太大影响,所以可以直接选择LRUBlockCache
  • 对于超大内存来说,在超大内存上使用LRUBlockCache,将会出现我们所担忧的情况:GC时,对线上造成很不稳定延迟影响。这种场景下,应该尽量利用堆外内存作为读缓存减小堆内内存的压力,所以可以直接选择BucketCache

在两边的极端情况下,我们可以根据内存大小,选择合适的内存模式,那么如果内存大小,在合理、正常的范围内该如何选择呢?

此时我们应该主要关注业务应用的类型:

  • 当业务主要为写多读少型应用时,写缓存利用率高,应该使用LRUBlockCache尽量提高堆内写缓存的使用率。
  • 当业务主要为写少读多型应用时,读缓存利用率高(通常也意味着需要稳定的低延迟响应),应该使用BucketCache尽量提高堆外读缓存的使用率。
  • 对于不明确或者多种类型混合的业务应用,建议使用BucketCache,保证读请求稳定性同时,堆内写缓存效率并不会很低。
  • 当前HBase可使用的内存高达153G,故将选择BucketCache的内存模型,来配置HBase,该模式下能够最大化利用内存,减少GC影响,对线上的实时服务较为有利。

得到配置项:
hbase.bucketcache.ioengine=offheap: 使用堆外缓存

确认使用的内存模式之后,接下来将通过计算确认 JavaHeap、对外读缓存堆内写缓存、LRU元数据 等内存空间具体的大小。

内存与磁盘比

讨论具体配置之前,我们从 HBase集群规划 引入一个Disk / JavaHeap Ratio的概念,来帮助我们设置内存相关的参数。

理论上我们假设 最优 情况硬盘维度下的Region个数JavaHeap维度下的Region个数 相等。

相应的计算公式为:

  • 硬盘容量维度Region个数: DiskSize / (RegionSize * ReplicationFactor)
  • JavaHeap维度Region个数JavaHeap * HeapFractionForMemstore / (MemstoreSize / 2 )

其中:

  • RegionSize:Region大小,配置项:hbase.hregion.max.filesize
  • ReplicationFactor:HDFS的副本数,配置项:dfs.replication
  • HeapFractionForMemstore:JavaHeap写缓存大小,即RegionServer内存中Memstore的总大小,配置项:hbase.regionserver.global.memstore.lowerLimit
  • MemstoreSize:Memstore刷写大小,配置项:hbase.hregion.memstore.flush.size

现在我们已知条件 硬盘维度JavaHeap维度相等,求 1 bytes的JavaHeap大小需要搭配多大的硬盘大小 ?
已知:
DiskSize / (RegionSize * ReplicationFactor) = JavaHeap * HeapFractionForMemstore / (MemstoreSize / 2 )
求:DiskSize / JavaHeap

进行简单的交换运算可得:
DiskSize / JavaHeap = (RegionSize / MemstoreSize) * ReplicationFactor * HeapFractionForMemstore * 2

以HBase的默认配置为例:
RegionSize: 10G
MemstoreSize: 128M
ReplicationFactor: 3
HeapFractionForMemstore: 0.4

计算:
10G / 128M) * 3 * 0.4 * 2 = 192

即理想状态下 ,RegionServer上 1 bytes的Java内存大小,需要搭配192bytes的硬盘大小最合适。

套用到当前集群中,HBase可用内存为152G,在LRUBlockCache模式下,对应的硬盘空间需要153G * 192 = 29T,这显然是比较不合理的。

在BucketCache模式下,当前 JavaHeap、HeapFractionForMemstore值还未确定,我们会根据这个 计算关系,和已知条件,对可用内存进行规划和调整,以满足合理的内存/磁盘比

已知条件:
内存模式:BucketCache
可用内存大小:153G
可用硬盘大小:18T
Region大小:30G
ReplicationFactor:3

未知变量:
JavaHeap
MemstoreSize
HeapFractionForMemstore

内存布局

在计算位置变量的具体值之前,我们有必要了解一下当前使用的内存模式中对应的内存布局。
BucketCache模式下,RegionServer的内存划分如下图:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第16张图片
简化版:

如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统_第17张图片
写缓存

从架构原理中我们知道,Memstore有4种级别的Flush,需要我们关注的是 Memstore、Region和RegionServer级别的刷写。

其中MemstoreRegion级别的刷写,并不会对线上造成太大影响,但是需要控制其阈值刷写频次来进一步提高性能。

RegionServer级别的刷写,将会阻塞请求,直至刷写完成对线上影响巨大,需要尽量避免

得到以下配置项:

  • hbase.hregion.memstore.flush.size=256M: 控制的Memstore大小默认值为128M太过频繁的刷写,会导致IO繁忙刷新队列阻塞等。 设置太高也有坏处,可能会较为频繁的触发RegionServer级别的Flush,这里设置为256M。
  • hbase.hregion.memstore.block.multiplier=3: 控制的Region flush上限默认值为2,意味着一个Region中,最大同时存储的Memstore大小为2 * MemstoreSize ,如果一个表的列族过多将频繁触发,该值视情况调整。

现在我们设置两个 经验值变量:

  • RegionServer总内存中,JavaHeap占比=0.35
  • JavaHeap最大大小=56G:超出此值表示GC有风险

计算得JavaHeap的大小为 153 * 0.35 = 53.55 ,没有超出预期的最大JavaHeap。如果超过最大期望值,则使用最大期望值代替,得JavaHeap大小为53G。

现在JavaHeapMemstoreSize已知,可以得到唯一的位置变量 HeapFractionForMemstore 的值为 0.48 。
得到以下配置项:

  • RegionServer JavaHeap堆栈大小: 53G
  • hbase.regionserver.global.memstore.upperLimit=0.58: 整个RS中Memstore最大比例,比lower大5-15%
  • hbase.regionserver.global.memstore.lowerLimit=0.48: 整个RS中Memstore最小比例
  • 写缓存大小为 53 * 0.48 = 25.44G

读缓存配置

当前内存信息如下:

A 总可用内存:153G
J JavaHeap大小:53G
      W 写缓存大小:25.44G
      R1 LRU缓存大小:?
R2 BucketCache堆外缓存大小:153 - 53 = 100G

因为读缓存堆内的LRU元数据堆外的数据缓存 组成,两部分占比一般为 1:9(经验值) 。
而对于总体的堆内内存,存在以下限制,如果超出此限制,则应该调低比例

LRUBlockCache + MemStore < 80% * JVM_HEAP

LRUBlockCache + 25.44 < 53 * 0.8
可得R1最大值16.96G
总读缓存:R = R1 + R2
R1:R2 = 1:9
R1 = 11G < 16G
R = 111G

配置堆外缓存涉及到的相关参数如下:

  • hbase.bucketcache.size=111 * 1024M: 堆外缓存大小,单位为M
  • hbase.bucketcache.percentage.in.combinedcache=0.9: 堆外读缓存所占比例,剩余为堆内元数据缓存大小
  • hfile.block.cache.size=0.15: 校验项,+upperLimit需要小于0.8

现在,我们再来计算 Disk / JavaHeap Ratio 的值,检查JavaHeap内存磁盘的大小是否合理:
RegionSize / MemstoreSize * ReplicationFactor * HeapFractionForMemstore * 2
30 * 1024 / 256 * 3 * 0.48 * 2 = 345.6
53G * 345.6 = 18T <= 18T

至此,已得到HBase中内存相关的重要参数:

RegionServer JavaHeap堆栈大小: 53G
hbase.hregion.max.filesize=30G
hbase.bucketcache.ioengine=offheap
hbase.hregion.memstore.flush.size=256M
hbase.hregion.memstore.block.multiplier=3
hbase.regionserver.global.memstore.upperLimit=0.58
hbase.regionserver.global.memstore.lowerLimit=0.48
hbase.bucketcache.size=111 * 1024M
hbase.bucketcache.percentage.in.combinedcache=0.9
hfile.block.cache.size=0.15

3.3 合并与切分

HFile合并

Compaction过程中,比较常见的优化措施是:

Major Compaction
    停止自动执行
    增大其处理线程数
Minor Compaction
    增加Memstore Flush大小
    增加Region中最大同时存储的Memstore数量

配置项如下:

# 关闭major compaction,定时在业务低谷执行,每周一次
hbase.hregion.majorcompaction=0
# 提高compaction的处理阈值
hbase.hstore.compactionThreshold=6
# 提高major compaction处理线程数
hbase.regionserver.thread.compaction.large=5
# 提高阻塞memstore flush的hfile文件数阈值
hbase.hstore.blockingStoreFiles=100
hbase.hregion.memstore.flush.size=256M
hbase.hregion.memstore.block.multiplier=3

Major Compaction 脚本

关闭自动compaction之后手动执行脚本的代码示例:

#!/bin/bash
if [ $# -lt 1 ]
then
    echo "Usage: "
    exit 1
fi
TMP_FILE=tmp_tables
TABLES_FILE=tables.txt
key=$1
echo "list" | hbase shell > $TMP_FILE
sleep 2
sed '1,6d' $TMP_FILE | tac | sed '1,2d' | tac | grep $key > $TABLES_FILE
sleep 2
for table in $(cat $TABLES_FILE); do
  date=`date "+%Y%m%d %H:%M:%S"`
        echo "major_compact '$table'" | hbase shell
  echo "'$date' major_compact '$table'" >> /tmp/hbase-major-compact.log
        sleep 5
done
rm -rf $TMP_FILE
rm -rf $TABLES_FILE
echo "" >> /tmp/hbase-major-compact.log

Region切分

在架构原理中我们知道,Region多有种切分策略,在Region切分时,将会有短暂时间内Region下线无服务,Region切分完成之后的Major Compaction中,将会移动父Region的数据子Region中,HMaster为了集群整体的负载均衡,可能会将子Region分配到其他RegionServer节点。

从以上描述中可以看到,Region的切分行为,其实是会对线上的服务请求,带来一定影响的。

Region切分设置中,使用默认配置,一般不会有太大问题,但是有没有 保证数据表负载均衡的情况下,Region不进行切分行为

有一种解决方案是使用 预分区 + 固定值切分策略,可以一定程度上,通过预估数据表数量,以及Region个数,从而在一段时间内,抑制Region不产生切分。

假设我们可以合理的预判一个表的当前总数据量为150G,每日增量为1G当前Region大小为30G。
那么我们建表的时候,至少要设定 (150 + 1 * 360) / 30 = 17 个分区,如此一来,一年内(360天)该表的数据增长,都会落到17个Region中,而不再切分。

当然对于一个不断增长的表,除非时间段设置的非常长,否则总有发生切分的一天。如果无限制的延长时间段,则会在一开始,就产生大量的空Region,这对HBase是极其不友好的,所以时间段是一个需要合理控制的阈值

在hbase-site.xml中配置Region切分策略ConstantSizeRegionSplitPolicy
hbase.regionserver.region.split.policy=org.apache.hadoop.hbase.regionserver.ConstantSizeRegionSplitPolicy

3.4 响应优化

HBase服务端

高并发情况下,如果HBase服务端处理线程数不够,应用层将会收到HBase服务端抛出无法创建新线程的异常,从而导致应用层线程阻塞。
可以释放调整HBase服务端配置以提升处理性能:

#  Master处理客户端请求最大线程数   
hbase.master.handler.count=256
# RS处理客户端请求最大线程数,如果该值设置过大,则会占用过多的内存,导致频繁的GC,或者出现OutOfMemory
hbase.regionserver.handler.count=256
# 客户端缓存大小,默认为2M  
hbase.client.write.buffer=8M
# scan缓存一次获取数据的条数,太大也会产生OOM
hbase.client.scanner.caching=100

另外,以下两项中,默认设置下超时太久、重试次数太多,一旦应用层连接不上HBse服务端将会进行近乎无限的重试,长连接无法释放,新请求不断进来,从而导致线程堆积应用假死等,影响比较严重,可以适当减少:
hbase.client.retries.number=3   
hbase.rpc.timeout=10000  

HDFS

适当增加处理线程等设置:

dfs.datanode.handler.count=64
dfs.datanode.max.transfer.threads=12288
dfs.namenode.handler.count=256
dfs.namenode.service.handler.count=256

同时,对于HDFS的存储设置也可以做以下优化:

# 可以配置多个,拥有多个元数据备份
dfs.name.dir
# 配置多个磁盘与路径,提高并行读写能力
dfs.data.dir
# dn同时处理文件的上限,默认为256,可以提高到8192
dfs.datanode.max.xcievers

应用层(客户端)

之前我们说到,HBase为了保证CP在A的实现上做了一定的妥协,导致HBase出现故障,并转移的过程中,会有较大的影响。

对于应用服务层来说,保证服务的 稳定性 是最重要的,为了避免HBase可能产生的问题,应用层应该采用 读写分离 的模式,来最大程度保证自身稳定性

应用层读写分离

可靠的应用层应使用 读写分离 的模式提高响应效率与可用性:

  1. 读写应用应该分别属于 不同的服务实例 ,避免牵一发而动全身
  2. 对于写入服务,数据异步写入redis或者kafka队列,由下游消费者同步至HBase,响应性能十分优异
  3. 需要处理数据,写入失败的事务处理与重写机制
  4. 对于读取服务,如果一个RS挂了,一次读请求,经过若干重试和超时,可能会持续几十秒甚至更久,由于和写入服务分离,可以做到互不影响
  5. 最好使用缓存层,缓解RS宕机问题,对于至关重要的数据,先查缓存再查HBase(见下文)

在应用层的 代码 中,同样有需要注意的小TIPS:

  • 如果在Spring中,将HBaseAdmin配置为Bean加载,则需配置为懒加载避免在启动时,链接HMaster失败,导致启动失败,从而无法进行一些降级操作。
  • scanner使用后及时关闭避免浪费客户端和服务器的内存
  • 查询时指定列簇或者指定要查询的列限定扫描范围
  • Put请求可以关闭WAL,但是优化不大
  • 最后,可以适当调整一下 连接池 设置:
# 配置文件加载为全局共享,可提升tps
setInt(“hbase.hconnection.threads.max”, 512); 
setInt(“hbase.hconnection.threads.core”, 64); 

3.5 使用缓存层

即使我们经过大量的准备调优设置,在真实使用场景中,随着HBase中承载的数据量越来越大、请求越来越多、并发越来越大,HBase不可避免的会有一些「毛刺」问题

如果你现在已经通过HBase,解决了大部分的线上数据存储与访问问题,但是有一小部分的数据,需要提供最快速的响应最低的延迟,由于HBase承载的东西太多,总是有延迟比较高的响应,此时需要怎么解决?

其实,对所有数据库软件来说,都会存在这样的场景。于是,类似关系型数据库中的数据库拆分等策略,也是可以应用到HBase上的

或者是将最关键、最热点的数据,使用 独立的HBase集群 来处理,或者是使用诸如 Redis等高性能的缓存软件,其核心思想就是,将最关键的业务数据独立存储,以提供最优质的服务,这个服务统称为缓存层。

3.6 其他配置

hbase-env.sh 的 HBase 客户端环境高级配置代码段
配置了G1垃圾回收器和其他相关属性:

-XX:+UseG1GC 
-XX:InitiatingHeapOccupancyPercent=65 
-XX:-ResizePLAB 
-XX:MaxGCPauseMillis=90  
-XX:+UnlockDiagnosticVMOptions 
-XX:+G1SummarizeConcMark 
-XX:+ParallelRefProcEnabled 
-XX:G1HeapRegionSize=32m 
-XX:G1HeapWastePercent=20 
-XX:ConcGCThreads=4 
-XX:ParallelGCThreads=16  
-XX:MaxTenuringThreshold=1 
-XX:G1MixedGCCountTarget=64 
-XX:+UnlockExperimentalVMOptions 
-XX:G1NewSizePercent=2 
-XX:G1OldCSetRegionThresholdPercent=5

hbase-site.xml 的 RegionServer 高级配置代码段安全阀
手动split region配置


    hbase.regionserver.wal.codec
    org.apache.hadoop.hbase.regionserver.wal.IndexedWALEditCodec


    hbase.region.server.rpc.scheduler.factory.class
    org.apache.hadoop.hbase.ipc.PhoenixRpcSchedulerFactory
    Factory to create the Phoenix RPC Scheduler that uses separate queues for index and metadata updates


    hbase.rpc.controllerfactory.class
    org.apache.hadoop.hbase.ipc.controller.ServerRpcControllerFactory
    Factory to create the Phoenix RPC Scheduler that uses separate queues for index and metadata updates


    hbase.regionserver.thread.compaction.large
    5


    hbase.regionserver.region.split.policy
    org.apache.hadoop.hbase.regionserver.ConstantSizeRegionSplitPolicy

四、使用技巧

4.1 建表规约

Rowkey规范

如无特殊情况,长度应控制在64字节内
充分分析业务需求后,确认需要查询的维度字段
get请求,则rowkey散列处理。
scan请求,rowkey前缀维度散列后,后续维度依照查询顺序或者权重拼接(视具体情况决定是否散列处理)。
    各个字段保持相同长度以支持左对齐的部分键扫描
    scan形式的数据表中,
需要提前统计,单个scan可扫描出的最大数量

列簇规范

如无特殊情况,一个表只有一个列簇统一使用info命名。
如果需要1以上的列簇,则原则上,一次请求的数据,不可跨列簇存储不超过3个列簇
示例:NAME =>'info'

压缩

统一使用SNAPPY压缩。
示例:COMPRESSION => 'SNAPPY'

版本

默认版本数为3,前期存储空间紧张的情况下,设置为1。
示例:VERSIONS => 1

布隆过滤器

视情情况使用,主要针对get查询提高性能。
kv示例:BLOOMFILTER => 'ROW',根据rowkey中的信息,生成布隆过滤器数据
kv+col示例:BLOOMFILTER => 'ROWCOL',根据rowkey+列信息生成布隆过滤器,针对get+指定列名的查询,产生的过滤器文件会比ROW大。

预分区

预分区需要通过评估整体表数据量来确认,当前hbase集群region块大小为30G

  • 历史大增量小的数据:给定的预分区数,足够支撑该表,永远(或者相当长的时间内)不split,即更新的所有数据,将进入已存在的region中,以减少splitcompaction造成的影响。
  • 历史小增量大的数据:预分区个数,需满足历史数据分存储,并支撑未来一段时间内(一个月以上)的增量数据。

预分区区间计算属性相同的表中,随机取出部分样本数据rowkey维度字段)。将样本转换成rowkey之后排序,并以样本个数/预分区个数为步长,预分区个数rowkey组成预分区区间
预分区代码示例:

/**
    * hbase region预分区工具
    *
    * @param filePath    样本文件路径
    * @param numOfSPlits 预分区个数
    **/
  def rowkeySplitedArr(filePath: String, numOfSPlits: Int) = {
    val file = Source.fromFile(filePath).getLines()
    val res = file.map {
      line =>
        val arr = line.split("_")
        val card = arr(0)
        val name = arr(1)
        MathUtil.MD5Encrypt32(card) + MathUtil.MD5Encrypt32(card)
    }.toList.sorted
    val count = res.length / numOfSPlits
    var str = ""
    for (i <- 0 until numOfSPlits) {
      str += s"\'${res(i * count)}\',"
    }
    println(str.substring(0, str.length - 1))
  }

4.2 客户端使用

服务端配置完成之后,如何更好的使用HBase集群,也需要花点心思测试与调整。
以Spark作为HBase读写客户端为例。

批量查询

Spark有对应的API,可以批量读取HBase数据,但是使用过程比较繁琐,这里安利一个小组件Spark DB Connector批量读取HBase的代码可以这么简单:

val rdd = sc.fromHBase[(String, String, String)]("mytable")
      .select("col1", "col2")
      .inColumnFamily("columnFamily")
      .withStartRow("startRow")
      .withEndRow("endRow")
done!

实时查询

以流式计算为例,Spark Streaming中,我们要实时查询HBase只能通过HBase Client API(没有队友提供服务的情况下)。
那么HBase Connection每条数据创建一次,肯定是不允许的,效率太低,对服务压力比较大,并且ZK的连接数,暴增影响服务

比较可行的方案是每个批次创建一个链接(类似for each Partiton中,每个分区创建一个链接,分区中数据共享链接)。但是这种方案也会造成部分连接浪费效率低下等。

如果可以做到一个Streaming中,所有批次所有数据,始终复用一个连接池是最理想的状态。

Spark中提供了Broadcast,这个重要工具可以帮我们实现这个想法,只要将创建的HBase Connection广播出去,所有节点就都能复用,但是真实运行代码时,你会发现HBase Connection不可序列化的对象,无法广播。。。

其实利用scala的lazy关键字可以绕个弯子来实现:

//实例化该对象,并广播使用
class HBaseSink(zhHost: String, confFile: String) extends Serializable {
  //延迟加载特性
  lazy val connection = {
    val hbaseConf = HBaseConfiguration.create()
    hbaseConf.set(HConstants.ZOOKEEPER_QUORUM, zhHost)
    hbaseConf.addResource(confFile)
    val conn = ConnectionFactory.createConnection(hbaseConf)
    sys.addShutdownHook {
      conn.close()
    }
    conn
  }
}

在Driver程序中,实例化该对象并广播,在各个节点中取广播变量的value进行使用。

广播变量只在具体调用value的时候,才会去创建对象,并copy到各个节点,而这个时候被序列化的对象,其实是外层的HBaseSink,当在各个节点上,具体调用connection,进行操作的时候,Connection才会被真正创建(在当前节点上),从而绕过了HBase Connection无法序列化的情况(同理也可以推导RedisSink、MySQLSink等)。

这样一来,一个Streaming Job,将会使用同一个数据库连接池,在Structured Streaming中的for each Write也可以直接应用

批量写入

同理安利组件

rdd.toHBase("mytable")
      .insert("col1", "col2")
      .inColumnFamily("columnFamily")
      .save()

这里边其实对HBase Client的Put接口包装了一层,但是当线上有大量实时请求,同时线下又有大量数据需要更新时直接这么写会对线上的服务造成冲击,具体表现可能为持续一段时间的短暂延迟严重的甚至可能会把RS节点整挂

大量写入的数据带来具体大GC开销,整个RS的活动都被阻塞了,当ZK来监测心跳时,发现无响应,就将该节点列入宕机名单,而GC完成后,RS发现自己“被死亡”了,那么就干脆自杀,这就是HBase的“朱丽叶死亡”。

这种场景下,使用bulkload是最安全、快速的,唯一的缺点是带来的IO比较高

大批量写入更新的操作,建议使用bulkload工具来实现。

实时写入

理同,实时查询,可以使用创建的Connection做任何操作。

结束语

我们从HBase的架构原理出发,接触了HBase大部分的核心知识点。
理论基础决定上层建筑,有了对HBase的总体认知,在后续的集群部署性能优化,以及实际应用中都能够比较游刃有余。
知其然而之所以然保持对技术原理的探索,不仅能学习到,其中许多令人惊叹的设计与操作,最重要的是,能够真正在业务应用中充分发挥其应有的性能。

你可能感兴趣的:(安装配置,#,调优,HBASE,HBase构建OLTP实时系统,高并发毫秒响应OLTP实时系统,HBase集群部署物理架构,HBase集群性能优化,Scala使用hbase集群)