HBase 中的行按行按字典顺序排序。此设计优化了扫描,允许您将相关的行或将要一起读取的行存储在彼此附近。但是,设计不良的行键是 _ 热点 _ 的常见来源。当大量客户端流量指向群集的一个节点或仅几个节点时,就会发生热点。此流量可能表示读取,写入或其他操作。流量超过负责托管该区域的单个机器,导致性能下降并可能导致区域不可用。这也可能对同一区域服务器托管的其他区域产生负面影响,因为该主机无法为请求的负载提供服务。设计数据访问模式非常重要,这样才能完全均匀地利用集群。
为了防止写入热点,设计行键使得真正需要位于同一区域的行是,但从更大的角度来看,数据被写入群集中的多个区域,而不是一次一个。下面描述了一些避免热点的常用技术,以及它们的一些优点和缺点。
盐
在这种意义上,Salting 与加密无关,但是指的是将随机数据添加到行键的开头。在这种情况下,salting 指的是向行键添加随机分配的前缀,以使其排序与其他方式不同。可能的前缀数对应于您希望跨数据传播的区域数。如果您有一些“热”行键模式在其他更均匀分布的行中反复出现,则 Salting 可能会有所帮助。请考虑以下示例,该示例显示 salting 可以在多个 RegionServers 之间传播写入负载,并说明了对读取的一些负面影响。
实施例 11.腌制实施例
假设您有以下行键列表,并且您的表被拆分,以便字母表中的每个字母都有一个区域。前缀“a”是一个区域,前缀“b”是另一个区域。在此表中,以“f”开头的所有行都在同一区域中。此示例关注具有以下键的行:
foo0001
foo0002
foo0003
foo0004
现在,想象一下,您希望将它们分布在四个不同的地区。您决定使用四种不同的盐:a
,b
,c
和d
。在这种情况下,这些字母前缀中的每一个都将位于不同的区域。应用盐后,您将改为使用以下 rowkeys。既然你现在可以写入四个不同的区域,理论上你在写入时的吞吐量是所有写入到同一区域时的四倍。
a-foo0003
b-foo0001
c-foo0004
d-foo0002
然后,如果添加另一行,将随机分配四个可能的盐值中的一个,并最终靠近其中一个现有行。
a-foo0003
b-foo0001
c-foo0003
c-foo0004
d-foo0002
由于此分配是随机的,因此如果要按字典顺序检索行,则需要执行更多工作。通过这种方式,salting 尝试增加写入的吞吐量,但在读取期间会产生成本。
哈希
您可以使用单向 _ 散列 _ 而不是随机分配,这将导致给定行始终使用相同的前缀“加盐”,从而将负载分散到 RegionServers,但是允许读取期间的可预测性。使用确定性哈希允许客户端重建完整的 rowkey 并使用 Get 操作正常检索该行。
示例 12.散列示例在上面的 salting 示例中给出相同的情况,您可以改为应用单向散列,该散列将导致具有键foo0003
的行始终并且可预测地接收a
前缀。然后,要检索该行,您就已经知道了该密钥。您还可以优化事物,以便某些键对始终位于同一区域,例如,反转键
防止热点的第三个常见技巧是反转固定宽度或数字行键,以便最常更改(最低有效数字)的部分是第一个。这有效地使行键随机化,但牺牲了行排序属性。
参见 https://communities.intel.com/community/itpeernetwork/datastack/blog/2013/11/10/discussion-on-designing-hbase-tables 和关于盐渍表的文章[来自 Phoenix 项目的 HTG3]以及 HBASE-11682 的评论中有关避免热点的更多信息的讨论。
在 Tom White 的书 Hadoop:The Definitive Guide (O'Reilly)的 HBase 章节中,有一个优化注释,用于观察导入过程与所有客户在音乐会中锁定步骤的现象敲击表中的一个区域(因此,单个节点),然后移动到下一个区域,等等。单调增加行键(即使用时间戳),这将发生。看看 IKai Lan 的这个漫画,为什么在类似 BigTable 的数据存储中单调增加行键是有问题的:单调递增值很差。通过将输入记录随机化为不按排序顺序,可以减轻由单调增加的密钥引起的单个区域的堆积,但通常最好避免使用时间戳或序列(例如,1,2,3)作为行键。
如果你确实需要将时间序列数据上传到 HBase,你应该学习 OpenTSDB 作为一个成功的例子。它有一个描述它在 HBase 中使用的模式的页面。 OpenTSDB 中的关键格式实际上是[metric_type] [event_timestamp],乍一看似乎与先前关于不使用时间戳作为关键字的建议相矛盾。然而,不同之处在于时间戳不在密钥的 _ 前导 _ 位置,并且设计假设是存在数十或数百(或更多)不同的度量类型。因此,即使具有混合度量类型的连续输入数据流,Puts 也分布在表中的各个区域点上。
有关一些 rowkey 设计示例,请参见 schema.casestudies 。
在 HBase 中,值总是用它们的坐标运算;当一个单元格值通过系统时,它将伴随着它的行,列名和时间戳 - 总是如此。如果您的行和列名称很大,特别是与单元格值的大小相比,那么您可能会遇到一些有趣的场景。其中一个是 Marc Limotte 在 HBASE-3551 尾部描述的情况(推荐!)。其中,为了便于随机访问而保留在 HBase 存储文件( StoreFile(HFile))上的索引可能最终占用 HBase 分配的 RAM 的大块,因为单元值坐标很大。上面引用的注释中的标记建议增加块大小,以便存储文件索引中的条目以更大的间隔发生,或者修改表模式,从而使得行和列名称更小。压缩也将使更大的指数。在用户邮件列表中查看主题问题 storefileIndexSize 。
大多数情况下,小的低效率并不重要。不幸的是,这是他们这样做的情况。无论为 ColumnFamilies,属性和行键选择何种模式,它们都可以在数据中重复数十亿次。
有关 HBase 在内部存储数据的详细信息,请参阅 keyvalue ,了解其重要性。
尽量保持 ColumnFamily 名称尽可能小,最好是一个字符(例如“d”表示数据/默认值)。
有关 HBase 在内部存储数据的详细信息,请参阅 KeyValue 以了解其重要性。
尽管详细的属性名称(例如,“myVeryImportantAttribute”)更易于阅读,但是更喜欢较短的属性名称(例如,“via”)来存储在 HBase 中。
See keyvalue for more information on HBase stores data internally to see why this is important.
保持它们尽可能短,以便它们仍然可用于所需的数据访问(例如 Get vs. Scan)。对数据访问无用的短键并不比具有更好的 get / scan 属性的更长键更好。在设计 rowkeys 时期望权衡。
长是 8 个字节。您可以在这八个字节中存储最多 18,446,744,073,709,551,615 的无符号数。如果将此数字存储为字符串 - 假设每个字符有一个字节 - 则需要将近 3 倍的字节。
不相信?下面是一些您可以自己运行的示例代码。
// long
//
long l = 1234567890L;
byte[] lb = Bytes.toBytes(l);
System.out.println("long bytes length: " + lb.length); // returns 8
String s = String.valueOf(l);
byte[] sb = Bytes.toBytes(s);
System.out.println("long as string length: " + sb.length); // returns 10
// hash
//
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(Bytes.toBytes(s));
System.out.println("md5 digest bytes length: " + digest.length); // returns 16
String sDigest = new String(digest);
byte[] sbDigest = Bytes.toBytes(sDigest);
System.out.println("md5 digest as string length: " + sbDigest.length); // returns 26
不幸的是,使用类型的二进制表示将使您的数据更难在代码之外读取。例如,这是在增加值时在 shell 中看到的内容:
hbase(main):001:0> incr 't', 'r', 'f:q', 1
COUNTER VALUE = 1
hbase(main):002:0> get 't', 'r'
COLUMN CELL
f:q timestamp=1369163040570, value=\x00\x00\x00\x00\x00\x00\x00\x01
1 row(s) in 0.0310 seconds
shell 尽最大努力打印一个字符串,在这种情况下,它决定只打印十六进制。区域名称中的行键也会发生相同的情况。如果你知道存储了什么就可以了,但如果可以将任意数据放在同一个单元格中,它也可能是不可读的。这是主要的权衡。
反向扫描 AP
HBASE-4811 实现了一个 API 来反向扫描表格或表格中的范围,从而减少了优化模式以进行正向或反向扫描的需要。此功能在 HBase 0.98 及更高版本中可用。有关详细信息,请参阅 Scan.setReversed()。
数据库处理中的一个常见问题是快速找到最新版本的值。使用反向时间戳作为密钥的一部分的技术可以极大地帮助解决此问题的特殊情况。同样可以在 Tom White 的书 Hadoop:The Definitive Guide(O'Reilly)的 HBase 章节中找到,该技术涉及将(Long.MAX_VALUE - timestamp
)附加到任何键的末尾,例如: [键] [reverse_timestamp]。
通过执行 Scan [key]并获取第一条记录,可以找到表中[key]的最新值。由于 HBase 键是按排序顺序排列的,因此该键在[key]的任何旧行键之前排序,因此是第一个。
将使用此技术而不是使用版本号,其意图是“永久”(或很长时间)保留所有版本,同时通过使用快速获取对任何其他版本的访问权限相同的扫描技术。
Rowkeys 的作用域为 ColumnFamilies。因此,在没有冲突的表中存在的每个 ColumnFamily 中可以存在相同的 rowkey。
Rowkeys 无法更改。它们可以在表中“更改”的唯一方法是删除行然后重新插入。这是关于 HBase dist-list 的一个相当常见的问题,因此第一次(和/或在插入大量数据之前)使 rowkeys 正确是值得的。
如果你预分割你的表,那么 _ 关键 _ 就可以理解你的 rowkey 如何在区域边界上分布。作为为什么这很重要的一个例子,考虑使用可显示的十六进制字符作为密钥的前导位置的例子(例如,“0000000000000000”到“fffffffffffffffff”)。通过Bytes.split
运行这些键范围(这是在Admin.createTable(byte[] startKey, byte[] endKey, numRegions)
中为 10 个区域创建区域时使用的拆分策略将生成以下拆分...
48 48 48 48 48 48 48 48 48 48 48 48 48 48 48 48 // 0
54 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 // 6
61 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -68 // =
68 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -126 // D
75 75 75 75 75 75 75 75 75 75 75 75 75 75 75 72 // K
82 18 18 18 18 18 18 18 18 18 18 18 18 18 18 14 // R
88 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -44 // X
95 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -102 // _
102 102 102 102 102 102 102 102 102 102 102 102 102 102 102 102 // f
(注意:前导字节在右侧列为注释。)鉴于第一次拆分是'0'而最后一次拆分是'f',一切都很好,对吧?没那么快。
问题是所有数据都将堆积在前 2 个区域和最后一个区域,从而产生“块状”(可能是“热”)区域问题。要了解原因,请参阅 ASCII 表。 '0'是字节 48,'f'是字节 102,但字节值(字节 58 到 96)之间存在巨大差距,_ 永远不会出现在此键空间 _ 中,因为唯一的值是[0 -9]和[af]。因此,中间区域永远不会被使用。为了使用该示例键空间进行预分割工作,需要自定义分割(即,不依赖于内置分割方法)。
第 1 课:预分割表通常是最佳实践,但您需要预先拆分它们,以便在密钥空间中可以访问所有区域。虽然此示例演示了十六进制键空间的问题,但 _ 任何 _ 键空间都会出现同样的问题。了解您的数据。
第 2 课:尽管通常不可取,但只要在密钥空间中可以访问所有创建的区域,使用十六进制密钥(更常见的是可显示的数据)仍可以使用预分割表。
总结此示例,以下是如何为十六进制密钥预先创建适当的拆分的示例:
public static boolean createTable(Admin admin, HTableDescriptor table, byte[][] splits)
throws IOException {
try {
admin.createTable( table, splits );
return true;
} catch (TableExistsException e) {
logger.info("table " + table.getNameAsString() + " already exists");
// the table already exists...
return false;
}
}
public static byte[][] getHexSplits(String startKey, String endKey, int numRegions) {
byte[][] splits = new byte[numRegions-1][];
BigInteger lowestKey = new BigInteger(startKey, 16);
BigInteger highestKey = new BigInteger(endKey, 16);
BigInteger range = highestKey.subtract(lowestKey);
BigInteger regionIncrement = range.divide(BigInteger.valueOf(numRegions));
lowestKey = lowestKey.add(regionIncrement);
for(int i=0; i < numRegions-1;i++) {
BigInteger key = lowestKey.add(regionIncrement.multiply(BigInteger.valueOf(i)));
byte[] b = String.format("%016x", key).getBytes();
splits[i] = b;
}
return splits;
}