`
wbj0110
  • 浏览: 1549930 次
  • 性别: Icon_minigender_1
  • 来自: 上海
文章分类
社区版块
存档分类
最新评论

【HBase】Rowkey设计

阅读更多

本章将深入介绍由HBase的存储架构在设计上带来的影响。如何设计表、row keycolumn等等,尽可能地使用到HBase存储上的优势。

Key设计

HBase有两个基础的主键结构:row keycolumn key。它们分别用来表征存储的数据和数据的排序顺序。以下的几节将讨论如何通过key设计解决存储设计中发现的一些问题。

概念

相比于物理存储,首先谈谈表的逻辑结构。与传统的面向列的关系型数据库为基本单元不同,HBase的基本存储单元为列簇(column family)。从图9-1可以看出,尽管在逻辑上表以Cell的形式存储,但在实际中,这些包含着重要信息的Cell以线性的方式存储。

9-1的左上部分显示了数据的逻辑视图,数据由行和列组成二维矩阵,由HBase的列簇、列组成了二维矩阵中的一维,rowkey组成了另一维。右上角的部分将逻辑视图映射为物理存储结构,逻辑视图中的Cell依次被存储,每个列簇独立形成一个文件,因此右上部分形成了cf1cf2两个文件。换句话说,一个列簇中的所有Cell都被存放在同一个Store File中。

由于HBase并不存储空的Cell(相当于RDBMS中的NULL值),因此,磁盘中包括的数据都有实实在在的值,即通过该Cellrowkey、列簇和列,可以顺利的访问Cell中包含的信息。

9-1 HBase物理存储

另外,同一个Cell中的多版本数据,也被独立地依次存储,并且打上它们存储时所带的时间戳。这些多版本数据以降序排列。因此,访问者最先拿到的是最新的数据。

每个加入了结构化信息的Cell被称为KeyValue单元,它不但含有列(column)和值(value)信息,还包括了rowkeytimestamp,并进行排序。首先按照rowkey的大小进行排序,然后通过columnkey进行排序。图9-1的右下部分给出了存储在物理storefile中的行的逻辑视图。HBaseAPI中有很多访问数据的方法,可以通过rowkey范围来取出多行,从而有效的过滤掉大量的记录,可以指定列簇,从而避免扫描不必要的StoreFile。当您只需要访问HBase表中的一个列簇时,强烈建议您只扫描一个列簇对应的文件。

Celltimestamp(或者称为版本)可以说是另一个非常有用的查询原则。StoreFile中存储了Cell所有的不同版本数据,因此,您可以查询一个Cell中最新两个小时的数据。但是,一个特定的Store File只能保存一定数目的版本数(可以通过参数配置)。

查询的下一级粒度为column
qualifier
。查询时,可以指定qualifier,这样只会查询出指定的列信息。也可以通过Filter指定查询要包含的qualifier或者不包含的qualifier。但是,HBase依然需要扫描每个Cell,来确定对应的qualifier是否要取出来。

Value是查询中最后一个限制原则,和qualifier限定查询的效率相同。您需要遍历每个Cell,来判断这个Cell的指定的比较参数是否一致。您只需要指定一个比较的规则即可。图9-2总结了KeyValue中不同查询方式的效率。

9-2 访问效率从左到右依次递减

9-1中左下角是至关重要的一部分。通过上文的分析,可以得到KeyValue中不同的查询方式具有不同的效率,因此,您不用改变Cell存储的内容,而只需改变value的存储位置,便可以达到不同的查询效率。

长窄表 vs. 短宽表

此刻,您或许会思考以何种方式来存储数据,是以长窄表,还是以短宽表呢?前者一行的列少,但表中行数多,而后者一行中的列数多,因此行数少。根据上一次讲到的KeyValue查询方式不同对性能的影响,可以得出结论,把需要查询的字段放在rowkey中,可以得到最高的查询效率。

同时,HBasesplit操作只会在行的边界上发生,这也更倾向于长窄表。设想,当您将一个用户的所有的Email信息存放在一行中。这对于大多数用户是没有问题的,但当一个用户的拥有大量的Email时,这一行的大小可能已经超过了file或者region的最大值,从而影响到split机制。

比较好的方式是将每一条Email存储为独立的一行,它的rowkey通过user IDmessage ID组合而成。从上节的图9-1,可以看出,在物理存储上,对于message ID是位于column
qualifier
上,还是位于rowkey中,它们是没有区别的,每一个Cell都存储着一条Email内容。下面给出了一个短宽表的例子:

<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 …”

以相同的信息承载,我们再给出长窄表的存储方式:

<userId>-<messageId>:<colfam>:<colqulifier>
: <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 …”

上面的长窄表中使用了空的qualifier,将message-id搬到了rowkey中,在查询时可以更加灵活高效,同时,每一条Email都存储为逻辑独立的一行数据,这会更有利于split操作。

Key部分字段查询

HBase的客户端API对于长窄表提供的另一个至关重要的功能便是,Key部分字段查询。

在上一节的例子中,我们将所有用户的每一条Email信息独立存储为一行。则宽短表可能将一个用户所有的Email信息存储在同一行中,每一列存储用户收件箱中的一条Email记录。要查询用户的数据时,只需要给出user ID,便可以取出这个用户的所有Email记录。

我们回到长窄表,rowkey中,在user ID后附加了message ID。如果您不知道这两个ID,将无法定位到具体的一行记录。处理这个问题的方法,便是Key部分字段查询:您可以指定一个rowkey的起始和结束值,即Start KeyEnd Key。将Start Key设置为User ID,将End Key设置为User Id + 1,便加查询指定User ID的所有记录。在rowkey范围查询中,结果中包含Start Key,不包含End Key。将Start Key设成User ID,可以精确的找到这个User IDScan得到的下一行,满足如下条件:

<userId>-<lowest-messageId>

换句话说,rowkey以排过序的user IDmessage ID组合而成。通过Scan操作,您可以遍历一个用户所有的Email记录,通过解析rowkey,可以得到每条记录对应的Message ID

Key部分查询是非常有用的,您可以理解为rowkey中字段的“左索引”,以下面的rowkey结构为例:

<userId>-<date>-<messageId>-<attachmentId>

您可以通过不断加强的精确匹配,通过rowkeyStart KeyEnd Key,来查询出指定的行。您可以将End KeyStart Key多一个字节,比如前面举的Email的例子,Start Key设置为12345End Key设置为123456。表9-1给出一些Start Key和它们对应的查询意义。

9-1 Start Key及其含义

Command

Description

<userId>

Scan over all messages for a given user ID.

<userId>-<date>

Scan over all messages on a given date for the given user ID.

<userId>-<date>-<messageId>

Scan over all parts of a message for a given user ID and date.

<userId>-<date>-<messageId>-<attachmentId>

Scan over all attachments of a message for a given user ID and
date.

这些Rowkey的组合和RDBMS中提供的很类似,您可以调整字段的排列顺序。您可以将date字段(Long类型)进行字节按位取反,则数据将以时间的降序进行排列。当然,您也可以采用如下的方法:

Long.MAX_VALUE -
<date-as-long>

这会使时间字段按降序排列,将最近的消息放在前面。

前面的例子,将时间放在了rowkey中的第二个字段,这只是一个示例。如果您没有用时间来查询的需求,那么,您可以把date字段从rowkey中去掉,也可使用另一些您会用到的字段。

在前面的例子中,rowkey被设计成了一些字段的组合。这样做也有一个缺点,即原子性。将收件箱中的数据存储在多行中,无法在一次操作中,对它们进行修改。如果您不会一次对收件箱中的所有邮件进行原子操作,那次长窄表是合适的,如果您有这种需求,您可以回到短宽表的设计。

分页

通过Key部分查询的机制,可以非常便捷地进行遍历。通过指定Start KeyEnd Key,来限制范围查询扫描的数量,然后,通过一个偏移量和限制数量,您可以将它们取到客户端。

通过“PageFilter”和“ColumnPaginationFilter”可以实现分页,这里讲到的方法主要是如何通过rowkey的设计来实现。对于单纯的分页功能来说,ColumnPaginationFilter是一个很好的方式,它可以避免在网络上传输额外的数据。

分页的步骤如下:

1.      Start Row的位置打开个一个scanner

2.      跳过offset行。

3.      读出limit行数据返回给调用者。

4.      关闭scanner

以收件箱的例子进行解释,可以对一个用户下的所有Email进行分页。假设平均一个用户拥有好几百封邮件。在Web客户端上,默认只展现50封邮件,其余的邮件要求用户点击Next按钮进行加载。

客户端可以将Start Key设置为user ID,将End Key设置为userID + 1。剩下的过程和前面讨论的过程一样,首先将offset设置为0,从数据库中读出50封邮件,用户点击Next按钮时,您可以将offset设置为50,跳过刚才读出的50行记录,从而返回第51到第100条。

这种方式对于页数不多的情景是非常有用的,但如果有上千页,那么就需要采用另一种分页的方式。您可以在rowkey中加入一个ID序列,用来表示这个Start Key的偏移量,您也可以使用date字段,记住您上次访问到的date值,将它加入到Start Key中。您可以忽略掉小时的部分。如果您使用了epoch时间格式(1970-01-01
08:00:00
到现在的秒数),那么您可以计算出上次取到的时期的零辰时对应的值。这样,您可以重新描扫一整天的数据,并且选择如何返回它们。

时间连续的数据

当处理由连续事件得到的数据时,即时间上连续的数据。这些数据可能来自于某个传感器网络、证券交易或者一个监控系统。它们显著的特点就是rowkey中含有事件发生时间。带来的一个问题便是HBase对于row的不均衡分布,它们被存储在一个唯一的rowkey区间中,被称为region,区间的范围被称为Start KeyEnd Key

对于单调递增的时间类型数据,很容易被散列到同一个Region中,这样它们会被存储在同一个服务器上,从而所有的访问和更新操作都会集中到这一台服务器上,从而在集群中形成一个hot spot,从而不能将集群的整体性能发挥出来。

要解决这个问题是非常容易的,只需要将所有的数据散列到全部的Region上即可。这是可以做到的,比如,在rowkey前面加上一个非线程序列,常常有如下选择:

Hash散列

您可以使用一个Hash前缀来保证所有的行被分发到多个Region服务器上。例如:

byte prefix =
(byte) (Long.hashCode(timestamp) % <number of regionservers>);

byte[] rowkey =
Bytes.add(Bytes.toBytes(prefix), Bytes.toBytes(timestamp);

这个公式可以产生足够的数字,将数据散列到所有的Region服务器上。当然,公式里假定了Region服务器的数目。如果您打算后期扩容您的集群,那么您可以把它先设置为集群的整数倍。生成的rowkey类似下面:

0myrowkey-1,
1myrowkey-2, 2myrowkey-3, 0myrowkey-4, 1myrowkey-5, \

2myrowkey-6, …

当他们将按如下顺序被发送到各个Region服务器上去:

0myrowkey-1

0myrowkey-4

1myrowkey-2

1myrowkey-5

换句话说,对于0myrowkey-10myrowkey-4的更新操作会被发送到同一个region服务器上去(假定它们没有被散列到两个region上去),1myrowkey-21myrowkey-5会被发送到同一台服务器上。

这种方式的缺点是,rowkey的范围必须通过代码来控制,同时对数据的访问,可能要访问多台region服务器。当然,可以通过多个线程同时访问,来实现并行化的数据读取。这种类似于只有mapMapReduce任务,可以大大增加IO的性能。

案例:Mozilla
Socoroo

Mozilla公司搭建了一个名为Socorrocrash报告系统,用来跟踪FirefoxThunderbirdcrash记录,存储所有的用户提交的关于程序非正常中止的报告。这些报告被顺序访问,通过Mozilla的开发团队进行分析,使得它们的应用软件更加稳定。

这些代码是开源的,包含着Python写的客户端代码。它们使用Thrift直接与HBase集群进行交互。下面的给出了代码中用于Hash时间的部分:

def
merge_scan_with_prefix(self,table,prefix,columns):

“”"

A generator based
iterator that yields totally ordered rows starting with a

given prefix. The
implementation opens up 16 scanners (one for each leading

hex character of
the salt) simultaneously and then yields the next row in

order from the
pool on each iteration.

“”"

iterators = []

next_items_queue =
[]

for salt in
’0123456789abcdef’:

salted_prefix =
“%s%s” % (salt,prefix)

scanner = self.client.scannerOpenWithPrefix(table,
salted_prefix, columns)

iterators.append(salted_scanner_iterable(self.logger,self.client,

self._make_row_nice,salted_prefix,scanner))

# The i below is
so we can advance whichever scanner delivers us the polled

# item.

for i,it in
enumerate(iterators):

try:

next = it.next

next_items_queue.append([next(),i,next])

except
StopIteration:

pass

heapq.heapify(next_items_queue)

while 1:

     try:

              while
1:

                       row_tuple,iter_index,next
= s = next_items_queue[0]

                       #tuple[1]
is the actual nice row.

                       yield
row_tuple[1]

                       s[0]
= next()

                       heapq.heapreplace(next_items_queue,
s)

     except
StopIteration:

              heapq.heappop(next_items_queue)

     except
IndexError:

              return

这些Python代码打开了一定数目的scanner,加上Hash后的前缀。这个前缀是一个单字符的,共有16个不同的字母。heapq对象将scanner的结果进行全局排序。

字段位置交换

在前面提到了Key部分扫描,您可以移动timestamp字段,将它放在前一个字段的前面。这种方法通过rowkey的组合来将一个顺序递增的timestamp字段放在rowkey的第二个位置上。

如果你的rowkey不单单含有一个字段,您可以交换它们的位置。如果你现在的rowkey只有一个timestamp字段,您有必要再选出一个字段放在rowkey中。当然,这也带来了一个缺点,即您常常只能通过rowkey的范围查询来访问数据,比如timestamp的范围。

案例:OpenTSDB

OpenTSDB项目用来保存由收集代码程序得到的服务器或者服务的运行参数,这些参数与采集时间是密切相关的。所有的数据被存储在HBase之中。通过用户界面可以对这些参数进行实时查询。

rowkey设计时,将metric ID带进了rowkey之中:

<metric-id><base-timestamp>…

由于整个系统具有很多参数,它们的ID分布在一个区间之中,通过这个前缀,读、写操作可以访问到所有的参数。

这种方式假设系统的查询主要是用来查找一个或者多个参数的值,并按照时间顺序对它们进行展现。

随机化

还有一种不同于散列的方法便是随机化,例如:

byte[] rowkey =
MD5(timestamp)

通过类似于MD5Hash函数,会使key被随机分布到一组region服务器上。对于时间连续性数据,这种方法显然不够理想,因此无法对整个时间范围进行查询。

您可以通过对timestamphash来构建rowkey,从而方便的进行单行记录的查询。当您的数据不需要通过时间范围查询,而主要是单条查询时,您可以采用这种方法。

9-3 读写性能平衡

对前面两种方法(Hash散列、随机化)进行总结,你会发现在读和写之间的性能上进行平衡,并不是没有意义的,它决定了您的rowkey结构。图9-3给出了顺序读和顺序写之间的性能关系。

时间连续数据的排序

下文将继续讨论,将时间连续的数据插入到新行中。当然,您也可以将时间连续的数据,存放在多个列中。由于每个列簇中的列,是按列名来进行排列的,您可以将这种排列顺序看成是一种索引。多个这种索引,可以通过多个列簇来实现,当然,这种存储模式设计常常是不建议使用的,但如果列的数目不多时,还是可以尝试的。

考虑到前面讲的收件箱的例子,将一个用户的所有邮件放在同一行中。这样,当您想按照它们的接收时间进行展现,同时按照主题进行排序,您可以利用列排序来实现。

首先要讨论的是主排序,换句话说,即大部分用户使用的邮箱排序视图。假设它们选择了按时间降序排列,您可以使用前文提到的方法:

Long.MAX_VALUE -
<date-as-long>

Email自身被存放在一个列簇之中,它们的排序顺序被存放在一个独立的列簇之中。

围绕列簇的增加,我们可以将所有的索引存储在一个独立的列簇中。进一步,您可以利用index ID,如idx-subject-descidx-to-asc等等进行前缀排序。紧接着,您需要附加排序的结果。它们真正的值是主索引中Cellrowkey。这意味着,您可以通过主表加载邮件的信息,或者在其它索引中将邮件信息冗余地再存一份,这样可以避免对主表的随机访问。HBase中常常使用反范式化(在计算机科学中,反范式化通过对数据库加入冗余信息或者分组信息优化读取性能来减少读的次数,从而用户体检的响应度。

将上述的模式实现出来如下:

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 …”

12345 : index :
idx-from-asc-mary@foobar.com : 1307099848 : 725aae5f-d72e…

12345 : index :
idx-from-asc-paul@foobar.com : 1307103848 : dcbee495-6d5e…

12345 : index :
idx-from-asc-pete@foobar.com : 1307097848 : 5fc38314-e290…

12345 : index :
idx-from-asc-sales@ignore.me : 1307101848 : cc6775b3-f249…

12345 : index :
idx-subject-desc-\xa8\x90\x8d\x93\x9b\xde : \

         1307103848
: dcbee495-6d5e-6ed48124632c

12345 : index :
idx-subject-desc-\xb7\x9a\x93\x93\x90\xd3 : \

         1307099848
: 725aae5f-d72e-f90f3f070419

在上面的代码中,idx-from-asc索使引通过Email地址进行升序排列,idx-subject-desc通过主题进行降序排列。这些主题通过字节按节取反存储,从而得到降序的效果。比如:

% String s = “Hello,”;

% for (int i = 0;
i < s.length(); i++) {

         print(Integer.toString(s.charAt(i)
^ 0xFF, 16));

}

b7 9a 93 93 90 d3

所有的索引信息被存放在列簇index中,用使前面讲到的前缀来进行排序。客户端可能读取整个列簇信息,并缓存所有内存,以便用户在各种排序规则下快速切换。如果要加载的数目过多,客户端可以读取按主题排序的前10列idx-subject-desc信息,来显示前10Email。通过前面提到的Caching Versus
Batching
,利用一行记录的批量加载scan,可以高效的实现索引的分页加载,另一个可选的方案是ColumnPaginationFilterColumnPrefixFilter来组合,进行分页遍历。

 

原文链接:http://www.smile000.com/hbase%E6%9D%83%E5%A8%81%E6%8C%87%E5%8D%97%E4%B8%AD%E6%96%87%E7%89%88-%E7%AC%AC%E4%B9%9D%E7%AB%A0-%E9%AB%98%E7%BA%A7%E7%94%A8%E6%B3%95rowkey%E8%AE%BE%E8%AE%A1/

 
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics