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);
Hbase目前对俩个或3个列族的处理不是很好,所以我们应尽可以保持列族数量少。目前 flushing 和 compactions 操作是以每一个 region 为基础的,所以如果一个列族大部分数据进行 flush 操作,将导致临近的列族也会 flush,即使它的数据量很小。当许多列族存在 flush 和 compaction操作时,会导致大量的 I/O 请求。(可以通过改变 flush 和 compaction 操作以列族为基础 来解决。)
查看 compaction 可以获取 compaction 更多信息。
尽可能只使用一个列族,引入第2,3个列族当且仅当你的数据访问是在列级别的。例如:你的一次数据访问只会请求同一列族的数据,但不会跨列族请求。
当一个表有多个列族时,应意识到 基数(例如:行数)的问题。 如果列族A 有100 万行,列族B 有10 亿行,那么列族A的数据很可能会分散到很多的region ,这会使列族A的scan 操作效率降低。
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.
第三种防止热点的方法是翻转固定长度或者数字格式的rowkey。这样可以使得rowkey中经常改变的部分(最没意义的部分)放在前面。这样可以有效的随机 rowkey,但是牺牲了 rowkey 的有序性。
在《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.
在Hbase中,value永远是和它的key一起传输的。当具体的值在系统间传输时,它的rowkey,列名,时间戳也会一起传输。如果你的rowkey和列名很大,甚至可以和具体的值相比较,那么你将会遇到一些有趣的情况。HBase storefiles中的索引(有助于随机访问)最终占据了HBase 分配的大量内存,因为具体的值和他的key很大。可以增加 block 大小使得 storefiles 索引在更大的时间间隔增加,或者修改表的模式以减小rowkey 和 列名的大小。压缩也有助于更大的索引。
大多时候较小的低效率是无关紧要的,但是在这种情况下,任何访问模式都需要列族名,列名,rowkey,所以它们会被访问数十亿次在你的数据中。
尽可能使列族名越短越好,最好是一个字符。(例如:'d' 代表data/default)。
冗长的属性名("myVeryImportantAttribute")是易读的,但是更短的属性名("via")存储在HBase中更好。
让 Rowkey 越短越好是合理的,这对必需的数据访问(get,scan)是有益的。但是当短 key 对数据访问是无用时它不及长 key 拥有更好的get/scan属性。当设计 rowkey 时,我们需要权衡,折中。
长整形是 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 中发生。如果你知道存的是什么那当然没什么,但是如果任意数据存放在具体的值中,那将难以阅读。这也需要权衡。
一个常见的数据库处理问题是快递获取数据的最近版本,使用翻转的时间戳作为rowkey的一部分对这个问题十分有用,可以将Long.MAX_VALUE - timestamp
追加到key的末尾,例如:[key][reverse_timestamp]
表中[key]的最新值可以通过scan [key]获得 [key]的第一条记录,因为Hbase中 rowkey 是有序的,最新的 [key]在任何更旧的[key]之前,所以第一条记录就是最新的。
这个技巧可以替代 使用 多版本数据,多版本数据会永久(很长时间)保存数据的所有版本。同时,这个技巧用一个scan操作就可以获得数据的所有版本。
rowkey 对于 列族是可见的,因此,同一个rowkey 可以无冲突存在 表中的每一个列族。
rowkey 是不可以改变的,改变的唯一方式是删除这行,再插入这行。这是相当普遍的问题,所以第一次正确获取 rowkey 是值得的,
如果你要预分你的表,理解你的 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 永远不会被使用。 因此,针对这个字符空间,为了预分裂,我们必须自己定义如何分裂。
下面这个例子展示了如何使用 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;
}
行的最大版本是通过 HColumnDescriptor定义在每一个列族的,默认的最大版本号是1.这是一个重要的参数,因为Hbase不会覆盖一行的值,而是每一行根据时间戳存储多个值。超过最大版本号的值将在 major compactions 时被删除,最大版本号可以根据你应用的需求增大或者减小。
不推荐设置 最大版本号 为很大的值(数百甚至更多),除非旧的数据对你而言十分重要。因为太多的版本会使 StoreFile 很大。
像最大版本号一样,最小版本号也是通过 HColumnDescriptor定义在每一个列族。默认的最小的版本号是0,意味着这个功能是禁用的。最小版本号参数,TTL参数一般组合起来和版本号一起完成这个功能:保留T分钟内的数据,但是至多N个版本,最少M个版本。其中,M是最小版本号,M<N.这个参数应该当列族的TTL参数开启时使用,而且一定要小于正常的版本号。
下面将讨论一些典型的hbase数据使用案例,可以学习 rwokey 和整体结构的的设计。 注意:这仅仅是可能方法的说明,不是详尽的列表。我们需要熟悉我们的数据,明确我们的需求。
有以下的案例:
假设如下的数据元素可以被获得:
我们可以上面数据保存在hbase的LOG_DATA表中,但是 rowkey是什么呢?从上面的数据,rwokey 可以是 主机名,时间戳,日志事件。但具体的呢?
rowkey :[timestamp][hostname][log-event] 会遇到之前提到的单调递增rowkey 问题。
有一个经常提到的“桶”时间戳方法,通过对时间戳取模来实现。如果时间方向的scan操作十分重要,这种方法是十分有用的。必须注意桶的数量,以为需要同样数量的scan 操作返回结果。
long bucket = timestamp % numBuckets;
现在rowkey 设计如下:
[bucket][timestamp][hostname][log-event]
像上面提到的,要选择一个时间范围内的数据,scan 操作需要对每一个 桶执行,假如有100个桶,就需要100个scan才能获得完整的数据。所以我们需要一个权衡。
rowkey [hostname][log-event][timestamp]
是一个备选,如果有大量的主机名需要读写,如果通过主机名的scan 操作是主要的这种方式就很有用。
如果获取最新的数据是最主要的访问数据方式,那么存储 时间戳 或者翻转时间戳(timestamp = Long.MAX_VALUE – timestamp)
,可以通过 scan [hostname][log-event]
快速获取最新的数据。
没有一种方法是错的,要看最合适的情况。
我们应该十分明确 rwokey 是存储在Hbase 的每一列的,如果主机名是 a,事件类型是 e1,那么最终的rowkey 长度就会十分小。然而,如果存储的主机名是myserver1.mycompany.com ,事件类型是 com.package1.subpackage2.subsubpackage3.ImportantService呢?
所以使用一些代替在 rowkey 是有意义的,至少有俩种方法:哈希和数字化。在主机名在 rwokey 关键位置的例子中,可能看起来像这样:
[MD5 hash of hostname] = 16 bytes
[MD5 hash of event-type] = 16 bytes
[timestamp] = 8 bytes
这种方法,除了 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的原始值都可以存储在列值中。
OpenTSDB 中的方法是有效的,OpenTSDB 做的是重写数据将一定时间周期内的行数据批量才能存入列中,详细的解释,可以查看:
一般情况下数据是这样存储的:
[hostname][log-event][timestamp1]
[hostname][log-event][timestamp2]
[hostname][log-event][timestamp3]
OpenTSDB 重写会会是这样:
[hostname][log-event][timerange]
上面的每一个的log-event都存入列中,还有相对于 timerange(例如:每5分钟)的时间偏移量。
这显然是十分高级的处理技巧,但是hbase让这成为可能。
假如hbase 被用来存储 顾客和订单信息,将由俩类主要记录类型:顾客记录类型和订单记录类型。 顾客记录包含如下信息:
假设顾客ID 和订单信息的组合可以唯一确定一笔订单,这俩个属性将会组合为 rwokey. 一个特别的 ORDER表达的rowkey 如下:
[customer number][order number]
然而,还有更多的设计需要做决定:原始的值是rowkey的最佳选择吗?
这里我们遇到了之前 日志数据案例的相同问题, customer number 的字符空间是什么?格式是什么?(数字型的,数字字母混合的)在hbase中使用定长 rowkey 是有益的,rowkey 也需要支持 字符空间的合理分布,相似的选项出现了:
用哈希组合 rowkey:
[MD5 of customer number] = 16 bytes
[MD5 of order number] = 16 bytes
用哈希,数字组合 rowkey:
[customer number 的长整形] = 8 bytes
[MD5 of order number] = 16 bytes
传统的方法是为 顾客和订单 建立各自的表,另一个选项是将所有的记录存入一张表。(例如:CUSTOMER++)
顾客记录的 rowkey:
[customer-id]
[type] = 类型 `1' 代表 customer record type
订单记录的 rowkey:
[customer-id]
[type] = 类型 `2' 代表 for order record type
[order]
特殊的CUSTOMER++方法的优点是可以通过 customer-id 组织所有不同类型的数据。(例如:1次scan 就可以获得 一个顾客的所有信息)缺点是不容易 scan 特殊的 记录类型。
现在我们需要考虑订单对象的建模,假设类结构如下: Order: 一个 Order 有许多 ShippingLocations(运送位置) LineItem 一个 ShippingLocations 有多个 LineItem 存储这种数据有多种选择:
[order-rowkey]
[shipping location number] (例如:第一个位置,第二个等等)
LINE_ITEM 表的 复合rowkey 如下:
[order-rowkey]
[shipping location number] (例如:第一个位置,第二个等等)
[line item number] (例如:, 第一个 lineitem,第二个等等)
这个范式化设计和RDBMS很想,但这不是你使用 hbase的唯一选择。这种方法下,你要获取任何一个订单信息,你需要:
就算 RDBMS 会在幕后都做这些,但是你必须认识到一个现实:那就是在hbase中没有join.
用单表存储所有记录 在这种方法中:所有信息都会存储在ORDER表中:
ORDER 的rowkey 设计如下:
[order-rowkey]
[ORDER record type]
ShippingLocation 的复合 rowkey 如下:
[order-rowkey]
[SHIPPING record type]
[shipping location number] (e.g., 1st location, 2nd, etc.)
LineItem 的复合 rowkey 如下:
[order-rowkey]
[LINE record type]
[shipping location number] (e.g., 1st location, 2nd, etc.)
[line item number] (e.g., 1st lineitem, 2nd, etc.)
LineItem 的复合 rowkey 如下:
[order-rowkey]
[LINE record type]
[line item number] (e.g., 1st lineitem, 2nd, etc., 必须意识到 line item number 对于整个 表式唯一的)
LineItem 列族情况如下:
itemNumber
quantity
price
shipToLine1 (denormalized from ShippingLocation)
shipToLine2 (denormalized from ShippingLocation)
shipToCity (denormalized from ShippingLocation)
shipToState (denormalized from ShippingLocation)
shipToZip (denormalized from ShippingLocation)
这种设计的优点是没有复杂的对象层次,缺点是任何信息的更新将会十分复杂。
这种方法是把整个表作为一个二进制大对象,例如: ORDER 表的rwokey设计和之前一样,有一个 "order" 的单列是一个可以反序列化为 Order, ShippingLocations, and LineItems.的对象。
可以有许多选择: JSON, XML, Java Serialization, Avro, Hadoop Writables。 它们的原理都是一样的:将对象用二进制编码。这种方法必须注意向后兼容性,即使对象模型改变了,我们也可以从hbase 读取对象的旧版本。
优点是可以用最小的IO管理复杂的对象,(例如:hbase 中 get 每个 order)确定值前面提到的向后兼容性,序列化的语言依赖(例如:Java Serialization 只能在java客户端工作),事实上,你想要获得二级制大对象的任意小的信息,你都必须反序列化整个对象,而且你很难用像hive这样的框架去处理你自定义的对象。
这章主要考虑高表和宽表,这是一些参考和注意点,每一个应用都需要考虑它自己的需求。
一个常见的问题是我们应该选择rowkey 还是版本号,典型的场景就是我们需要保留一行数据的的许多版本。用rowkey的方法就是将时间戳作为 rowkey 的一部分,那样每一次数据的更新都不会覆盖。
通常来讲,我们选择 rowkey
另一个常见问题是我们应该用 行还是 列。这个问题的典型场景就是1行有100万个列好,还是100万行每行只有1列好? 通常来将,我们选择 rowkey 要清楚的是,在这个场景下选择rowkey就会有很宽的表,并不是标准使用案例的数十个或数百个表。但是也有一种折中方案,那就是下面的”Rows as Columns“。
在选择行或者列的折中方案是将分开的行数据打包存进列中,对一定数量的行,OpenTSDB 是最好的例子,用单行数据代表一个时间范围,离散时间按照 列处理。这个方法一般比较复杂,需要重写你的数据,优点是I/O很高效。
下面例子是一个相当普遍的问题,如何处理每个用户列表数据在hbase. 问题: 如何存储大量的列表数据在hbase以及我们努力寻找哪种数据访问模式最有意义 一个选择是存储多数数据作为key,我们可以这样做:
<FixedWidthUserName><FixedWidthValueId1>:"" (no value)
<FixedWidthUserName><FixedWidthValueId2>:"" (no value)
<FixedWidthUserName><FixedWidthValueId3>:"" (no value)
另一个选择是我们完全使用:
<FixedWidthUserName><FixedWidthPageNum0>:<FixedWidthLength><FixedIdNextPageNum><ValueId1><ValueId2><ValueId3>...
<FixedWidthUserName><FixedWidthPageNum1>:<FixedWidthLength><FixedIdNextPageNum><ValueId1><ValueId2><ValueId3>...
其中每行包含许多值,所以获取前30个值的方法如下:
scan { STARTROW => 'FixedWidthUsername' LIMIT => 30}
另一种使用场景可以像这样:
get 'FixedWidthUserName\x00\x00\x00\x00'
常规用法只会获取列表的前30项数据,很少有访问列表的更多数据,一些用户的列表数据可能少于30项,一些用户可能有数百万。
单值模式看起来要占更多空间在hbase,但是可以更加灵活的检索和分页,这种方式对get分页或者scan分页有重大性能优势吗?
我最初的理解是页数未知的话scan操作可能更快,(缓存设置合适)但是get 应该更快如果我们一直访问同一页数据,我最终收到许多人在性能上给我相反的意见。我假设页大小是相对一致的,目前为止的大多数场景我们可以保证在页长度不变的情况下我们只需要一页的数据,我将假设我们需要罕见的更新,而且插入列表数据的中部。
答案:
如果我理解正确,最终是需要存储"user, valueid, value"这样的三元组,像这样:
"user123, firstname, Paul",
"user234, lastname, Smith"
(其中userid 是固定宽度,valuesid 是固定长度)
你的访问模式可能是这样: 对于用户X ,列出从valueidY开始的30条数据。所有返回数据需要按 valueid 排序吗?
hbase的常见设计问题:宽还是高?
注意没有一种方法从本质上比另一种占用更多磁盘空间。你仅仅是将具体值的key从”左“(row key)移动到了”右“(column qualifiers ),在这些方法下,每一个 key/value 依然存储完整的 rowkey, column family name.
手动分页更加复杂,像跟踪每一页的许多事情,如果有插入还需要重新分页,这看起来更加复杂。它可能有轻微的速度优势在极高的吞吐量下,唯一真正知道它们的方法就是亲自去实践。如果你没有时间去实践和比较,我的建议是用最简单的方法:每个 user+value一行。