HBase 數據庫檢索性能優化策略

原文轉發自:https://www.ibm.com/developerworks/cn/java/j-lo-HBase/

HBase 數據表介紹

HBase 數據庫是一個基於分佈式的、面向列的、主要用於非結構化數據存儲用途的開源數據庫。其設計思路來源於 Google 的非開源數據庫”BigTable”。

HDFS 爲 HBase 提供底層存儲支持,MapReduce 爲其提供計算能力,ZooKeeper 爲其提供協調服務和 failover(失效轉移的備份操作)機制。Pig 和 Hive 爲 HBase 提供了高層語言支持,使其可以進行數據統計(可實現多表 join 等),Sqoop 則爲其提供 RDBMS 數據導入功能。

HBase 不能支持 where 條件、Order by 查詢,只支持按照主鍵 Rowkey 和主鍵的 range 來查詢,但是可以通過 HBase 提供的 API 進行條件過濾。

HBase 的 Rowkey 是數據行的唯一標識,必須通過它進行數據行訪問,目前有三種方式,單行鍵訪問、行鍵範圍訪問、全表掃描訪問。數據按行鍵的方式排序存儲,依次按位比較,數值較大的排列在後,例如 int 方式的排序:1,10,100,11,12,2,20…,906,…。

ColumnFamily 是“列族”,屬於 schema 表,在建表時定義,每個列屬於一個列族,列名用列族作爲前綴“ColumnFamily:qualifier”,訪問控制、磁盤和內存的使用統計都是在列族層面進行的。

Cell 是通過行和列確定的一個存儲單元,值以字節碼存儲,沒有類型。

Timestamp 是區分不同版本 Cell 的索引,64 位整型。不同版本的數據按照時間戳倒序排列,最新的數據版本排在最前面。

Hbase 在行方向上水平劃分成 N 個 Region,每個表一開始只有一個 Region,數據量增多,Region 自動分裂爲兩個,不同 Region 分佈在不同 Server 上,但同一個不會拆分到不同 Server。

Region 按 ColumnFamily 劃分成 Store,Store 爲最小存儲單元,用於保存一個列族的數據,每個 Store 包括內存中的 memstore 和持久化到 disk 上的 HFile。

圖 1 是 HBase 數據表的示例,數據分佈在多臺節點機器上面。

圖 1. HBase 數據表示例

圖 1. HBase 數據表示例

HBase 調用 API 示例

類似於操作關係型數據庫的 JDBC 庫,HBase client 包本身提供了大量可以供操作的 API,幫助用戶快速操作 HBase 數據庫。提供了諸如創建數據表、刪除數據表、增加字段、存入數據、讀取數據等等接口。清單 1 提供了一個作者封裝的工具類,包括操作數據表、讀取數據、存入數據、導出數據等方法。

清單 1.HBase API 操作工具類代碼

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HColumnDescriptor;
import org.apache.hadoop.hbase.HTableDescriptor;
import org.apache.hadoop.hbase.KeyValue;
import org.apache.hadoop.hbase.client.Get;
import org.apache.hadoop.hbase.client.HBaseAdmin;
import org.apache.hadoop.hbase.client.HTable;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.ResultScanner;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.util.Bytes;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class HBaseUtil {
private Configuration conf = null;
private HBaseAdmin admin = null;

protected HBaseUtil(Configuration conf) throws IOException {
 this.conf = conf;
 this.admin = new HBaseAdmin(conf);
}

public boolean existsTable(String table)
 throws IOException {
 return admin.tableExists(table);
}

public void createTable(String table, byte[][] splitKeys, String... colfams)
 throws IOException {
HTableDescriptor desc = new HTableDescriptor(table);
for (String cf : colfams) {
HColumnDescriptor coldef = new HColumnDescriptor(cf);
desc.addFamily(coldef);
 }
if (splitKeys != null) {
admin.createTable(desc, splitKeys);
} else {
admin.createTable(desc);
 }
}

public void disableTable(String table) throws IOException {
admin.disableTable(table);
}

public void dropTable(String table) throws IOException {
 if (existsTable(table)) {
 disableTable(table);
 admin.deleteTable(table);
 }
}
 
public void fillTable(String table, int startRow, int endRow, int numCols,
 int pad, boolean setTimestamp, boolean random,
 String... colfams) throws IOException {
 HTable tbl = new HTable(conf, table);
 for (int row = startRow; row <= endRow; row++) {
 for (int col = 0; col < numCols; col++) {
 Put put = new Put(Bytes.toBytes("row-"));
 for (String cf : colfams) {
 String colName = "col-";
 String val = "val-";
 if (setTimestamp) {
 put.add(Bytes.toBytes(cf), Bytes.toBytes(colName),
 col, Bytes.toBytes(val));
 } else {
 put.add(Bytes.toBytes(cf), Bytes.toBytes(colName),
 Bytes.toBytes(val));
 }
 }
 tbl.put(put);
 }
 }
 tbl.close();
 }

public void put(String table, String row, String fam, String qual,
 String val) throws IOException {
 HTable tbl = new HTable(conf, table);
 Put put = new Put(Bytes.toBytes(row));
 put.add(Bytes.toBytes(fam), Bytes.toBytes(qual), Bytes.toBytes(val));
 tbl.put(put);
 tbl.close();
 }

 public void put(String table, String row, String fam, String qual, long ts,
 String val) throws IOException {
 HTable tbl = new HTable(conf, table);
 Put put = new Put(Bytes.toBytes(row));
 put.add(Bytes.toBytes(fam), Bytes.toBytes(qual), ts, Bytes.toBytes(val));
 tbl.put(put);
 tbl.close();
 }

 public void put(String table, String[] rows, String[] fams, String[] quals,
 long[] ts, String[] vals) throws IOException {
 HTable tbl = new HTable(conf, table);
 for (String row : rows) {
 Put put = new Put(Bytes.toBytes(row));
 for (String fam : fams) {
 int v = 0;
 for (String qual : quals) {
 String val = vals[v < vals.length ? v : vals.length];
 long t = ts[v < ts.length ? v : ts.length - 1];
 put.add(Bytes.toBytes(fam), Bytes.toBytes(qual), t,
 Bytes.toBytes(val));
 v++;
 }
 }
 tbl.put(put);
 }
 tbl.close();
 }

 public void dump(String table, String[] rows, String[] fams, String[] quals)
 throws IOException {
 HTable tbl = new HTable(conf, table);
 List<Get> gets = new ArrayList<Get>();
 for (String row : rows) {
 Get get = new Get(Bytes.toBytes(row));
 get.setMaxVersions();
 if (fams != null) {
 for (String fam : fams) {
 for (String qual : quals) {
 get.addColumn(Bytes.toBytes(fam), Bytes.toBytes(qual));
 }
 }
 }
 gets.add(get);
 }
 Result[] results = tbl.get(gets);
 for (Result result : results) {
 for (KeyValue kv : result.raw()) {
 System.out.println("KV: " + kv +
 ", Value: " + Bytes.toString(kv.getValue()));
 }
 }
 }
 
 private static void scan(int caching, int batch) throws IOException {
 HTable table = null;
 final int[] counters = {0, 0};

 Scan scan = new Scan();
 scan.setCaching(caching); // co ScanCacheBatchExample-1-Set Set caching and batch parameters.
 scan.setBatch(batch);
 ResultScanner scanner = table.getScanner(scan);
 for (Result result : scanner) {
 counters[1]++; // co ScanCacheBatchExample-2-Count Count the number of Results available.
 }
 scanner.close();
 System.out.println("Caching: " + caching + ", Batch: " + batch +
 ", Results: " + counters[1] + ", RPCs: " + counters[0]);
 }
}

操作表的 API 都有 HBaseAdmin 提供,特別講解一下 Scan 的操作部署。

HBase 的表數據分爲多個層次,HRegion->HStore->[HFile,HFile,...,MemStore]。

在 HBase 中,一張表可以有多個 Column Family,在一次 Scan 的流程中,每個 Column Family(Store) 的數據讀取由一個 StoreScanner 對象負責。每個 Store 的數據由一個內存中的 MemStore 和磁盤上的 HFile 文件組成,對應的 StoreScanner 對象使用一個 MemStoreScanner 和 N 個 StoreFileScanner 來進行實際的數據讀取。

因此,讀取一行的數據需要以下步驟:

1. 按照順序讀取出每個 Store

2. 對於每個 Store,合併 Store 下面的相關的 HFile 和內存中的 MemStore

這兩步都是通過堆來完成。RegionScanner 的讀取通過下面的多個 StoreScanner 組成的堆完成,使用 RegionScanner 的成員變量 KeyValueHeap storeHeap 表示。一個 StoreScanner 一個堆,堆中的元素就是底下包含的 HFile 和 MemStore 對應的 StoreFileScanner 和 MemStoreScanner。堆的優勢是建堆效率高,可以動態分配內存大小,不必事先確定生存週期。

接着調用 seekScanners() 對這些 StoreFileScanner 和 MemStoreScanner 分別進行 seek。seek 是針對 KeyValue 的,seek 的語義是 seek 到指定 KeyValue,如果指定 KeyValue 不存在,則 seek 到指定 KeyValue 的下一個。

Scan類常用方法說明

scan.addFamily()/scan.addColumn():指定需要的 Family 或 Column,如果沒有調用任何 addFamily 或 Column,會返回所有的 Columns;

scan.setMaxVersions():指定最大的版本個數。如果不帶任何參數調用 setMaxVersions,表示取所有的版本。如果不掉用 setMaxVersions,只會取到最新的版本.;

scan.setTimeRange():指定最大的時間戳和最小的時間戳,只有在此範圍內的 Cell 才能被獲取;

scan.setTimeStamp():指定時間戳;

scan.setFilter():指定 Filter 來過濾掉不需要的信息;

scan.setStartRow():指定開始的行。如果不調用,則從表頭開始;

scan.setStopRow():指定結束的行(不含此行);

scan. setCaching():每次從服務器端讀取的行數(影響 RPC);

scan.setBatch():指定最多返回的 Cell 數目。用於防止一行中有過多的數據,導致 OutofMemory 錯誤,默認無限制。

HBase 數據表優化

HBase 是一個高可靠性、高性能、面向列、可伸縮的分佈式數據庫,但是當併發量過高或者已有數據量很大時,讀寫性能會下降。我們可以採用如下方式逐步提升 HBase 的檢索速度。

預先分區

默認情況下,在創建 HBase 表的時候會自動創建一個 Region 分區,當導入數據的時候,所有的 HBase 客戶端都向這一個 Region 寫數據,直到這個 Region 足夠大了才進行切分。一種可以加快批量寫入速度的方法是通過預先創建一些空的 Regions,這樣當數據寫入 HBase 時,會按照 Region 分區情況,在集羣內做數據的負載均衡。

Rowkey 優化

HBase 中 Rowkey 是按照字典序存儲,因此,設計 Rowkey 時,要充分利用排序特點,將經常一起讀取的數據存儲到一塊,將最近可能會被訪問的數據放在一塊。

此外,Rowkey 若是遞增的生成,建議不要使用正序直接寫入 Rowkey,而是採用 reverse 的方式反轉 Rowkey,使得 Rowkey 大致均衡分佈,這樣設計有個好處是能將 RegionServer 的負載均衡,否則容易產生所有新數據都在一個 RegionServer 上堆積的現象,這一點還可以結合 table 的預切分一起設計。

減少ColumnFamily 數量

不要在一張表裏定義太多的 ColumnFamily。目前 Hbase 並不能很好的處理超過 2~3 個 ColumnFamily 的表。因爲某個 ColumnFamily 在 flush 的時候,它鄰近的 ColumnFamily 也會因關聯效應被觸發 flush,最終導致系統產生更多的 I/O。

緩存策略 (setCaching)

創建表的時候,可以通過 HColumnDescriptor.setInMemory(true) 將表放到 RegionServer 的緩存中,保證在讀取的時候被 cache 命中。

設置存儲生命期

創建表的時候,可以通過 HColumnDescriptor.setTimeToLive(int timeToLive) 設置表中數據的存儲生命期,過期數據將自動被刪除。

硬盤配置

每臺 RegionServer 管理 10~1000 個 Regions,每個 Region 在 1~2G,則每臺 Server 最少要 10G,最大要 1000*2G=2TB,考慮 3 備份,則要 6TB。方案一是用 3 塊 2TB 硬盤,二是用 12 塊 500G 硬盤,帶寬足夠時,後者能提供更大的吞吐率,更細粒度的冗餘備份,更快速的單盤故障恢復。

分配合適的內存給 RegionServer 服務

在不影響其他服務的情況下,越大越好。例如在 HBase 的 conf 目錄下的 hbase-env.sh 的最後添加 export HBASE_REGIONSERVER_OPTS="-Xmx16000m $HBASE_REGIONSERVER_OPTS”

其中 16000m 爲分配給 RegionServer 的內存大小。

寫數據的備份數

備份數與讀性能成正比,與寫性能成反比,且備份數影響高可用性。有兩種配置方式,一種是將 hdfs-site.xml 拷貝到 hbase 的 conf 目錄下,然後在其中添加或修改配置項 dfs.replication 的值爲要設置的備份數,這種修改對所有的 HBase 用戶表都生效,另外一種方式,是改寫 HBase 代碼,讓 HBase 支持針對列族設置備份數,在創建表時,設置列族備份數,默認爲 3,此種備份數只對設置的列族生效。

WAL(預寫日誌)

可設置開關,表示 HBase 在寫數據前用不用先寫日誌,默認是打開,關掉會提高性能,但是如果系統出現故障 (負責插入的 RegionServer 掛掉),數據可能會丟失。配置 WAL 在調用 Java API 寫入時,設置 Put 實例的 WAL,調用 Put.setWriteToWAL(boolean)。

批量寫

HBase 的 Put 支持單條插入,也支持批量插入,一般來說批量寫更快,節省來回的網絡開銷。在客戶端調用 Java API 時,先將批量的 Put 放入一個 Put 列表,然後調用 HTable 的 Put(Put 列表) 函數來批量寫。

客戶端一次從服務器拉取的數量

通過配置一次拉去的較大的數據量可以減少客戶端獲取數據的時間,但是它會佔用客戶端內存。有三個地方可進行配置:

1)在 HBase 的 conf 配置文件中進行配置 hbase.client.scanner.caching;

2)通過調用 HTable.setScannerCaching(int scannerCaching) 進行配置;

3)通過調用 Scan.setCaching(int caching) 進行配置。三者的優先級越來越高。

RegionServer 的請求處理 IO 線程數

較少的 IO 線程適用於處理單次請求內存消耗較高的 Big Put 場景 (大容量單次 Put 或設置了較大 cache 的 Scan,均屬於 Big Put) 或 ReigonServer 的內存比較緊張的場景。

較多的 IO 線程,適用於單次請求內存消耗低,TPS 要求 (每秒事務處理量 (TransactionPerSecond)) 非常高的場景。設置該值的時候,以監控內存爲主要參考。

在 hbase-site.xml 配置文件中配置項爲 hbase.regionserver.handler.count。

Region 大小設置

配置項爲 hbase.hregion.max.filesize,所屬配置文件爲 hbase-site.xml.,默認大小 256M。

在當前 ReigonServer 上單個 Reigon 的最大存儲空間,單個 Region 超過該值時,這個 Region 會被自動 split 成更小的 Region。小 Region 對 split 和 compaction 友好,因爲拆分 Region 或 compact 小 Region 裏的 StoreFile 速度很快,內存佔用低。缺點是 split 和 compaction 會很頻繁,特別是數量較多的小 Region 不停地 split, compaction,會導致集羣響應時間波動很大,Region 數量太多不僅給管理上帶來麻煩,甚至會引發一些 Hbase 的 bug。一般 512M 以下的都算小 Region。大 Region 則不太適合經常 split 和 compaction,因爲做一次 compact 和 split 會產生較長時間的停頓,對應用的讀寫性能衝擊非常大。

此外,大 Region 意味着較大的 StoreFile,compaction 時對內存也是一個挑戰。如果你的應用場景中,某個時間點的訪問量較低,那麼在此時做 compact 和 split,既能順利完成 split 和 compaction,又能保證絕大多數時間平穩的讀寫性能。compaction 是無法避免的,split 可以從自動調整爲手動。只要通過將這個參數值調大到某個很難達到的值,比如 100G,就可以間接禁用自動 split(RegionServer 不會對未到達 100G 的 Region 做 split)。再配合 RegionSplitter 這個工具,在需要 split 時,手動 split。手動 split 在靈活性和穩定性上比起自動 split 要高很多,而且管理成本增加不多,比較推薦 online 實時系統使用。內存方面,小 Region 在設置 memstore 的大小值上比較靈活,大 Region 則過大過小都不行,過大會導致 flush 時 app 的 IO wait 增高,過小則因 StoreFile 過多影響讀性能。

HBase 配置

建議 HBase 的服務器內存至少 32G,表 1 是通過實踐檢驗得到的分配給各角色的內存建議值。

表 1. HBase 相關服務配置信息

模塊 服務種類 內存需求
HDFS HDFS NameNode 16GB
HDFS DataNode 2GB
HBase HMaster 2GB
HRegionServer 16GB
ZooKeeper ZooKeeper 4GB

HBase 的單個 Region 大小建議設置大一些,推薦 2G,RegionServer 處理少量的大 Region 比大量的小 Region 更快。對於不重要的數據,在創建表時將其放在單獨的列族內,並且設置其列族備份數爲 2(默認是這樣既保證了雙備份,又可以節約空間,提高寫性能,代價是高可用性比備份數爲 3 的稍差,且讀性能不如默認備份數的時候。

實際案例

項目要求可以刪除存儲在 HBase 數據表中的數據,數據在 HBase 中的 Rowkey 由任務 ID(數據由任務產生) 加上 16 位隨機數組成,任務信息由單獨一張表維護。圖 2 所示是數據刪除流程圖。

圖 2. 數據刪除流程圖

圖 2. 數據刪除流程圖

最初的設計是在刪除任務的同時按照任務 ID 刪除該任務存儲在 HBase 中的相應數據。但是 HBase 數據較多時會導致刪除耗時較長,同時由於磁盤 I/O 較高,會導致數據讀取、寫入超時。

查看 HBase 日誌發現刪除數據時,HBase 在做 Major Compaction 操作。Major Compaction 操作的目的是合併文件,並清除刪除、過期、多餘版本的數據。Major Compaction 時 HBase 將合併 Region 中 StoreFile,該動作如果持續長時間會導致整個 Region 都不可讀,最終導致所有基於這些 Region 的查詢超時。

如果想要解決 Major Compaction 問題,需要查看它的源代碼。通過查看 HBase 源碼發現 RegionServer 在啓動時候,有個 CompactionChecker 線程在定期檢測是否需要做 Compact。源代碼如圖 3 所示。

圖 3. CompactionChecker 線程代碼圖

圖 3. CompactionChecker 線程代碼圖

isMajorCompaction 中會根據 hbase.hregion.majorcompaction 參數來判斷是否做 Major Compact。如果 hbase.hregion.majorcompaction 爲 0,則返回 false。修改配置文件 hbase.hregion.majorcompaction 爲 0,禁止 HBase 的定期 Major Compaction 機制,通過自定義的定時機制 (在凌晨 HBase 業務不繁忙時) 執行 Major 操作,這個定時可以是通過 Linux cron 定時啓動腳本,也可以通過 Java 的 timer schedule,在實際項目中使用 Quartz 來啓動,啓動的時間配置在配置文件中給出,可以方便的修改 Major Compact 啓動的時間。通過這種修改後,我們發現在刪除數據後仍會有 Compact 操作。這樣流程進入 needsCompaction = true 的分支。查看 needsCompaction 判斷條件爲 (storefiles.size() - filesCompacting.size()) > minFilesToCompact 觸發。同時當需緊縮的文件數等於 Store 的所有文件數,Minor Compact 自動升級爲 Major Compact。但是 Compact 操作不能禁止,因爲這樣會導致數據一直存在,最終影響查詢效率。

基於以上分析,我們必須重新考慮刪除數據的流程。對用戶來說,用戶只要在檢索時對於刪除的任務不進行檢索即可。那麼只需要刪除該條任務記錄,對於該任務相關聯的數據不需要立馬進行刪除。當系統空閒時候再去定時刪除 HBase 數據表中的數據,並對 Region 做 Major Compact,清理已經刪除的數據。通過對任務刪除流程的修改,達到項目的需求,同時這種修改也不需要修改 HBase 的配置。

圖 4. 數據刪除流程對比圖

圖 4. 數據刪除流程對比圖

檢索、查詢、刪除 HBase 數據表中的數據本身存在大量的關聯性,需要查看 HBase 數據表的源代碼才能確定導致檢索性能瓶頸的根本原因及最終解決方案。

結束語

HBase 數據庫的使用及檢索優化方式均與傳統關係型數據庫存在較多不同,本文從數據表的基本定義方式出發,通過 HBase 自身提供的 API 訪問方式入手,舉例說明優化方式及注意事項,最後通過實例來驗證優化方案可行性。檢索性能本身是數據表設計、程序設計、邏輯設計等的結合產物,需要程序員深入理解後才能做出正確的優化方案。

相關主題

  • 參考 developerWorks 中國關於 HBase 知識 檢索頁面,查看 IBM 開發者論壇公佈的關於 HBase 的相關文章。
  • 查看文章“淺談 HBase”,作者對於 HBase 數據表作了基礎解釋。
  • 查看書籍《HBase Definition》,作者爲 HBase 創始人,對 HBase 數據庫進行權威解答。
  • 查看博客“HBase雜談”,作者有較多的實際經驗。
  • developerWorks Java 技術專區:這裏有數百篇關於 Java 編程各個方面的文章。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章