再談|Rowkey設計_HBase表設計

  • 2019 年 12 月 15 日
  • 筆記

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

Hotspotting

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

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

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

Salting

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

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

foo0001  foo0002  foo0003  foo0004

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

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』這個前綴。然後為了重新獲得這一行,需要先知道它的鍵。可以進一步優化這一方法,如使得將特定的鍵對總是在相同的region。

Reversing the Key(反轉鍵)

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

單調遞增行鍵/時序數據

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

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

簡化行和列

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

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

列族

盡量使列族名小,最好一個字元。(如:f 表示)

屬性

詳細屬性名 (如:」myVeryImportantAttribute」) 易讀,最好還是用短屬性名 (e.g., 「via」) 保存到HBase.

行鍵長度

讓行鍵短到可讀即可,這樣對獲取數據有幫助(e.g., Get vs. Scan)。短鍵對訪問數據無用,並不比長鍵對get/scan更好。設計行鍵需要權衡

位元組模式

long類型有8位元組。8位元組內可以保存無符號數字到18,446,744,073,709,551,615。如果用字元串保存——假設一個位元組一個字元——需要將近3倍的位元組數。

下面是示例程式碼,可以自己運行一下:

long l = 1234567890L;  byte[] lb = Bytes.toBytes(l);  System.out.println("long bytes length: " + lb.length);    String s = String.valueOf(l);  byte[] sb = Bytes.toBytes(s);  System.out.println("long as string length: " + sb.length);    MessageDigest md = MessageDigest.getInstance("MD5");  byte[] digest = md.digest(Bytes.toBytes(s));  System.out.println("md5 digest bytes length: " + digest.length);      String sDigest = new String(digest);  byte[] sbDigest = Bytes.toBytes(sDigest);  System.out.println("md5 digest as string length: " + sbDigest.length);

不幸的是,用二進位表示會使數據在程式碼之外難以閱讀。下例便是當需要增加一個值時會看到的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=x00x00x00x00x00x00x00x01  1 row(s) in 0.0310 seconds

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

倒序時間戳

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

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

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

行鍵和列族

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

行鍵不可改

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

行鍵和region split的關係

如果已經 pre-split (預裂)了表,接下來關鍵要了解行鍵是如何在region邊界分布的。為了說明為什麼這很重要,可考慮用可顯示的16位字元作為鍵的關鍵位置(e.g., 「0000000000000000」 to 「ffffffffffffffff」)這個例子。通過 Bytes.split來分割鍵的範圍(這是當用 Admin.createTable(byte[] startKey, byte[] endKey, numRegions) 創建region時的一種拆分手段),這樣會分得10個region。

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  

但問題在於,數據將會堆放在前兩個region以及最後一個region,這樣就會導致某幾個region由於數據分布不均勻而特別忙。為了理解其中緣由,需要考慮ASCII Table的結構。根據ASCII表,「0」是第48號,「f」是102號;但58到96號是個巨大的間隙,考慮到在這裡僅[0-9]和[a-f]這些值是有意義的,因而這個區間里的值不會出現在鍵空間( keyspace ),進而中間區域的region將永遠不會用到。為了pre-split這個例子中的鍵空間,需要自定義拆分。

教程1:預裂表( pre-splitting tables ) 是個很好的實踐,但pre-split時要注意使得所有的region都能在鍵空間中找到對應。儘管例子中解決的問題是關於16位鍵的鍵空間,但其他任何空間也是同樣的道理。

教程2:16位鍵(通常用到可顯示的數據中)儘管通常不可取,但只要所有的region都能在鍵空間找到對應,它依舊能和預裂表配合使用。

一下case說明了如何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");        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;  }

原文地址:https://help.aliyun.com/document_detail/59035.html?spm=a2c4g.11186623.6.773.3c3e7bb29cWzA0