HBase Rowkey设计

一、HBase的Schema和cf列族

1、Schema的创建修改

    Hbase模式建立或更新可以通过Hbase shell工具或者Hbase java API中的Admin类。当列族发生变动时hbase表必须处于disabled状态。例如:

Configuration config = HBaseConfiguration.create();
Admin admin = new Admin(conf);
String table = "myTable";

admin.disableTable(table);

HColumnDescriptor cf1 = ...;
admin.addColumn(table,cf1);         //增加新列族
HColumnDescriptor cf2 = ...;
admin.modifyColumn(table,cf2);     //修改列族

admin.enableTable(table);

 2、列族的数量

    Hbase目前对两个或3个列族的处理不是很好,所以我们尽可能保持列族数量少。目前flushing和compactions操作是以每个region为基础的,所以如果一个列族大部分数据进行flush操作,将导致临近的列族也会flush,即使它的数据量很少。当许多列族存在flush和compaction操作时,会导致大量的I/O请求。

    尽可能只使用一个列族,引入第2、3个列族当且仅当你的数据访问是在列级别的。例如:你的一次数据访问只会请求同一列族的数据,但不会垮列族请求。

3、列族的基数

    当一个表有多个列族时,应当意识到基数(例如:行数)的问题。如果列族A有100万行,列族B有10亿行,那么列族A的数据很可能会分撒到很多的region,这会使列族A的scan操作效率降低。

二、RowKey设计

1、热点

    hbase中的行是以rowkey的字典排序的,这种设计优化了scan 操作,可以将相关的 行 以及会被一起读取的行 存取在临近位置,便于 scan 。 然而,糟糕的 rowkey 设计是 热点 的源头。 热点发生在大量的客户端直接访问集群的一个或极少数节点。访问可以是读,写,或者其他操作。大量访问会使 热点region 所在的单个机器超出自身承受能力,引起性能下降甚至是 region 不可用。这也会影响同一个 regionserver 的其他 regions,由于主机无法服务其他region 的请求。设计良好的数据访问模式以使集群被充分,均衡的利用。

    为了避免写热点,设计 rowkey 使得 不同行在同一个 region,但是在更多数据情况下,数据应该被写入集群的多个region,而不是一个。下面是一些常见的避免 热点的方法以及它们的优缺点:

①加盐

    这里的加盐不是密码学中的加盐,而是在rowkey 的前面增加随机数。具体就是给 rowkey 分配一个随机前缀 以使得它和之前排序不同。分配的前缀种类数量应该和你想使数据分散到不同的 region 的数量一致。 如果你有一些 热点 rowkey 反复出现在其他分布均匀的 rwokey 中,加盐是很有用的。考虑下面的例子:它将写请求分散到多个 RegionServers,但是对读造成了一些负面影响。

    加盐例子:假如你有下列 rowkey,你表中每一个 region 对应字母表中每一个字母。 以 'a' 开头是同一个region, 'b'开头的是同一个region。在表中,所有以 'f'开头的都在同一个 region, 它们的 rowkey 像下面这样:

foo0001
foo0002
foo0003
foo0004

    现在假如你需要将上面这个 region 分散到 4个 region。你可以用4个不同的盐:'a', 'b', 'c', 'd'.在这个方案下,每一个字母前缀都会在不同的 region 中。加盐之后,你有了下面的 rowkey:

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

    所以,你可以向4个不同的 region 写,理论上说,如果所有人都向同一个region 写的话,你将拥有之前4倍的吞吐量。现在,如果再增加一行,它将随机分配a,b,c,d中的一个作为前缀,并以一个现有行作为尾部结束:

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

    因为分配是随机的,所以如果你想要以字典序取回数据,你需要做更多工作。加盐这种方式增加了写时的吞吐量,但是当读时有了额外代价。

②哈希

    除了加盐,你也可以使用哈希,哈希会使同一行永远用同一个前缀加盐。哈希也可以使负载分散到整个集群,但是读却是可以预测的。使用确定的哈希可以让客户端重构完成的 rowkey,使用Get 操作获取正常的获取某一行数据。

    哈希例子:像在加盐方法中给出的那个例子,你可以使用某种哈希方法使得 foo0003 这样的 rowkey 的前缀永远是 ‘a',然后,为了取得某一行,你可以通过哈希获得 相应的 rowkey. 你也可以优化哈希方法,使得某些rowkey 永远在同一个 region.

③翻转key

    第三种防止热点的方法是翻转固定长度或者数字格式的rowkey。这样可以使得rowkey中经常改变的部分(最没意义的部分)放在前面。这样可以有效的随机 rowkey,但是牺牲了 rowkey 的有序性。

2、单调递增 rwokey(时间连续序列)

    在《Hadoop 权威指南》中,有一个优化的注意点:当所有客户端一段时间内一致写入某一个region,然后再接着写入下一个 region。例如:像单调递增的 rowkey(时间戳) ,就会发生这种现象。 可以查看 Kai Lan的漫画monotonically increasing values are bad. 说明了为什么单调递增的rowkey 在分布式表格系统(Hbase)中是有问题的。这种单调递增的rowkey 堆积在同一个region 的问题可以通过 随机化 输入记录来缓和。但通常来讲我们应该避免使用时间戳或者序列(1,2,3)来作为主键。

    如果你的确需要在Hbase存储时间序列数据,可以学习 OpenTSDB,它是个成功的例子。链接schema有一页描述了OpenTSDB在hbase中使用的模式。OpenTSDB中的key模式是:[元数据类型][时间戳],初看起来这似乎违反了不使用时间戳作为rowkey的原则,然而,区别是时间戳并没有在rowkey的关键位置,而且这个设计假设拥有许多元数据类型。因此,即使有连续的混合着元数据的输入数据,它们也会Put进入表中不同的regions.

3、尽量减少行和列的大小

    在Hbase中,value永远是和它的key一起传输的。当具体的值在系统间传输时,它的rowkey,列名,时间戳也会一起传输。如果你的rowkey和列名很大,甚至可以和具体的值相比较,那么你将会遇到一些有趣的情况。HBase storefiles中的索引(有助于随机访问)最终占据了HBase 分配的大量内存,因为具体的值和他的key很大。可以增加 block 大小使得 storefiles 索引在更大的时间间隔增加,或者修改表的模式以减小rowkey 和 列名的大小。压缩也有助于更大的索引。

    大多时候较小的低效率是无关紧要的,但是在这种情况下,任何访问模式都需要列族名,列名,rowkey,所以它们会被访问数十亿次在你的数据中。

4、列族

    尽可能使列族名越短越好,最好是一个字符。(例如:'d' 代表data/default)。

5、属性

    冗长的属性名("myVeryImportantAttribute")是易读的,但是更短的属性名("via")存储在HBase中更好。

6、Rowkey 长度

    让 Rowkey 越短越好是合理的,这对必需的数据访问(get,scan)是有益的。但是当短 key 对数据访问是无用时它不及长 key 拥有更好的get/scan属性。当设计 rowkey 时,我们需要权衡,折中。

7、字节 模式

    长整形是 8 字节,你可以存储无符号数至到18,446,744,073,709,551,615 用 8 字节。如果你用字符串形式存储,一个字符一个字节,你需要将近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 会尽力打印字符串,但是这种情况下,它只能打印 16进制。 这也会在你的 rowkey 中发生。如果你知道存的是什么那当然没什么,但是如果任意数据存放在具体的值中,那将难以阅读。这也需要权衡。

8、翻转时间戳

    一个常见的数据库处理问题是快递获取数据的最近版本,使用翻转的时间戳作为rowkey的一部分对这个问题十分有用,可以将Long.MAX_VALUE - timestamp追加到key的末尾,例如:[key][reverse_timestamp]

    表中[key]的最新值可以通过scan [key]获得 [key]的第一条记录,因为Hbase中 rowkey 是有序的,最新的 [key]在任何更旧的[key]之前,所以第一条记录就是最新的。

    这个技巧可以替代 使用 多版本数据,多版本数据会永久(很长时间)保存数据的所有版本。同时,这个技巧用一个scan操作就可以获得数据的所有版本。

9、rowkey 和 列族

    rowkey 对于 列族是可见的,因此,同一个rowkey 可以无冲突存在 表中的每一个列族。

10、rowkey 的不变性

    rowkey 是不可以改变的,改变的唯一方式是删除这行,再插入这行。这是相当普遍的问题,所以第一次正确获取 rowkey 是值得的,

11、rowkey 和 region 分裂的关系

    如果你要预分你的表,理解你的 rowkey 如何以 region 的边界分布是十分重要的,考虑这个例子,使用可显示的16进制字符作为 key 的关键位置。(例如"0000000000000000" to "ffffffffffffffff") 对10个region 运行这些key 序列通过 Bytes.split(这是一种分裂策略当创建region 通过 Admin.createTable(byte[] startKey, byte[] endKey, numRegions 方式时)将会产生下面的结果:

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'. 一切都很好,不是吗?不要急于下结论

   问题是所有数据都堆积在前俩个 和最后一个 region,因此产生了 热点问题。为了理解原因,可以查看 ASCII Table.字符'0'对应字节 48,字符'f'对应字节102,所以字节58-96没有对应的字符,因为字符值只有'0'-'9','a'-'f'.所以,中间的 region 永远不会被使用。 因此,针对这个字符空间,为了预分裂,我们必须自己定义如何分裂。

  • 经验1:预分裂表通常是很好的方法,但是你应该使得所有的 region 都可以 对应 所有的 分裂字符。然而在这个演示例子中问题是使用了16进制的字符空间,同样的问题也会在任意的字符空间发生,所以我们必须熟悉我们数据才能进行正确的预分裂。
  • 经验2:虽然通常不建议,但是使用16进制的字符空间也可以使得所有的region 对应所有的字符。

    下面这个例子展示了如何使用 16进制字符空间进行合适的预分region:

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;
}
三、版本号

1、最大版本号

    行的最大版本是通过 HColumnDescriptor定义在每一个列族的,默认的最大版本号是1.这是一个重要的参数,因为Hbase不会覆盖一行的值,而是每一行根据时间戳存储多个值。超过最大版本号的值将在 major compactions 时被删除,最大版本号可以根据你应用的需求增大或者减小。

    不推荐设置 最大版本号 为很大的值(数百甚至更多),除非旧的数据对你而言十分重要。因为太多的版本会使 StoreFile 很大。

2、最小版本号

    像最大版本号一样,最小版本号也是通过 HColumnDescriptor定义在每一个列族。默认的最小的版本号是0,意味着这个功能是禁用的。最小版本号参数,TTL参数一般组合起来和版本号一起完成这个功能:保留T分钟内的数据,但是至多N个版本,最少M个版本。其中,M是最小版本号,M<N.这个参数应该当列族的TTL参数开启时使用,而且一定要小于正常的版本号。

四、Schema Design Case Studies

    下面将讨论一些典型的hbase数据使用案例,可以学习 rwokey 和整体结构的的设计。 注意:这仅仅是可能方法的说明,不是详尽的列表。我们需要熟悉我们的数据,明确我们的需求。

    有以下的案例:

  • 日志数据/时间序列数据
  • 日志数据/增强型时间序列数据
  • 顾客数据/订单数据
  • 高,宽,中型的Schema Design
  • 表格数据

1、日志数据和时间序列数据

    假设如下的数据元素可以被获得:

  • 主机名
  • 时间戳
  • 日志事件
  • 值/消息

    我们可以上面数据保存在hbase的LOG_DATA表中,但是 rowkey是什么呢?从上面的数据,rwokey 可以是 主机名,时间戳,日志事件。但具体的呢?

①时间戳在rowkey 的主要位置

    rowkey :[timestamp][hostname][log-event] 会遇到之前提到的单调递增rowkey 问题。

    有一个经常提到的“桶”时间戳方法,通过对时间戳取模来实现。如果时间方向的scan操作十分重要,这种方法是十分有用的。必须注意桶的数量,以为需要同样数量的scan 操作返回结果。

long bucket = timestamp % numBuckets;

   现在rowkey 设计如下:

[bucket][timestamp][hostname][log-event]

    像上面提到的,要选择一个时间范围内的数据,scan 操作需要对每一个 桶执行,假如有100个桶,就需要100个scan才能获得完整的数据。所以我们需要一个权衡。

②主机名在rwokey 的关键位置

    rowkey [hostname][log-event][timestamp]是一个备选,如果有大量的主机名需要读写,如果通过主机名的scan 操作是主要的这种方式就很有用。

③时间戳,翻转时间戳

    如果获取最新的数据是最主要的访问数据方式,那么存储 时间戳 或者翻转时间戳(timestamp = Long.MAX_VALUE – timestamp),可以通过 scan [hostname][log-event]快速获取最新的数据。

    没有一种方法是错的,要看最合适的情况。

④变长还是固定长度的 rowkey

    我们应该十分明确 rwokey 是存储在Hbase 的每一列的,如果主机名是 a,事件类型是 e1,那么最终的rowkey 长度就会十分小。然而,如果存储的主机名是myserver1.mycompany.com ,事件类型是 com.package1.subpackage2.subsubpackage3.ImportantService呢?

    所以使用一些代替在 rowkey 是有意义的,至少有俩种方法:哈希和数字化。在主机名在 rwokey 关键位置的例子中,可能看起来像这样:

    (1)用哈希合成 rwokey:
[MD5 hash of hostname] = 16 bytes
[MD5 hash of event-type] = 16 bytes
[timestamp] = 8 bytes

    (2)用数字合成rowkey:   

     这种方法,除了 LOG_DATA 之外,我们还需要一张查找表 LOG_TYPES。LOG_TYPES的主键可以是:

[type] (表明是 hostname 还是 event-type)
[bytes] ( hostname or event-type的 原始字节长度)

     所以最终的符合rowkey如下:

[ hostname 对应的长整形] = 8 bytes
[event-typ 对应的长整形] = 8 bytes
[timestamp] = 8 bytes

    无论是使用 哈希 还是数字,hostname,event-typ的原始值都可以存储在列值中。

2、日志数据和增强型时间序列数据

    OpenTSDB 中的方法是有效的,OpenTSDB 做的是重写数据将一定时间周期内的行数据批量才能存入列中,详细的解释,可以查看:

    一般情况下数据是这样存储的:

[hostname][log-event][timestamp1]
[hostname][log-event][timestamp2]
[hostname][log-event][timestamp3]

    OpenTSDB重写会是这样

[hostname][log-event][timerange]

   上面的每一个的log-event都存入列中,还有相对于 timerange(例如:每5分钟)的时间偏移量。 

   这显然是十分高级的处理技巧,但是hbase让这成为可能。

3、Case Study - Customer/Order

    假如hbase 被用来存储 顾客和订单信息,将由俩类主要记录类型:顾客记录类型和订单记录类型。 顾客记录包含如下信息:

相关推荐