HBase客戶端避坑指南


本文參考范欣欣hbase原理及實踐書籍以及自己實際應用中整理

1.RPC重試配置要點

在HBase客戶端到服務端的通信過程中,可能會碰到各種各樣的異常。例如有幾種常見導致重試的異常:

  • 待訪問Region所在的RegionServer發生宕機,此時Region已經被挪到一個新的RegionServer上,但由於客戶端meta緩存的因素,首次RPC請求仍然訪問到了老的RegionServer上。后續將重試發起RPC。

  • 服務端負載較大,導致單次RPC響應超時。客戶端后續將繼續重試,直到RPC成功或者超過容忍最大延遲。

  • 訪問meta表或者ZooKeeper異常。

首先來了解一下HBase常見的幾個超時參數:

  • hbase.rpc.timeout:表示單次RPC請求的超時時間,一旦單次RPC超時超過該時間,上層將收到TimeoutException。默認為60000,單位毫秒。

  • hbase.client.retries.number:表示調用API時最多容許發生多少次RPC重試操作。默認為35,單位次。

  • hbase.client.pause:表示連續兩次RPC重試之間的sleep時間,默認100,單位毫秒。注意,HBase的重試sleep時間是按照隨機退避算法來計算的,若hbase.client.pause=100,則第一次RPC重試前將休眠100ms左右 ,第二次RPC重試前將休眠200ms左右,第三次RPC重試前將休眠300ms左右,第四次重試將休眠500ms左右,第五次重試前將休眠1000ms左右,第六次重試則將休眠2000ms左右....也就是重試次數越多,則休眠的時間會越來越長。因此,若按照默認的hbase.client.retries.number=35的話,則可能長期卡在休眠和重試兩個步驟中。

  • hbase.client.operation.timeout:表示單次API的超時時間,默認為1200000,單位毫秒。注意,get/put/delete等表操作稱之為一次API操作,一次API可能會有多次RPC重試,這個operation.timeout限制的是 API操作的總超時。

假設某業務要求單次HBase的讀請求延遲不超過1秒,那么該如何設置上述4個超時參數呢?

首先,很明顯hbase.client.operation.timeout應該設成1秒。

其次,在SSD集群上,如果集群參數設置合適且集群服務正常,則基本可以保證p99延遲在100ms以內,因此hbase.rpc.timeout設成100ms。

這里,hbase.client.pause用默認的100ms。

最后,在1秒鍾之內,第一次PRC耗時100ms,休眠100ms;第二次RPC耗時100ms,休眠200ms;第三次RPC耗時100ms,休眠300ms;第四次RPC耗時100ms,休眠500ms。因此,在hbase.client.operation.timeout內,至少可執行4次RPC重試,真實的單次 RPC耗時可能更短(因為有hbase.rpc.timeout保證了單次RPC最長耗時),所以hbase.client.retries.number可以稍微設大一點(保證在1秒內有更多的重試,從而提高請求成功的概率),設成6次。

2.CAS接口

CAS接口是Region級別串行執行的,吞吐受限。HBase客戶端提供一些重要的CAS(Compare And Swap)接口,例如:

boolean checkAndPut(byte[] row, byte[] family,byte[] qualifier,byte[] value, Put put)
long incrementColumnValue(byte[] row,byte[] family,byte[] qualifier,long amount)

這些接口在高並發場景下,能很好的保證讀取寫入操作的原子性。例如有多個分布式的客戶端同時更新一個計數器count,則可以通過increment接口來保證任意時刻只有一個客戶端能成功原子地執行count++操作。

但是需要特別注意的一點是,這些CAS接口在RegionServer這邊是Region級別串行執行的。也就是說同一個Region內部的多個CAS操作是嚴格串行執行的,不同Region間的多個CAS操作可以並行執行。

這里可以簡要說明一下CAS(以checkAndPut為例)的設計原理:

  1. 服務端首先需要拿到Region的行鎖(row lock),否則容易出現兩個線程同時修改一行數據的情況,從而破壞了行級別的原子性。

  2. 等待該Region內的所有寫入事務都已經成功提交並在mvcc上可見。

  3. 通過get操作拿到需要check的行數據,進行條件檢查。若條件不符合,則終止CAS。

  4. 將checkAndPut的put數據持久化。

  5. 釋放第1步拿到的行鎖。

關鍵在於第2步,必須要等所有正在寫入的事務成功提交並在mvcc上可見。由於branch-1的HBase是寫入完成時,是先釋放行鎖,再sync WAL,最后推mvcc(寫入吞吐更高)。所以,第1步拿到行鎖之后,若跳過第2步則可能未讀取到最新的版本,從而導致以下情況的發生:

兩個客戶端並發對x=100這行數據進行increment操作時:

  • 客戶端A讀取到x=100,開始進行increment操作,將x設成101。

  • 注意此時客戶端A行鎖已釋放,但A的Put操作mvcc仍不可見。客戶端B依舊讀到老版本x=100,進行increment操作,又將x設成101。

這樣,客戶端認為成功執行了兩次increment操作,但是服務端卻只increment了一次,導致語義矛盾。

因此,對那些依賴CAS(Compare-And-Swap: 指increment/append這樣的讀后寫原子操作)接口的服務,需要意識到這個操作的吞吐是受限的,因為CAS操作本質上Region級別串行執行的。當然,在HBase2.x上已經調整設計,對同一個Region內的不同行可以並行執行CAS,這大大提高的Region內的CAS吞吐。

3.Scan Filter設置

HBase作為一個數據庫系統,提供了多樣化的查詢過濾手段。最常用的就是Filter,例如一個表有很多個列簇,用戶想找到那些列簇不為C的數據。那么,可設計一個如下的Scan:

Scan scan = new Scan;
scan.setFilter(new FamilyFilter(CompareOp.NOT_EQUAL, new BinaryComparator(Bytes.toBytes("C"))));

如果想查詢列簇不為C且Qualifier在[a, z]區間的數據,可以設計一個如下的Scan:

Scan scan = new Scan;
FamilyFilter ff = new FamilyFilter(CompareOp.NOT_EQUAL, new BinaryComparator(Bytes.toBytes("C")));
ColumnRangeFilter qf = new ColumnRangeFilter(Bytes.toBytes("a"), true, Bytes.toBytes("b"), true);
FilterList filterList = new FilterList(Operator.MUST_PASS_ALL, ff,qf);
scan.setFilter(filterList);

上面代碼使用了一個帶AND的FilterList來連接FamilyFilter和ColumnRangeFilter。

有了Filter,大量無效數據可以在服務端內部過濾,相比直接返回全表數據到客戶端然后在客戶端過濾,要高效很多。但是,HBase的Filter本身也有不少局限,如果使用不恰當,仍然可能出現極其低效的查詢,甚至對線上集群造成很大負擔。后面將列舉幾個常見的例子。

(1)PrefixFilter

PrefixFilter是將rowkey前綴為指定字節串的數據都過濾出來並返回給用戶。例如,如下scan會返回所有rowkey前綴為'def'的數據。注意,這個scan雖然能拿到預期的效果,但卻並不高效。因為對於rowkey在區間(-oo, def)的數據,scan會一條條 依次掃描一次,發現前綴不為def,就讀下一行,直到找到第一個rowkey前綴為def的行為止,代碼如下:

Scan scan = new Scan;
scan.setFilter(new PrefixFilter(Bytes.toBytes("def")));

這主要是因為目前HBase的PrefixFilter設計的相對簡單粗暴,沒有根據具體的Filter做過多的查詢優化。這種問題其實很好解決,在scan中簡單加一個startRow即可,RegionServer在發現scan設了startRow,首先尋址定位到這個startRow,然后從這個位置開始掃描數據,這樣就跳過了大量的(-oo, def)的數據。代碼如下:

Scan scan = new Scan;
scan.setStartRow(Bytes.toBytes("def"));
scan.setFilter(new PrefixFilter(Bytes.toBytes("def")));

當然,更簡單直接的方式,就是將PrefixFilter直接展開成掃描[def, deg)這個區間的數據,這樣效率是最高的,代碼如下:

Scan scan = new Scan;
scan.setStartRow(Bytes.toBytes("def"));
scan.setStopRow(Bytes.toBytes("deg"));

在設置StopRow的時候,可以考慮使用字符“~”拼接,因為hbase rowkey是以ascii碼來排序的,ascii碼中常見字符排序是(0~9排序) < (A~Z大寫字母排序) < (a~z小寫字母排序) < (~),這里的“~”字符是比小寫的z還要大(詳細見https://baike.baidu.com/item/ASCII/309296?fromtitle=ascii%E7%A0%81&fromid=99077&fr=aladdin)。這時候比如我們查賬號為987654321的所有交易數據為可以如下設置:

Scan scan = new Scan;
scan.setStartRow(Bytes.toBytes("987654321"));
scan.setStopRow(Bytes.toBytes("987654321~"));

此外,如果rowkey中變態的還包含了中文,“~”字符也可能不能完全包含所有的數據,這時候可以將字符“~”換成十六進制的0xFF,將0xFF轉為String類型,拼接到賬號后面。

(2)PageFilter

在HBASE-21332中,有一位用戶說,他有一個表,表里面有5個Region,分別為(-oo, 111), [111, 222), [222, 333), [333, 444), [444, +oo)。表中這5個Region,每個Region都有超過10000行的數據。他發現通過如下scan掃描出來的數據居然超過了3000行:

Scan scan = new Scan;
scan.withStartRow(Bytes.toBytes("111"));
scan.withStopRow(Bytes.toBytes("4444"));
scan.setFilter(new PageFilter(3000));

乍一看確實很詭異,因為PageFilter就是用來做數據分頁功能的,應該要保證每一次掃描最多返回不超過3000行。但是需要注意的是,HBase里面Filter狀態全部都是Region內有效的,也就是說,Scan一旦從一個Region切換到另一個Region之后, 之前那個Filter的內部狀態就無效了,新Region內用的其實是一個全新的Filter。具體這個問題來說,就是PageFilter內部計數器從一個Region切換到另一個Region之后,計數器已經被清0。因此,這個Scan掃描出來的數據將會是:

  • 在[111,222)區間內掃描3000行數據,切換到下一個region [222, 333)。

  • 在[222,333)區間內掃描3000行數據,切換到下一個region [333, 444)。

  • 在[333,444)區間內掃描3000行數據,發現已經到達stopRow,終止。

因此,最終將返回9000行數據。

理論上說,這應該算是HBase的一個缺陷,PageFilter並沒有實現全局的分頁功能,因為Filter沒有全局的狀態。我個人認為,HBase也是考慮到了全局Filter的復雜性,所以暫時沒有提供這樣的實現。當然如果想實現分頁功能,可以不通過Filter,而直接通過limit來實現,代碼如下:

Scan scan = new Scan;
scan.withStartRow(Bytes.toBytes("111"));
scan.withStopRow(Bytes.toBytes("4444"));
scan.setLimit(1000);

但是,如果你用的hbase不是1.4.0以上版本的,是沒有setLimit()的。這個時候也有一種方式就是PageFilter+指定split策略來實現。

上面已經說了,如果要查詢的數據分布在了多個region,PageFilter就不靈了。那我們就想辦法讓要查詢的數據都可以在一個region里就行了。方式如下:

指定split策略為DelimitedKeyPrefixRegionSplitPolicy,該split策略的介紹如下:

A custom RegionSplitPolicy implementing a SplitPolicy that groups rows by a prefix of the row-key with a delimiter. Only the first delimiter for the row key will define the prefix of the row key that is used for grouping.This ensures that a region is not split “inside” a prefix of a row key.

I.e. rows can be co-located in a region by their prefix.

As an example, if you have row keys delimited with _ , like userid_eventtype_eventid, and use prefix delimiter _, this split policy ensures that all rows starting with the same userid, belongs to the same region.

也就是保證相同前綴的數據在同一個region中,例如rowKey的組成為:userid_timestamp_transno,指定的delimiter為 _ ,則split的的時候會確保userid相同的數據在同一個region中。

也就是使用這個split策略,在做split找region的中心點時候,會將userid考慮在內 (更多內容可參考https://blog.csdn.net/fenglibing/article/details/82735979)。

這樣子就完美解決了。

使用方式如下:

  • 通過代碼指定

 創建后查看表信息

  • hbase shell方式指定

disable 'test1'
drop 'test1'
create 'test1',{NAME => 'f1'},METADATA => {'DelimitedKeyPrefixRegionSplitPolicy.delimiter' => '_','SPLIT_POLICY' => 'org.apache.hadoop.hbase.regionserver.DelimitedKeyPrefixRegionSplitPolicy' }

 創建后查看表信息

 

3.少量寫和批量寫

HBase是一種對寫入操作非常友好的系統,但是當業務有大批量的數據要寫入到HBase中時,仍會碰到寫入瓶頸的問題。為了適應不同數據量的寫入場景,HBase提供了3種常見的數據寫入API:

  • table.put(put)——這是最常見的單行數據寫入API,在服務端是先寫WAL,然后寫MemStore,一旦MemStore寫滿就flush到磁盤上。這種寫入方式的特點是,默認每次寫入都需要執行一次RPC和磁盤持久化。因此,寫入吞吐量受限於磁盤帶寬,網絡帶寬,以及flush的速度。但是,它能保證每次寫入操作都持久化到磁盤,不會有任何數據丟失。最重要的是,它能保證put操作的原子性。

  • table.put(List<Put> puts)——HBase還提供了批量寫入的接口,特點是在客戶端緩存一批put,等湊足了一批put,就將這些數據打包成一次RPC發送到服務端,一次性寫WAL,並寫MemStore。相比第一種方式,省去了多次往返RPC以及多次刷盤的開銷,吞吐量大大提升。不過,這個RPC操作的 耗時一般都會長一點,因此一次寫入了多行數據。另外,如果List<put>內的put分布在多個Region內,則並不能保證這一批put的原子性,因為HBase並不提供跨Region的多行事務,換句話說,就是這些put中,可能有一部分失敗,一部分成功,失敗的那些put操作會經歷若干次重試。

  • bulk load——本質是通過HBase提供的工具直接將待寫入數據生成HFile,將這些HFile直接加載到對應的Region下的CF內。在生成HFile時,跟HBase服務端沒有任何RPC調用,只有在load HFile時會調用RPC,這是一種完全離線的快速寫入方式。bulk load應該是最快的批量寫手段,同時不會對線上的集群產生巨大壓力,當然在load完HFile之后,CF內部會進行Compaction,但是Compaction是異步的且可以限速,所以產生的IO壓力是可控的。因此,對線上集群非常友好。

例如,我們之前碰到過一種情況,有兩個集群,互為主備,其中一個集群由於工具bug導致數據缺失,想通過另一個備份集群的數據來修復異常集群。最快的方式,就是把備份集群的數據導一個快照拷貝到異常集群,然后通過CopyTable工具掃快照生成HFile,最后bulk load到異常集群,就完成了數據的修復。

另外的一種場景是,用戶在寫入大量數據后,發現選擇的split keys不合適,想重新選擇split keys建表。這時,也可以通過Snapshot生成HFile再bulk load的方式生成新表。

4.業務發現請求延遲很高,但是HBase服務端延遲正常

某些業務發現HBase客戶端上報的p99和p999延遲非常高,但是觀察了HBase服務端這邊的p99和p999延遲則正常。這種情況一般需要觀察HBase客戶端這邊的監控和日志。按照我們的經驗,一般來說,有這樣一些常見問題:

  • HBase客戶端所在進程Java GC。由於HBase客戶端作為業務代碼的一個Java依賴,則如果業務進程一旦發生較為嚴重的Full GC就可能導致HBase客戶端看到的延遲很高。

  • 業務進程所在機器的CPU或者網絡負載較高,對於上層業務來說一般不涉及磁盤資源的開銷,所以主要看load和網絡是否過載。

  • HBase客戶端層面的bug,這種情況出現的概率不大,但也不排除有這種可能。

5.Batch數據量太大,導致異常

Batch數據量太大,可能導致MultiActionResultTooLarge異常。HBase的batch接口,容許用戶把一批操作通過一次RPC發送到服務端,以便提升系統的吞吐量。這些操作可以是Put、Delete、Get、Increment、Append等等一系列操作。像Get或者Increment的Batch操作中,需要先把對應的數據塊(Block)從HDFS中讀取到HBase內存中,然后通過RPC返回相關數據給客戶端。

如果Batch中的操作過多,則可能導致一次RPC讀取的Block數據量很多,容易造成HBase的RegionServer出現OOM,或者出現長時間的Full GC。因此,HBase的RegionServer會限制每次請求的Block總字節數,一旦超過則會報MultiActionResultTooLarge異常。此時,客戶端最好控制每次Batch的操作個數,以免服務端為單次RPC消耗太多內存。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM