Rowkey设计原则
hotspotting
HBase中的行由Rowkey(行键)按字典顺序排序。此设计优化了扫描(scan),将相关的行或将要一起读取的行存储在彼此附近。设计不良的行键会导致hotspotting发生。当大量客户端流量(traffic)指向群集的一个节点或少数几个节点时,hotspotting就会发生。此流量可能表示读取、写入或其他操作。流量超过负责托管该区域的单个机器的极限,就会导致性能下降并可能使得区域不可用。这也可能对同一RegionServer托管的其他区域产生负面影响,因为该主机无法为请求的负载提供服务。为充分、均匀地利用集群,须良好的设计数据访问模式。
为了防止hotspotting写入,设计Rowkey时应该尽量使数据被写入群集中的多个区域,除非确实有必要使数据写在同一个区域里。下面描述了一些避免hotspotting的常用技术及其优缺点。
salting
在这里,salting与加密无关,是指将随机数据添加到行键的开头。具体而言,salting是向行键添加随机分配的前缀,以使其排序与其他方式不同。所有可能的前缀数对应于数据的区域数。如果有一些“热”行键模式在其他更均匀分布的行中反复出现,则Salting可能会有所帮助。考虑以下示例,该示例显示salting可以在多个RegionServers之间分散写入负载,同时也说明了其对读取的负面影响。
Salting 示例
假设有以下行键列表,表按每个字母对应一个区域来分割。前缀“a”对应一个区域,前缀“b”对应另一个区域。在此表中,以“f”开头的所有行都在同一区域中。此示例关注具有以下键的行:
foo0001
foo0002
foo0003
foo0004
现在,希望将它们分布在四个不同的地区,可以使用四种不同的salt:a,b,c和d。在这种情况下,每个字母前缀的都对应不同的区域。应用salt后,将改为使用以下4个rowkey。由于现在将数据写入了四个不同的区域,理论上写入时的吞吐量将是所有数据写入到同一区域时的四倍。
a-foo0003
b-foo0001
c-foo0004
d-foo0002
然后,如果新添加另一行,将给它随机分配四个可能的salt值中的一个,并靠近其中一个现有行。
a-foo0003
b-foo0001
c-foo0003
c-foo0004
d-foo0002
由于前缀的分配是随机的,因此如果要按字典顺序检索行,则需要执行更多工作。通过这种方式,salting增加写入的吞吐量,但在读取操作的开销会变大。
Hashing
除了随机分配前缀之外,可以使用单向Hashing,使给定行salting时始终使用同一的前缀,从而将负载分散到RegionServer,也允许读取时可以预测。使用确定性哈希允许客户端重建完整的rowkey并使用Get操作正常检索该行。
Hashing示例
考虑上述salting示例中给出的情况,可以改为使用单向Hashing得到行键为foo0003的行,并且可预测其前缀“a”。然后,要检索该行,须先知道它的键。还可以进一步优化,比如使某些键对始终对应着特定区域。
反转键
第三个防止hotspotting的技巧是反转固定长度或可数的键,使最常更改的部分(最低有效位数)位于第一位。这个方法有效地使行键随机化,但牺牲了行的排序属性。
简化行与列
在HBase中,值是作为一个cell(单元)保存在系统之中的,伴随着它的行、列名和时间戳。如果行名和列名很大(特别是比单元的值还要大时),那么可能会遇到一些特别的状况。在HBase的StoreFile中,有一个用于随机访问而保留的索引,如果访问一个单元的坐标过大、占用很大的内存,则该索引会被用尽。针对这个问题,可以增大块的大小,也可以设置较小的行名和列名。在这里,微小的“低效”也不能忽略,因为列族、属性和Rowkey都可能在数据中多次重复。
列族
使列族名尽可能小,最好是一个字符(例如“d”表示数据/默认值)。
属性
尽管详细的属性名称(例如“myVeryImportantAttribute”)易于阅读,但是较短的属性名称(例如“via”)更适合存储在HBase中。
Rowkey长度
设置Rowkey应该尽可能的短,使它们更适合于数据访问(如Get和Scan)。在设计rowkey时需要权衡。
字节模式
long类型是8个字节。8个字节内存储的最大无符号数18446744073709551615。如果将此数字存储为字符串(假设每个字符有一个字节),则需要将近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
倒序时间戳
数据库处理中的一个常见问题是快速找到最新版本的值。使用倒序时间戳作为键的一部分对解决这个问题很有帮助。该技术将(Long.MAX_VALUE-timestamp)附加到键的末尾,例如:[键][reverse_timestamp]。
执行Scan [key]并获取第一条记录,就可以找到表中[key]的最新值。 此技术可以替代版本号的使用,其意图是“永久”(或很长时间)保留所有版本。同时,通过使用Scan,可以快速获取其他版本。
Rowkey和列族
Rowkey的作用域为列族。因此,同样的Rowkey可以存在于同一个表的不同列族中。
Rowkey不可改变
Rowkey是无法更改的。唯一一个在表中“更改”Rowkey的方法是删除行然后重新插入。这是一个常见的问题,因此一开始(以及插入大量数据之前)就需要确保rowkey是正确的。
RowKey与区域split的关系
如果预分割(pre-split)了表,那么了解rowkey如何在区域边界上分布是很重要的。为说明这一点,可以考虑用十六进制字符作为键的关键位置(例如,“0000000000000000”到“fffffffffffffffff”)。通过Bytes.split分割这些键的范围,这样会分得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
问题是所有数据都将堆积在前两个区域和最后一个区域,从而产生“块状”区域问题。要了解原因,须参阅ASCII表。有意义的值是[0-9]和[a-f],在ASCII表中,'0'是第48号,'f'是第102号;第58号到第96号之间存在巨大的间隙,这个区间内的值不会出现在键空间(keyspace)中,因此中间区域将不会被使用。为了预分割该示例中的键空间,需要自定义分割。
方法1:预分割表(pre-splitting table)通常是较好的方法,但pre-split时须注意使所有的区域在键空间中有所对应。虽然示例演示的是十六进制键空间的问题,但在其他键空间也是同样的道理。
方法2:尽管通常不可取,但只要所有区域都能在键空间中有所对应,十六进制的键也可以与预分割表配合使用。
以下是十六进制键预分区的示例:
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;
}