2017年前后留在旧笔记本电脑里的笔记,先发布后修改。
一. 建表
建表语句示例:
CREATE TABLE IF NOT EXISTS JL_TRACK(
JLID VARCHAR NOT NULL,
CJSJ VARCHAR NOT NULL, //数据采集时间
LON FLOAT, //经度
LAT FLOAT, //纬度
CONSTRAINT PK PRIMARY KEY(JLID,CJSJ)
) DATA_BLOCK_ENCODING=’FAST_DIFF’, SALT_BUCKETS=96, COMPRESSION=’SNAPPY’, IMMUTABLE_ROWS = true;
1.1 数据压缩- COMPRESSION
如果启用压缩,HBase在写入数据块到HDFS之前会首先对数据块进行压缩,再落盘,从而可以减少磁盘空间使用量。而在读数据的时候首先从HDFS中加载出block块之后进行解压缩,然后再缓存到BlockCache,最后返回给用户。写路径和读路径分别如下:
结合上图,来看看数据压缩对资源使用情况以及读写性能的影响:
(1) 资源使用情况:压缩最直接、最重要的作用即是减少数据硬盘容量,理论上snappy压缩率可以达到5:1,但是根据测试数据不同,压缩率可能并没有理论上理想;压缩/解压缩无疑需要大量计算,需要大量CPU资源;根据读路径来看,数据读取到缓存之前block块会先被解压,缓存到内存中的block是解压后的,因此和不压缩情况相比,内存前后基本没有任何影响。
(2) 读写性能:因为数据写入是先将kv数据值写到缓存,最后再统一flush的硬盘,而压缩是在flush这个阶段执行的,因此会影响flush的操作,对写性能本身并不会有太大影响;而数据读取如果是从HDFS中读取的话,首先需要解压缩,因此理论上读性能会有所下降;如果数据是从缓存中读取,因为缓存中的block块已经是解压后的,因此性能不会有任何影响;一般情况下大多数读都是热点读,缓存读占大部分比例,压缩并不会对读有太大影响。
可见,压缩特性就是使用CPU资源换取磁盘空间资源,对读写性能并不会有太大影响。HBase目前提供了三种常用的压缩方式:GZip | LZO | Snappy,下面表格是官方分别从压缩率,编解码速率三个方面对其进行对比:
综合来看,Snappy的压缩率最低,但是编解码速率最高,对CPU的消耗也最小,目前一般建议使用Snappy。
由于时间关系,我并没有对上述三种压缩展开标准化的测试。在集群中我采用了snappy压缩算法,在两个阶段(第一阶段约5亿数据量,第二阶段约60亿数据量),无压缩情况下总存储(包含系统中其他存储)从约1.5T增长到约9T,采用SNAPPY压缩则从约900G增长到4.2T。SNAPPY压缩至少可节约50%的空间。
我简单做过在约35亿条、96个分区的大表中,采用压缩和不采用压缩对读写性能的影响,结果是写性能没有任何影响(原因是写时首先写内存,然后再异步地执行压缩并落盘)。
而对于全表统计和大规模的SCAN,采用SNAPPY压缩反而有约10%-15%的性能提升(由于在做测试时,集群一直在同时在执行其他任务,我无法给出十分可靠的对比)。分析原因,压缩后存储量的减少使得读取同样条数的数据时,硬盘IO所需时间更小。而对于这样大规模的数据读和检索操作,硬盘IO瓶颈和网络IO瓶颈对性能的影响会非常显著。
但是相对应的,采用压缩会比不采用有更高的CPU利用率。如果存在大规模并发的SCAN请求,可能需要考虑CPU利用率对计算性能的影响。
1.2 数据编码- DATA_BLOCK_ENCODING
除了数据压缩之外,HBase还提供了数据编码功能。和压缩一样,数据在落盘之前首先会对KV数据进行编码;但又和压缩不同,数据块在缓存前并没有执行解码,因此即使后续命中缓存的查询也是编码的数据块,需要解码后才能获取到具体的KV数据。写路径和读路径分别如下:
同样,来看看数据压缩对资源使用情况以及读写性能的影响:
(1) 资源使用情况:和压缩一样,编码最直接、最重要的作用也是减少数据硬盘容量,但是数据压缩率一般没有数据压缩的压缩率高,理论上只有5:2;编码/解码一般也需要大量计算,需要大量CPU资源;根据读路径来看,数据读取到缓存之前block块并没有被解码,缓存到内存中的block是编码后的,因此和不编码情况相比,相同数据block快占用内存更少,即内存利用率更高。
(2) 读写性能:和数据压缩相同,数据编码也是在数据flush到hdfs阶段执行的,因此并不会直接影响写入过程;前面讲到,数据块是以编码形式缓存到blockcache中的,因此同样大小的blockcache可以缓存更多的数据块,这有利于读性能。另一方面,用户从缓存中加载出来数据块之后并不能直接获取KV,而需要先解码,这却不利于读性能。可见,数据编码在内存充足的情况下会降低读性能,而在内存不足的情况下需要经过测试才能得出具体结论。
HBase目前提供了四种常用的编码方式:Prefix | Diff | Fast_Diff | Prefix_Tree。
从网络上各路人马做的性能对比来看,Prefix_Tree似乎拥有最好的随机读性能、压缩比和在不同blocksize下的稳定性,而在SCAN场景中性能有所降低(但同时CPU占用率也会降低)。但是有博主指出在生产环境中Prefix_Tree算法会偶然导致COMPACTION操作失败和SCAN MISS,这是由于在较低版本HBase中此算法还不完善导致的。
同时,有博主指出Prefix_Tree 编码和Snappy压缩算法同时使用会导致性能没有提升但CPU占用率大幅增加,不利于并发操作。
因此,我们集群采用了更为成熟且性能也不错的FAST_DIFF算法。我没有及时地记录不采用编码和采用编码的情况下各项性能的变化,但从实际运营中可以确定的是,采用编码会降低内存消耗,大幅降低FULL GC的触发几率。
1.3 加盐预分区- SALT_BUCKETS
Phoenix在建表和建索引的时候可以指定SALT_BUCKETS数,即分区数,从而提高插入和查询性能。它的原理时,建表时预先建立指定数量的分区,并确定各个分区的盐值范围。所以一个分区也称为一个盐桶(SALT_BUCKETS)。
在写入数据时,在rowkey前随机插入一个盐值,从而使得数据落到不同的分区中。
加盐预分区可使数据均衡地落到不同的Region中。如果再配合集群中基于分区的负载均衡策略,可使得数据地读、写都较为均衡地分布在集群中。这首先可以避免读写热点问题,其次可以充分利用集群中计算和存储资源,平衡计算和存储负载。
对于大表,一般的建议是集群总的CPU核数为N,则SALT_BUCKETS为 0.5N ~ N 之间。
1.4 不可变表- IMMUTABLE_ROWS
指定IMMUTABLE_ROWS=true,即指定表中的数据仅会有新增操作,不会对原有数据进行覆盖修改或删除。如果后期发现有同样rowkey的数据upsert,则抛弃此数据。这会加快数据插入,并避免在存在索引情况下覆盖或删除造成的索引校验占用大量计算资源。
不可变行适合于像采集数据或日志这样数据不断追加而不会覆盖的场景。
1.5 Rowkey设计
Phoenix允许用户设计CONSTRAINT PRIMARY KEY,即将表的几个字段组合成primary key,作为HBase中的rowkey。我们知道,基于rowkey的查询非常快,于是我们可以将经常要作为条件查询的字段组合成rowkey。
但是需要注意的是,如果查询中不包含组合主键的第一个字段,则查询速度会大幅降低。如果不包含第二个字段而仅包含第三个或之后的字段,则查询效率会进一步大幅降低。以此类推。
当表建立后,CONSTRAINT PRIMARY KEY包含的字段和顺序均不可变。所以PK的设计要充分考虑业务的需求,做好规划。
1.6 其他注意事项
字段名尽可能短,因为字段名作为key会被写入到内存和硬盘的每个对应的键值对中;
在满足业务需求的前提下,字段数尽可能少;
适当划分列族。应当把经常一起读写的字段划分到同一列族中;如果一张大表里有些字段几乎不可能一起被读,那就拆分到不同列族里。因为检索中如果包含某一列,则其所在列族里的所有列都会一起被读取传输。
二. 二级索引
Phoenix的二级索引实际上是基于HBase本身的索引机制。它把需要索引的字段和主键提取出来作为rowkey存储到一张新表中(全局索引),或者将需要索引的字段作为新的列族插入到rowkey前面(本地索引)。
2.1 全局索引-GLOBAL INDEX
示例:
CREATE INDEX G_IDX_ON_GC ON COLLECTION_RECORD_KKGC(CREATE_TIME,CAR_TYPE) DATA_BLOCK_ENCODING=’FAST_DIFF’, COMPRESSION=’SNAPPY’;
全局索引适用于多读少写的场景,在写操作上会给性能带来极大的开销,因为所有的更新和写操作(DELETE,UPSERT VALUES和UPSERT SELECT)都会引起索引的更新,在读数据时,Phoenix将通过索引表来达到快速查询的目的。
它有一个缺陷,如果查询语句中的条件字段或返回字段不是索引字段,就会触发全表扫描。
如上面所示的索引中,如果执行以下语句,则不会用到索引:
SELECT CAR_SPEED FROM COLLECTION_RECORD_KKGC WHERE CREATE_TIME=’20180629130000’ AND CAR_TYPE=‘1’;
解决办法是创建索引时把相关字段include进来。如:
CREATE INDEX G_IDX_ON_GC ON COLLECTION_RECORD_KKGC(CREATE_TIME,CAR_TYPE) INCLUDE(CAR_SPEED) DATA_BLOCK_ENCODING=’FAST_DIFF’, COMPRESSION=’SNAPPY’;
2.2 本地索引-LOCAL INDEX
示例:
CREATE LOCAL INDEX L_IDX_ON_GC_TIME ON COLLECTION_RECORD_KKGC(CREATE_TIME) DATA_BLOCK_ENCODING=’FAST_DIFF’, COMPRESSION=’SNAPPY’;
与Global Index不同,当使用Local Index的时候即使查询的所有字段都不在索引字段中时也会用到索引进行查询(这是由Local Index自动完成的)。
本地索引适用于写多读少的场景,Phoneix在查询时会自动选择是否使用本地索引,索引数据将放到表数据所在的服务器中。由于无法预先确定region的位置,所以在读取数据时会检查每个region上的数据因而带来一定性能开销。
2.3 可变索引与不可变索引
当表被声明为不可变表时,建立的索引将成为不可变索引。在表数据有变化时,不可变索引仅会执行追加操作,而不会有其他更新。这会降低索引更新带来的写入性能损失。
需要注意的是,索引是否可变仅与建表时刻表的是否可变有关。索引建立后,表的可变状态改变并不会改变索引表的可变状态。
如果索引表为不可变,表为可变,则删除操作可能会导致索引不能更新而操作失败。
三. 数据本地化与数据文件合并
3.1 数据本地化比例-locality
数据本地化指数指表的每个region中,数据位于region所在region server的比例。因为DataNode和RegionServer通常会部署在相同的机器上,所以会产生Locality这样的概念。
HBase的Locality是通过HDFS的Block复制实现的。在复制Block时,HBase是这样选择副本的位置的:
第一个副本写到本地节点上;
第二个副本写到另一个机架的随机节点上;
第三个副本写到相同机架的一个随机选择的其他节点上;
如果还有更多的副本,这些副本将会写到集群上的随机节点上。
就是这样,在flush或compact后,HBase的Region实现了Locality。当一个RegionServer处在failover的情况下(rebalance或重启)时,可能会分配到一些没有本地StoreFiles的Region(因为此时没有可用的本地副本)。然而,有新数据再写入这些Region的时候,或者是对表进行compact的时候,StoreFiles将会被重写,这些Region也会再次变成RegionServer的“local”Region。
执行数据检索时,region优先从本地读取数据。但是如果有属于这个region 的数据处于别的regionserver上,则会通过网络从别的regionserver上读取数据,从而影响性能。
在HBase Master UI 页面上,点击表名进入表的详情页,可以看到表的每个分区的locality指数。如果发现有不少分区locality不为1,则需要手动执行major_compact。
3.2 数据文件合并-compaction
在HBase中,每当memstore的数据flush到磁盘后,就形成一个storefile,当storefile的数量越来越大时,会严重影响HBase的读性能 ,所以必须将过多的storefile文件进行合并操作。Compaction是Buffer-flush-merge的LSM-Tree模型的关键操作,主要起到如下几个作用:
1. 合并文件
2. 清除删除、过期、多余版本的数据
3. 提高读写数据的效率
HBase中实现了两种compaction的方式:minor 和 major。这两种合并的区别是:
Minor操作只用来做部分文件的合并操作以及包括minVersion=0并且设置ttl的过期版本清理,不做任何删除数据、多版本数据的清理工作;
Major操作是对Region下的HStore下的所有StoreFile执行合并操作,最终的结果是整理合并出一个文件,执行清理操作,并执行数据本地化。如果表的编码格式或压缩格式有变化,将同时执行数据的重新编码和压缩。
HBase后台会持续监控region及其Hfile,自动执行minor compaction。但我禁用了本集群的major compaction。具体原因和配置见《集群配置优化调整》文档。
手动major compaction 指令:
Hbase shell>> major_compact ‘COLLECTION_RECORD_KKGC’
四. 分区操作与负载均衡
HBase基于每个Region server 上的region 数量实现简单的负载均衡,官方认为这种实现是简洁而高效的,能满足绝大部分的需求。而除了建表时预分区之外,表的分区数量在以下两种情况下会发生变化:
分区分割(split)
分区合并(merge)
hbase中的Region是一张表的子集,也就是说把一张表在水平方向上切割成若干个region。HBase中针对表采用”Range分区”,把rowkey的完整区间切割成一个个的”Key Range” ,每一个”Key Range”称为一个Region,所以说region其实是按照连续的rowKey存储的区间。
不同Region分布到不同Region Server上,region是Hbase集群分布数据的最小单位,或者说region是HBase中分布式存储和负载均衡的最小单元,但不是存储的最小单元。存储的最小单元是store file(也叫Hfile)。Store File是存放数据的地方,里面存的是一个列簇的数据,每一条数据都是key-value,store file的内部是按照rowkey有序排列的,但是store file之间是无序的。
4.1 HBASE的负载均衡机制-balance
HBase基于每个Region server 上的region 数量实现简单的负载均衡。也就是说,只要每个Region server上的分区数量相等或数量差别在某个可接受的范围内,那么就认为集群的负载是均衡的。
如果集群已经启用了负载均衡,那么若建表时为表做了预分区,则表的每个分区都会尽可能均衡地分散到集群的各个节点上。但是如果集群重启或者某个节点死掉或者新增了节点而发生重新负载均衡,就会出现一种让人头疼的情况:对于各个Region server而言,其上的分区数量的确是均衡的;但对于具体某张表的各个分区却不是均衡地分布在各个节点上。而表有大有小,对不同表的读写负载也往往相差巨大。那么就可能出现读写负载不能均衡分散到各个节点,造成性能下降甚至出现个别节点因负载过高而经常抛出超时异常或OOM。
HBase的负载均衡机制贯穿在整个集群的平衡运行期内,以特定时间间隔(由hbase.balancer.period 参数控制,默认是5分钟)执行。但是,当遇到如下场景时不进行全局负载均衡:
均衡负载开关balanceSwitch关闭
HMaster未完成初始化操作
RIT中有未处理完的Region(当前有region处于splitting状态)
有正在处理的Dead RegionServer
RegionServer上的平均Region数量小于等于1
HBase的默认负载均衡过程如下:
HMaster统计所有RegionServer的负载情况,计算整个集群中所有负载的Region总量numRegions,并把这些Region按照负载的情况进行排序。
计算每个RegionServer需要承载的Region的平均值,然后计算每个RegionServer负载的上限和下限。假设集群分区数为100,RegionServer数为6,计算公式为:
avg = numRegions / numServers=100/6=16.7
min=Math.floor(avg * (1 - slop))
max=Math.ceil(avg * (1 + slop))
其中slop是一个可配置的参数,声明负载均衡的抖动范围。假设slop为0.1,则:
min=15,
max=19
判定如果集群中负载的最小值大于min,并且最大值小于max,这时直接返回不需要进行负载均衡。否则执行下一步。
从大到小遍历排序好的RegionServer,找出全部分区数大于max的RegionServer,并计算需要迁移的分区数。随机打乱这些节点上的分区,并随机选取出各自超出数量的分区,记录到中间变量regionsToMove中。
按负载从小到大遍历RegionServer,找出所有分区数小于min的节点,并从regionsToMove中为他们分配分区,直到regionsToMove所有分区已分配完或者没有节点的分区数小于min。
如果第4步后regionsToMove中还有分区没有分配,则从中选取一个分区分配给当前负载最小的RegionServer,重复这个步骤直到分配完成。
如果第4步后仍有RegionServer 负载小于min,则从当前负载最大的RegionServer随机选取一个分区分配给当前负载最小的RegionServer,直到所有RegionServer负载都大于min。
7.检查确认分区迁移是否结束,最终结果是否符合负载均衡要求。
自动负载均衡过程中分区的卸载、迁移、装载以及之后必须要执行的major compaction会对当时集群的读写性能造成十分恶劣的影响。并且如前文所说,因表的大小和读写负载不同,基于分区数的负载均衡往往并不能实现真正满足要求的负载均衡。在生产环境中的正确做法是,关闭自动负载均衡策略,定期在负载低谷时期执行手动的负载均衡。
关闭自动负载均衡命令:
Hbase shell> balance_switch false
开启自动负载均衡命令:
Hbase shell> balance_switch true
开启自动负载均衡后,手动触发命令:
Hbase shell> balancer
4.2 HBASE分区分割-split
不管建表时是否预先划分了分区,在数据不断增长的过程中,都有可能因数据超出分区设定的最大存储空间或者因数据瞬时写入过快过大而导致表的一个或多个分区在某些时刻自动分裂成两个分区。有时候也需要根据业务和负载的需要对分区执行手动切分。更大的Region使得我们集群上的Region的总数量更少,而数量更少的region能让集群运行更顺畅。如果region数目太多就会造成读写性能下降,也会增加ZooKeeper的负担。但是region数目太少会妨碍可扩展性,降低读写并发性能,会导致压力不够分散。综合权衡集群的负载和性能,需要把过大的region做切分,切成更小的region分散到更多regionServer上去,以缓解region server过大的压力,从而均衡每一台region server负载。
下图为官方对分割过程的描述:
当前我们使用的Hbase 1.2版本默认采用IncreasingToUpperBoundRegionSplitPolicy,这个策略中,最小的分裂大小和table的某个region server的region 个数有关,当store file的大小大于如下公式得出的值的时候就会split,公式如下:
Min (R^2 * “hbase.hregion.memstore.flush.size”, “hbase.hregion.max.filesize”)
其中R为同一个table中在同一个region server中region的个数。
Split过程会造成region短暂下线,在业务繁忙时可能会造成较恶劣影响。此外,在对单表的写入负载非常高时,有时会因为split或compact的速度跟不上flush的速度而出现split失败造成超大分区或分区大小未达到要求就被分割而出现大量的小分区,从而持续影响性能。我们的生产环境建表时一般要求启用加盐预分区,并禁止了自动分割。需要定期检查region大小,如果出现超大分区,需要手动分割。
手动分割命令:
HBase shell> split ' encodeRegionName', 'splitKey'
4.3 HBASE分区合并-merge
如果某些表因为意外的分割而出现小分区从而影响读写性能,那么就需要手动执行分区合并。分区合并是预分区分割相反的过程,HBase并没有自动的分区合并机制。
手动分区合并命令:
HBase shell> merge ‘encodeRegionName1’, ‘encodeRegionName2’
最好只对处于同一RegionServer的、key range连续的两个分区执行合并。合并操作并不需要重启集群。
4.4 HBASE迁移-move
与手动负载均衡强相关的操作是手动迁移分区。手动负载均衡就是根据集群的实际负载情况制定分区迁移计划并执行。
手动迁移命令:
HBase shell> move ‘encodeRegionName’, ‘ServerName’