# 三、HBase 表的设计

在数据库中的表之间没有任何关系，也没有索引、数据类型、默认值、计算列，或者任何其他在现代 SQL 数据库，甚至其他 NoSQL 数据库中得到的奇特功能。正因为如此，HBase 中的数据建模看似简单——您必须处理的唯一构造是表、行和列族。

但是在 HBase 中对数据的访问总是通过行键，所以如何构造键对数据库的性能和可用性有着巨大的影响。在上一章的访问日志表中，我使用了行键格式{systemID}|{userId}|{period}。这种格式意味着该表对于一个读取场景是好的，但是对于其他场景是坏的。

如果我通常在特定系统和用户的上下文中阅读该表，那么行键格式是很好的。要获取用户的访问日志，我可以使用排除所有其他用户和系统的边界扫描，我将获得非常快速的响应。即使该表有数亿行，返回数百行的扫描也需要几秒钟才能运行。

但是如果我想找到任何人在某个特定的*日*使用的所有系统，这个行键结构并不是最佳的。因为周期在键的末尾，所以我不能为扫描设置边界，我需要读取每一行。有数亿行，这可能需要几个小时。如果我想这样使用该表，那么{period}|{systemID}|{userID}的行键结构会更好。

这种替代结构有它自己的问题，我们很快就会看到。这也意味着按周期查询很快，但是如果我想为一个系统找到一个用户的访问日志，那么我将再次读取每一行。

![](img/00010.jpeg)提示:设计行键的唯一“正确”方法是知道如何访问表，并在键结构中对访问进行建模。通常，您需要牺牲二级读取场景来支持主场景。

数据访问模式是关于您的查询正在做什么，以及您正在运行多少个查询，而您的行键结构也对此有很大的影响。HBase 是一个分布式数据库，它有能力支持高并发读写，但前提是您的表设计允许。

HBase 中的单个逻辑表实际上是在存储层拆分的，并存储在称为区域的许多部分中。对区域中数据的访问由一个 HBase 区域服务器实例提供，在生产环境中，您将有许多区域服务器在集群中运行。如果您正确设计了表，不同的区域可以由不同的区域服务器托管，从而为该表的并发读写提供了高性能。

但这也取决于你的行键。表格按行键拆分，每个区域都有一个开始和结束行键。如果您的设计意味着您的所有行键都以相似的值开始，那么它们就不会分布在许多区域中，您也不会获得高并发性。

表 3 显示了 access-logs 表中更多的示例行键，使用了不同的结构(为了可读性，在管道周围添加了空格):

| 选项 1: {period}&#124;{systemID}&#124;{userID} | 选项 2: {systemID}&#124;{userID}&#124;{period} | 选项 3: {userID}&#124;{systemID}&#124;{period} |
| 201510 &#124;杰里科&#124;戴夫 | 杰里科&#124;戴夫&#124; 201510 | 戴夫&#124;杰里科&#124; 201510 |
| 201510 &#124;杰里科&#124;埃尔顿 | 杰里科&#124;埃尔顿&#124; 201510 | 埃尔顿&#124;杰里科&#124; 201510 |
| 201511 &#124;铁饼&#124;埃尔顿 | 铁饼&#124;埃尔顿&#124; 201511 | 埃尔顿&#124;铁饼&#124; 201511 |
| 201510 年耶利哥 11 号和平 | 杰里科\和平\ 201 510 | 弗雷德·杰里科·201 510 |

 3:行键设计

对于选项 1，每行都以相同的五个字符开始；行与行之间的距离非常小，因此它们可能都在同一个区域。这意味着它们都将由同一个区域服务器提供服务，如果我们同时读写一组这样的行，我们将无法平衡多个区域服务器的负载，也无法获得最大的性能。

![](img/00010.jpeg)提示。对于需要高性能的表，不要使用顺序值(基于日期或时间戳)作为行键。有时，您需要使用顺序值来支持您想要读取数据的方式，但请注意，这样做时会限制并发性能。

选项 2 更好——从第一个字符开始，我们有两个不同的值。在这种情况下，我们可能会在一个区域中找到三行，在另一个区域中找到一行。如果我们有许多记录访问的系统，并且标识有很大的不同，我们可以有几十个区域，并支持高水平的并发读/写访问，在多个区域服务器之间保持平衡。

如果我们需要最大的性能，选项 3 是最好的。我们可能拥有比系统更多的用户，因此用户标识的超集可能有数百或数千个值，并且它们之间可能都有很好的距离。通过这种方法，我们可以拥有数百个区域，如果我们要通过添加更多区域服务器来扩展生产集群，我们仍然会在每个服务器中拥有多个区域，并支持高并发性。

![](img/00011.jpeg)注意:您需要平衡并发的性能考虑和扫描性能。支持每秒数千个并发写入的行键设计对于事件流来说是好的，但是如果它不能让您高效地读取场景中的数据，那就不是正确的选择。

随着数据量的增加，HBase 运行自己的管理作业来维护或提高性能，管理的一部分是将大型表拆分为区域。从技术上讲，您可以让 HBase 自动为您的表创建区域，但是您也可以在创建表时自己显式地这样做。

使用我们在第 2 章中使用的简单 create 语句，我们的表是用单个区域创建的，并且它们不会被拆分，直到它们增长到超过某个区域的配置大小，并且 HBase 将它们一分为二。最大大小有所不同，但通常是 128MB 的倍数，这通常太大了——到拆分发生时，您已经失去了很多性能，因此最好预先拆分您的表。

预拆分意味着您可以在 create 语句中告诉 HBase 表应该以多少个区域开始，以及每个区域中行键的上边界。如果你能定义边界，这种方法最有效，这意味着每个区域的大小应该大致相同。

这并不像听起来那么难。通用唯一标识(UUIDs)或部分 UUIDS 是行键(或行键的第一部分)的好选择。使用十六进制表示，您可以在第一个字符上进行拆分，这样您的表中就有了 16 个区域。代码清单 18 显示了带有 SPLITS 属性的 create 命令，该属性定义了区域的边界:

 18:创建带有预分割区域的表格

```java
          create 'access-logs', 't', {SPLITS => ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']}

```

HBase 负载平衡器很好地将区域分散在服务器群中，因此在一个有四个区域服务器的小集群中，我们可以预期每个服务器上运行四个访问日志区域；如果我们扩展到 16 个区域服务器，我们将在每台服务器上有一个区域，我们可以预期读写的高并发性。

如果您的数据在各地区之间分布不均匀，那么您将会遇到热点问题，即某些地区的数据超过了其合理的份额，并且不得不处理更多的负载。HBase 仍然运行预拆分表的管理作业，因此如果您确实有一个过大的区域，那么在某个时候它将被 HBase 自动拆分。

![](img/00010.jpeg)提示。您可以创建更多的区域，但是每个区域都有自己的内存和磁盘分配，因此拥有更多的区域是有成本的。一个很好的经验法则是，每个区域服务器的目标总数大约为 100 个区域，因此您应该将更多的区域分配给性能最关键的表。

如果您随机生成数据，使用 UUIDs 将在您的区域中提供良好的数据分布，但是如果您想要良好的分布并且想要对键使用事务 id，您可以对它们进行散列以产生行键。

表 4 显示了如果我们根据用户标识的 MD5 哈希构建行关键字，访问日志表中的行将如何分布。MD5 给出了一种生成确定性哈希的简单方法；该算法不适合保护数据，但极不可能与用户标识这样的小输入值发生冲突:

| 行键 | 部分哈希行键 | 地区 |
| 戴夫&#124;杰里科&#124; 201510 | 16108 &#124;杰里科&#124; 201510 | 1(键 0 到 1) |
| 埃尔顿&#124;杰里科&#124; 201510 | d5fe7 &#124;杰里科&#124; 201510 | 13(键 d 至 e) |
| 弗雷德·杰里科·201 510 | 570a9 &#124;杰里科&#124; 201510 | 5(键 5 至 6) |

 4:部分散列行键

正如我们将在第 9 章“区域服务器内部”中看到的，区域内的列系列是 HBase 中的物理存储单元。一个区域的单个族中的所有列都存储在同一文件中，因此通常同时访问的列应该位于同一列族中。

柱族可以包含大量的柱，但是与区域一样，拥有许多柱族会产生开销。通常，每个表只需要一个列系列，并且官方的 HBase 文档建议不要超过三个系列。

如果数据具有不同的访问模式，您可以向表中添加多个列族，例如第 1 章中的社交使用表，它为脸书和推特的使用提供了单独的族。

在最初的示例中，我包含了额外的列族来帮助说明表是如何在 HBase 中构造的，但是在实际的系统中，我会合理化设计，删除标识符和总数的列族，并将这些数据作为列存储在其他列族中。

修改后的设计只有两个柱族，fb 和 tw，合并了所有数据:

fb =脸书，用户的脸书详细信息和活动

fb:id =用户 id

fb:t =总使用量

fb:{period} =该期间内的使用情况

tw = Twitter，用户的 Twitter 详细信息和活动

tw:id = user ID

tw:t =总使用量

tw:{period} =该时间段内的使用情况

如果我们期望为每个社交网络捕获相似数量的数据，这种设计将是合适的。但是，如果使用严重倾斜，我们可能会遇到柱族在区域内分割的问题。

如果我们的表增长到 1 亿行，并且我们正在运行一个大型集群，我们可能会决定将其分成 1000 个区域。这将为脸书专栏家族提供 1000 个数据文件，为 Twitter 提供 1000 个数据文件(这不完全正确，但我们将在第 9 章中解释这一说法)。

在这一亿行中，如果只有一百万行包含任何推特数据，那么当我们查询推特数据时，我们的区域划分将对性能产生负面影响。如果我们对 Twitter 行进行大规模扫描，可能需要读取所有 1000 个区域。

对于 100 万行，我们可能发现只有 100 个区域的最佳性能，因此由于表中有多个列系列，我们损害了较少填充系列的性能。在这种情况下，更好的设计是两个表，facebook-usage 和 twitter-usage，每个表都有一个单独的列族，因此它们可以独立调整。

![](img/00010.jpeg)提示。设计具有单个列族的表，除非您知道数据将有不同的访问要求，但基数相似。如果大多数行在每个列族中都有数据，则具有两列或三列族的表效果最好。

HBase 中的所有数据都存储为字节数组，因此单元格值可以表示任何类型的数据。字符串是最可移植的数据类型；您可以使用标准编码(如 UTF-8)，不同的客户端将能够以相同的方式处理数据。

如果您使用单个 HBase 客户端，那么使用本机数据类型而不是字符串将会对您使用的存储量进行小规模优化，但这可能是一种微优化，其优势超过了在所有列中使用标准数据类型的优势。

一致地使用一种数据类型使您的数据访问代码更简单—您可以集中编码和解码逻辑，并且对于不同的列没有不同的方法。您的字符串值也可以是复杂的对象——将 JSON 存储在 HBase 单元中，然后在客户端中反序列化——是一种非常有效的模式。

用 HBase 术语来说，无论你存储什么都是一个字节数组，尽管客户端对它们的解释可能不同，但对服务器来说，不同数据类型的唯一区别是它们使用的存储量。HBase 不强制限制单个单元中字节数组的大小，但是您应该将单元大小保持在 10MB 以下，以获得最佳性能。

表 5 显示了一个示例 HBase 表，我们可以使用它将 Syncfusion 的简洁系列中的所有书籍存储在一个简单的表中，该表只有一个列族 b:

| 列限定符 | 客户端数据类型 | 内容 |
| --- | --- | --- |
| 乙:t | 线 | 书名 |
| 乙:甲 | 线 | 作者姓名 |
| 乙:丁 | 长的 | 发布日期(UNIX 时间戳) |
| 乙:丙 | 字节数组 | 封面图像(PNG) |
| 乙:女 | 字节数组 | 下载文件(PDF) |

 5: Syncfusion 的简洁库

HBase 的字节输入、字节输出单元格值的方法有一个例外:计数器列。计数器列以与其他列相同的方式存在于表的列族中，但它们的更新方式不同——HbBase 提供了自动递增计数器值的操作，而无需先读取计数器值。

对于 HBase Shell，incr 命令增加一个计数器单元值，或者创建一个计数器列(如果它不存在的话)。您可以选择指定要增加的数量；如果没有，那么 HBase 将递增 1。代码清单 19 用两个命令显示了这一点，这两个命令将计数器单元格添加到行 rk1 中——第一个命令添加一个新的单元格 c:1，默认增量为 1，第二个命令添加一个单元格 c:2，增量为 100:

 19:递增计数器列

```java
          hbase(main):006:0> incr 'counters', 'rk1', 'c:1'
          COUNTER VALUE = 1
          0 row(s) in 0.0130 seconds

          hbase(main):007:0> incr 'counters', 'rk1', 'c:2', 100
          COUNTER VALUE = 100
          0 row(s) in 0.0090 seconds

```

来自 Shell 的响应显示了更新后计数器的值。计数器是一个非常有用的特性，尤其是对于高容量、高并发的系统。在访问日志表中，我可以使用计数器列来记录用户在系统上花费的时间。

一个系统审计组件需要将当前使用情况添加到任何已经记录的时间段中，如果用户有多个打开的会话，我们可以对同一单元格进行并发更新。如果审计组件的不同实例手动读取现有的值，添加到其中并同时放入更新，那么我们将有一个竞争条件，更新将丢失。

有了计数器列，审核组件的每个实例都会发出一个增量命令，而不必读取现有值，并且 HBase 会负责正确更新数据，防止出现任何争用情况。

计数器列可以用与其他单元格值相同的方式读取，尽管在 HBase Shell 中，它们显示为原始字节数组的十六进制表示，如代码清单 20 所示，它读取在前面的命令中设置的值。请注意，HBase Shell 使用不寻常的 ASCII/二进制编码显示数据，因此 x01 是 c:1 中的值 1，x00d 是 c:2 中的值 100:

 20:计数器列值

```java
          hbase(main):008:0> get 'counters', 'rk1'
          COLUMN                          CELL                                                                                   
           c:1                            timestamp=1446726973017, value=\x00\x00\x00\x00\x00\x00\x00\x01                        
           c:2                            timestamp=1446726979178, value=\x00\x00\x00\x00\x00\x00\x00d                           
          2 row(s) in 0.0140 seconds

```

![](img/00011.jpeg)注意:您应该始终使用客户端的增量命令创建计数器列。如果您将它创建为具有自定义值的普通列，然后尝试递增它，您将会得到以下错误:“试图递增宽度不是 64 位的字段。”这就是说，您不能增加不在计数器列中的值。

在这一章中，我们研究了 HBase 中表设计的关键部分:构造行键、预分割区域以及使用列和列族。

没有一个单一的设计能解决所有的糖化血红蛋白问题；您需要注意性能和使用注意事项，尤其是您的行键设计。您需要根据您期望的访问模式来设计表，随着您对需求的了解越来越多，在开发过程中重新设计表并不罕见。

现在我们对 HBase 如何存储数据有了很好的工作知识；在接下来的几章中，我们将看看如何使用 HBase 提供的开箱即用的 API 远程访问这些数据:Java、节俭和 REST。