HBase RowKey设计

热点现象

HBase中的记录行按行键的字典顺序进行排序。这种设计有利于扫描(scan)记录。因此我们可以合理的设计行键,将相关的行或者需要一起读取的行放得靠近一些。不过设计得不好的行键也是热点现象的常见来源。当大量客户端流量指向集群中一个或少数几个节点时就容易产生热点现象。这里说的流量指的是读、写或其他操作。这些客户端流量可能会压倒负责托管某个区域(region)的机器,并因此导致性能下降甚至该区域的不可用。因为无法响应请求,这台主机上托管的其他region也可能会受到不良影响。因此如何设计数据访问模式以使集群全面均衡地发挥作用就很重要了。

为防止写数据的热点出现,就需要调整行键设计。原本有些数据非常需要写入同一个region,但是从更高的角度上来看,这些数据就应该分发到集群的多个region上,而非是一次性写入某一个region。下面会介绍一些常用的避免热点的方案,以及这些方案的优点和缺点。

盐化

这里说的盐与加密盐并无关系,只是将一串随机数添加到行键的头部。这里说的盐化指的是将一串随机分配的前缀添加到行键以使其按与原本不同的方式进行排序。不同前缀的数量应该和设计的region的数量一致。如果在均匀分布的行模式中反复出现了少量热点行模式,此时使用盐化的方式是很有用的。看一下下面的实例,在实例中演示了是如何使用盐化将数据分散到不同的regionServer中的,以及盐化对读取数据的影响。

假设我们有如下行键列表。存储记录的表按字母表给每个字母分配一个region,比如前缀“a”是一个region,前缀“b”就是另一个region。在表中所有以“f”开头的行键的记录就会被写入到相同的region中。在这个例子中我们有如下这样的一批行键:

foo0001
foo0002
foo0003
foo0004

现在我们想把这些行键对应的记录分发到不同的region中。我们将使用4个不同的盐a、b、c和d作为前缀。这样,包含盐前缀的记录就会被写到对应的region上。使用盐以后,我们会得到下面这样的行键。因为现在是将记录写到四个不同的region,相比只写入到一个region理论上会有四倍的吞吐量。

a-foo0003
b-foo0001
c-foo0004
d-foo0002

如果要添加一行新的记录,在现有的行键上随机填上四个盐中的一个作为前缀。

a-foo0003
b-foo0001
c-foo0003
c-foo0004
d-foo0002

因为这种分配是随机的,同一条记录有可能会分配到不同的前缀,这样要想按字典顺序检索出记录就得做更多的工作。

使用盐化的方式扩大了写入的吞吐量,却也增加了读取数据的开销。

哈希

要替代随机分配盐的方案,可以使用单向哈希。这使用单向哈希每个行都会分配到一个固定的前缀。这样记录仍可以被写到不同的region上,不过在读的时候可以保证做到精确读取。使用确定性哈希允许客户端重建完整的行键并使用Get操作准确检索到记录。

仍然是前面盐化方案中的例子,可以使用单向哈希给“foo0003”这条记录始终分配一个可以预知的前缀“a”。这样在检索这一行记录的时候就可以就可以准确计算出行键。基于这个方案我们可以对行键做一些优化,比如让特定的一对行键总是被分配到相同的region中。

反转行键

第三个常见的防止热点的技巧是反转行键。也就是将固定宽度或数值类型的行键反转,这样行键中变化最频繁的部分(最低有效位)就会排到第一位。这有效地对行键进行了随机化,不过却牺牲了行排序属性。

单调递增行键或时间序列数据

在使用HBase时要警惕一个现象:所有的客户端集中锁定访问表的一个region(也就是hbase的一个节点),然后又全部移动向下一个region,就这样推进并形成循环。在使用单调递增的行键(比如使用时间戳)时就容易发生这样的情况。在BigTable类的数据存储中使用单调递增的行键是一个坏的选择。 这里有一幅漫画 描述了为什么单调递增是不好的。虽然可以采用随机化的方式打乱输入记录的排序顺序来减轻单调递增行键造成的单个region的访问热点的问题,但是通常也应尽量避免使用时间戳或者一个自增序列(比如1、2、3)作为行键。

如果确实需要上传时间序列数据到HBase,可以学习一个成功案例: OpenTSDB 。这个项目有一个页面专门描述了它在HBase中使用的schema。OpenTSDB实际上使用的行键格式是:[metric_type][event_timestamp]。乍一看这和我们前面说的不建议使用时间戳作为行键是冲突的。差别就在于这里没有将时间戳置于行键的前导位置,并且在设计中假设存在几十几百个(或者更多)不同的metric_type。因此,尽管有不间断的输入数据流,相关的Puts操作也会被分发向不同的region。

尝试最小化行键和列的大小

在HBase中,每一个值都有与其对应的坐标。比如HBase系统中的一个cell value,它的坐标就是行键名、列名还有时间戳。如果行键名和列名很大——尤其是与cell value一起比较的时候——就可能会遇到一些有趣得情况。在 HBASE-3551 中Marc Limotte就描述了这样的一种情形。这里描述的问题是保存在HBase存储文件(HFile)上以便于随机访问的索引占用了大部分分配给HBase的RAM,原因就是cell value的坐标太大了。刚才引用的这个问题,在评论中有人建议调大blocksize,这样为HBase存储记录新建索引的时间间隔就会大一些,或者是修改表设计使用更小的列名和行键名。此外,压缩也能导致索引变大, 在这里 提到了一个相关的案例。

大多数时候这种小的低效率并没有大的影响。不幸的是它们确实有发生的契机,尤其是在需要访问几十亿条数据的时候。

行键长度

行键长度固然是越短越好,但是应尽量保证行键有意义,以便于对数据进行访问(包括Get和scan)。一个短的行键并不比一个虽然长却有利于scan或get的行键更好。因此在设计行键时需要反复权衡。

关于列族

列族名称应该尽量的小,最好是1个字母,比如使用d来替换default或data。

关于属性名称

尽管冗长的属性名称(比如: myVeryImportantAttribute )更容易理解,但是保存在HBase中的属性名称应该是越短越好(比如使用via)。

字节模式

一个长整型值的长度是8个字节。在这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
MessageDigestmd = 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

使用字节代表一个类型有一个缺点:将会使数据在代码以外的地方很难阅读。下面这个例子演示了在hbase shell中对一个值执行increase操作的结果:

hbase(main):001:0> incr 't', 'r', 'f:q', 1
COUNTERVALUE = 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

hbase shell会尽最大努力打印一个字符串。在这种情况下,它只会打印16进制的值。同样的情况也会发生在region内的行键上。通常如果知道正在写入的数据是什么是最好的,不过写入cell内的可以是任何内容,包括可读的和不可读的。这点在设计时需要仔细衡量。

反向时间戳

数据库中一个常见的问题是如何快速的找到一个值的最新版本。在某些特殊情况下使用反向时间戳技术作为行键的一部分可以很好地处理这个问题。反向时间戳即(Long.MAX_VALUE – timestamp)。通常是将反向时间戳追加在行键末尾,即[行键][reverse_timestamp]。这样,在通过一个原始的行键执行scan搜索时检索到的第一个值就是这个原始行键所对应的最新的值。

使用反向时间戳替换HBase的Versions管理的目的是永久保留一些值的所有版本,并在使用相同的Scan方案的时候仍然能够保持快速访问其它任何版本的能力。

行键和列族

行键的作用域是列族。因此一个表中的列族可以有相同的行键而不冲突。

行键的不变性

行键是不可变的。一个表中的行键可以被“改变”的方式就是将记录删除后再重新插入。

行键和region split的关系

如果要对表进行预分区,那么弄清楚region边界处的行键分布就很重要了。下面的例子说明了为什么这很重要,在实例中使用了可视化十六进制字符作为行键(比如”0000000000000000″ 到 “ffffffffffffffff”)。使用Bytes.split(这也是用Admin.createTable(byte[] startKey, byte[] endKey, numRegions)方法创建分区时使用的split策略)处理这个范围内的行键,计划创建 10 个分区,目前有如下的splits数组作为splitKey:

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

(注意:右侧的注释说明了前导字节的值)。如上面所示的,第一个split字节是字符‘0’,最后一个split字节是字符‘f’。看起来就是这样,没毛病,是不是?先别着急下定论。

问题是所有的数据将会堆积在前两个region以及最后一个region处,并因此产生lumpy region(也有可能是hot region)问题。要理解为什么,可以先参考ASCII表。字符‘0’的byte值是48,字符‘f’的byte值是102。但是在字节值中有一个巨大的间隙(字节58 到 96)永远不会出现在行键空间中,因为有效值是[0-9]和[a-f]。因此中间部分的region将永远不会出现。要使示例的行键设计在预分区工作中生效,需要使用一个自定义的split策略(而不仅仅是依赖内置的split方法)。

从刚才描述的问题,我们能够获得如下两条经验:

经验一:预分区通常是一个不错的方案,但是需要确保预分区得到的所有regions是对行键可达的。在这个例子里演示了使用十六进制字符作为行键前缀造成的问题,类似的问题也可能发生在其他类型的行键设计上。因此首先要做的一件事情就是充分了解要写入HBase的数据。

经验二:尽管通常不建议,使用十六进制字节(通常建议使用可视化数据)作为预分区表的行键前缀也是可行的——只要创建的所有分区对于设计的行键是可达的即可。

为了好好结束这个例子,下面提供了一种适合十六进制行键的预分区split生成方案:

    public static boolean createTable(Adminadmin, HTableDescriptortable, byte[][] splits) throws IOException {
        try {
            admin.createTable(table, splits);
            return true;
        } catch (TableExistsException e) {
            logger.info("table " + table.getNameAsString() + " already exists");
            return false;
        }
    }
 
    public static byte[][] getHexSplits(String startKey, String endKey, int numRegions) {
        byte[][] splits = new byte[numRegions - 1][];
        BigIntegerlowestKey = new BigInteger(startKey, 16);
        BigIntegerhighestKey = new BigInteger(endKey, 16);
        BigIntegerrange = highestKey.subtract(lowestKey);
        BigIntegerregionIncrement = range.divide(BigInteger.valueOf(numRegions));
        lowestKey = lowestKey.add(regionIncrement);
        for (int i = 0; i < numRegions - 1; i++) {
            BigIntegerkey = lowestKey.add(regionIncrement.multiply(BigInteger.valueOf(i)));
            byte[] b = String.format("%016x", key).getBytes();
            splits[i] = b;
        }
        return splits;
    }

参考文档

Configuring The Blocksize For HBase

##################

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章