一、概述
HBase有以下几个特点:
- HBase列的可以动态增加,并且列为空就不存储数据,节省存储空间.
- hbase自动切分数据,使得数据存储自动具有水平scalability.
- Hbase可以提供高并发读写操作的支持。
- HBase不能支持条件查询,只支持按照Row key来查询.
- 暂时不能支持Master server的故障切换,当Master宕机后,整个存储系统就会挂掉.
因为HBase的这些特点,是它和Mysql的等关系型数据库的应用场景和设计理念完全不同。传统关系型数据库(mysql,oracle)数据存储方式主要如下:
上图是个很典型的数据储存方式,我把每条记录分成3部分: 主键、记录属性、索引字段 。我们会对索引字段建立索引,达到二级索引的效果。
但是随着业务的发展,查询条件越来越复杂,需要更多的索引字段,且很多值都不存在,如下图:
上图是6个索引字段, 实际情况可能是上百个甚至更多,并且还需要根据多个索引字段刷选。查询性能越来越低,甚至无法满足查询要求。关系型数据里的局限也开始显现,于是很就出现了Nosql等非关系数据库。
HBase作为一个列族数据库很强大,很多人就想把数据从mysql迁到hbase,存储的内容还是跟上图一样,主键设为为rowkey。其他各个字段的数据,存储一个列族下的不同列。但是想对索引字段查询就没有办法,目前还没有比较好的基于bigtable的二级索引方案,所以无法对索引字段做查询。这时候其实可以转换下思维,可以把数据倒过来,如下图:
把各个索引字段的值作为rowkey,然后把记录的主键和属性值按照一定顺序存在对应rowkey的value里。上图只有一个列族,是最简单的方式。 Value里的记录可以设置成定长的byte[],多个记录集合通过移位快速查询到。
但是上面只适合单个索引字段的查询。如果要同时对多个索引字段查询,比如查询“浙江”and“手机”,需要取出两个value,再解析出各自的主键求交集。如果每条记录的属性有上百个,对性能影响很大。
接下来的的问题就是解决多索引字段查询的问题。我们将主键字段和属性字段分开存储 ,储存在不同的列族下,多索引查询只需要取出列族1下的数据,再去最小集合的列族2里取得想要的值。储存如下图:
为什么是不同列族,而不是一个列族下的两个列?
列族数据库数据文件是按照列族分的。在取数据时,都会把一个列族的所有列数据都取出来,事实上我们并不需要把记录明细取出来,所以把这部分数据放到了另一个列族下。
接下来是对列族2扩展,列族2储存更多的列,用来做各种刷选、计算处理。如下图:
二、HBase和RDBMS的区别
1、数据类型
Hbase只有简单的字符类型,所有的类型都是交由用户自己处理,它只保存字符串。用户需要自己进行类型转换。而关系数据库有丰富的类型和存储方式。
2、数据操作
HBase只有很简单的插入、查询、删除、清空等操作,表和表之间是分离的,没有复杂的表和表之间的关系。而传统数据库通常有各式各样的函数和连接操作。
3、存储模式
HBase是基于列存储的,每个列族都由几个文件保存,不同的列族的文件时分离的。而传统的关系型数据库是基于表格结构和行模式保存的 。
4、数据维护
HBase的更新操作不应该叫更新,它实际上是插入了新的数据原来的旧版本仍然保留着,而传统数据库是替换修改。
5、可伸缩性
Hbase这类分布式数据库就是为了这个目的而开发出来的,所以它能够轻松增加或减少硬件的数量,并且对错误的兼容性比较高。而传统数据库通常需要增加中间层才能实现类似的功能。
三、Hbase检索时间复杂度
既然使用Hbase的目的是高效、高可靠、高并发的访问海量非结构化数据,那么Hbase检索数据的时间复杂度是关系到基于Hbase的业务系统开发设计的重中之重,Hbase的运算有多快,我们从计算机算法的数学角度做简要分析,以便读者理解后文的项目实例中Hbase业务建模及设计模式中的考量因素。
我们先以如下变量定义Hbase的相关数据信息:
n=表中KeyValue条目数量(包括Put结果和Delete留下的标记)
b=HFile里数据库(HFileBlock)的数量
e=平均一个HFile里面KeyValue条目的数量(如果知道行的大小,可以计算得到)
c=每行里列的平均数量
我们知道Hbase中有两张特殊表:-ROOT-&.META.,其中.META.表记录Region分区信息,同时,.META.也可以有多个Region分区,同时-ROOT-表又记录.META.表的Region信息,但-ROOT-只有一个Region,而-ROOT-表的位置由Hbase的集群管控框架,即Zookeeper记录。
关于-ROOT-&.META.表的细节这里不再累述,感兴趣的读者可以参阅Hbase–ROOT-及.META.表资料,理解HbaseIO及数据检索时序原理。
Hbase检索一条数据的流程如下图所示。
如上图我们可以看出,Hbase检索一条客户数据需要的处理过程大致如下:
(1)如果不知道行健,直接查找列key-value值,则你需要查找整个region区间,或者整个Table,那样的话时间复杂度是O(n),这种情况是最耗时的操作,通常客户端程序是不能接受的,我们主要分析针对行健扫描检索的时间复杂度情况,也就是以下2至4步骤的内容。
(2)客户端寻找正确的RegionServer和Region。花费3次固定运算找到正确的region,包括查找ZooKeeper,查找-ROOT-表,找找.META表,这是一次O(1)运算。
(3)在指定Region上,行在读过程中可能存在两个地方,如果还没有刷写到硬盘,那就是在MemStore中,如果已经刷写到硬盘,则在一个HFile中。假定只有一个HFile,这一行数据要么在这个HFile中,要么在Memstore中。
(4)对于后者,时间复杂度通常比较固定,即O(loge),对于前者,分析起来要复杂得多,在HFile中查找正确的数据块是一次时间复杂度为O(logb)的运算,找到这一行数据后,再查找列簇里面keyvalue对象就是线性扫描过程了(同一列簇的列数据通常是在同一数据块中的),这样的话扫描的时间复杂度是O(elb),如果列簇中的列数据不在同一数据块,则需要访问多个连续数据块,这样的时间复杂度为O(c),因此这样的时间复杂度是两种可能性的最大值,也就是O(max(c,elb)。
综上所述,查找Hbase中某一行的时间开销为:
O(1)用于查找region
O(loge)用来在region中定位KeyValue,如果它还在MemStore中
O(logb)用来查找HFile里面正确的数据块
O(max(celb)用来查找HFile
四、HBase的模式设计原则及优化
1、列簇(clumn family)
不要在一张表里定义太多的列簇,目前hbase不能很好处理超过3个列簇的表。hbase的flush和压缩是基于region的,当一个列簇所存储的数据达到flush阈值时,该表的所有列簇将同时进行flush操作,这将带来不必要的I/O开销。
同时还要到同一个表中不同列簇所存储的记录数量的差别,即列簇的势。当列簇数量差别过大将会使包含记录数量较少的列簇的数据分散在多个region上,而region可能是分布在不同的regionserver上,这样当进行查询等操作,系统的效率会受到一定的影响。
2、行健(row key)
在HBase中,row key可以是任意字符串,最大长度64KB,实际应用中一般为10~100bytes,存为byte[]字节数组,一般设计成定长的。
row key是按照字典序存储,因此,设计row key时,要充分利用这个排序特点,将经常一起读取的数据存储到一块,将最近可能会被访问的数据放在一块。
其次,还要避免使用时序或单调行健。因为当数据到来时,hbase首先根据记录的行健来确定存储位置,及region位置。如果行健是时序或单调行健,那么连续到来的数据将会被分配到同一个region中,而此时系统中的其他region/regionserver将处于空闲状态,这是分布式系统不希望看到的。 可以将时序作为行键的第二个字段,并为行键添加一个前缀。
3、尽量最小化行健和列簇的大小
hbase中一条记录是由存储该值的行健,对应的列以及该值的时间戳决定。hbase中索引是为了加速随机访问的速度。该索引的创建是基于”行健+列簇:列+时间戳+值”的,如果行健和列簇的大小过大,将会增加索引的大小,加重系统的负担。
4、版本数量
创建表的时候,可以通过HColumnDescriptor.setMaxVersions(int maxVersions)
设置表中数据的最大版本,如果只需要保存最新版本的数据,那么可以设置setMaxVersions(1)
。HBase在进行数据存储时,新数据不会直接覆盖旧的数据,而是进行追加操作,不同的数据通过时间戳进行区分。默认每行数据存储三个版本,建议不要将其设置过大。
5、存入内存
创建表的时候,可以通过HColumnDescriptor.setInMemory(true)
将表放到RegionServer的缓存中,保证在读取的时候被cache命中。
6、TTL
创建表的时候,可以通过HColumnDescriptor.setTimeToLive(int timeToLive)
设置表中数据的存储生命期,过期数据将自动被删除,例如如果只需要存储最近两天的数据,那么可以设置setTimeToLive(2 * 24 * 60 * 60)
。
7、合并和分片(compact&split)
在HBase中,数据在更新时首先写入WAL 日志(HLog)和内存(MemStore)中,MemStore中的数据是排序的,当MemStore累计到一定阈值时,就会创建一个新的MemStore,并且将老的MemStore添加到flush队列,由单独的线程flush到磁盘上,成为一个StoreFile。于此同时, 系统会在zookeeper中记录一个redo point,表示这个时刻之前的变更已经持久化了(minor compact)。
StoreFile是只读的,一旦创建后就不可以再修改。因此Hbase的更新其实是不断追加的操作。当一个Store中的StoreFile达到一定的阈值后,就会进行一次合并(major compact),将对同一个key的修改合并到一起,形成一个大的StoreFile,当StoreFile的大小达到一定阈值后,又会对 StoreFile进行分割(split),等分为两个StoreFile。
由于对表的更新是不断追加的,处理读请求时,需要访问Store中全部的StoreFile和MemStore,将它们按照row key进行合并,由于StoreFile和MemStore都是经过排序的,并且StoreFile带有内存中索引,通常合并过程还是比较快的。
实际应用中,可以考虑必要时手动进行major compact,将同一个row key的修改进行合并形成一个大的StoreFile。同时,可以将StoreFile设置大些,减少split的发生。
五、HBase的表设计实例
基于Hbase的系统设计与开发中,需要考虑的因素不同于关系型数据库,Hbase模式本身很简单,但赋予你更多调整的空间,有一些模式写性能很好,但读取数据时表现不好,或者正好相反,类似传统数据库基于范式的OR建模,在实际项目中考虑Hbase设计模式是,我们需要从以下几方面内容着手:
- 这个表应该有多少个列簇
- 列簇使用什么数据
- 每个列簇应有多少个列
- 列名应该是什么,尽管列名不必在建表时定义,但是读写数据时是需要的
- 单元应该存放什么数据
- 每个单元存储什么时间版本
- 行健结构是什么,应该包括什么信息
以下我们以一个使用Hbase技术的客户案例为例来展示。
1、场景介绍
客户简介:客户是一个互联网手机游戏平台,需要针对广大手游玩家进行手游产品的统计分析,需要存储每个手游玩家即客户对每个手游产品的关注度(游戏热度),且存储时间维度上的关注度信息,从而能针对客户的喜好进行挖掘并进行类似精准营销的手游定点推送,广告营销等业务,从而扩大该平台的用户量并提升用户粘着度。
该平台上手游产品分类众多,总共在500余以上,注册玩家(用户帐号)数量在200万左右,在线玩家数量5万多,每天使用手游频率峰值在10万/人次以上,年增量10%以上。
根据以上需求,手游产品动态增长,无法确定哪些手游产品需要被存储,全部存储又会超过200列,造成大量空间浪费,玩家每天使用手游的频率及分类不确定,客户注册用户超百万,按天的使用热度数据量超过1000万行,海量数据也使得表查询及业务分析需要的集群数量庞大及SQL优化,效率低下,因此传统关系型数据库不适合该类数据分析和处理的需求,在项目中我们决定采用Hbase来进行数据层的存储的分析。
2、高表设计
让我们回到上文中设计模式来考虑该客户案例中表的设计,我们需要存储玩家信息,通常是微信号,QQ号及在该手游平台上注册的帐号,同时需要存储该用户关注什么手游产品的信息。而用户每天会玩一个或者多个手游产品,每个产品玩一次或者多次,因此存储的应该是该用户对某一手游产品的关注度(使用次数),该使用次数在每天是一个动态的值,而用户对手游产品也是一个多对多的key value键值的集合。该手游平台厂商关心的是诸如“XXX客户玩家关注YYY手游了么?”,“YYY手游被用户关注了么?”这类的业务维度分析。
假设每天每个手游玩家对每个产品的关注度都存在该表中,则一个可能的设计方案是每个用户每天对应一行,使用用户ID+当天的时间戳
作为行健,建立一个保存手游产品使用信息的列簇,每列代表该天该用户对该产品的使用次数。
本案例中我们只设计一个列簇,一个特定的列簇在HDFS上会由一个Region负责,这个region下的物理存储可能有多个HFile,一个列簇使得所有列在硬盘上存放在一起,使用这个特性可以使不同类型的列数据放在不同的列簇上,以便隔离,这也是Hbase被称为面向列存储的原因,在这张表里,因为所有手游产品并没有明显的分类,对表的访问模式也不需区分手游产品类型,因此并不需要多个列簇的划分,你需要意识到一点:一旦创建了表,任何对该表列簇的动作通常都需要先让表offline。
我们可以使用HbaseShell创建表,Hbaseshell脚本示例如下:
然后向该表中插入数据,最后存储的示例样本如下:
表设计解释如下:
rowkey为QQ121102645$20141216表示帐号为QQ121102645的手游玩家(以QQ号联邦认证的)在2014年12月16日当天的游戏记录;列簇degeeInfo记录该行账户当天对每种产品类型的点击热度(游戏次数),比如SpaceHunter: 1
表示玩(或者点开)SpaceHunter(时空猎人)的次数为1次。
现在我们需要检验这张表是否满足需求,为此最重要的事是定义访问模式,也就是应用系统如何访问Hbase表中的数据,在整个Hbase系统设计开发过程中应该尽早这么做。
我们现在来看,我们设计的该Hbase表是否能回答客户关心的问题:比如“帐号为QQ121102645的用户关注过哪些手游?”,沿着这个方向进一步思考,有相关业务分析的问题:“QQ121102645用户是否玩过3CountryBattle(三国3)手游?”“哪些用户关注了DTLegend(刀塔传奇)?”“3CountryBattle(三国3)手游被关注过吗?”
基于现在的prodFocus表设计,要回答“帐号为QQ121102645的用户关注过哪些手游?”这个访问模式,可以在表上执行一个简单的Scan扫描操作,该调用会返回整个QQ121102645前缀的整个行,每一行的列簇进行列遍历就能找到用户关注的手游列表。
代码如下:
HTablePool pool = new HTablePool();
HTableInterface prodTable = pool.getTable(“prodFocus”);
Scan a = new Scan();
a.addFamily(Bytes.toBytes(“degreeInfo”));
a.setStartRow(Bytes.toBytes(“QQ121102645”));
ResultScanner results = prodTable.getScanner(a);
List<KeyValue> list = result.list();
List<String> followGamess = new ArrayList<String>();
for(Result r:results){
KeyValue kv = iter.next();;
String game =kv.get(1];
followGames.add(user);
}
因为prodFocus表rowkey设计为用户ID $ 当天的时间戳,因此我们创建以用户“QQ121102645”为检索前缀的Scan扫描,扫描返回的ResultScanner即为该用户相关的所有行数据,遍历每行的“degreeInfo”列簇中的各个列即可获得该用户所有关注(玩过)的手游产品。
第二个问题“QQ121102645用户是否玩过3CountryBattle(三国3)手游”的业务跟第一个类似,客户端代码可以用Scan找出行健为QQ121102645前缀的所有行,返回的result集合可以创建一个数组列表,遍历这个列表检查3CountryBattles手游是否作为列名存在,即可判断该用户是否关注某一手游,相应代码与上文问题1的代码类似:
HTablePool pool = new HTablePool();
HTableInterface prodTable = pool.getTable(“prodFocus”);
Scan a = new Scan();
a.addFamily(Bytes.toBytes(“degreeInfo”));
a.setStartRow(Bytes.toBytes(“QQ121102645”));
ResultScanner results = prodTable.getScanner(a);
List<Integer> degrees = new ArrayList<Integer>();
List<KeyValue> list = results.list();
Iterator<KeyValue> iter = list.iterator();
String gameNm =“3CountryBattle”;
while(iter.hasNext()){
KeyValue kv = iter.next();
if(gameNm.equals(Bytes.toString(kv.getKey()))){
return true;
}
}
prodTable.close();
return false;
代码解释:同样通过扫描前缀为“QQ121102645”的Scan执行表检索操作,返回的List<keyValue>
数组中每一Key-value是degreeInfo列簇中每一列的键值对,即用户关注(玩过)的手游产品信息,判断其Key值是否包含“3CountryBattle”的游戏名信息即可知道该用户是否关注该手游产品。
看起来这个表设计是简单实用的,但是如果我们接着看第三个和第四个业务问题“哪些用户关注了DTLegend(刀塔传奇)?”“3CountryBattle(三国3)手游被关注过吗?”
如你所看到的,现有的表设计对于多个手游产品是放在列簇的多个列字段中的,因此当某一用户对产品的喜好趋于多样化的时候(productkey-value键值对会很多,意味着某一rowkey的表列簇会变长,这本身也不是大问题,但它影响到了客户端读取的代码模式,会让客户端应用代码变得很复杂。
同时,对于第三和第四问题而言,每增加一种手游关注的key-value键值,客户端代码必须要先读出该用户的row行,再遍历所有行列簇中的每一个列字段。从上文Hbase索引的原理及内部检索的机制我们知道,行健是所有Hbase索引的决定性因素,如果不知道行健,就需要把扫描限定在若干HFile数据块中,更麻烦的是,如果数据还没有从HDFS读到数据块缓存,从硬盘读取HFile的开销更大,从上文Hbase检索的时间复杂度分析来看,现在的Hbase表设计模式下需要在Region中检索每一列,效率是列的个数*O(max(elb),从理论上已经是最复杂的数据检索过程。
对关注该平台业务的客户公司角度考虑,第三个第四个的业务问题更加关注客户端获取分析结果的实时分析的性能,因此从设计模式上应该设计更长的行健,更短的列簇字段,提高Hbase行健的检索效率并同时减少访问宽行的开销。
3、宽表设计
Hbase设计模式的简单和灵活允许您做出各种优化,不需要做很多的工作就可以大大简化客户端代码,并且使检索的性能获得显著提升。我们现在来看看prodFocus表的另一种设计模式,之前的表设计是一种宽表(widetable)模式,即一行包括很多列。每一列代表某一手游的热度。同样的信息可以用高表(talltable)形式存储,新的高表形式设计的产品关注度表结构如下所示:
表解释:将产品在某一天被某用户关注的关联关系设计到rowkey中,而其关注度数据只用一个key-value来存储,行健Daqier_weixin1398765386465串联了两个值,产品名和用户的帐号,这样原来表设计中某一用户在某天的信息被转换为一个“产品-关注的用户”的关系,这是典型的高表设计。
HFile中的key-value对象存储列簇名字。使用短的列簇名字在减少硬盘和网络IO方面很有帮助。这种优化方式也可以应用到行健,列名,甚至单元。紧凑的rowkey存储业务数据意味应用程序检索时,IO负载方面会大大降低。这种新表设计在回答之前业务关心的“哪些用户关注了XXXX产品?”或者“XXXX产品被关注过吗?”这类问题时,就可以基于行健使用get()直接得到答案,列簇中只有一个单元,所以不会有第一种设计中多个key-value遍历的问题,在Hbase中访问驻留在BlockCache的一个窄行是最快的读操作。从IO方面来看,扫描这些行在一个宽行上执行get命令然后遍历所有单元相比,从RegionServer读取的数据量是相同的,但索引访问效率明显大大提高了。
例如要分析“3CountryBattles(三国群雄)手游是否被QQ121102645用户关注?”时,客户端代码示例如下:
HTablePool pool = new HTablePool();
HTableInterface prodTable = pool.getTable(“prodFocusV2”);
String userNm =“QQ121102645”;
String gameNm =“3CountryBattles”;
Get g = new Get(Bytes.toBytes(userNm+”$”+gameNm));
g.addFamily(Bytes.toBytes(“degreeInfo”));
Result r = prodTable.get(g);
if(!r.isEmpty()){
return true;
}
table.close();
return false;
代码解释:由于prodFocusV2的rowkey设计改为被关注产品$用户Id的高表模式,手游产品及用户信息直接存放在行健中,因此代码以手游产品名“3CountryBattles$”
加用户帐号“QQ121102645”的Byte数据作为Get键值,在表上直接执行Get操作,判断返回的Result结果集是否为空即可知道该手游产品是否被用户关注。
4、其他优化
当然还有一些其他优化技巧。你可以使用MD5值做为行健,这样可以得到定长的rowkey。使用散列键还有其他好处,你可以在行健中使用MD5去掉“$”分隔符,这会带来两个好处:一是行键都是统一长度的,可以帮助你更好的预测读写性能。第二个好处是,不再需要分隔符后,scan的操作代码更容易定义起始和停止键值。这样的话你使用基于用户$手游名
的MD5散列值来设定Scan扫描紧邻的起始行(startRow和stopRow)就可以找到该手游受关注的最新的热度信息。
使用散列键也会有助于数据更均匀的分布在region上。如该案例中,如果客户的关注度是正常的(即每天都有不同的客户玩不同的游戏),那数据的分布不是问题,但有可能某些客户的关注度是天生倾斜的(即某用户就是喜欢某一两个产品,每天热度都在这一两个产品上),那就会是一个问题,你会遇到负载没有分摊在整个Hbase集群上而是集中在某一个热点的region上,这几个region会成为整体性能的瓶颈,而如果对Daqier_weixin$1398765386465
模式做MD5计算并把结果作为行键,你会在所有region上实现一个均匀的分布。
使用MD5散列prodFocusV2表后的表示例如下: