HBase 有两种基本的键结构:行键(row key)和列键(column key)。两者都可以存储有意义的信息,一种是键本身存储的内容,另一种是键的排列顺序。
概念
HBase 的表中数据分隔主要是使用列族(column family)而不是列,并非列式存储,如下图,表示了用户逻辑上把一个单元格的数据存到了一张表中,实际上底层存储是按列族线性地存储单元格,并包含了一些必要信息:
左上图是数据的逻辑视图,列键包含了列族和列(cf1:c1、cf2:c1...),每一行必须有一个行键(r1、r2...),可以通过行键获取到一行的所有列。
右上图表示逻辑视图转换为实际物理存储,每一行的每个单元格被有序存储,不同列族的数据存储在不同的文件(store file)中,同一个列族的数据存储在一个文件中(实际上会有多个 HFile,逻辑上属于同一个)。并且单元格的每个历史版本也会在存储文件中存在。
HBase 不存储 NULL 值,所以表可以很稀疏,没有值的单元格不会占用存储空间。每个单元格在存储文件中都存储了该记录在表中所处位置的相关信息。
如右下图所示,每一个单元格都包含:行键、列族、列键、写入时间戳和列值。并且按照时间戳降序排列。在读取 HFile 时最新的值会先被读到。单元格在 HBase 中被叫做 KeyValue,存储时先按行键排序,再按列键排序。因此用户可以按行键、或按行键的范围指定查询,同时指定列族,可以有效的减少检索的文件。
列和时间戳也可以用来筛选数据。查询数据时可以指定返回的列(可以配合过滤器 Filter 实现比较复杂的筛选),也可以指定时间范围进行过滤。
列值是最后一个筛选条件,与列的筛选类似,对每一个 KeyValue 都要经过过滤器检查,对性能的提升不明显,会减少返回给客户端的数据量。
下图展示了使用不同字段对筛选的性能影响大小。筛选的效率从左至右明显下降,所以在 KeyValue 设计时,可以考虑把更重要的筛选信息移动位置,如上图左下图展示的,在不改变数据量的情况下提高查询性能。
高表与宽表
HBase 的表可以设计为高表(tall-narrow table)和宽表(flat-wide table)两类。前者行多列少,后者行少列多。根据之前介绍的 KeyValue 的信息筛选效率,应该尽量的将需要查询的维度或信息存储在行键中,筛选的效率是最高的。
此外,HBase 按行进行数据分片,因此高表更有优势。设想将用户的所有电子邮件都存储在一行,以用户ID作为行键,当邮件数量大时,极限场景下一个用户的数据超过了 HFile 的最大限制,而此次 HFile 是无法根据行键拆分的。(下面表示的是一行数据在物理存储上的形式)
<userId> : <colfam> : <messageId> : <timestamp> : <email-message>
12345 : data : 5fc38314-e290-ae5da5fc375d : 1307097848 : "Hi Lars, ..."
12345 : data : 725aae5f-d72e-f90f3f070419 : 1307099848 : "Welcome, and ..."
12345 : data : cc6775b3-f249-c6dd2b1a7467 : 1307101848 : "To Whom It ..."
12345 : data : dcbee495-6d5e-6ed48124632c : 1307103848 : "Hi, how are ..."
更好的方式转换为高表,每个邮件一行,是行键以用户ID(userId)和消息ID(messageId)组合。消息ID在 KeyValue 中向左移动,不仅解决了 HFile 拆分的问题,同时提高了消息ID的检索效率。
<userId>-<messageId> : <colfam> : <qualifier> : <timestamp> : <email-message>
12345-5fc38314-e290-ae5da5fc375d : data : : 1307097848 : "Hi Lars, ..."
12345-725aae5f-d72e-f90f3f070419 : data : : 1307099848 : "Welcome, and ..."
12345-cc6775b3-f249-c6dd2b1a7467 : data : : 1307101848 : "To Whom It ..."
12345-dcbee495-6d5e-6ed48124632c : data : : 1307103848 : "Hi, how are ..."
部分键扫描
HBase 的扫描功能和基于 HTable 的API更适合在高表上筛选数据,可以通过只包含部分键的扫描检索数据,同时不丢失查询的粒度。
在上面的例子中,在宽表的场景中,可以使用用户ID取到用户行,并通过列名(消息ID)筛选数据。在高表的场景中,需要有用户ID和消息ID同时确定一行数据,否则不能找到一个特定的邮件。可以使用包含部分键的扫描解决这个问题:扫描操作设置一个开始和结束的用户ID(默认结果是不包含终止键的,所以结束键一般需要设为 userId + 1
)。
HBase 内部会按字典序找到第一个行键的位置(要么是起始键,要么是起始键的下一个键):<userId>-<lowest-messageId>
,开始扫描这个用户的所有邮件,紧接着下一个用户的所有邮件,直至到扫描到结束键。
我们可以使用包含部分键的扫描机制设计出比较有效的左对齐索引,当一个字段被加到行键中,就多了一个可以检索的维度(注意检索必须包含从左到右的某几个字段,如果只检索中间的某个字段,效率还是不高):<userId>-<date>-<messageId>-<attachmentId>
需要保证行键中的每个字段都设置一个固定的长度,并且每个字段的值都补齐到这个长度,这样才能保证字典序按预期排列。如果用户ID:2和10,期望得到的行键是:"02XXXX"、"10XXXX",与预期一致。如果不补齐长度,得到的行键为:"10XXXX"、"2XXXX",与预期不同。
上面设计的行键可以实现一下索引:
<userId> 扫描一个特定用户下的所有消息
<userId>-<date> 扫描一个特定用户下特定日期的所有消息
<userId>-<date>-<messageId> 扫描一个特定用户特定日期下的指定消息的全部内容
<userId>-<date>-<messageId>-<attachmentId> 扫描一个特定用户特定日期下的指定消息的一个附件
类似的,可以控制每个字段的内容以达到控制排序的目的。例如可以把日期反转,得到按日期降序排列的效果:Long.MAX_VALUE - <date-as-long>
行键的设计并非有固定模式,需要视业务需要而定,例如上面的行键设计看似不错,但是如果业务上需要统一修改一个用户所有邮件或邮件所有附件的属性值,是无法保证原子性的操作的(HBase 只支持行数据原子操作),宽表可能更加适合。
分页
使用扫描可以很方便的遍历查询数据子集的行。可以设定起始键和终止键限制扫描的范围,同时在 Client 端添加 offset
和 limit
参数筛选数据。
具体过程如下:
- 在起始键位置打开一个扫描器
- 跳过 offset 数目的行
- 读取 limit 数码的行,并返回
- 关闭扫描器
通过不通的行键设计,可以实现多种方式的子集扫描和分也。例如按时间逆序排列,总是会先读到最新到达的数据。
时间序列
当处理流式数据时,最常见的数据就是按时间序列组织的数据。数据可能来自传感器,监控系统,实时交易系统等。这些数据的特点是行键都代表了事件发生的时间。由于 HBase 的数据组织方式,这样的数据在存储时会出现一个问题:数据热点问题,由于业务的特性在某些特定范围内数据量会非常大,会造成 Region 的数据量分布不均匀,同时数据写入会集中到特定的 Region 上,导致性能下降。
为了解决这类问题,需要将数据能够更均匀的分散到各个 Region 上,在此介绍几种方法:
salting 方式
可以使用 salting 前缀来保证数据分散到所有 Region 上:
byte prefix = (byte) (Long.hashCode(timestamp) % <number of region servers>);
byte[] rowkey = Bytes.add(Bytes.toBytes(prefix), Bytes.toBytes(timestamp);
上面前缀的计算方法,利用时间戳的离散哈希值,并假设 RegionServer 数量固定的情况,能够使数据均匀的分布在各个 Region 上。
缺点是,单次对原始的行键进行范围扫描(原始连续的行键添加了随机前缀后,离散到各个 Region 中)效率比较低,但是可以通过多线程并行读取的方式提高效率。
字段交换/提升权重
如果行键设计包含了多个字段,可以将时间戳字段(如果是一个字段,或其他不离散的第一个字段)在不影响业务的前提下,调整位置。如果行键只包含时间戳,可以将其他字段提取出来放到行键的第一位。
参考时序数据库 OpenTSDB,数据存储在 HBase 中,行键类似以下结构:
<metric-id>-<base-timestamp>
通过指标ID将数据离散,得到与 salting 前缀相同的效果。
随机化
另一种完全不同的方式是将行键随机化(哈希或MD5算法):
byte[] rowkey = MD5(timestamp)
能够将行键分散到所有 Region 上,缺点就是不能在按照顺序进行扫描,不适用与时序的数据。该方式比较适合随机读写的场景。
使用时需要在行键设计和读写性能上找到平衡点,如上图与读写方式的关系。
References:
《HBase 权威指南》