HBase 与传统关系数据库(例如MySQL,PostgreSQL,Oracle等)在架构的设计以及为应用程序提供的功能方面有很大的不同。HBase 权衡了其中一些功能,以实现更好的可扩展性以及更灵活的模式。与关系数据库相比,HBase 表的设计有很大的不同。下面将通过解释数据模型向您介绍 HBase 表设计的基础知识,并通过一个例子深入探讨 HBase 表的设计。
1. HBase数据模型
HBase 数据模型与我们在关系数据库中使用或了解的数据模型有很大不同。如 BigTable 原始论文所述,它是一个稀疏,分布式,持久的多维有序 Map,由行键,列以及时间戳进行索引。我们可能会听到人们将其称为键值存储,面向列族的数据库,有时甚至是存储版本化 Map 的数据库,这些描述都是正确的。HBase 数据模型的最简单描述是表,由行和列组成。这与关系数据库中比较相像,但也就是这点与 RDBMS 数据模型相似。实际上,甚至行和列的概念也略有不同。首先,我们定义一些概念,供后面使用:
- 表(Table):HBase 以表的形式组织数据。表名必须由可以在文件系统路径中可以使用的字符组成。
- 行(Row):通过行键进行唯一标识。行键没有数据类型,以字节数组来存储。
- 列族(Column Family):行中数据按列族分组。列族还影响数据在 HBase 中的物理存储,必须预先定义列族并且不能随便对其进行修改。表中每一行都具有相同的列族,但列族中不一定都有相同列。
- 列限定符(Column Qualifier):列族中的数据通过列限定符(或简称为列)进行寻址查询。列限定符不需要预先制定,不同行的列限定符不必保持一致。与行键一样,列限定符也没有数据类型,以字节数组来存储。
- 单元(Cell):行键,列族和列限定符唯一标识一个单元。存储在单元中的数据称为该单元的值,同样也没有数据类型,以字节数组来存储。
- 时间戳:单元中的值会进行版本化控制。版本由版本号进行标识,默认情况下,版本号是写入单元的时间戳。如果在写入时未指定时间戳,则使用当前时间戳。如果读取时未指定时间戳,则返回最新时间戳的单元值。每个列族的单元值版本数量由 HBse 分别维护,默认保留三个版本数据。
HBase 中的表如下图所示:
上表由两个列族(Personal
和 Office
)组成。每个列族都有两列,Personal
列族的两列为 Name
、ResidencePhone
,Office
列族的两列为 Phone
、Address
。
HBase 用于数据处理的API包含三种主要方法:Get,Put和Scan。Get 和 Put 方法针对特定行,并且需要提供行键。Scan 方法作用在一定范围的行上。该范围可以由开始行键和终止行键定义,如果没有指定开始行键和终止行键,则遍历整个表。
你也可以把 HBase 看成一个多维度的 Map 模型去理解它的数据模型。上表第一行表示为多维 Map 如下所示:
一个行键映射一个列族数组,列族数组中的每个列族又映射一个列限定符数组,列限定符数组中的每一个列限定符又映射到一个时间戳数组,每个时间戳映射到不同版本的值,即单元本身。如果我们要查询行键映射的条目,则可以从所有列中获取数据。如果我们要查询指定列族映射的条目,则可以从该列族下所有列中获取数据。如果我们要查询指定列限定符映射的条目,则可以获取所有时间戳以及相关的值。默认情况下仅返回最新版本的数据,我们可以在查询中请求多个版本的数据。可以认为行键等价于关系数据库表中的主键。在表创建后,我们不能选择其他列将作为行键。换句话说,在将数据放入表之后,我们不能选择 Personal
列族中的 Name
列作为行键。
我们也可以将 HBase 视为键值存储(如下图所示),可以理解行键,列族,列限定符,时间戳的组合作为键,存储在单元中的实际数据为值。稍后,当我们深入了解底层存储的细节时,我们会发现,如果要从给定的行中读取特定单元数据时,HBase 会去读取一个数据块,里面除了有要查询的单元数据,可能同时也会获取到其它单元数据:
如果 HBase 表作为键值存储来看,主键可以只是行键,或者是行键,列族,列限定符,时间戳的组合,具体取决于我们要寻址的单元。如果我们对一行中的所有单元都感兴趣,则主键是行键。如果我们只关注指定单元,则需要将对应的列族和列限定符作为主键的一部分。
2. HBase表设计基础
正如上面强调的那样,HBase 数据模型与关系数据库系统完全不同。因此,设计 HBase 表的方法与关系数据库系统的方法不同。在设计 HBase 表时需要考虑以下问题:
- 行键的结构是什么样,应该包含什么信息。
- 表应该有多少列族。
- 列族中应该存储什么样的数据。
- 每个列族应该有多少列。
- 列名是什么,尽管无需在创建表时定义列名,但是在写入或读取数据时需要知道它们。
- 单元中应该存储什么样的数据。
- 每个单元中存储多少个时间版本。
HBase 表设计的最重要的是定义行键结构。定义行键结构,重要的是预先定义访问模式(读和写)。除此之外,还需要考虑 HBase 表的一些特性:
- 仅对行键进行索引。
- 表是根据行键存储的。表中的行根据行键的字典序来进行排序,表中每一块区域的划分都是基于开始行键以及终止行键来决定的。
- HBase 表中的所有内容都以字节数组存储,没有数据类型。
- 仅保证行级别的原子性。跨行不会保证原子性,这意味着不存在多行事务。
- 列族必须在创建表时预先定义。
- 列限定符是动态的,可以在表创建之后写入数据时定义。列限定符以字节数组的形式存储,因此我们甚至可以将真实数据存储其中。
学习这些概念的一种最好方法是通过示例来演示。我们以 Twitter 上用户相关注为例进行说明。关注者与关注的本质上是一个图,存储在专门的图数据库可以更有效地使用此类数据集。但是,这个特殊的用例为在 HBase 表中建模提供了一个很好的示例。
在对表进行建模之前第一步是定义应用程序的访问模式。在 Twitter 用户关注下访问模式可以定义如下。
读取模式:
- 用户关注了谁?
- 用户A是否关注了用户B?
- 谁关注了用户A?
写模式:
- 用户关注一个新用户。
- 用户取消关注了某个人。
2.1 方案一
下面我们开始考虑表的,并探讨其优缺点。如下图所示的表设计,该表每一行代表着某个用户以及他所关注的所有用户,行键是关注者的用户ID,列名为关注用户序号,单元值为关注用户Id:
带有数据的表设计如下图所示:
在这种表结构的设计下,第一个问题’用户关注了谁’很好解决,但对于第二个问题’用户A是否关注了用户B’这个问题在列很多(关注的用户很多)的时候,需要遍历所有列去找到用户B,这样的代价会比较大。并且当添加新的关注用户时,因为不知道给这个新用户分配什么样的列序号,因此需要遍历列族中的所有列找出最后一个列,并将最后一列的序号 1给新的关注用户作为列序号,这样的代价会很大。一种可能的解决方案是保留一个计数器,记录当前列序号,如下图所示:
表中的数据跟之前一样,只是添加了一个计数器,用于记录用户所关注的用户数量。根据上图表的设计,将新关注用户添加到关注用户列表中所需的步骤如下:
- 第一步获取当前计数器表示的列序号(
count:4
)。 - 第二步更新列序号值,加1(
count:5
)。 - 第三步添加一个新条目。
- 第四步将新数据(
5:Lui,count:5
)写回HBase。
如你所看到的,保持计数器会让客户端代码变的很复杂。每次往A的关注用户列表中增加一个用户,必须先从 HBase 表里读出计数,增加一个用户,更新计数器。这个过程看起来有点像关系型数据库里的事务。
2.2 方案二
上面的设计在使用计数器后有所改进,但还是不能解决所有问题。取消关注用户仍然很棘手,我们必须遍历所有列以找出我们需要删除的列。最大的问题是,因为 HBase 不会对跨行或跨RPC调用进行事务保证,在添加关注用户时我们必须在客户端代码中实现某种事务逻辑。
读取计数器以及更新计数器需要有事务的支持,这样会让客户端变的比较复杂。解决这个问题的唯一办法是去掉计数器。
我们之前提到的一个特性是列限定符是动态的,并且像单元一样以字节数组存储。这样一来,我们便可以将任意数据放入列限定符中,基于这个特性我们再改进表的设计。如下图所示,在这种设计中,不再需要计数器,列限定符使用被关注的用户名称,而不在是他们在关注用户列表中的位置。在这种设计下添加关注用户变得不那么复杂(直接添加,不需要计数器获取列序号)。取消关注也得到了简化(直接找到对应列,不需要遍历):
现在,表中使用用户名作为列限定符,单元值可以是任意内容,因为单元不能是空的,需要我们存储点东西,所以输入数字1。
这种设计几乎解决了所有问题。在读取访问模式中,只剩下第三个问题’谁关注了用户A?’。在当前设计中,由于仅对行键进行索引,因此我们需要进行全表扫描才能知道谁关注了用户A。这就告诉我们,关注的用户也应该以某种方式进行索引。
2.3 方案三
有两种方法可以解决这个问题。第一种方法是新建一张表,里面保存用户以及所有关注他的用户。第二种方法是在同一张表中使用不同的行键信息,存储用户以及所有关注他的用户的信息,并能从行键上区分是关注还是被关注,例如,行键为 A_following
的这行保存着用户A关注的所有用户,而行键为 A_followed
的这行保存着所有关注用户A的用户。这样我们的第三个问题只需要查询行键为 A_followed
就能知道谁关注了用户A。
当前表结构还可以进一步的优化。如下图所示的表:
方案一和方案二都是使用的宽表形式。也就是说一行包括很多列。同样的信息可以使用高表形式存储。每行代表一个’关注与被关注’关系。行键里使用了
串联了两个值,你也可以使用你喜欢的任意字符。
在此设计中,有两点需要注意:行键现在由关注用户和被关注用户组成,同时列族的名字被设计成只有一个字母f。列族名称这样的设计可以通过减少从 HBase 读取/写入的数据来减少I/O负载(磁盘和网络),因为列族名称也是返回给客户端的 KeyValue 对象的一部分。
保存了一些样例数据的表如下图所示:
按高表而不是宽表进行设计。把用户名放进列限定符可以节省为了得到用户名到用户表中查询的时间。其负面影响就是,如果用户在用户表里更新他们的名字,你不得不在本表的所有单元里更新用户名字。
表的这种新设计在回答读模式第二个问题’用户A是否关注了用户B?’时会比以前方案快,基于行键使用 Get 操作得到一行也就得到答案了,不用再像早期表设计中那样遍历该行的所有列。获取关注的所有用户从 Get 操作变成简短的 Scan。取消关注变为简单的删除操作。
高表并不总是表设计的最好选择,为了获取高表的性能好处,会在某些操作上放弃了原子性原则。在前面方案中我们可以在一行上用单个 Put 操作更新任何用户的关注列表。Put 运算在行级别是原子不可分的。在这个方案里,我们放弃了这样做的能力
注意,在表中不同的行键可能其长度也不一样。由于每次对表的调用要传输的数据都是不一样的,因此这对性能也会由影响。解决此问题的方法是对行键进行散列。为了在表中有相同长度的行键,我们可以对不同用户ID进行散列并将其拼接在一起。如下图所示我们使用 MD5 对用户Id以及其所关注的用户Id进行散列并拼接 md5(follower)md5(followed)
。这样我们就有固定长度的行键,每个用户ID为16个字节。如果我们要要查询某个用户,我们可以计算对应的散列值来查询表:
使用MD5作为行键的一部分可以得到固定长度和更好的分布。
3. 总结
本文介绍了HBase模式设计的基础知识。首先介绍了数据模型,然后讨论了设计 HBase 表时要考虑的一些因素。下面是HBase一些关键特性的总结:
- 行键是 HBase 表设计中最重要的一环,决定了应用程序如何与 HBase 表进行交互,还会影响从 HBase 中读取的性能。
- HBase 表很灵活,我们可以以字节数组的形式存储任何内容。
- 将具有相似访问模式的所有内容存储在同一列族中。
- 仅对行键进行索引。
- 高表使操作更快,更简单,但是失去了原子性。宽表,其中每一行都有很多列,允许行级别的原子性。
- HBase并不支持事务,所有操作尽量在一次API请求中完成。
- 哈希可以使固定长度的键有更好的分布,但会失去字符串暗含的有序性。
- 列限定符可用于存储数据,就像单元一样。
- 列限定符的长度会影响存储空间,因为可以将数据放入其中。长度也会影响访问数据时的磁盘和网络I/O代价。
- 列族名称的长度会影响通过网络发送到客户端的数据大小(在KeyValue对象中)。
原文:Introduction to HBase Schema Design