全部產品
Search
文件中心

ApsaraDB for HBase:Rowkey設計

更新時間:Jul 06, 2024

HBase的RowKey設計可以說是使用HBase最為重要的事情,直接影響到HBase的效能,常見的RowKey的設計問題及對應訪問。

RowKey的行由行鍵按字典順序排序,這樣的設計最佳化了掃描,允許儲存相關的行或者那些將被一起讀的鄰近的行。然而,設計不好的行鍵是導致 hotspotting 的常見原因。當大量的用戶端流量( traffic )被定向在叢集上的一個或幾個節點時,就會發生 hotspotting。這些流量可能代表著讀、寫或其他動作。流量超過了承載該地區的單個機器所能負荷的量,這就會導致效能下降並有可能造成地區的不可用。在同一 RegionServer 上的其他地區也可能會受到其不良影響,因為主機無法提供服務所請求的負載。設計使叢集能被充分均勻地使用的資料訪問模式是至關重要的。

為了防止在寫操作時出現hotspotting,設計行鍵時應該使得資料盡量同時往多個地區上寫,而避免只向一個地區寫,除非那些行真的有必要寫在一個地區裡。

下面介紹了集中常用的避免hotspotting的技巧,它們各有優劣。

Salting

Salting 從某種程度上看與加密無關,它指的是將隨機數放在行鍵的起始處。進一步說,salting給每一行鍵隨機指定了一個首碼來讓它與其他行鍵有著不同的排序。所有可能首碼的數量對應於要分散資料的地區的數量。如果有幾個“hot”的行鍵模式,而這些模式在其他更均勻分布的行裡反覆出現,salting就能到協助。下面的例子說明了salting能在多個RegionServer間分散負載,同時也說明了它在讀操作時候的負面影響。

假設行鍵的列表如下,表按照每個字母對應一個地區來分割。首碼‘a’是一個地區,‘b’就是另一個地區。在這張表中,所有以‘f’開頭的行都屬於同一個地區。這個例子關注的行和鍵如下:

foo0001
foo0002
foo0003
foo0004

現在,假設想將它們分散到不同的地區上,就需要用到四種不同的salts :a,b,c,d。在這種情況下,每種字母首碼都對應著不同的一個地區。用上這些salts後,便有了下面這樣的行鍵。由於現在想把它們分到四個獨立的地區,理論上輸送量會是之前寫到同一地區的情況的輸送量的四倍。

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

如果想新增一行,新增的一行會被隨機指定四個可能的salt值中的一個,並放在某條已存在的行的旁邊。

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

由於首碼的指派是隨機的,因而如果想要按照字典順序找到這些行,則需要做更多的工作。從這個角度上看,salting增加了寫操作的輸送量,卻也增大了讀操作的開銷。

Hashing

可用一個單向的 hash 散列來取代隨機指派首碼。這樣能使一個給定的行在“salted”時有相同的首碼,從某種程度上說,這在分散了RegionServer間的負載的同時,也允許在讀操作時能夠預測。確定性hash( deterministic hash )能讓用戶端重建完整的行鍵,以及像正常的一樣用Get操作重新獲得想要的行。

考慮和上述salting一樣的情景,現在可以用單向hash來得到行鍵foo0003,並可預測得‘a’這個首碼。然後為了重新獲得這一行,需要Crowdsourced Security Testing道它的鍵。可以進一步最佳化這一方法,如使得將特定的鍵對總是在相同的地區。

Reversing the Key(反轉鍵)

第三種預防hotspotting的方法是反轉一段固定長度或者可數的鍵,來讓最常改變的部分(最低顯著位, the least significant digit )在第一位,這樣有效地打亂了行鍵,但是卻犧牲了行排序的屬性。

單調遞增行鍵/時序資料

在一個叢集中,一個匯入資料的進程鎖住不動,所有的client都在等待一個地區(因而也就是一個單個節點),過了一會後,變成了下一個地區。 如果使用了單調遞增或者時序的key便會造成這樣的問題。使用了順序的key會將本沒有順序的資料變得有順序,把負載壓在一台機器上。所以要盡量避免時間戳記或者序列(比如1, 2, 3)這樣的行鍵。

如果需要匯入時間順序的檔案(如log)到HBase中,可以學習OpenTSDB的做法。它有一個頁面來描述它的HBase模式。OpenTSDB的Key的格式是[metric_type][event_timestamp],乍一看,這似乎違背了不能將timestamp做key的建議,但是它並沒有將timestamp作為key的一個關鍵位置,有成百上千的metric_type就足夠將壓力分散到各個地區了。因此,儘管有著連續的資料輸入流,Put操作依舊能被分散在表中的各個地區中。

簡化行和列

在HBase中,值是作為一個單元儲存在系統的中的,要定位一個單元,需要行,列名和時間戳記。通常情況下,如果行和列的名字要是太大(甚至比value的大小還要大)的話,可能會遇到一些有趣的情況。在HBase的隱藏檔(storefiles)中,有一個索引用來方便值的隨機訪問,但是訪問一個單元的座標要是太大的話,會佔用很大的記憶體,這個索引會被用盡。要想解決這個問題,可以設定一個更大的塊大小,也可以使用更小的行和列名 。壓縮也能得到更大指數。

大部分時候,細微的低效不會影響很大。但不幸的是,在這裡卻不能忽略。無論是列族、屬性和行鍵都會在資料中重複上億次。

列族

盡量使用較小的列族名,最好為一個字元(例如:f)。

屬性

詳細屬性名稱(比如myVeryImportantAttribute)易讀,最好還是用短屬性名稱(比如via)儲存到HBase。

行鍵長度

讓行鍵短到可讀即可,這樣對擷取資料有協助(比如Get vs. Scan)。短鍵對訪問資料無用,並不比長鍵對get或scan更好。設計行鍵需要權衡。

位元組模式

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

這個Shell儘力在列印一個字串,但在這種情況下,它決定只將進位列印出來。當在地區名內行鍵會發生相同的情況。如果知道儲存的是什麼,那自是沒問題,但當任意資料都可能被放到相同單元的時候,這將會變得難以閱讀。這是最需要權衡之處。

倒序時間戳記

一個資料庫處理的通常問題是找到最近版本的值。採用倒序時間戳記作為鍵的一部分可以對此特定情況有很大協助。該技術包含追加(Long.MAX_VALUE - timestamp)到key的後面,如[key][reverse_timestamp] 。

表內[key]的最近的值可以用[key]進行Scan,找到並擷取第一個記錄。由於HBase行鍵是排序的,該鍵排在任何比它老的行鍵的前面,所以是第一個。

該技術可以用於代替版本數,其目的是儲存所有版本到“永遠”(或一段很長時間) 。同時,採用同樣的Scan技術,可以很快擷取其他版本。

行鍵和列族

行鍵在列族範圍內。所以同樣的行鍵可以在同一個表的每個列族中存在而不會衝突。

行鍵不可改

行鍵不能改變。唯一可以“改變”的方式是刪除然後再插入。這是一個常問問題,所以要注意開始就要讓行鍵正確(且/或在插入很多資料之前)。

行鍵和地區split的關係

如果已經 pre-split(預裂)了表,接下來關鍵要瞭解行鍵是如何在地區邊界分布的。為了說明為什麼這很重要,可考慮用可顯示的16位字元作為鍵的關鍵位置(比如“0000000000000000” to “ffffffffffffffff”)這個例子。通過Bytes.split來分割鍵的範圍(這是當用 Admin.createTable(byte[] startKey, byte[] endKey, numRegions)建立地區時的一種拆分手段),這樣會分得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 Table的結構。根據ASCII表,“0”是第48號,“f”是102號;但58到96號是個巨大的間隙,考慮到在這裡僅[0-9]和[a-f]這些值是有意義的,因而這個區間裡的值不會出現在鍵空間( keyspace ),進而中間地區的地區將永遠不會用到。為了pre-split這個例子中的鍵空間,需要自訂拆分。

教程1:預裂表( pre-splitting tables ) 是個很好的實踐,但pre-split時要注意使得所有的地區都能在鍵空間中找到對應。儘管例子中解決的問題是關於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;
}