雲端計算(二十八)-【HBase】Rowkey設計
本章將深入介紹由HBase的儲存架構在設計上帶來的影響。如何設計表、row key、column等等,儘可能地使用到HBase儲存上的優勢。
Key設計
HBase有兩個基礎的主鍵結構:row key和column key。它們分別用來表徵儲存的資料和資料的排序順序。以下的幾節將討論如何通過key設計解決儲存設計中發現的一些問題。
概念
相比於物理儲存,首先談談表的邏輯結構。與傳統的面向列的關係型資料庫為基本單元不同,HBase的基本儲存單元為列簇(column family)。從圖9-1可以看出,儘管在邏輯上表以Cell的形式儲存,但在實際中,這些包含著重要資訊的Cell以線性的方式儲存。
圖9-1的左上部分顯示了資料的邏輯檢視,資料由行和列組成二維矩陣,由HBase的列簇、列組成了二維矩陣中的一維,rowkey組成了另一維。右上角的部分將邏輯檢視對映為物理儲存結構,邏輯檢視中的Cell依次被儲存,每個列簇獨立形成一個檔案,因此右上部分形成了cf1和cf2兩個檔案。換句話說,一個列簇中的所有Cell都被存放在同一個Store File中。
由於HBase並不儲存空的Cell(相當於RDBMS中的NULL值),因此,磁碟中包括的資料都有實實在在的值,即通過該Cell的rowkey、列簇和列,可以順利的訪問Cell中包含的資訊。
圖9-1 HBase物理儲存
另外,同一個
每個加入了結構化資訊的Cell被稱為KeyValue單元,它不但含有列(column)和值(value)資訊,還包括了rowkey和timestamp,並進行排序。首先按照rowkey的大小進行排序,然後通過column的key進行排序。圖9-1的右下部分給出了儲存在物理storefile中的行的邏輯檢視。HBase的API中有很多訪問資料的方法,可以通過rowkey範圍來取出多行,從而有效的過濾掉大量的記錄,可以指定列簇,從而避免掃描不必要的StoreFile
Cell的timestamp(或者稱為版本)可以說是另一個非常有用的查詢原則。StoreFile中儲存了Cell所有的不同版本資料,因此,您可以查詢一個Cell中最新兩個小時的資料。但是,一個特定的Store File只能儲存一定數目的版本數(可以通過引數配置)。
查詢的下一級粒度為column
qualifier。查詢時,可以指定qualifier,這樣只會查詢出指定的列資訊。也可以通過Filter指定查詢要包含的qualifier或者不包含的qualifier。但是,HBase依然需要掃描每個Cell,來確定對應的qualifier是否要取出來。
Value是查詢中最後一個限制原則,和qualifier限定查詢的效率相同。您需要遍歷每個Cell,來判斷這個Cell的指定的比較引數是否一致。您只需要指定一個比較的規則即可。圖9-2總結了KeyValue中不同查詢方式的效率。
圖9-2 訪問效率從左到右依次遞減
圖9-1中左下角是至關重要的一部分。通過上文的分析,可以得到KeyValue中不同的查詢方式具有不同的效率,因此,您不用改變Cell儲存的內容,而只需改變value的儲存位置,便可以達到不同的查詢效率。
長窄表 vs. 短寬表
此刻,您或許會思考以何種方式來儲存資料,是以長窄表,還是以短寬表呢?前者一行的列少,但表中行數多,而後者一行中的列數多,因此行數少。根據上一次講到的KeyValue查詢方式不同對效能的影響,可以得出結論,把需要查詢的欄位放在rowkey中,可以得到最高的查詢效率。
同時,HBase的split操作只會在行的邊界上發生,這也更傾向於長窄表。設想,當您將一個使用者的所有的Email資訊存放在一行中。這對於大多數使用者是沒有問題的,但當一個使用者的擁有大量的Email時,這一行的大小可能已經超過了file或者region的最大值,從而影響到split機制。
比較好的方式是將每一條Email儲存為獨立的一行,它的rowkey通過user ID和message
ID組合而成。從上節的圖9-1,可以看出,在物理儲存上,對於message ID是位於column
qualifier上,還是位於rowkey中,它們是沒有區別的,每一個Cell都儲存著一條Email內容。下面給出了一個短寬表的例子:
<userId> :
<colfam> : <messageId> : <timestamp> : <email-message>
12345 : data :
5fc38314-e290-ae5da5fc375d : 1307097848 : “Hi Lars, …”
12345 : data :
725aae5f-d72e-f90f3f070419 : 1307099848 : “Welcome, and …”
12345 : data :
cc6775b3-f249-c6dd2b1a7467 : 1307101848 : “To Whom It …”
12345 : data :
dcbee495-6d5e-6ed48124632c : 1307103848 : “Hi, how are …”
以相同的資訊承載,我們再給出長窄表的儲存方式:
<userId>-<messageId>:<colfam>:<colqulifier>
: <timestamp> : <email-message>
12345-5fc38314-e290-ae5da5fc375d
: data : : 1307097848 : “Hi Lars, …”
12345-725aae5f-d72e-f90f3f070419
: data : : 1307099848 : “Welcome, and …”
12345-cc6775b3-f249-c6dd2b1a7467
: data : : 1307101848 : “To Whom It …”
12345-dcbee495-6d5e-6ed48124632c
: data : : 1307103848 : “Hi, how are …”
上面的長窄表中使用了空的qualifier,將message-id搬到了rowkey中,在查詢時可以更加靈活高效,同時,每一條Email都儲存為邏輯獨立的一行資料,這會更有利於split操作。
Key部分欄位查詢
HBase的客戶端API對於長窄表提供的另一個至關重要的功能便是,Key部分欄位查詢。
在上一節的例子中,我們將所有使用者的每一條Email資訊獨立儲存為一行。則寬短表可能將一個使用者所有的Email資訊儲存在同一行中,每一列儲存使用者收件箱中的一條Email記錄。要查詢使用者的資料時,只需要給出user ID,便可以取出這個使用者的所有Email記錄。
我們回到長窄表,rowkey中,在user ID後附加了message ID。如果您不知道這兩個ID,將無法定位到具體的一行記錄。處理這個問題的方法,便是Key部分欄位查詢:您可以指定一個rowkey的起始和結束值,即Start Key和End Key。將Start Key設定為User ID,將End Key設定為User Id + 1,便加查詢指定User ID的所有記錄。在rowkey範圍查詢中,結果中包含Start Key,不包含End Key。將Start Key設成User ID,可以精確的找到這個User ID,Scan得到的下一行,滿足如下條件:
<userId>-<lowest-messageId>
換句話說,rowkey以排過序的user ID和message ID組合而成。通過Scan操作,您可以遍歷一個使用者所有的Email記錄,通過解析rowkey,可以得到每條記錄對應的Message ID。
Key部分查詢是非常有用的,您可以理解為rowkey中欄位的“左索引”,以下面的rowkey結構為例:
<userId>-<date>-<messageId>-<attachmentId>
您可以通過不斷加強的精確匹配,通過rowkey的Start Key和End Key,來查詢出指定的行。您可以將End Key比Start Key多一個位元組,比如前面舉的Email的例子,Start Key設定為12345,End Key設定為123456。表9-1給出一些Start Key和它們對應的查詢意義。
表9-1 Start Key及其含義
Command |
Description |
<userId> |
Scan over all messages for a given user ID. |
<userId>-<date> |
Scan over all messages on a given date for the given user ID. |
<userId>-<date>-<messageId> |
Scan over all parts of a message for a given user ID and date. |
<userId>-<date>-<messageId>-<attachmentId> |
Scan over all attachments of a message for a given user ID and |
這些Rowkey的組合和RDBMS中提供的很類似,您可以調整欄位的排列順序。您可以將date欄位(Long型別)進行位元組按位取反,則資料將以時間的降序進行排列。當然,您也可以採用如下的方法:
Long.MAX_VALUE -
<date-as-long>
這會使時間欄位按降序排列,將最近的訊息放在前面。
前面的例子,將時間放在了rowkey中的第二個欄位,這只是一個示例。如果您沒有用時間來查詢的需求,那麼,您可以把date欄位從rowkey中去掉,也可使用另一些您會用到的欄位。
在前面的例子中,rowkey被設計成了一些欄位的組合。這樣做也有一個缺點,即原子性。將收件箱中的資料儲存在多行中,無法在一次操作中,對它們進行修改。如果您不會一次對收件箱中的所有郵件進行原子操作,那次長窄表是合適的,如果您有這種需求,您可以回到短寬表的設計。
分頁
通過Key部分查詢的機制,可以非常便捷地進行遍歷。通過指定Start Key和End Key,來限制範圍查詢掃描的數量,然後,通過一個偏移量和限制數量,您可以將它們取到客戶端。
通過“PageFilter”和“ColumnPaginationFilter”可以實現分頁,這裡講到的方法主要是如何通過rowkey的設計來實現。對於單純的分頁功能來說,ColumnPaginationFilter是一個很好的方式,它可以避免在網路上傳輸額外的資料。
分頁的步驟如下:
1. 從Start Row的位置打開個一個scanner。
2. 跳過offset行。
3. 讀出limit行資料返回給呼叫者。
4. 關閉scanner。
以收件箱的例子進行解釋,可以對一個使用者下的所有Email進行分頁。假設平均一個使用者擁有好幾百封郵件。在Web客戶端上,預設只展現50封郵件,其餘的郵件要求使用者點選Next按鈕進行載入。
客戶端可以將Start Key設定為user ID,將End Key設定為userID + 1。剩下的過程和前面討論的過程一樣,首先將offset設定為0,從資料庫中讀出50封郵件,使用者點選Next按鈕時,您可以將offset設定為50,跳過剛才讀出的50行記錄,從而返回第51到第100條。
這種方式對於頁數不多的情景是非常有用的,但如果有上千頁,那麼就需要採用另一種分頁的方式。您可以在rowkey中加入一個ID序列,用來表示這個Start Key的偏移量,您也可以使用date欄位,記住您上次訪問到的date值,將它加入到Start
Key中。您可以忽略掉小時的部分。如果您使用了epoch時間格式(1970-01-01
08:00:00到現在的秒數),那麼您可以計算出上次取到的時期的零辰時對應的值。這樣,您可以重新描掃一整天的資料,並且選擇如何返回它們。
時間連續的資料
當處理由連續事件得到的資料時,即時間上連續的資料。這些資料可能來自於某個感測器網路、證券交易或者一個監控系統。它們顯著的特點就是rowkey中含有事件發生時間。帶來的一個問題便是HBase對於row的不均衡分佈,它們被儲存在一個唯一的rowkey區間中,被稱為region,區間的範圍被稱為Start Key和End Key。
對於單調遞增的時間型別資料,很容易被雜湊到同一個Region中,這樣它們會被儲存在同一個伺服器上,從而所有的訪問和更新操作都會集中到這一臺伺服器上,從而在叢集中形成一個hot spot,從而不能將叢集的整體效能發揮出來。
要解決這個問題是非常容易的,只需要將所有的資料雜湊到全部的Region上即可。這是可以做到的,比如,在rowkey前面加上一個非執行緒序列,常常有如下選擇:
Hash雜湊
您可以使用一個Hash字首來保證所有的行被分發到多個Region伺服器上。例如:
byte prefix =
(byte) (Long.hashCode(timestamp) % <number of regionservers>);
byte[] rowkey =
Bytes.add(Bytes.toBytes(prefix), Bytes.toBytes(timestamp);
這個公式可以產生足夠的數字,將資料雜湊到所有的Region伺服器上。當然,公式裡假定了Region伺服器的數目。如果您打算後期擴容您的叢集,那麼您可以把它先設定為叢集的整數倍。生成的rowkey類似下面:
0myrowkey-1,
1myrowkey-2, 2myrowkey-3, 0myrowkey-4, 1myrowkey-5, \
2myrowkey-6, …
當他們將按如下順序被髮送到各個Region伺服器上去:
0myrowkey-1
0myrowkey-4
1myrowkey-2
1myrowkey-5
…
換句話說,對於0myrowkey-1和0myrowkey-4的更新操作會被髮送到同一個region伺服器上去(假定它們沒有被雜湊到兩個region上去),1myrowkey-2和1myrowkey-5會被髮送到同一臺伺服器上。
這種方式的缺點是,rowkey的範圍必須通過程式碼來控制,同時對資料的訪問,可能要訪問多臺region伺服器。當然,可以通過多個執行緒同時訪問,來實現並行化的資料讀取。這種類似於只有map的MapReduce任務,可以大大增加IO的效能。
案例:Mozilla
Socoroo
Mozilla公司搭建了一個名為Socorro的crash報告系統,用來跟蹤Firefox和Thunderbird的crash記錄,儲存所有的使用者提交的關於程式非正常中止的報告。這些報告被順序訪問,通過Mozilla的開發團隊進行分析,使得它們的應用軟體更加穩定。
這些程式碼是開源的,包含著Python寫的客戶端程式碼。它們使用Thrift直接與HBase叢集進行互動。下面的給出了程式碼中用於Hash時間的部分:
def
merge_scan_with_prefix(self,table,prefix,columns):
“”"
A generator based
iterator that yields totally ordered rows starting with a
given prefix. The
implementation opens up 16 scanners (one for each leading
hex character of
the salt) simultaneously and then yields the next row in
order from the
pool on each iteration.
“”"
iterators = []
next_items_queue =
[]
for salt in
’0123456789abcdef’:
salted_prefix =
“%s%s” % (salt,prefix)
scanner = self.client.scannerOpenWithPrefix(table,
salted_prefix, columns)
iterators.append(salted_scanner_iterable(self.logger,self.client,
self._make_row_nice,salted_prefix,scanner))
# The i below is
so we can advance whichever scanner delivers us the polled
# item.
for i,it in
enumerate(iterators):
try:
next = it.next
next_items_queue.append([next(),i,next])
except
StopIteration:
pass
heapq.heapify(next_items_queue)
while 1:
try:
while
1:
row_tuple,iter_index,next
= s = next_items_queue[0]
#tuple[1]
is the actual nice row.
yield
row_tuple[1]
s[0]
= next()
heapq.heapreplace(next_items_queue,
s)
except
StopIteration:
heapq.heappop(next_items_queue)
except
IndexError:
return
這些Python程式碼打開了一定數目的scanner,加上Hash後的字首。這個字首是一個單字元的,共有16個不同的字母。heapq物件將scanner的結果進行全域性排序。
欄位位置交換
在前面提到了Key部分掃描,您可以移動timestamp欄位,將它放在前一個欄位的前面。這種方法通過rowkey的組合來將一個順序遞增的timestamp欄位放在rowkey的第二個位置上。
如果你的rowkey不單單含有一個欄位,您可以交換它們的位置。如果你現在的rowkey只有一個timestamp欄位,您有必要再選出一個欄位放在rowkey中。當然,這也帶來了一個缺點,即您常常只能通過rowkey的範圍查詢來訪問資料,比如timestamp的範圍。
案例:OpenTSDB
OpenTSDB專案用來儲存由收集程式碼程式得到的伺服器或者服務的執行引數,這些引數與採集時間是密切相關的。所有的資料被儲存在HBase之中。通過使用者介面可以對這些引數進行實時查詢。
在rowkey設計時,將metric ID帶進了rowkey之中:
<metric-id><base-timestamp>…
由於整個系統具有很多引數,它們的ID分佈在一個區間之中,通過這個字首,讀、寫操作可以訪問到所有的引數。
這種方式假設系統的查詢主要是用來查詢一個或者多個引數的值,並按照時間順序對它們進行展現。
隨機化
還有一種不同於雜湊的方法便是隨機化,例如:
byte[] rowkey =
MD5(timestamp)
通過類似於MD5的Hash函式,會使key被隨機分佈到一組region伺服器上。對於時間連續性資料,這種方法顯然不夠理想,因此無法對整個時間範圍進行查詢。
您可以通過對timestamp的hash來構建rowkey,從而方便的進行單行記錄的查詢。當您的資料不需要通過時間範圍查詢,而主要是單條查詢時,您可以採用這種方法。
圖9-3 讀寫效能平衡
對前面兩種方法(Hash雜湊、隨機化)進行總結,你會發現在讀和寫之間的效能上進行平衡,並不是沒有意義的,它決定了您的rowkey結構。圖9-3給出了順序讀和順序寫之間的效能關係。
時間連續資料的排序
下文將繼續討論,將時間連續的資料插入到新行中。當然,您也可以將時間連續的資料,存放在多個列中。由於每個列簇中的列,是按列名來進行排列的,您可以將這種排列順序看成是一種索引。多個這種索引,可以通過多個列簇來實現,當然,這種儲存模式設計常常是不建議使用的,但如果列的數目不多時,還是可以嘗試的。
考慮到前面講的收件箱的例子,將一個使用者的所有郵件放在同一行中。這樣,當您想按照它們的接收時間進行展現,同時按照主題進行排序,您可以利用列排序來實現。
首先要討論的是主排序,換句話說,即大部分使用者使用的郵箱排序檢視。假設它們選擇了按時間降序排列,您可以使用前文提到的方法:
Long.MAX_VALUE -
<date-as-long>
Email自身被存放在一個列簇之中,它們的排序順序被存放在一個獨立的列簇之中。
圍繞列簇的增加,我們可以將所有的索引儲存在一個獨立的列簇中。進一步,您可以利用index ID,如idx-subject-desc, idx-to-asc等等進行字首排序。緊接著,您需要附加排序的結果。它們真正的值是主索引中Cell的rowkey。這意味著,您可以通過主表載入郵件的資訊,或者在其它索引中將郵件資訊冗餘地再存一份,這樣可以避免對主表的隨機訪問。HBase中常常使用反正規化化(在電腦科學中,反正規化化通過對資料庫加入冗餘資訊或者分組資訊優化讀取效能)來減少讀的次數,從而使用者體檢的響應度。
將上述的模式實現出來如下:
12345 : data :
5fc38314-e290-ae5da5fc375d : 1307097848 : “Hi Lars, …”
12345 : data :
725aae5f-d72e-f90f3f070419 : 1307099848 : “Welcome, and …”
12345 : data : cc6775b3-f249-c6dd2b1a7467
: 1307101848 : “To Whom It …”
12345 : data :
dcbee495-6d5e-6ed48124632c : 1307103848 : “Hi, how are …”
…
12345 : index :
[email protected] : 1307099848 : 725aae5f-d72e…
12345 : index :
[email protected] : 1307103848 : dcbee495-6d5e…
12345 : index :
[email protected] : 1307097848 : 5fc38314-e290…
12345 : index :
[email protected] : 1307101848 : cc6775b3-f249…
…
12345 : index :
idx-subject-desc-\xa8\x90\x8d\x93\x9b\xde : \
1307103848
: dcbee495-6d5e-6ed48124632c
12345 : index :
idx-subject-desc-\xb7\x9a\x93\x93\x90\xd3 : \
1307099848
: 725aae5f-d72e-f90f3f070419
…
在上面的程式碼中,idx-from-asc索使引通過Email地址進行升序排列,idx-subject-desc通過主題進行降序排列。這些主題通過位元組按節取反儲存,從而得到降序的效果。比如:
% String s = “Hello,”;
% for (int i = 0;
i < s.length(); i++) {
print(Integer.toString(s.charAt(i)
^ 0xFF, 16));
}
b7 9a 93 93 90 d3
所有的索引資訊被存放在列簇index中,用使前面講到的字首來進行排序。客戶端可能讀取整個列簇資訊,並快取所有記憶體,以便使用者在各種排序規則下快速切換。如果要載入的數目過多,客戶端可以讀取按主題排序的前10列idx-subject-desc資訊,來顯示前10封Email。通過前面提到的Caching
Versus
Batching,利用一行記錄的批量載入scan,可以高效的實現索引的分頁載入,另一個可選的方案是ColumnPaginationFilter和ColumnPrefixFilter來組合,進行分頁遍歷。
原文連結:http://www.smile000.com/hbase%E6%9D%83%E5%A8%81%E6%8C%87%E5%8D%97%E4%B8%AD%E6%96%87%E7%89%88-%E7%AC%AC%E4%B9%9D%E7%AB%A0-%E9%AB%98%E7%BA%A7%E7%94%A8%E6%B3%95rowkey%E8%AE%BE%E8%AE%A1/