HBase1.2官方文档——RegionServer Sizing Rules of Thumb
RegionServer 大小的经验规则
Lars Hofhansl写了很棒的关于RegionServer内存大小的帖子blog post。结论是你需要的内存可能比你想象的要多。他研究了region大小、memstore大小、HDFS副本因子和其他要检查的因素的影响。
“就我自己而言,我会给专门运行HBase的每台机器配置6T左右的最大磁盘空间,除非你有量非常大的读取操作的工作负载。
在那种情况下,Java堆内存应该是32GB(20G分配给regions,128M分配给memstore,剩下的为默认值)。”
"Personally I would place the maximum disk space per machine that can be served exclusively with HBase around 6T, unless you have a very read-heavy workload. In that case the Java heap should be 32GB (20G regions, 128M memstores, the rest defaults)."
— Lars Hofhansl http://hadoop-hbase.blogspot.com/2013/01/hbase-region-server-memory-sizing.html
35. 关于列族的个数
现在HBase并不能很好的处理两个或者三个以上的列族,所以尽量让你的列族数量少一些。目前,flush和compaction操作是针对一个Region。所以当一个列族操作大量数据的时候会引发一个flush。
那些不相关的列族也会进行flush操作,尽管他们没有多少数据。Compaction操作现在是根据一个列族下的全部文件的数量触发的,而不是根据文件大小触发的。
当很多的列族在flush和compaction时,会造成很多没用的I/O负载(要想解决这个问题,需要将flush和compaction操作只针对一个列族) 。 更多紧缩信息, 参考Compaction。
尽量在你的schema中使用一个列族。只有你的所有查询操作只访问一个列族的时候,可以引入第二个和第三个列族.例如,你有两个列族,但你查询的时候总是访问其中的一个,从来不会两个一起访问。
35.1. 列族的基数
一个表存在多列族,注意基数(如, 行数). 如果列族A有100万行,列族B有10亿行,列族A可能被分散到很多很多Region(及RegionServer)。这导致扫描列族A低效。
36. 行键(RowKey)设计
36.1. Hotspotting
HBase中的行是按行键的字典顺序进行排序的。这个设计优化了扫描,允许您存储相关的行,或将一起被读取的行存储在彼此的附近。然而,不好的行键设计是一个常见的造成热点的原因。
当大量的客户端流量被定向到一个节点或一个集群的几个节点时,就会出现热点。流量可能是读、写或其他操作。
流量超过了负责承载该Region的那台机器的承载能力,导致性能下降,并可能导致该Region不可用。
这也会对这个RegionServer承载的其他Region产生不利影响,因为该主机无法为所请求的负载提供服务。设计数据访问模式是非常重要的,这样才能充分充分地利用集群。
为了防止因为写数据而出现热点,设计你的行键把真正需要放在相同的Region的行写入在集群中的多个Region,而不是每次写到同一个Region。
以下介绍一些避免热点的常见技术,以及它们的一些优点和缺点。
这里的加盐与密码学无关,而是指将随机数据添加到行键的开头。在这种情况下,salting指的是将随机分配的前缀添加到行键中,以使其排序不同于原来行键的顺序。
可能的前缀的数量对应于您想要分发数据的Region的数量。如果您有一些“热”行键模式,在其他更均匀分布的行中出现,那么Salting就会很有帮助。
考虑下面的例子,这表明salting可以在多个RegionServer上分发写负载,并说明了一些对读取的负面影响。
foo0001 foo0002 foo0003 foo0004
现在,想象一下你想要把这些分布在四个不同的Region。你决定使用4个不同的salt: a, b, c 和 d。在这个情景下,这些字母前缀的每一个都在不同的Region。
在应用了这些salts后,你的行键由以下代替。既然你现在可以写四个独立的Region,理论上你写的时候,比起所有的数据都写在同一个Region,你就会有四倍的吞吐量。
a-foo0003 b-foo0001 c-foo0004 d-foo0002
然后,如果再添加另一行,它将随机分配四种可能的盐值之一,并排在现有的行附近。
a-foo0003 b-foo0001 c-foo0003 c-foo0004 d-foo0002
由于该赋值是随机的,所以如果您想以字典顺序获取这些行,则需要做更多的工作。通过这种方式,salting尝试增加写操作的吞吐量,但是在读取过程中却有成本。
作为随机赋值的替代方案,你可以使用one-way hash给一个行加相同的前缀,这种方法能在RegionServer间分发负载,但是在读取数据时是可预测的。
使用一个确定性的哈希允许客户端重建完整的rowkey,并使用Get操作检索行。
foo0003添加一个可预测的前缀a。
然后,要获取这一行,你就可以知道行键的key是什么。你还可以做一些优化,以便特定的键可以存储到相同的Region。
防止热点问题的第三种常见的方法是反转一个固定宽度或是数字行键,使得最常变化的部分(最低有效位)排在第一位。这有效地随机地排列了行键,但是牺牲了行排序属性。
参见https://communities.intel.com/community/itpeernetwork/datastack/blog/2013/11/10/discussion-on-designing-hbase-tables 和Phoenix项目的 article on Salted Tables,
还有HBASE-11682中内容的讨论,都可以获得关于热点更多的信息。
36.2. 单调递增行键/时序数据
在Tom White的Hadoop: The Definitive Guide (O’Reilly)一书的HBase章节中,描述了一段优化的笔记,关于一个值得注意的问题:
在一个集群中,一个导入数据的进程一动不动,所有的client都在等待表的一个region(就是一个节点),过了一会儿后,变成了下一个region...
如果使用了单调递增或者时序的行键(例如使用了timestamp做行键)就会造成这样的问题。
详情可以参见IKai画的漫画monotonically increasing values are bad,关于为什么单调递增的行键对于类似于BigTable的数据存储是有问题的。
由单调递增行键引发的把负载压在一台机器上的问题,可以通过随机化输入记录使其没有顺序的方式进行缓解,但总之要尽量避免时间戳或者(e.g. 1, 2, 3)这样的key。
如果你需要导入时间顺序的文件(如log)到HBase中,可以学习OpenTSDB的做法。他有一个页面来描述他的在HBase中schema.OpenTSDB的Key的格式是[metric_type][event_timestamp],
乍一看,似乎违背了不将timestamp做key的建议,但是他并没有将timestamp作为key的一个关键位置,有成百上千的metric_type就足够将压力分散到各个region了。
因此,即使有一个连续的带有metric_type的输入数据流,这些数据的Put操作也会分布在表中不同的Region中。
参见 schema.casestudies 获取一些行键设计的例子。
36.3. 尽量最小化行和列的大小(为何我的存储文件指示很大?)
在HBase中,值是作为一个单元(Cell)保存在系统的中的,要定位一个单元,需要行,列名和时间戳作为坐标。
通常情况下,如果你的行和列的名字要是太大(甚至比value的大小还要大)的话,你可能会遇到一些有趣的情况。例如Marc Limotte 在 HBASE-3551(推荐!)尾部提到的现象。
在HBase的存储文件StoreFile (HFile)中,有一个索引用来方便值的随机访问,但是访问一个单元的坐标要是太大的话,会占用很大的内存,这些索引就会用尽HBase分配的内存。
Mark在上面提到的评论中建议,可以设置一个更大的块大小,使在存储文件中索引的条目有更大的间隔,或者修改表结构以使用更小的列名。也可以对大的索引进行压缩。
参考话题 a question storefileIndexSize 用户邮件列表.
大部分时候,小的低效不会影响很大。不幸的是,这里会是个问题。无论以什么模式检索列族,属性和行键,它们都会在数据中重复数十亿次。
参考 keyvalue 获取更多信息,关于HBase 内部保存数据,了解为什么这很重要。
36.3.1. 列族
尽量使列族名小,最好一个字符。(如 "d" 表示 data/default).
参考 keyvalue 获取更多信息,关于HBase 内部保存数据,了解为什么这很重要。
36.3.2. 属性(列标识符)
尽管详细的属性名 (如, "myVeryImportantAttribute") 易读,最好还是用短属性名 (e.g., "via") 保存到HBase.
参考 keyvalue 获取更多信息,关于HBase 内部保存数据,了解为什么这很重要。
36.3.3. 行键长度
让行键短到可读即可,这样对获取数据有用(e.g., Get vs. Scan)。 对访问数据来说无用的短键,并不比带有更好的get/scan属性的长键更好。设计行键需要权衡。
A short key that is useless for data access is not better than a longer key with better get/scan properties.
36.3.4. 字节数组模式
long 类型有 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 // 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
不幸的是,使用二进制来表示一个类型,会增加你的数据在你的代码以外的阅读难度。例如,这是当你增加一个值时在hbase 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
HBase shell尽可能地打印了一个字符串,在这个情况下,它只打印成16进制。对于在Region名里的行键也会有同样的情况发生。如果你知道了被存储的可能不可读的数据是什么数据的话是没问题的。
这是主要的代价。
36.4. 倒序时间戳
Reverse Scan API
HBASE-4811 implements an API to scan a table or a range within a table in reverse, reducing the need to optimize your schema for forward or reverse scanning. This feature is available in HBase 0.98 and later. See https://hbase.apache.org/apidocs/org/apache/hadoop/hbase/client/Scan.html#setReversed%28boolean for more information. |
一个数据库处理的通常问题是快速找到最近版本的值。采用倒序时间戳作为键的一部分可以对此特定情况有很大帮助。
在Tom White的Hadoop书籍的HBase 章节也能找到: The Definitive Guide (O'Reilly), 该技术包含追加(Long.MAX_VALUE - timestamp) 到key的后面,如 [key][reverse_timestamp].
表内[key]的最近的值可以用[key]进行 Scan 找到并获取第一个记录。由于 HBase 行键是排序的,该键排在任何比它老的行键的前面,所以必然是第一个。
该技术可以用于代替 Number of Versions ,其目的是保存所有版本到“永远”(或一段很长时间) 。同时,采用同样的Scan技术,可以很快获取其他版本。
楼主注:因为key+倒叙时间戳组合成行键,所以只存一个版本的数据,而scan出来的数据按这个组合行键的大小顺序排列,就能把最近版本的行排在第一位。
而存储多个版本的数据,scan出来的相同key的结果会按版本的时间戳由大到小排列,把最近的版本的行排在第一位。
36.5. 行键和列族
行键在列族范围内。所以同样的行键可以在同一个表的每个列族中存在而不会冲突。
36.6. 行键的不变性
如果你给表创建了预分区,理解你的行键是如何被分布到各个Region中去的就是关键了。
举个例子来说明为什么这是重要的,这个例子使用了以下列出的16进制字符作为行键的开头部分(从"0000000000000000" 到 "ffffffffffffffff")。
由Bytes.split(在用Admin.createTable(byte[] startKey, byte[] endKey, numRegions创建表及其Region时使用的分割策略)把这些key分布到10个Region中去,
Bytes.split("0000000000000000".getBytes(), "ffffffffffffffff".getBytes(), 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',一切都很好,对吧?没那么快。
问题是,所有的数据都将被堆放到前两个Region和最后一个Region,由此产生热点。要理解为什么,参考ASCII Table。
'0'的ascii值是48,'f'的ascii值是102,但是在acsii值(58到96)之间有一个很大的缺口,而这个缺口内的值不在行键范围内,因为行键值是[0-9]和[a-f]。
因此,中间的Region不会被使用。要使预分区能装载这样的行键,需要自定义分区(不要信赖内建的分区方法)。
课程#1:预分区表是最好的经验,但是你创建的每一个预分区都要有行键能落进去。虽然这个例子以16进制的键阐明了这个问题,但同样的问题可以发生在任何一种行键值域。要了解你的数据。
课程#2:通常不建议,但使用16进制的行键(更常见的是可显示数据)的数据仍然可以被分布到预分区中,只要所有的分区都能使行键域中的键落进去。
为了总结这个例子,以下的例子展示了如何合理地为16进制行键创建预分区:
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; }
37. 版本数量
37.1. 最大版本数
行的版本的最大数量是 HColumnDescriptor 对每个列族可以单独设置,默认的最大版本数是1。
这个设置是很重要的,在Data Model有描述,因为HBase是不会去覆盖一个值的,他只会在后面在追加写,存储用时间戳(和列标识符)来区分的每行不同的值、过早的版本会在执行major合并的时候删除。
这个版本的最大数量可以根据具体的应用增加减少。
不推荐将版本最大值设到一个很高的水平 (如, 成百或更多),除非老数据对你很重要。因为这会导致存储文件StoreFile变得极大。
37.2. 最小版本数量Minimum Number of Versions
和行的最大版本数一样,最小版本数也是通过HColumnDescriptor 在每个列族中设置的。最小版本数缺省值是0,表示该特性禁用。
最小版本数参数和存活时间(time-to-live)一起使用,结合行的版本数这个参数允许配置如“保存最后T秒有价值数据,最多N个版本,但最少约M个版本”(M是最小版本数,M<N)。
该参数仅在存活时间对列族启用,且必须小于行版本数。
38. 支持的数据类型Supported Datatypes
HBase 通过Put和Result支持 "bytes-in/bytes-out" 接口,所以任何可被转为字节数组的东西可以作为值存入。输入可以是字符串,数字,复杂对象,甚至图像,只要他们能转为字节。
存在值的实际长度限制 (如 保存 10-50MB 对象到 HBase 可能对查询来说太长); 搜索邮件列表获取本话题的对话。
HBase的所有行都遵循 Data Model, 包括版本化。 设计时需考虑到这些,以及列族的块大小。
38.1. 计数器Counters
一种值得一提的所支持的数据类型,是“计数器”(如, 具有原子递增数值的能力)。参考 HTable的 Increment.
同步计数器在RegionServer中完成,不是客户端。
39. 联合Joins
如果有多个表,不要在模式设计中忘了Joins 的潜在因素。
40. 存活时间Time To Live (TTL)
列族可以设置TTL秒数,HBase 在到达时间后将自动删除数据。影响 全部 行的全部版本 - 甚至当前版本。HBase里面TTL 时间时区是 UTC。
只包含了过期行的存储文件将在minor compaction时被删除,设置hbase.store.delete.expired.storefile为false可以禁用这个功能,设置最小版本数为0以外的值也可以禁用此功能。
参考HColumnDescriptor获取更多信息。
HBase最近的版本也支持基于每个单元设置存活时间。参见HBASE-10560获取更多信息。单元TTL是在一个变化的请求(Append、Increment、Put等等)中使用Mutation#setTTL设置属性的方式提交的。
如果设置了TTL属性,它会被应用在这个服务器上被这个操作更新的所有的单元。在单元TTL处理和列族处理上,有两个要注意的区别:
-
单元TTLs以毫秒为单位表示,而不是秒。
-
一个单元TTLs不能延长一个单元的有效生命周期到超出了一个列的级别TTL设置。
41. 保留删除的单元 Keeping Deleted Cells
默认情况下,删除标记可以追溯到时间的开始。这就是说 Get或 Scan操作将不会看到已删除的单元(行或列),即使Get或Scan操作指定了在删除标记被添加以前的时间范围。
列族可以选择是否保留被删除的单元。这就是说,被删除的单元仍然可以被获取到,只要这些操作指定的时间范围在删除单元生效之前结束。这甚至允许在删除进行时,进行即时查询。
被删除的单元仍然受TTL控制,并永远不会超过被删除单元的“最大版本数”。新的scan 选项"raw" 返回所有已删除的行和删除标志。
KEEP_DELETED_CELLS
Using HBase Shellhbase> alter ‘t1′, NAME => ‘f1′, KEEP_DELETED_CELLS => true
Example 19. Change the Value of KEEP_DELETED_CELLS
Using the API
... HColumnDescriptor.setKeepDeletedCells(true); ...
举例说明在一个表上设置KEEP_DELETED_CELLS属性的基本效果。
首先,没有设置:
hbase(main):016:0> create 'test', {NAME=>'e', VERSIONS=>2147483647} put 'test', 'r1', 'e:c1', 'value', 10 put 'test', 'r1', 'e:c1', 'value', 12 put 'test', 'r1', 'e:c1', 'value', 14 delete 'test', 'r1', 'e:c1', 11 hbase(main):017:0> scan 'test', {RAW=>true, VERSIONS=>1000} ROW COLUMN+CELL r1 column=e:c1, timestamp=14, value=value r1 column=e:c1, timestamp=12, value=value r1 column=e:c1, timestamp=11, type=DeleteColumn r1 column=e:c1, timestamp=10, value=value 1 row(s) in 0.0120 seconds hbase(main):018:0> flush 'test' 0 row(s) in 0.0350 seconds hbase(main):019:0> scan 'test', {RAW=>true, VERSIONS=>1000} ROW COLUMN+CELL r1 column=e:c1, timestamp=14, value=value r1 column=e:c1, timestamp=12, value=value r1 column=e:c1, timestamp=11, type=DeleteColumn 1 row(s) in 0.0120 seconds hbase(main):020:0> major_compact 'test' 0 row(s) in 0.0260 seconds hbase(main):021:0> scan 'test', {RAW=>true, VERSIONS=>1000} ROW COLUMN+CELL r1 column=e:c1, timestamp=14, value=value r1 column=e:c1, timestamp=12, value=value 1 row(s) in 0.0120 seconds
注意如何让被删除的单元消失。
现在运行相同的测试,不同的是只在表上设置了KEEP_DELETED_CELLS(你可以表或每个列族上设置)
hbase(main):005:0> create 'test', {NAME=>'e', VERSIONS=>2147483647, KEEP_DELETED_CELLS => true} 0 row(s) in 0.2160 seconds => Hbase::Table - test hbase(main):006:0> put 'test', 'r1', 'e:c1', 'value', 10 0 row(s) in 0.1070 seconds hbase(main):007:0> put 'test', 'r1', 'e:c1', 'value', 12 0 row(s) in 0.0140 seconds hbase(main):008:0> put 'test', 'r1', 'e:c1', 'value', 14 0 row(s) in 0.0160 seconds hbase(main):009:0> delete 'test', 'r1', 'e:c1', 11 0 row(s) in 0.0290 seconds hbase(main):010:0> scan 'test', {RAW=>true, VERSIONS=>1000} ROW COLUMN+CELL r1 column=e:c1, timestamp=14, value=value r1 column=e:c1, timestamp=12, value=value r1 column=e:c1, timestamp=11, type=DeleteColumn r1 column=e:c1, timestamp=10, value=value 1 row(s) in 0.0550 seconds hbase(main):011:0> flush 'test' 0 row(s) in 0.2780 seconds hbase(main):012:0> scan 'test', {RAW=>true, VERSIONS=>1000} ROW COLUMN+CELL r1 column=e:c1, timestamp=14, value=value r1 column=e:c1, timestamp=12, value=value r1 column=e:c1, timestamp=11, type=DeleteColumn r1 column=e:c1, timestamp=10, value=value 1 row(s) in 0.0620 seconds hbase(main):013:0> major_compact 'test' 0 row(s) in 0.0530 seconds hbase(main):014:0> scan 'test', {RAW=>true, VERSIONS=>1000} ROW COLUMN+CELL r1 column=e:c1, timestamp=14, value=value r1 column=e:c1, timestamp=12, value=value r1 column=e:c1, timestamp=11, type=DeleteColumn r1 column=e:c1, timestamp=10, value=value 1 row(s) in 0.0650 seconds
当且仅当移除数据的原因是它们带有删除标记时,KEEP_DELETED_CELLS会避免从HBase中移除Cells。所以启用了KEEP_DELETED_CELLS后,带有删除标记的单元会在以下两种情形下被移除:
1. 你写入更多版本的数据,直到超过了设置的最大版本值
2. 你给Cells设置了TTL,且Cells存在的时间已经超过了设置的TTL
42.二级索引和改变查询路径Secondary Indexes and Alternate Query Paths
本节标题也可以为"如果表的行键像这样 ,但我又想像那样查询该表."。一个普通的例子是,行键的格式是"用户-时间戳",但是在特定的时间范围内,对用户的活动有报告要求。
因此,以用户进行检索会简单,因为用户处于行键的开头位置,但时间不是。
没有一个单一的答案可以最好的处理这个检索问题,因为它要依赖于:
-
用户的数量
-
数据的大小和数据到达率(data arrival rate)
-
报告需求的灵活性(例如:完全专门的日期选择和预先配置的范围)
-
希望达到的查询执行速度 (例如,对于某些特定的报告来说,90秒可能是合理的,但是对于其他人来说可能太长了)
并且解决方案也受集群大小的影响,还有你需要在解决方案中投入多少处理能力。下面的小节将介绍常用的技术。这是一种全面的、但不是详尽的方法列表。
二级索引需要额外的集群空间和处理,这一点也不奇怪。这正是RDBMS中发生的情况,因为创建索引的行为需要空间和处理周期来更新。在这方面,RDBMS产品在处理替代索引管理方面更加先进。
但是,HBase在更大的数据量上表现更好,因此这是一个特性的权衡。
在实现这些方法时要注意Apache HBase Performance Tuning。另外,参考David Butler在邮件列表HBase, mail # user - Stargate+hbase中的回复。
42.1.过滤查询 Filter Query
根据具体应用,可能适合用 Client Request Filters。在这种情况下,没有二级索引被创建。然而,不要在应用(如单线程客户端)中,对像这样大的表尝试全表扫描。
42.2.定期更新二级索引Periodic-Update Secondary Index
二级索引可以在另一个表中被创建,并通过MapReduce任务定期更新。任务可以在当天执行,但依赖于加载策略,可能会同主表失去同步。
参考 mapreduce.example.readwrite 获取更多信息。
42.3.双写二级索引 Dual-Write Secondary Index
另一个策略是在将数据写到集群的同时创建二级索引(如:写到数据表,同时写到索引表)。如果该方法在数据表存在之后采用,则需要利用MapReduce任务来生成已有数据的第二索引。(参考secondary.indexes.periodic).
42.4. 汇总表 Summary Tables
对时间跨度长 (e.g., 年报) 和数据量巨大,汇总表是一个常用的方法。这些二级索引数据可通过MapReduce任务生成到另一个表。
参考 mapreduce.example.summary 获取更多信息。
42.5. 协处理二级索引 Coprocessor Secondary Index
协处理动作像 RDBMS 触发器。这个功能在0.92中添加。更多参考 coprocessors
43. 约束Constraints
HBase当前支持传统(SQL)数据库中的'约束'。约束被建议使用在对表的属性实施业务规则(例如,确保值在1到10的范围内)。
约束还可以被使用在参照完整性上,但是强烈不建议这样因为启用完整性检查它将极大地降低写入表的吞吐量。关于使用约束的大量文档内容参见0.94版开始的Constraint。
44. 模式设计用例研究 Schema Design Case Studies
下面的内容将介绍一些典型的使用HBase获取数据的用例,以及如何设计和构建rowkey。注意:这只是一个潜在方法的说明,而不是一个详尽的列表。
了解你的数据,了解你的处理需求。
在阅读本篇案例之前,非常推荐你首先阅读HBase and Schema Design的剩余部分
下面的案例研究:
-
日志数据 / 时间序列数据 Log Data / Timeseries Data
-
日志数据 / 增强时间序列 Log Data / Timeseries on Steroids
-
客户 / 订单 Customer/Order
-
高、宽、中等的schema设计 Tall/Wide/Middle Schema Design
-
列举数据 List Data
44.1. 日志数据 / 时间序列数据 Case Study - Log Data and Timeseries Data
假定在收集以下的数据
-
主机名
-
时间戳
-
日志事件
-
值、消息
我们可以把它们存储在一个名为LOG_DATA的HBase表中,但行键是怎样的呢?从这些属性来看,行键将是主机名、时间戳和日志事件中的一部分的组合,但具体是什么呢?
44.1.1. 时间戳放在行键的开头部分 Timestamp In The Rowkey Lead Position
行键 [timestamp][hostname][log-event]会出现行键单调递增的问题,已在Monotonically Increasing Row Keys/Timeseries Data描述过了。
经常提到的另一种模式是关于"入桶"的时间戳,通过对时间戳进行取余操作实现。如果面向时间的扫描是重要的,那么这是个有用的方法。
必须关注桶的数量,因为这需要相同数量的扫描才能返回结果。
long bucket = timestamp % numBuckets;
要构建的行键:
[bucket][timestamp][hostname][log-event]
如上所述,为特定的时间范围检索数据,需要对每个bucket执行一次扫描Scan。例如,100个桶,会为行键域中行键提供一个广泛的分布,但是它需要100个Scans获取一个时间戳的数据,这是代价。
44.1.2. 主机名在行键的开头部分 Host In The Rowkey Lead Position
如果行键域中的值的读写操作可以分布到大量的主机上,主键 [hostname][log-event][timestamp] 是一个候选。如果要优先通过主机名扫描,这个方法有用。
44.1.3. 时间戳还是反转时间戳? Timestamp, or Reverse Timestamp?
如果最重要的访问路径是提取最近的事件,那么将时间戳存储为反向时间戳(例如 timestamp = Long.MAX_VALUE – timestamp)将创建一个可以在[hostname][log-event]上扫描的属性以快速获取最近捕获的事件的属性。
这两种方法都不是错误的,它只取决于最适合的情况。
反向扫描API Reverse Scan API
HBASE-4811 实现了一个API用来反向扫描一张表或者是表的一个范围,以减少因正向或反向扫描表而对你的shema进行的优化工作。这个功能在HBase0.98及以后的版本可用。 参见 https://hbase.apache.org/apidocs/org/apache/hadoop/hbase/client/Scan.html#setReversed%28boolean 获取更多信息。 |
44.1.4. 可变长度或是固定长度的行键? Variable Length or Fixed Length Rowkeys?
重要的是要记住,在HBase的每一列上都有行键。如果主机名是a,而事件类型是e1,那么所产生的rowkey将非常小。
然而,如果获取的主机名是myserver1.mycompany.com,事件类型是com.package1.subpackage2.subsubpackage3.ImportantService,行键是什么呢?
在rowkey中使用一些替换可能是有意义的。至少有两种方法:hash 和 数字化。在主机名是行键开头位置的例子里,行键可能替换成这样:
使用Hash合成行键:
-
[主机名的MD5 hash] = 16 bytes
-
[事件类型的MD5 hash] = 16 bytes
-
[时间戳] = 8 bytes
使用数字化替换合成的行键:#######这个替换方式的例子还没有搞明白########
对于这个方法,除了 LOG_DATA 之外,我们还需要一张查找表 LOG_TYPES。LOG_TYPES的主键可以是:
-
[type]
(例如,一个字节表明是主机名还是事件类型) -
[bytes]
原始主机名或事件类型的可变长度字节。
这个行键的一个列可以是一个有符号的长整数,这个数值可以用HBase counter来获取。
所以产生的行键可以是:
-
[代替主机名的长整型] = 8 bytes
-
[代替事件类型的长整型] = 8 bytes
-
[时间戳] = 8 bytes
无论通过Hash还是数字化替换的方法,主机和事件类型的原始值可以被存储在列中。
44.2.日志数据和增强型时间序列数据 Case Study - Log Data and Timeseries Data on Steroids
OpenTSDB 中的方法是有效的,OpenTSDB 做的是重写数据将一定时间周期内的行数据批量存入列中,详细的解释,可以查看:
http://opentsdb.net/schema.html, 和HBaseCon2012的 Lessons Learned from OpenTSDB。
以下是这个大体的概念是怎样实现的:例如,数据以这样的方式被获取
[hostname][log-event][timestamp1] [hostname][log-event][timestamp2] [hostname][log-event][timestamp3]
每一个详细的事件都有各自不同的rowkey,但被重写成这样
[hostname][log-event][timerange]
并且以上每一个事件被转化存储在列中,这个列是 以这个时间范围开始的时间点的时间偏移量(例如,每5分钟)命名的。这显然是一种非常高级的处理技术,但是HBase使这成为可能。
44.3. 客户/订单 Case Study - Customer/Order
假如hbase 被用来存储 顾客和订单信息,将有两类主要记录类型:顾客记录类型和订单记录类型。 顾客记录包含如下信息:
-
顾客编号
-
顾客姓名
-
地址(例如,城市、州、邮编)
-
手机号等等
订单记录包含如下信息:
-
顾客编号
-
订单编号
-
交易日期
-
一系列关于发货地点和订单详情(详见Order Object Design)的嵌套对象
假设顾客ID 和订单信息的组合可以唯一确定一笔订单,这俩个属性将会组合为 rowkey. ORDER表的rowkey 如下:
[顾客编号][订单编号]
然而,还有更多的设计决定要做:原始的值是rowkey的最佳选择吗?
这里我们遇到了和之前日志数据案例的相同问题,customer number的行键域是什么?格式是什么?(数字型的,数字字母混合的)因为在hbase中使用定长 rowkey 是有益的,rowkey 也需要支持在行键域的合理分布,相似的选项出现了:
使用Hash合成行键:
-
[顾客编号的MD5] = 16 bytes
-
[订单编号的MD5] = 16 bytes
用数字和哈希组合 rowkey:
-
[替代的顾客编号] = 8 bytes
-
[订单编号的MD5] = 16 bytes
44.3.1. 单表? 多表?
传统的方法是为 顾客和订单 建立各自的表,另一个选项是将多种记录类型存入一张表。(例如:CUSTOMER++)
顾客记录类型的 rowkey:
-
[customer-id]
-
[type] = '1'表示顾客记录类型
订单记录类型的 Rowkey:
-
[customer-id]
-
[type] = '2'表示订单记录类型
-
[order]
这个特别的CUSTOMER++方法的优点是可以通过 customer-id 组织所有不同类型的数据(例如:1次scan 就可以获得 一个顾客的所有信息),缺点是不容易 scan 特定的 记录类型。
44.3.2. 订单对象设计 Order Object Design
现在我们需要考虑订单对象的建模,假设类结构如下:
Order: 一个 Order 有许多 ShippingLocations(发货位置)
LineItem:一个 ShippingLocations 有多个 LineItem(订单详情)
存储这种数据有多种选择:
完全范式化(标准化) Completely Normalized
用这种方法,将分成3个独立的表:ORDER,SHIPPING_LOCATION和 LINE_ITEM。
ORDER 表的rowkey 在上面已经提到 schema.casestudies.custorder。
SHIPPING_LOCATION表的 复合 rowkey 如下:
-
[order-rowkey]
-
[shipping location number]
(例如:第一个位置,第二个等等)
LINE_ITEM 表的 复合rowkey 如下:
-
[order-rowkey]
-
[shipping location number]
(例如:第一个位置,第二个等等) -
[line item number]
(例如:,第一个 lineitem,第二个等等)
这个范式化建模和使用RDBMS的方法很像,但这不是你使用 hbase情况下的唯一选择。这种方法的缺点是检索任何订单的信息,您将需要:
-
从ORDER表中获取订单
-
扫描SHIPPING_LOCATION 表以获取订单的ShippingLocation信息
-
扫描LINE_ITEM 表获取每一个ShippingLocation的具体信息
就算 RDBMS 会在幕后都做这些,但是你必须认识到一个现实:那就是在hbase中没有join.
用单表存储记录类型 Single Table With Record Types
在这种方法中,在ORDER表中会存储:
上述Order表的行键:schema.casestudies.custorder
-
[order-rowkey]
-
[ORDER record type]
ShippingLocation 复合rowkey 如下:
-
[order-rowkey]
-
[SHIPPING record type]
-
[shipping location number]
(例如:第一个位置,第二个等等)
LineItem复合rowkey如下:
-
[order-rowkey]
-
[LINE record type]
-
[shipping location number]
(例如:第一个位置,第二个等等) -
[line item number]
(例如:,第一个 lineitem,第二个等等)
反范式化 Denormalized
一种上面存储记录类型的单表的变体方法是反范式化,展平一些对象层次结构,例如折叠ShippingLocation 属性到 每一个 LineItem 实例。
A variant of the Single Table With Record Types approach is to denormalize and flatten some of the object hierarchy, such as collapsing the ShippingLocation attributes onto each LineItem instance.
LineItem的记录的复合 rowkey 如下:
-
[order-rowkey]
-
[LINE record type]
-
[line item number]
(例如:第一个 lineitem,第二个等等,必须意识到 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)
这种设计的优点是没有复杂的对象层次,缺点是任何信息的更新将会十分复杂。
二进制大对象 Object BLOB
这种方法是,把整个Order对象图作为一个二进制大对象。例如: 上述ORDER 表的rowkey schema.casestudies.custorder,有一个 "order" 的单列含有一个可以被反序列化的对象,这个对象包含 Order, ShippingLocations, and LineItems。
可以有许多选择: JSON, XML, Java Serialization, Avro, Hadoop Writables 等等。它们的原理都是一样的:将对象图编码成一个字节数组。这种方法必须注意向后兼容性,即使对象模型改变了,我们也可以从hbase 读取对象的旧有存储结构。
优点是可以用最小的IO管理复杂的对象图(例如:这个例子中在hbase 中 get 每个 order)。
但是缺点包括,上述的警告:序列化的向后兼容性,序列化的语言依赖(例如:Java Serialization 只能在java客户端工作),
事实上,你想要获得二级制大对象的任意小的信息,你都必须反序列化整个对象,而且你很难用像hive这样的框架去处理像这样的自定义对象。
44.4. 高、宽、中等表结构的设计 Case Study - "Tall/Wide/Middle" Schema Design Smackdown
本节将描述出现的其他模式设计问题,特别是关于高和宽的表。这是一些大体的指导方针,而不是法则,每一个应用都需要考虑它自己的需求。
44.4.1. 多行和多版本 Rows vs. Versions
一个常见的问题是我们应该倾向选择多行还是HBase内建的版本,典型的场景就是我们需要保留一行数据的的许多版本(例如,它明显高于HBase默认的1个最大版本数)。
用行的解决方法rows-approach就需要将时间戳作为 rowkey 的一部分,那样每一次数据的更新都不会发生数据覆盖。
通常来讲,我们选择 多行
44.4.2. 多行 和 多列 Rows vs. Columns
另一个常见问题是我们应该用 多行 还是 多列。这个问题的典型场景就是宽表的极端场景,例如是1行有100万个列好,还是100万行每行只有1列好?
通常来将,我们选择 多行。要清楚的是,这个指导方针是对于这个宽表的极端场景的,不是在一个标准的用例,如需要存储数十个或上百个列。
但是也有一种折中方案,那就是下面的”Rows as Columns“。
44.4.3. 把多行看做多列 Rows as Columns
选择多行还是多列的折中方案是将某些行的每行数据拆分开,打包存进列中。OpenTSDB 是这个场景下最好的例子,用单行数据代表一个时间范围,把离散的事件存入到列中。
这个方法一般比较复杂,可能需要增加额外的复杂性去重写你的数据,但优点是I/O很高效。参见schema.casestudies.log-steroids以获取这个方法的概览
44.5. 列表数据 Case Study - List Data
下面是用户列表中关于一个相当常见的问题的交流:在Apache HBase中如何处理每个用户的list data。
-
问题 *
我们在考虑如何在hbase中存储大量的(per-user) list data,并且我们在努力寻找哪种数据访问模式最有意义。一个选择是在一个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,列出从valueid Y开始的30条数据”。这些返回的数据需要按 valueid 排序吗?
关于它的版本,每一个 user + value 应该是一行,而不是自己构建一个复杂的行内分页机制,除非你确实确定它是必需的。
你的两个选项反映了人们在设计HBase结构时所遇到的一个常见问题:我应该选择“高表”还是“宽表”?
你的第一个结构是“高表”:每一行表示一个用户的一个值,所以在表中一个用户有很多行;行键是 user+valueid,可能会有一个单独的列标识符表示“值”。
如果您想按行键对已经排序的行进行扫描(因此我上面的问题,关于这些id是否正确排序),这是非常棒的。你可以在任何user+valueid开始,读取30个。
你所放弃的是为一个用户的所有行提供事务保证的能力,但听起来你并不需要那样。推荐用这种方式做 (参见 schema.smackdown)
你的第二个选项是“宽表”:你可以在一行中存储一组值,使用不同的列标识符(在这里,列标识符是valueid)。简单的方法是在一行中存储一个用户的所有值。
我猜你跳到了那个“标页数”的版本,因为你认为在一行中存储数百万列会对性能不好,这可能是对的,也可能不是;
只要你不尝试在单个请求中做太多事情,或者做类似扫描并返回行中的所有单元格的事情,这种设计从本质上是不差的。客户端有一些方法,允许你获得特定的列。
请注意,这两种情况都没有使用更多的磁盘空间;您只是将一个值的部分标识信息“转移”到左边(在行键中,在选项1中)或到右边(在选项2中的列标识符中)。
在实现上,每个键/值仍然存储整个行键和列族名(如果这一点令人有困惑的话,花一个小时观看Lars George的关于理解HBase结构设计的优秀视频http://www.youtube.com/watch?v=_HLoH_PgrLk)。
一个手工的分页版本有很多复杂的东西,如你所指出的,比如必须跟踪每一页中有多少东西,如果插入新的值就要重新shuffle,等等。这似乎要复杂得多。
在极高的吞吐量下,它可能会有一些微小的速度优势(或劣势),而真正知道这一点的唯一方法就是尝试它。如果您没有时间以两种方式构建并进行比较,那么我的建议就是从最简单的选项开始(每个user+value 做1行)。
45. 操作和性能配置选项 Operational and Performance Configuration Options
参考性能小节perf.schema以获取更多关于操作和性能设计选项的信息,例如Bloom Filters,Table-configured regionsizes, compression, 和 blocksizes。