本文致力于从架构原理、集群部署、性能优化与使用技巧等方面,阐述在如何基于HBase构建容纳大规模数据、支撑高并发、毫秒响应、稳定高效的OLTP实时系统 。
Client——>Zookeeper——>HMaster——>RegionServer——>HDFS
Client是执行查询、写入等对HBase表数据进行增删改查的使用方,可以是使用HBase Client API编写的程序,也可以是其他开发好的HBase客户端应用。
Zookeeper同HDFS一样,HBase使用Zookeeper作为集群协调与管理系统。
在HBase中其主要的功能与职责为:
协调过程示意图如下:
除了作为集群中的协调者,Zookeeper还为Client提供了 hbase:meta 表的存储信息。
客户端要访问HBase中的数据,只需要知道Zookeeper集群的连接信息,访问步骤如下:
可以看到,客户端操作数据过程并不需要HMaster的参与,通过Zookeeper间接访问RegionServer来操作数据。
第一次请求将会产生3次RPC,之后使用相同的rowkey时,客户端将直接使用缓存下来的位置信息,直接访问RegionServer,直至缓存失效(Region失效、迁移等原因)。
通过Zookeeper的读写流程如下:
hbase:meta 表存储了集群中所有Region的位置信息。
表结构如下:
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 通过 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在HBase集群中的功能与职责:
RegionServer内部结构图如下:
BlockCache为RegionServer中的读缓存,一个RegionServer共用一个BlockCache。
RegionServer处理客户端读请求的过程:
BlockCache有两种实现方式,有不同的应用场景,各有优劣:
我们将在「性能优化」一节中具体讨论如何判断应该使用哪种内存模式。
全称 Write Ahead Log ,是 RegionServer 中的预写日志。
所有写入数据,默认情况下,都会先写入WAL中,以保证RegionServer宕机重启之后,可以通过WAL来恢复数据,一个RegionServer中共用一个WAL。
RegionServer的写流程如下:
WAL会通过日志滚动的操作,定期对日志文件进行清理(已写入HFile中的数据可以清除),对应HDFS上的存储路径为 /hbase/WALs/${HRegionServer_Name} 。
一个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进行排序。如下图所示:
写请求到RegionServer之后,并没有立刻写入磁盘中,而是先写入内存中的Memstore(内存中数据丢失问题,可以通过回放WAL解决)以提升写入性能。
Region中的Memstore,会根据特定算法,将内存中的数据,将会刷写到磁盘,形成Storefile文件,因为数据在Memstore中为已排序,顺序写入磁盘性能高、速度快。
在这种 Log-Structured Merge Tree架构模式下,随机写入HBase拥有相当高的性能。
Memstore刷磁盘形成的StoreFile,以HFile格式,存储HBase的KV数据于HDFS之上。
HDFS为HBase提供底层存储系统,通过HDFS的高可用、高可靠等特性,保障了HBase的数据安全、容灾与备份。
对于客户端来说,将请求发送到需要写入的RegionServer中,等待RegionServer写入WAL、Memstore之后,即返回写入成功的ack信号。
对于RegionServer来说,写入的数据,还需要经过一系列的处理步骤。
首先我们知道Memstore是在内存中的,将数据放在内存中,可以得到优异的读写性能,但是同样也会带来麻烦:
对于第一个问题,虽然可以通过WAL机制在重启的时候,进行数据回放,但是对于第二个问题,则必须将内存中的数据持久化到磁盘中。
在不同情况下,RegionServer通过不同级别的刷写策略,对Memstore中的数据进行持久化,根据触发刷写动作的时机,以及影响范围,可以分为不同的几个级别:
Memstore刷写时,会阻塞线上的请求响应,由此可以看到,不同级别的刷写,对线上的请求,会造成不同程度影响的延迟:
所以在Memstore的刷写方面,需要尽量避免出现RegionServer级别的刷写动作。
数据在经过Memstore刷写到磁盘时,对应的会写入WAL sequence的相关信息,已经持久化到磁盘的数据,就没有必要通过WAL记录的必要。
RegionServer会根据这个sequence值,对WAL日志进行滚动清理,防止WAL日志数量太多,RegionServer启动时,加载太多数据信息。
同样,在Memstore的刷写策略中,可以看到,为了防止WAL日志数量太多,达到指定阈值之后,将会选择WAL记录中,最早的一个或者多个Region进行刷写。
经过前文的了解,我们现在可以知道HBase中一条数据完整的读取操作流程中,Client会和Zookeeper、RegionServer等发生多次交互请求。
基于HBase的架构,一条数据可能存在RegionServer中的三个不同位置:
RegionServer接收到的一条数据查询请求,只需要从以上三个地方,检索到数据即可。
在HBase中的检索顺序依次是:BlockCache -> Memstore -> HFiles。
其中,BlockCache、Memstore都是直接在内存中进行高性能的数据检索。
而HFiles则是真正存储在HDFS上的数据:
如何在大量的HFile中快速找到所需要的数据呢?
为了提高检索HFiles的性能,HBase支持使用 Bloom Fliter 对HFiles进行快读定位。
Bloom Filter(布隆过滤器)是一种数据结构,常用于大规模数据查询场景,其能够快速判断一个元素一定不在集合中,或者可能在集合中。
Bloom Filter由 一个长度为m的位数组 和 k个哈希函数 组成。
其工作原理如下:
由此可见,Bloom Filter中:
HBase中支持使用以下两种Bloom Filter:
两者的区别仅仅是:是否使用列信息作为Bloom Filter的条件。
这就是Bloom Filter在HBase检索数据的应用场景:
当然,如果没有指定创建Bloom Filter,RegionServer将会花费比较多的力气,一个个检索HFile,来判断数据是否存在。
通过Bloom Filter快速定位到需要检索的数据,所在的HFile之后的操作,自然是从HFile中读出数据,并返回。
据我们所知,HFile是HDFS上的文件(或大或小都有可能),现在HBase面临的一个问题,就是如何在HFile中 快速检索获得指定数据?
HBase随机查询的高性能,很大程度上取决于底层HFile的存储格式,所以这个问题可以转化为 HFile的存储格式,该如何设计,才能满足HBase 快速检索 的需求。
Memstore内存中的数据,在刷写到磁盘时,将会进行以下操作:
至此,已经完成了第一个 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已经生成,如下图所示:
生成HFile之后该如何使用呢?
HFile的索引数据(包括 Bloom Filter索引和数据索引信息)会在 Region Open 的时候被加载到读缓存中,之后数据检索经过以下过程:
可以看到,在HFile的数据检索过程中,一次读请求,只有 真正确认数据存在, 且 需要读取硬盘数据的时候,才会 执行硬盘查询操作。
同时,得益于 分层索引 与 分块存储,在Region Open加载索引数据的时候,再也不必和老版本(0.9甚至更早,HFile只有一层数据索引并且统一存储)一样加载所有索引数据到内存中,导致启动缓慢甚至卡机等问题。
Bloom Filter解决了如何在大量的HFile中快速定位数据,所在的HFile文件,虽然有了Bloom Filter的帮助,大大提升了检索效率,但是对于RegionServer来说,要检索的HFile数量并没有减少。
为了再次提高HFile的检索效率,同时避免大量小文件的产生,造成性能低下,RegionServer会通过Compaction机制,对HFile进行合并操作。
常见的Compaction触发方式有:
Minor Compaction 只执行简单的文件合并操作,选取较小的HFiles,将其中的数据顺序写入新的HFile后,替换老的HFiles。
但是如何在众多HFiles中,选择本次Minor Compaction,要合并的文件却有不少讲究:
可以看到,Minor Compaction不会合并过大的HFile,合并的HFile数量也有严格的限制,以避免产生太大的IO操作,Minor Compaction经常在Memstore Flush后触发,但不会对线上读写请求造成太大延迟影响。
相对于Minor Compaction 只合并选择的一部分HFile合并、合并时只简单合并数据文件的特点,Major Compaction则将会把Store中的所有HFile合并成一个大文件,将会产生较大的IO操作。
同时将会清理三类无意义数据:被删除的数据、TTL过期数据、版本号超过设定版本号的数据,Region Split过程中产生的Reference文件也会在此时被清理。
Major Compaction定期执行的条件由以下两个参数控制:
集群中各个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通过Compaction机制,使底层HFile文件数,保持在一个稳定的范围,减少一次读请求产生的IO次数、文件Seek次数,确保HFiles文件检索效率,从而实现高效处理线上请求。
如果没有Compaction机制,随着Memstore刷写的数据越来越多,HFile文件数量将会持续上涨,一次读请求生产的IO操作、Seek文件的次数将会越来越多,反馈到线上,就是读请求延迟越来越大。
然而,在Compaction执行过程中,不可避免的仍然会对线上造成影响。
简而言之,Compaction机制保证了HBase的读请求一直保持低延迟状态,但付出的代价是Compaction执行期间大量的读延迟毛刺和一定的写阻塞(写入量巨大的情况下)。
HBase通过 LSM-Tree架构提供了高性能的随机写,通过缓存、Bloom Filter、HFile与Compaction等机制提供了高性能的随机读。
至此,HBase已经具备了作为一个高性能读写数据库的基本条件。如果HBase仅仅到此为止的话,那么其也只是个在架构上和传统数据库有所区别的数据库而已,作为一个高性能读写的分布式数据库来说,其拥有近乎可以无限扩展的特性。
支持HBase进行自动扩展、负载均衡的是Region Split机制。
在HBase中,提供了多种Split策略,不同的策略触发条件各不相同。
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的切分点定义如下:
得到切分点之后,核心的切分流程分为 prepare - execute - rollback 三个阶段。
在内存中,初始化两个子Region(HRegionInfo对象),准备进行切分操作。
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正式提供服务 |
如果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中创建了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时进行数据移动。
至此,我们已经揭开了HBase架构与原理的大部分神秘面纱,在后续做集群规划、性能优化与实际应用中,为什么这么调整,以及为什么这么操作,都将一一映射到HBase的实现原理上。
如果你希望了解HBase的更多细节,可以参考《HBase权威指南》。
经过冗长的理论初步了解过HBase架构与工作原理之后,搭建HBase集群是使用HBase的第一个步骤。
需要注意的是,HBase集群一旦部署使用,再想对其作出调整需要付出惨痛代价(线上环境中),所以如何部署HBase集群是使用的第一个关键步骤。
如以下的软件混合型集群状况:
这个集群不管是规模、还是服务部署方式相信都是很多都有公司的「标准」配置。
那么这样的集群有什么问题呢?
如果仅仅HBase是一个非「线上」的系统,或者充当一个历史冷数据存储的大数据库,这样的集群其实一点问题也没有,因为对其没有任何苛刻的性能要求。
但是如果希望HBase作为一个线上能够承载海量并发、实时响应的系统,这个集群随着使用时间的增加很快就会崩溃。
从 硬件混合型 来说,一直以来Hadoop都是以宣称能够用低廉、老旧的机器撑起一片天。
这确实是Hadoop的一个大优势,然而前提是作为离线系统使用。
离线系统的定义,即跑批的系统,如:Spark、Hive、MapReduce等,没有很强的时间要求,显著的吞吐量大,延迟高。
因为没有实时性要求,几台拖拉机跑着也没有问题,只要最后能出结果并且结果正确就OK。
那么在我们现在的场景中,对HBase的定义已经不是一个离线系统,而是一个实时系统。
对于一个硬性要求很高的实时系统来说,如果其中几台老机器拖了后腿也会引起线上响应的延迟。
既然硬件拖后腿,那么硬件升级自然是水到渠成。
现在我们有全新的高配硬件可以使用,参考如下:
这样的集群可能还会存在什么问题呢?
从 软件混合型 来说,离线任务最大的特点就是吞吐量特别高,瞬间读写的数据量,可以把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的稳定性,从根本上,分离了软硬件对HBase所带来的影响,将会拥有比之前两组集群配置 更稳定的响应和更高的性能。
其他硬件推荐
另外值得注意的是,Zookeeper节点建议设置5个节点,5个节点能保证Leader快速选举,并且最多可以允许2个节点宕机的情况下正常使用。
硬件上可以选择使用虚拟机,因为zk节点本身消耗资源并不大,不需要高配机器。但是5个虚拟节点不能在一个物理机上,防止物理机宕机影响所有zk节点。
以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进行 调优设置,以确保其能够以最完美的状态运行,在当前集群环境中,尽可能发挥硬件的优势。
为了方便后续配置项计算说明,假设我们可用的集群硬件状况如下:
对于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。
得到以下配置项:
我们知道RegionServer中的BlockCache有两种实现方式:
这两种模式的详细说明可以参考 CDH官方文档。
为HBase选择合适的 内存模式 以及根据 内存模式 计算相关配置项是调优中的重要步骤。
首先我们可以根据可用内存大小来判断使用哪种内存模式。
先看 超小内存(假设8G以下) 和 超大内存(假设128G以上) 两种极端情况:
在两边的极端情况下,我们可以根据内存大小,选择合适的内存模式,那么如果内存大小,在合理、正常的范围内该如何选择呢?
此时我们应该主要关注业务应用的类型:
得到配置项:
hbase.bucketcache.ioengine=offheap: 使用堆外缓存
确认使用的内存模式之后,接下来将通过计算确认 JavaHeap、对外读缓存、堆内写缓存、LRU元数据 等内存空间具体的大小。
讨论具体配置之前,我们从 HBase集群规划 引入一个Disk / JavaHeap Ratio的概念,来帮助我们设置内存相关的参数。
理论上我们假设 最优 情况下 硬盘维度下的Region个数 和 JavaHeap维度下的Region个数 相等。
相应的计算公式为:
其中:
现在我们已知条件 硬盘维度和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的内存划分如下图:
从架构原理中我们知道,Memstore有4种级别的Flush,需要我们关注的是 Memstore、Region和RegionServer级别的刷写。
其中Memstore和Region级别的刷写,并不会对线上造成太大影响,但是需要控制其阈值和刷写频次来进一步提高性能。
而RegionServer级别的刷写,将会阻塞请求,直至刷写完成,对线上影响巨大,需要尽量避免。
得到以下配置项:
现在我们设置两个 经验值变量:
计算得JavaHeap的大小为 153 * 0.35 = 53.55 ,没有超出预期的最大JavaHeap。如果超过最大期望值,则使用最大期望值代替,得JavaHeap大小为53G。
现在JavaHeap、MemstoreSize已知,可以得到唯一的位置变量 HeapFractionForMemstore 的值为 0.48 。
得到以下配置项:
当前内存信息如下:
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
配置堆外缓存涉及到的相关参数如下:
现在,我们再来计算 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
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
关闭自动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可能产生的问题,应用层应该采用 读写分离 的模式,来最大程度保证自身稳定性。
应用层读写分离
可靠的应用层应使用 读写分离 的模式提高响应效率与可用性:
- 读写应用应该分别属于 不同的服务实例 ,避免牵一发而动全身
- 对于写入服务,数据异步写入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配置
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中,以减少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的连接数,会暴增影响服务。
比较可行的方案是每个批次创建一个链接(类似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的总体认知,在后续的集群部署、性能优化,以及实际应用中都能够比较游刃有余。
知其然而之所以然,保持对技术原理的探索,不仅能学习到,其中许多令人惊叹的设计与操作,最重要的是,能够真正在业务应用中充分发挥其应有的性能。