在HBase客戶端到服務端的通信過程中,可能會碰到各種各樣的異常。例如有幾種常見導致重試的異常:
-
待訪問Region所在的RegionServer發生宕機,此時Region已經被挪到一個新的RegionServer上,但由於客戶端meta緩存的因素,首次RPC請求仍然訪問到了老的RegionServer上。后續將重試發起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為例)的設計原理:
-
服務端首先需要拿到Region的行鎖(row lock),否則容易出現兩個線程同時修改一行數據的情況,從而破壞了行級別的原子性。
-
等待該Region內的所有寫入事務都已經成功提交並在mvcc上可見。
-
通過get操作拿到需要check的行數據,進行條件檢查。若條件不符合,則終止CAS。
-
將checkAndPut的put數據持久化。
-
釋放第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返回相關數據給客戶端。
