第9章 高级用法
9.1 行键设计
9.1.1 概念
HBase的表中的数据分割主要使用列族而不是列,这与一般的列式存储数据库的概念有所不同。
右上角的图片展示了逻辑布局如何转换为实际的物理存储布局。每一行的单元格被有序存储,同时不同列族的数据存储在不同文件中。换句话说,磁盘上一个列族下所有的单元格都存储在一个存储文件(store file)中,不同列族的单元格不会出现在同一个存储文件中。
因为HBase不存储任何在表中没有值的单元格(在RDBMS中,NULL可作为空值存储),磁盘文件中也只有这些已经有值的单元格。同时每个单元格在实际存储时也保存了行键和列键,所以每个单元格都单独存储了它在表中所处位置的相关信息。
此外,同一个单元格的多个版本被单独存储为连续的单元格,当单元格被存储时还需要添加必要的时间戳。单元格按照时间戳降序排列,所以在HFile的Reader读取数据时,最新的值先被读到,这也是HBase设计模式中典型的读取数据的方式。
9.1.2 高表与宽表
到目前为止,用户可能会问该如何在HBase中存储自己的数据。通常情况下,HBase中的表可以设计为高表和宽表两类。前者指表中列少而行多,后者则正好相反。根据之前我们介绍过的KeyValue信息的筛选粒度信息,用户应当尽量将需要查询的维度或信息存储在行键中,因为用它筛选数据的效率最高。
此外,HBase只能按行分片,因此高表更有优势。设想用户将一个其所有电子邮件都存在一行中。这在大部分情况下都是合适的,但也有些人的收件箱中有大量的邮件。大到一行数据就超过了最大HFile的限制,此时这个HFile无法拆分,同时也导致region无法在合适的位置进行拆分。
解决此问题的更好的方法是把一个用户的每个电子邮件都存在单独的一行中,而行键可以是用户ID和消息ID的组合。以下是宽表在磁盘上的数据分布,并包括一些示例:
以下是高表在磁盘上的数据布局:
9.1.3 部分键扫描
使用高表的例子中,消息ID在行键中作为用户ID的后缀。如果用户没有这两个ID确切的值,用户就无法得到一个特定的电子邮件。为避免出现这个问题,用户可以使用包含部分键的扫描:用户可以将扫描操作中键的开始和结束键设置为一个用户的ID,当然结束键要设置为userId+1。
由于表中没有等于起始键的行键,它会定位扫描下行:
<userId>-<lowest-messageId>
也就是说,它是排序时最小的用户ID和消息ID的组合。此后扫描会遍历这个用户的所有邮件,同时用户可以从行键中抽取消息ID。
用户可以使用包含部分键的扫描机制设计出非常有效的左对齐索引(字典序从左到右排序),当一个字段被加到键中时就多了一个可以检索的维度
<userId>-<date>-<messageId>-<attachmentId>
用户需要保证行键中每个字段的值都被补齐到这个字段所设的长度,这
样字典序才会按预期排列(按二进制内容比较,并升序排列)。用户需要
为每个字段设定一个固定的长度来保证每个字段比较时只会与同字段内
容从左向右比较,否则可能出现溢出的情况。
之前我们采用的组合键看上去是一种通用的优秀方法,但其原子性是
个很突出的问题。由于一个收件箱中的数据现在分布在多行中,所以不
可能在一个简单操作中修改一个收件箱的全局属性。如果用户不需要
次修改整个收件箱中所有邮件的消息,之前我们提到的高表设计就非常
适合。但如果用户有这种修改需求,宽表可能更为适合,因为 HBase能
保证数据操作的行级原子性。
9.1.4 分页
具体步骤如下:
- 在起始键的位置处打开一个扫描器。
- 跳过offset数目的行。
- 读取limit数目的行,并返回给上层应用。
- 关闭扫描器。
客户端可以把起始行设为用户ID,同时将结束行设为用户ID+1。然后使用上面提到的方法,当offset为0时,用户可以读取50封邮件。当用户单击Next按钮时,offset设为50并跳过前50行,并返回第51~100行。
当行数很少时,这种方法可行。但是,当需要对几千行数据进行分页时,就需要采用其他方法了。用户可以添加一个序列ID到行键中来帮助起始键定位到对应偏移量的位置。
9.1.5 时间序列
当处理流式事件时,最常见的数据就是按时间序列组织的数据。这些数据可能来自电网的一个传感器、一个股票交易软件或一个信息化的监控系统。这些数据的突出特点是它们的行键都代表了事件发生的时间。由于HBase的数据组织方式,这样的数据在存储时会出现一个问题:这些数据会被有序存储到一个特定的范围内,也就是一个有特定起始键和停止键的region中。
由于一个 region只能由一个服务器管理,所以所有的更新都会集中在一台服务器上。这会导致系统产生读写热点,并由于写入数据过分集中而导致整个系统性能下降。
要解决这个问题,用户需要想办法将数据分散到所有的region服务器上去。有很多方法可以达到这个目的,例如,在行键前添加一个不连续的前缀。通常情况下有如下选择。
salting方式
用户可以使用salting前缀来保证数据分散到所有region服务器。例如:
byte prefix =(byte)(Long.hashCode(timestamp)%<number of region servers>);
byte [] rowKey = Bytes.add (Bytes. oBytes(prefix), Bytes.toBytes(timestamp);
这个公式将产生足够的前缀数以确保将数据分散到所有 region服务器中去。当然,这个公式假定服务器数目固定,如果用户的集群规模可能扩大就应当将前缀数翻倍。生成的行键可能如下:
0myrowkey-1, 1myrowkey-2, 2myrowkey-3, 0myrowkey-4, 1myrowkey-5,\ 2myrowkey-6,...
当键被排序之后并发送到对应的region时,它们的顺序如下:
0myrowkey-1
0myrowkey-4
1myrowkey-2
1myrowkey-5
这样做的缺点是当用户要扫描一个连续的范围时,可能需要对每个region服务器都发起请求(因为之前连续的数据,现在已经分散到不同的服务器中)。这样也会带来好处,用户可以多线程并行地读取数据。这有些类似于一个小规模的Mapreduce作业,这样查询的吞吐量会有所提高。
字段交换/提升权重
使用9.1.3节介绍的方法,用户可以将时间戳字段移开或添加其他字段作为前缀。这样做其实是想利用组合行键的思想来让连续递增的时间戳在行键中的位置从第位变到第二位。
如果用户设计的行键已经包含多个字段了,则可以调整它们的位置。如果行键只包含了时间戳,则用户应当将其他字段从列键或值中提取出来,然后放到行键的前端。
将时间戳从组合键的左边向右移动也有缺点:用户用户不能访问指定时间范围内的数据。
随机化
另一种完全不同的方式是将行键随机化,例如:
byte[] rowKey =MD5(timestamp);
采用MD5之类的散列函数能将行键分散到所有的region服务器上。对于时间连续的数据,这种方法明显不是个好方法。因为随机化之后,用户将不能再按时间范围扫描数据。
另一方面,由于用户可以用散列的方式重新生成行键,随机化的方式很适合每次只读取一行数据的应用。如果用户的数据不需要连续扫描而只需随机读取,用户就可以使用这种策略。
简单总结以上方法后,用户会发现在优化读写性能的同时找到正确的平衡点并不是件简单的事情。它关系到用户的数据访问模式,该模式最终决定了用户如何设计行键的结构。图9-3展示了不同解决方案对读写性能的影响。
使用salt前缀或将某些不是连续取值的主键字段提前,可以使分散写压力并提高写入性能,同时扫描连续的键子集也可以提高读性能。但是,如果用户只需要随机读取数据,那么随机行键就更有用,因为它能完全避免某个 region成为读写热点。
9.1.6 时间顺序关系
以上数据都会按照产生的时间顺序以独立行插入到HBase中,但是也可以使用另一种方式,即将新的事件以发生时间为列进行插入。因为列在HBase中是按列族组织的,所以每个列族下的列可以作为一个辅助索引单独进行排序,如同RDBMS。虽然这不是推荐的设计模式,但少量的索引可能正是用户所需要的。
以之前的收件箱应用为例,它将用户的所有邮件存在一行中。由于用户需要按照收件顺序显示邮件,同时也有可能需要按照题目等顺序进行排序。设计时可以利用基于列的排序来显示收件箱的不同视图。
记住,不要为一张表设置过多的列族,特别是当数据量大的列族和数据
量小的列族混用(大小指的是存储的数据量),用户可以把收件箱的邮件
存储在一张表中,同时把辅助索引存在另一张表中。缺点是这样用户不能保证修改两张表的原子性(HBase只保证行级原子性)。同时请参考9。3
节来克服这个限制。
邮件内容存储在主要的列族下,索引被单独存储在另一个列族下。用户可以把邮件题目提取出来并添加到列键的前面来建立辅助索引。如果用户需要对题目进行降序排列,则需要再额外添加一个列族。
为了避免太多的列族,用户可以把所有辅助索引存储在一个单独的列族下,同时列键(列名)的最左段使用索引ID这个前缀来表示不同的排序,例如,idx- subject-desc和idx-to-asc等。接下来用户要填入实际的排序值。单元格的值是主索引的键,同时主索引中存储着消息的信息。用户需要从以下3种存储方式中选择一种:从主表(主索引)中读取消息的内容,只展示辅助索引中存储的信息,或把消息中的信息冗余地存储到辅助索引中从而避免在主信息源中进行随机读取。反范式化在HBase中十分常见,它可用于减少读取时间,从而大大提高用户响应速度。
将以上的表设计模式付诸实施可以得到如下表:
在前面这段代码中,一个索引(idx-from-asc)按照电子邮件地址升序排列,另一个索引(idx-subject-desc)按照题目降序排列。同时题目按照位反序排列以达到降序排列的目的,所以其内容已经不可读了。
9.2 高级模式
略~
9.3 辅助索引
尽管HBase没有为辅助索引提供原生支持,但是有些应用场景仍需要使用辅助索引。通常的需求是用户能够通过主坐标(行键、列族和列限定符)来查找一个单元格,也可以通过一个其他类型的坐标来进行查找。此外,用户可以按照辅助索引的顺序从主表扫描数据。
与关系型数据库系统中的索引类似,辅助索引存储了一个新坐标和现有的坐标之间的映射关系。以下列出了一些可行的解决方案。
由客户端管理索引
把责任完全转移到应用层的典型做法是把一个数据表和一个(或者多个)查找映射表结合起来。每当程序写数据表时,它也同时更新映射表(也被称为辅助索引表)。读数据时可以直接在主表中进行查询,从辅助索引表中先査找原表行键,再在原表中读取实际数据。
这种做法既有优点也有缺点。首先,优点是整个逻辑都由客户端代码处理,用户可以按照需求设计映射关系。不过这样做的缺点更多,由于HBase中不能保证跨行操作的原子性,例如,以事务的角度来看,用户不能保证主表和依赖表的一致性。用户可以使用定期修剪工作来部分解决这个问题,例如,使用MapReduce程序来扫描表,删除过时的条目,或增加缺失的条目。
缺少事务的支持可能导致数据被存储在数据表中,但是在辅助索引表中没有相应的映射。这种情况可能发生在主表被更新后且索引表被成功写入前,如果这段时间出现了任何导致操作失败的问题都会使辅助索引和主表不一致。这个问题可以通过先写辅助索引表,在操作的最后再写数据表来缓解。如果在这个过程中有任何操作失败,就会出现孤立的映射,不过它们很容易被异步的定期修剪工作删除掉。
带索引的事务型HBase
开源的带索引的事务型HBase(Indexed-Transactional HBase,简称为 ITHBase)项目提供了一个不同的解决方案。它扩展了HBase,并增加了特殊的客户端和服务器端类的实现。最核心的扩展是增加了用来保证所有的辅助索引更新操作一致性的事务功能。
以上所述的客户端管理索引在单独的表中存储主键和辅助键之间的映射关系,与之不同的是,这些操作在ITHBase中被自动处理,同时主表与辅助索引表之间的关系由相应的描述符定义。结合事务支持的索引更新,这个方案提供了个HBase辅助索引的完全实现。
带索引的HBase
在HBase中增加辅助索引的另外一种解决方案是带索引的HBase(简称IHBase)。这种解决方案放弃了为每个索引使用单独的表,而是完全在内存中维护索引。当一个region第一次打开,或者一个memstore被刷写到磁盘上时,用户可以通过扫描整个region来建立索引。根据用户配置的region大小的不同,这个操作可能花费大量的时间和IO资源。
当磁盘上的数据有索引时,内存中数据搜索的方式如下:它直接使用内存中的数据来搜索索引相关的详细数据。这个方案的优点是索引永远都是同步的,并且不需要额外的事务控制。
与基于表的索引相比,使用这种方法有两方面不同。一方面它很快,所有需要的索引数据都在内存中,因此可以执行很快的二分查找来定位行。另外一方面,它也需要大量额外的堆空间来维护索引。由于用户的需求和想要索引的数据量不同,IHBase有时并不能建立用户需要的所有索引。
协处理器
目前也有一些方法基于协处理器来实现索引方案。使用协处理器框架提供的服务器端的钩子函数可以实现类似于ITHBase和IHBase的索引,并且不用替换任何客户端类和服务器端类。协处理器将为每一个region载入索引层,并维护索引。(类似MYSQL的触发器。)
9.4 搜索集成
略~
9.5 事务
略~
9.6 布隆过滤器
使用布隆过滤器的根本原因是默认机制决定了一个存储文件是否包含特定的受限于可用块索引的行键,同时这个索引又是相当粗粒度的,该索引只存储了文件包含块的开始键。
使用布隆过滤器的好处是,用户可以立即判断一个文件是否包含特定的行
键。这个过滤器的特性是:如果这个文件不包含这个行,它会给你一个明确的答复。但是如果文件包含这一行时,答复却可能有误,即它声称文件包含这个数据而实际却并非如此。错误肯定答复的数量可以被调整,通常被设置为1%,这意味着过滤器中关于一个文件包含一个请求行的报告中有1%是错误的,因此可能会有一个块被错误地加载和检查。
为了提高效率,用户还必须使用一种特定的更新模式:如果用户定期修改所有行,那么大部分的存储文件都将包含用户查找行的数据。这种场景不适
合使用布隆过滤器。但是,如果用户批量更新数据,使得一行数据每次只被写入到少数几个存储文件中,那么过滤器就能够为减少整个系统IO操作的数量发挥很大作用。
图9-5总结了不同级别布隆过滤器的选择标准。
9.7 版本管理
9.7.1 隐式版本管理
在用户确保具服务器上的时钟是同步的之前,有些问题就已经被指出了。其中一个可能出现的问题是,当用户跨服务器把数据存储在多行中时,使用隐式的时间戳可能让用户最终得到完全不同的时间集。
用户可以通过在存储这些值时设置商定的或者共享的时间戳来避免这个问题。put操作允许用户设置一个客户端的时间戳作为替代,并以此覆盖服务器的时间。
region被拆分暴露了服务器时间不一致引起的另一个问题。假设用户把一个值存储在一个比机群中所有其他服务器都超前一小时的服务器上,并且使用服务器隐式的时间戳。十分钟后这个region被拆分了,并且用户一半的数据更新被移到了另一个服务器上。5分钟后,当用户再向同样的列中插入一个新值时,服务器会自动添加时间戳。现在新值被认为比之前添加的数据更老,因为第一个版本的时间戳比当前服务器的时间超前一小时。此时如果
用户使用标准的get方法去检索这个值的最新版本,则会得到第一个之前存的值。
例9.1 删除显式时间戳的示例应用程序
for(int count = 1; count <=6; count++)(①
Put put = new Put(ROW1);
put.add (COLFAM1, QUAL1, count, Bytes.toBytes ("val-"+ count));②
table. put (put);
Delete delete = new Delete(ROW1);③
delete.deleteColumn(COLFAM1, QUAL1, 5);
delete.deleteColumn(COLFAM1, QUAL1, 6);
table.delete(delete);
①存储同一列6次
②使用循环变量把版本设为一个特殊的值。
③删除最新的两个版本。
当你运行这个例子时,你应该可以看到以下输出:
After put calls...
KV: row1/colfam1: qual1/6/Put/vlen=5, Value: val-6
KV: row1/colfam1: qual1/5/Put/vlen=5, Value: val-5
KV: row1/colfam1: qual1/5/Put/vlen=5, Value: val-4
After put call...
KV: row1/colfam1: qual1/4/Put/vlen=5, Value: val-4
KV: row1/colfam1: qual1/3/Put/vlen=5, Value: val-3
KV: row1/colfam1: qual1/2/Put/vlen=5, Value: val-2
有趣的现象是,用户使得版本2和版本3复活了。这是由于服务器把内部处理推迟到一个定义好的时间执行。该列的老版本仍然存在,因此删除较新的版本会使它们再次出现。
这种情形只可能出现在major合并被执行之前,在这之后老版本会被永久删除,而保留的版本数基于已配置的最大保留版本数。
最后,在处理时间戳时,还有另外需要注意的一个问题:删除标记。在 HBase中,删除操作的本质是添加一个带有特定时间戳的墓碑标记到存储中。在此基础上,它屏蔽直接指定的对应版本的数据,或者一个列删除标记会抹去比给定时间戳更老的所有版本的数据。例9.2用Shell展示了上述情况。
例9.2 使用显式时间戳删除可以屏蔽之前的put操作
hbase(main): 001: 0> create 'testtable', 'colfam1'
0 row(s)in 1. 1100 seconds
hbase(main): 002: 0>Time.now.to_i
=>1308900346
hbase(main): 003: 0> put 'testtable', 'row1', 'colfam1:qual', 'val1' ①
0 row (s)in 0.0290 seconds
hbase (main): 004: 0> scan 'testtable'
ROW COLUMN+CELL
row1column=colfam1:qual1, timestamp=1308900355026, value=val1
1 row(s)in 0.0360 seconds
hbase(main): 005: 0> delete 'testtable', 'row1', 'colfam1:qual1' ②
0 row(sin.0280 seconds
hbase(main 006: 0> scan 'testtable’
ROW COLUMN+CELL
0 row(s)in 0.0260 seconds
hbase(main): 007: 0> put ‘testtable’, 'row1‘, 'colfam1:qual·’,'val1',\
Time.now.to_i-50000 ③
0 row(s)in 0.0260 seconds
hbase(main ) 008: 0> scan 'testtable'
ROW COLUMN+CELL
0 row(s) 0.0260 seconds
hbase(main): 009: 0> flush 'tasttable'
0 row(sin. 2720 seconds
hbase(main):010: 0> major_ compact 'testtable'
0 row(sin.0420 seconds
hbase(main):01l: 0> put 'testtable', 'row1', 'colfam1:qual1', 'val1',\
Time.now.to_i-50000 ⑤
0 row(s in 0.0280 seconds
hbase (main: 012: 0> scan 'testtable'
ROW COLUMN+CELL
row1 column=colfam1:qual1, timestamp=1308900423953, value=val1
1 row(s)in.0290 seconds
①向新创建的表的列中存入一个值,运行扫描来验证结果。
②删除列的所有值,这将会设置一个带有当前时间戳的删除标记。
③再一次把值存入列中,但是使用一个过去的时间戳,随后的扫描没有返回被屏蔽的值。
④通过对表的刷写和major合并来移除这个删除标记。
⑤再次存储带有过去时间戳的值,随后的扫描如预期一样显示了插入的值。
9.7.2 自定义版本控制
由于你可以指定自己时间戳的值,因此也可以创建自己的版本控制计划。在覆盖服务器端基于同步的服务器时间的时间戳时,用户可以不使用基于时间的版本。
用户必须为每个put操作都这么做,否则服务器将插入一个基于服务器端时间的时间戳。表或者列的描述中有一个标志可以表明你使用的是自定义的时间戳(即自定义的版本控制策略)。如果用户没有设置这个值,那么它将在后台被服务器时间替换掉。