並發拉取HBase大量指定列數據時卡住的問題排查
最近遇到一例,並發拉取HBase大量指定列數據時,導致應用不響應的情形。記錄一下。
背景
退款導出中,為了獲取商品規格編碼,需要從HBase表 T 裏拉取對應的數據。 T 對商品數據的存儲采用了 表名:字段名:id 的列存儲方式。由於這個表很大,且為詳情公用,因此不方便使用 scan 的方式,擔心帶來集群的不穩定,進而影響詳情和導出的整體穩定性。
要用 multiGet 的方式來獲取多個訂單的這個列的數據。 就必須動態生成相應的列,然後在 HBase 獲取數據的時候指定列集合。 現有記錄集合 List
然而,當 HBase指定列名集合比較大的時候, 似乎是有問題的。導致了堆內存爆了。
CPU 曲線也是直線上升
排查與解決
從錯誤日誌上看,是因為 HBase 獲取數據卡住了。 而此次的變更在於是增加了一個可以並發獲取HBase指定列數據的插件。 因為某種原因,必須生成和指定大量列去查詢HBase數據, 因此疑點主要鎖定在 HBase 獲取大量動態列數據是不是有問題,是不是因為指定列太多了。
原來的代碼如下:
private List<Result> fetchDataFromHBase(List<OneRecord> data, List<String> rowKeys, HBaseDataConf hbaseDataConf) { List<Result> hbaseResults = multiTaskExecutor.exec(rowKeys, subRowkeys -> haHbaseService.getRawData(subRowkeys, hbaseDataConf.getTable(), "cf", generateCols(hbaseDataConf.getFetchDataConf(), data), "", true), 200); return hbaseResults; }
問題出在 subRowkeys -> haHbaseService.getRawData(subRowkeys, hbaseDataConf.getTable(), "cf", generateCols(hbaseDataConf.getFetchDataConf(), data)
這一行上。 data 是記錄全集,這樣 generalCols 會拿到所有訂單的商品ID 對應的列集合。而 subRowkeys 是按照指定任務數分割後的 HBase Rowkeys 子集合。 假如 data 有 8000 條記錄,那麽 generateCols(hbaseDataConf.getFetchDataConf(), data) 會生成幾萬條動態列,而 subRowkeys 只有不足 200 條。 顯然, generateCols 裏的 data 應該是對應劃分後的 subRowkeys 的那些記錄,而不是全部記錄。
修改後的代碼如下:
private List<Result> fetchDataFromHBase(List<OneRecord> data, HBaseDataConf hbaseDataConf) {
List<Result> hbaseResults = multiTaskExecutor.exec(data,
partData -> fetchDataFromHBasePartially(partData, hbaseDataConf), 200);
return hbaseResults;
}
private List<Result> fetchDataFromHBasePartially(List<OneRecord> partData, HBaseDataConf hbaseDataConf) {
List<String> rowKeys = RowkeyUtil.buildRowKeys(partData, hbaseDataConf.getRowkeyConf());
logger.info("hbase-rowkeys: {}", rowKeys.size());
return haHbaseService.getRawData(rowKeys, hbaseDataConf.getTable(),
"cf", generateCols(hbaseDataConf.getFetchDataConf(), partData), "", true);
}
這裏,generalCols 用來生成的動態列就只對應分割後的記錄集合。修改後,問題就解決了。
原因
那麽,為什麽數萬條指定列會導致HBase獲取數據時內存爆掉了呢?
在 獲取 HBase 數據的地方打日誌:
String cf = (cfName == null) ? "cf" : cfName;
logger.info("columns: {}", columns);
List<Get> gets = buildGets(rowKeyList, cf, columns, columnPrefixFilters);
logger.info("after buildGet: {}", gets.size());
Result[] results = getFromHbaseFunc.apply(tableName, gets);
logger.info("after getHBase: {}", results.length);
發現: columns 日誌打出來了, after buildGet 沒有打出來。程序卡住了。可以推斷,是 buildGets 這一步卡住了。為什麽卡在了 buildGets 這一步呢?
鎖定嫌疑
寫一個單測,做個小實驗。 先弄個串行的實驗。 1000個訂單, 列數從 2000 增長 24000
@Test
def "testMultiGetsSerial"() {
expect:
def columnSize = 12
def rowkeyNums = 1000
def rowkeys = (1..rowkeyNums).collect { "E001" + it }
(1..columnSize).each { colsSize ->
def columns = (1..(colsSize*2000)).collect { "tc_order_item:sku_code:" + it }
def start = System.currentTimeMillis()
List<Get> gets = new HAHbaseService().invokeMethod("buildGets", [rowkeys, "cf", columns, null])
gets.size() == rowkeyNums
def end = System.currentTimeMillis()
def cost = end - start
println "num = $rowkeyNums , colsSize = ${columns.size()}, cost (ms) = $cost"
}
}
耗時如下:
num = 1000 , colsSize = 2000, cost (ms) = 2143
num = 1000 , colsSize = 4000, cost (ms) = 3610
num = 1000 , colsSize = 6000, cost (ms) = 5006
num = 1000 , colsSize = 8000, cost (ms) = 8389
num = 1000 , colsSize = 10000, cost (ms) = 8921
num = 1000 , colsSize = 12000, cost (ms) = 12467
num = 1000 , colsSize = 14000, cost (ms) = 11845
num = 1000 , colsSize = 16000, cost (ms) = 12589
num = 1000 , colsSize = 18000, cost (ms) = 20068
java.lang.OutOfMemoryError: GC overhead limit exceeded
按照實際運行的並發情況做個實驗。 從 1000 到 6000 個訂單,列集合數量 從 1000 - 10000。 用並發來構建 gets 。
@Test
def "testMultiGetsConcurrent"() {
expect:
def num = 4
def columnSize = 9
(1..num).each { n ->
def rowkeyNums = n*1000
def rowkeys = (1..rowkeyNums).collect { "E001" + it }
(1..columnSize).each { colsSize ->
def columns = (1..(colsSize*1000)).collect { "tc_order_item:sku_code:" + it }
def start = System.currentTimeMillis()
List<Get> gets = taskExecutor.exec(
rowkeys, { new HAHbaseService().invokeMethod("buildGets", [it, "cf", columns, null]) } as Function, 200)
gets.size() == rowkeyNums
def end = System.currentTimeMillis()
def cost = end - start
println "num = $rowkeyNums , colsSize = ${columns.size()}, cost (ms) = $cost"
println "analysis:$rowkeyNums,${columns.size()},$cost"
}
}
}
耗時如下:
num = 1000 , colsSize = 1000, cost (ms) = 716
num = 1000 , colsSize = 2000, cost (ms) = 1180
num = 1000 , colsSize = 3000, cost (ms) = 1378
num = 1000 , colsSize = 4000, cost (ms) = 2632
num = 1000 , colsSize = 5000, cost (ms) = 2130
num = 1000 , colsSize = 6000, cost (ms) = 4328
num = 1000 , colsSize = 7000, cost (ms) = 4524
num = 1000 , colsSize = 8000, cost (ms) = 5612
num = 1000 , colsSize = 9000, cost (ms) = 5804
num = 2000 , colsSize = 1000, cost (ms) = 1416
num = 2000 , colsSize = 2000, cost (ms) = 1486
num = 2000 , colsSize = 3000, cost (ms) = 2434
num = 2000 , colsSize = 4000, cost (ms) = 4925
num = 2000 , colsSize = 5000, cost (ms) = 5176
num = 2000 , colsSize = 6000, cost (ms) = 7217
num = 2000 , colsSize = 7000, cost (ms) = 9298
num = 2000 , colsSize = 8000, cost (ms) = 11979
num = 2000 , colsSize = 9000, cost (ms) = 20156
num = 3000 , colsSize = 1000, cost (ms) = 1837
num = 3000 , colsSize = 2000, cost (ms) = 2460
num = 3000 , colsSize = 3000, cost (ms) = 4516
num = 3000 , colsSize = 4000, cost (ms) = 7556
num = 3000 , colsSize = 5000, cost (ms) = 6169
num = 3000 , colsSize = 6000, cost (ms) = 19211
num = 3000 , colsSize = 7000, cost (ms) = 180950
……
可見,耗時隨著rowkey 數應該是線性增長; 而隨著指定列集合的增大,會有超過線性的增長和波動。超線性增長是算法引起的,波動應該是由線程池執行引起的。
如果有 8800 個訂單,指定 24000 個列, 可想而知,有多慢了。
查看buildGets代碼,其中嫌疑最大的就是 addColumn 方法。這個方法添加列時,將列加入了 NavigableSet<byte[]> 這個數據結構裏。NavigableSet是一個排序的集合。HBase 的 NavigableSet 實現類是 TreeSet, 是基於紅黑樹實現的。紅黑樹查詢一個元素的復雜度是在O(Log2n) 。添加N個元素的復雜度在 n*O(Log2n) 。 如果添加大量列,就可能導致CPU計算消耗大,並發的情況會加劇。
為什麽HBase要用NavigableSet
那麽, HBase 列數據集的結構為什麽要用排序的Set 而不用普通的 Set 呢?是因為指定列從HBase獲取數據時,HBase會將滿足條件的數據拿出來,依次與指定列進行匹配過濾,這時候要應用到查找列功能。當指定列非常大時,TreeSet 的效率比 HashSet 的要大。
結語
因為一個低級的錯誤,導致堆內存爆了; 又因為這個錯誤,深入了解了 HBase 讀取列數據的一些內幕。 幸~~ 後續還需要深入學習下 紅黑樹的算法實現。
【完】
並發拉取HBase大量指定列數據時卡住的問題排查