HBase最佳实践

  1. 云栖社区>
  2. 博客>
  3. 正文

HBase最佳实践

xiaohei.info 2019-11-19 13:36:27 浏览1039
展开阅读全文

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

一、架构原理

1.1 基本架构

hbase_

从上层往下可以看到HBase架构中的角色分配为:

  • Client
  • Zookeeper
  • HMaster
  • RegionServer
  • HDFS

Client

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

Zookeeper

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

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

  • 存储整个集群HMaster与RegionServer的运行状态
  • 实现HMaster的故障恢复与自动切换
  • 为Client提供元数据表的存储信息

HMaster、RegionServer启动之后将会在Zookeeper上注册并创建节点(/hbasae/master 与 /hbase/rs/*),同时 Zookeeper 通过Heartbeat的心跳机制来维护与监控节点状态,一旦节点丢失心跳,则认为该节点宕机或者下线,将清除该节点在Zookeeper中的注册信息。

当Zookeeper中任一RegionServer节点状态发生变化时,HMaster都会收到通知,并作出相应处理,例如RegionServer宕机,HMaster重新分配Regions至其他RegionServer以保证集群整体可用性。

当HMaster宕机时(Zookeeper监测到心跳超时),Zookeeper中的 /hbasae/master 节点将会消失,同时Zookeeper通知其他备用HMaster节点,重新创建 /hbasae/master 并转化为active master。

协调过程示意图如下:

HBaseArchitecture_zk

除了作为集群中的协调者,Zookeeper还为Client提供了 hbase:meta 表的存储信息

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

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

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

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

通过Zookeeper的读写流程如下:

HBaseArchitecture_metainfo

hbase:meta 表

hbase:meta 表存储了集群中所有Region的位置信息

表结构如下:

HBaseArchitecture_metadata

  • Rowkey格式:tableName,regionStartKey,regionId

    • 第一个region的regionStartKey为空
    • 示例:ns1:testTable,,xxxxreigonid
  • 只有一个列族info,包含三个列:

    • regioninfo:RegionInfo的proto序列化格式,包含regionId,tableName,startKey,endKey,offline,split,replicaId等信息
    • server:RegionServer对应的server:port
    • serverstartcode:RegionServer的启动时间戳

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

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

HMaster

HBaseArchitecture_hmaster

HBase整体架构中HMaster的功能与职责如下:

  • 管理RegionServer,监听其状态,保证集群负载均衡且高可用
  • 管理Region,如新Region的分配、RegionServer宕机时该节点Region的分配与迁移
  • 接收客户端的DDL操作,如创建与删除表、列簇等信息
  • 权限控制

如我们前面所说的,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内部结构图如下:

HBaseArchitecture_regionserver

BlockCache

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

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

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

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

  • On-Heap的LRUBlockCache

    • 优点:直接中Java堆内内存获取,响应速度快
    • 缺陷:容易受GC影响,响应延迟不稳定,特别是在堆内存巨大的情况下
    • 适用于:写多读少型、小内存等场景
  • Off-Heap的BucketCache

    • 优点:无GC影响,延迟稳定
    • 缺陷:从堆外内存获取数据,性能略差于堆内内存
    • 适用于:读多写少型、大内存等场景

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

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进行排序。如下图所示:

HBaseArchitecture_memstore

写请求到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是在内存中的,将数据放在内存中可以得到优异的读写性能,但是同样也会带来麻烦:

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

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

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

  • Memstore级别:Region中任意一个MemStore达到了 hbase.hregion.memstore.flush.size 控制的上限(默认128MB),会触发Memstore的flush。
  • Region级别:Region中Memstore大小之和达到了 hbase.hregion.memstore.block.multiplier hbase.hregion.memstore.flush.size 控制的上限(默认 2 128M = 256M),会触发Memstore的flush。
  • 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内存)。
  • RegionServer中HLog数量达到上限:将会选取最早的 HLog对应的一个或多个Region进行flush(通过参数hbase.regionserver.maxlogs配置)。
  • HBase定期flush:确保Memstore不会长时间没有持久化,默认周期为1小时。为避免所有的MemStore在同一时间都进行flush导致的问题,定期的flush操作有20000左右的随机延时。
  • 手动执行flush:用户可以通过shell命令 flush ‘tablename’或者flush ‘region name’分别对一个表或者一个Region进行flush。

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

  • 对于Memstore与Region级别的刷写,速度是比较快的,并不会对线上造成太大影响
  • 对于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中的三个不同位置:

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

RegionServer接收到的一条数据查询请求,只需要从以上三个地方检索到数据即可,在HBase中的检索顺序依次是:BlockCache -> Memstore -> HFiles

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

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

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

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

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

Bloom Filter(布隆过滤器)是一种数据结构,常用于大规模数据查询场景,其能够快速判断一个元素一定不在集合中,或者可能在集合中

Bloom Filter由 一个长度为m的位数组k个哈希函数 组成。

其工作原理如下:

  1. 原始集合写入一个元素时,Bloom Filter同时将该元素 经过k个哈希函数映射成k个数字,并以这些数字为下标,将 位数组 中对应下标的元素标记为1
  2. 当需要判断一个元素是否存在于原始集合中,只需要将该元素经过同样的 k个哈希函数得到k个数字

    • 取 位数组 中对应下标的元素,如果都为1,则表示元素可能存在
    • 如果存在其中一个元素为0,则该元素不可能存在于原始集合中
  3. 因为哈希碰撞问题,不同的元素经过相同的哈希函数之后可能得到相同的值

    • 对于集合外的一个元素,如果经过 k个函数得到的k个数字,对应位数组中的元素都为1,可能是该元素存在于集合中
    • 也有可能是集合中的其他元素”碰巧“让这些下标对应的元素都标记为1,所以只能说其可能存在
  4. 对于集合中的不同元素,如果 经过k个函数得到的k个数字中,任意一个重复

    • 位数组 中对应下标的元素会被覆盖,此时该下标的元素不能被删除(即归零)
    • 删除可能会导致其他多个元素在Bloom Filter表示不“存在”

由此可见,Bloom Filter中:

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

HBaseArchitecture_bloomfilter

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

  • ROW:基于 Rowkey 创建的Bloom Filter
  • ROWCOL:基于 Rowkey+Column 创建的Bloom Filter

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

使用ROWCOL时,可以让指定列的查询更快,因为其通过Rowkey与列信息来过滤不存在数据的HFile,但是相应的,产生的Bloom Filter数据会更加庞大

而只通过Rowkey进行检索的查询,即使指定了ROWCOL也不会有其他效果,因为没有携带列信息。

通过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内存中的数据在刷写到磁盘时,将会进行以下操作:

  • 会先现在内存中创建 空的Data Block数据块 包含 预留的Header空间。而后,将Memstore中的KVs一个个顺序写满该Block(一般默认大小为64KB)。
  • 如果指定了压缩或者加密算法,Block数据写满之后将会对整个数据区做相应的压缩或者加密处理。
  • 随后在预留的Header区写入该Block的元数据信息,如 压缩前后大小、上一个block的offset、checksum 等。
  • 内存中的准备工作完成之后,通过HFile Writer输出流将数据写入到HDFS中,形成磁盘中的Data Block。
  • 为输出的Data Block生成一条索引数据,包括 {startkey、offset、size} 信息,该索引数据会被暂时记录在内存中的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 Block 和 Bloom 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已经生成,如下图所示:

HBaseArchitecture_hfile

检索HFile

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

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

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

可以看到,在HFile的数据检索过程中,一次读请求只有 真正确认数据存在 且 需要读取硬盘数据的时候才会 执行硬盘查询操作

同时,得益于 分层索引分块存储,在Region Open加载索引数据的时候,再也不必和老版本(0.9甚至更早,HFile只有一层数据索引并且统一存储)一样加载所有索引数据到内存中导致启动缓慢甚至卡机等问题。

HBaseArchitecture_hfileindex

1.5 HFile Compaction

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

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

常见的Compaction触发方式有:

  • Memstore Flush检测条件执行
  • RegionServer定期检查执行
  • 用户手动触发执行

Minor Compaction

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

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

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

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

HBaseArchitecture_minorcompact

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。

HBaseArchitecture_majorcompact

Compaction的优缺点

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

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

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

  • 对于Major Compaction来说,合并过程将会占用大量带宽、IO资源,此时线上的读延迟将会增大
  • 对于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 Filter、HFile与Compaction等机制提供了高性能的随机读

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

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

Split策略与触发条件

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

HBaseArchitecture_splitpolicy

如上图所示,不同版本中使用的默认策略在变化。

  • 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的切分点定义如下:

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

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

prepare阶段

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

execute阶段

HBaseArchitecture_regionsplitinfo

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文件

    • reference是一个特殊的文件,体现在其文件名与文件内容上
    • 文件名组成:${父Region对应切分点所在的HFile文件}.${父Region}
    • 文件内容:[splitkey]切分点rowkey[top?]true/false,true为top上半部分,false为bottom下半部分
    • 根据reference文件名,可以快速找到对应的父Region、其中的HFile文件、HFile切分点,从而确认该子Region的数据范围
    • 数据范围确认完毕之后进行正常的数据检索流程(此时仍然检索父Region的数据
  6. 将子Region的目录拷贝到HBase根目录下,形成新的Region
  7. 父Regin通知修改 hbase:meta 表后下线,不再提供服务

    • 此时并没有删除父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 表中的split和offline为true的Region,对应的子Region是否存在reference文件,如果不存在则删除父Region数据。

负载均衡

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

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

HBaseArchitecture_regionsplit

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

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

二、集群部署

经过冗长的理论初步了解过HBase架构与工作原理之后,搭建HBase集群是使用HBase的第一个步骤。

需要注意的是,HBase集群一旦部署使用,再想对其作出调整需要付出惨痛代价(线上环境中),所以如何部署HBase集群是使用的第一个关键步骤。

2.1 集群物理架构

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

硬件混合型 指的是该集群机器配置参差不齐,混搭结构

软件混合型 指的是该集群部署了一套类似CDH全家桶套餐

如以下的集群状况:

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

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

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

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

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

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

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

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

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

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

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

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

既然硬件拖后腿,那么硬件升级自然是水到渠成。

现在我们有全新的高配硬件可以使用,参考如下:

  • 集群规模:30
  • 部署服务:HBase、Spark、Hive、Impala、Kafka、Zookeeper、Flume、HDFS、Yarn等
  • 硬件情况:内存、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。

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

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

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

其他硬件推荐

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

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

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

2.2 安装与部署

以CDH集群为例安装HBase。

使用自动化脚本工具进行安装操作:

# 获取安装脚本,上传相关安装软件包至服务器(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_memory_cdh

简化版:

hbase_bucketcache

写缓存

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

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

而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

现在JavaHeap、MemstoreSize已知,可以得到唯一的位置变量 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: <table key>"
    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可能产生的问题,应用层应该采用 读写分离 的模式来最大程度保证自身稳定性。

应用层读写分离

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

  • 读写应用应该分别属于 不同的服务实例 ,避免牵一发而动全身
  • 对于写入服务,数据异步写入redis或者kafka队列,由下游消费者同步至HBase,响应性能十分优异

    • 需要处理数据写入失败的事务处理与重写机制
  • 对于读取服务,如果一个RS挂了,一次读请求经过若干重试和超时可能会持续几十秒甚至更久,由于和写入服务分离可以做到互不影响

    • 最好使用缓存层来环节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配置

<property><name>hbase.regionserver.wal.codec</name><value>org.apache.hadoop.hbase.regionserver.wal.IndexedWALEditCodec</value></property><property><name>hbase.region.server.rpc.scheduler.factory.class</name><value>org.apache.hadoop.hbase.ipc.PhoenixRpcSchedulerFactory</value><description>Factory to create the Phoenix RPC Scheduler that uses separate queues for index and metadata updates</description></property><property><name>hbase.rpc.controllerfactory.class</name><value>org.apache.hadoop.hbase.ipc.controller.ServerRpcControllerFactory</value><description>Factory to create the Phoenix RPC Scheduler that uses separate queues for index and metadata updates</description></property><property><name>hbase.regionserver.thread.compaction.large</name><value>5</value></property><property><name>hbase.regionserver.region.split.policy</name><value>org.apache.hadoop.hbase.regionserver.ConstantSizeRegionSplitPolicy</value></property>

四、使用技巧

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中,以减少split与compaction造成的影响。
    • 历史小增量大的数据:预分区个数需满足历史数据等分存储,并支撑未来一段时间内(一个月以上)的增量数据
  • 预分区区间计算:属性相同的表中随机取出部分样本数据(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的连接数会暴增影响服务。
比较可行的方案是每个批次创建一个链接(类似foreachPartiton中每个分区创建一个链接,分区中数据共享链接)。但是这种方案也会造成部分连接浪费、效率低下等。

如果可以做到一个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中的foreachWrite也可以直接应用。

写入场景

批量写入

同理安利组件

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的总体认知,在后续的集群部署、性能优化以及实际应用中都能够比较游刃有余。

知其然而之所以然,保持对技术原理的探索,不仅能学习到其中许多令人惊叹的设计与操作,最重要的是能够真正在业务应用中充分发挥其应有的性能。

网友评论

登录后评论
0/500
评论
xiaohei.info
+ 关注