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 被用来存储 顾客和订单信息,将由俩类主要记录类型:顾客记录类型和订单记录类型。 顾客记录包含如下信息: