因爲要對HBase中的鏈路數據進行分析,考慮到直接掃描HBase表對HBase集羣壓力較大,因此通過掃描HFile文件來完成。
HBase的中數據表是按照小時來存儲的,在掃描某一個小時的數據表時,首先建立該表的快照(Snapshot),再基於HBase提供的TableSnapshotInputFormat類來完成HFile的讀取,核心的代碼如下:
val inputs = spark.sparkContext.newAPIHadoopRDD(
job.getConfiguration,
classOf[TableSnapshotInputFormat],
classOf[org.apache.hadoop.hbase.io.ImmutableBytesWritable],
classOf[org.apache.hadoop.hbase.client.Result])
運行了一段時間過後,由於業務數據量不斷增加,導致程序處理一個小時表的時間越來越長,發現程序運行最耗時的地方在於讀取HFile進行反序列化的過程,如圖所示:
第一個stage耗時23min,該stage主要是讀取HFile,將中間結果進行shuffle write,其並行度爲48,正好等於HBase表的region個數,由於第一個階段的序列化比較耗時,因此提高程序速度的方式在於加大第一個階段的並行度,也即讀取HFile的初始並行度。我們讀取HFile使用的format是org.apache.hadoop.hbase.mapreduce.TableSnapshotInputFormat類,查看其split方法:
public static List<TableSnapshotInputFormatImpl.InputSplit> getSplits(Configuration conf) throws IOException {
String snapshotName = getSnapshotName(conf);
Path rootDir = FSUtils.getRootDir(conf);
FileSystem fs = rootDir.getFileSystem(conf);
SnapshotManifest manifest = getSnapshotManifest(conf, snapshotName, rootDir, fs);
List<HRegionInfo> regionInfos = getRegionInfosFromManifest(manifest);
Scan scan = extractScanFromConf(conf);
Path restoreDir = new Path(conf.get("hbase.TableSnapshotInputFormat.restore.dir"));
SplitAlgorithm splitAlgo = getSplitAlgo(conf);
int numSplits = conf.getInt("hbase.mapreduce.splits.per.region", 1);
return getSplits(scan, manifest, regionInfos, restoreDir, conf, splitAlgo, numSplits);
}
其中的regionInfos是HBase表的region列表,其中有個參數hbase.mapreduce.splits.per.region,默認爲1,查看getSplits方法中的實現:
當該參數大於1時,會對每個region進行分割讀取,同時需要實現org.apache.hadoop.hbase.util.SplitAlgorithm的分割算法.
首先我們實現對HBase region讀取時的切分策略,由於HBase表在寫入的過程中採用的是HexStringSplit分割方式,故實現的切分邏輯如下:
class TraceSplit extends HexStringSplit {
override def split(start: Array[Byte], end: Array[Byte], numSplits: Int, inclusive: Boolean): Array[Array[Byte]] = {
var keyStart = start
var keyEnd = end
if (StringUtils.isEmpty(Bytes.toStringBinary(start))) {
keyStart = "00000000".getBytes
}
if (StringUtils.isEmpty(Bytes.toStringBinary(end))) {
keyEnd = "FFFFFFFF".getBytes
}
super.split(keyStart, keyEnd, numSplits, inclusive)
}
}
由於第一個Region的startKey和最後一個Region的endKey爲空,爲了保持切分的連續性,將第一個Region的startKey設置爲00000000,最後一個Region的endKey設置爲FFFFFFFF,採用的切分邏輯即HexStringSplit的split方法。
設置切分的方法爲:
TableSnapshotInputFormat.setInput(job, snapshot, path, new TraceSplit(), splitPerRegion)
將splitPerRegion設爲2,即每個region切分爲2個,則讀取並行度爲96。
(注: 該圖與上一個執行圖執行的數據並不是同一個小時的,當前小時的數據未切分前運行時間需要1個小時)
將hbase.mapreduce.splits.per.region設置爲大於1之後,出現了以下問題,也即第一個stage中出現了task failed情況:
19/03/01 11:23:27 ERROR executor.Executor: Exception in task 16.0 in stage 0.0 (TID 18)
org.apache.hadoop.ipc.RemoteException(org.apache.hadoop.hdfs.protocol.AlreadyBeingCreatedException): Failed to CREATE_FILE /user/gwjiang/reflux/benchmark/output/v2/restore/bfe4e1ff-20c5-41ec-b6db-5b48f808344c/data/default/leyden-2019-02-28-21/313225b6e5b440c96f161f8cb54670f0/recovered.edits/3780577.seqid for DFSClient_NONMAPREDUCE_623145177_155 on 10.101.5.10 because this file lease is currently owned by DFSClient_NONMAPREDUCE_-1558587010_154 on 10.101.5.10
at org.apache.hadoop.hdfs.server.namenode.FSNamesystem.recoverLeaseInternal(FSNamesystem.java:2455)
at org.apache.hadoop.hdfs.server.namenode.FSDirWriteFileOp.startFile(FSDirWriteFileOp.java:357)
at org.apache.hadoop.hdfs.server.namenode.FSNamesystem.startFileInt(FSNamesystem.java:2303)
at org.apache.hadoop.hdfs.server.namenode.FSNamesystem.startFile(FSNamesystem.java:2223)
at org.apache.hadoop.hdfs.server.namenode.NameNodeRpcServer.create(NameNodeRpcServer.java:728)
at org.apache.hadoop.hdfs.protocolPB.ClientNamenodeProtocolServerSideTranslatorPB.create(ClientNamenodeProtocolServerSideTranslatorPB.java:413)
at org.apache.hadoop.hdfs.protocol.proto.ClientNamenodeProtocolProtos$ClientNamenodeProtocol$2.callBlockingMethod(ClientNamenodeProtocolProtos.java)
at org.apache.hadoop.ipc.ProtobufRpcEngine$Server$ProtoBufRpcInvoker.call(ProtobufRpcEngine.java:447)
at org.apache.hadoop.ipc.RPC$Server.call(RPC.java:989)
at org.apache.hadoop.ipc.Server$RpcCall.run(Server.java:850)
at org.apache.hadoop.ipc.Server$RpcCall.run(Server.java:793)
at java.security.AccessController.doPrivileged(Native Method)
at javax.security.auth.Subject.doAs(Subject.java:422)
at org.apache.hadoop.security.UserGroupInformation.doAs(UserGroupInformation.java:1840)
at org.apache.hadoop.ipc.Server$Handler.run(Server.java:2489)
at org.apache.hadoop.ipc.Client.getRpcResponse(Client.java:1489)
at org.apache.hadoop.ipc.Client.call(Client.java:1435)
at org.apache.hadoop.ipc.Client.call(Client.java:1345)
at org.apache.hadoop.ipc.ProtobufRpcEngine$Invoker.invoke(ProtobufRpcEngine.java:227)
at org.apache.hadoop.ipc.ProtobufRpcEngine$Invoker.invoke(ProtobufRpcEngine.java:116)
at com.sun.proxy.$Proxy14.create(Unknown Source)
at org.apache.hadoop.hdfs.protocolPB.ClientNamenodeProtocolTranslatorPB.create(ClientNamenodeProtocolTranslatorPB.java:297)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java)
從報錯信息來看,是因爲兩個進程同時創建一個文件造成的互斥錯誤。HBase在創建snapshot時需要指定一個restore目錄,並且將region的元信息複製到該目錄中,由於region是48個,因此restore下的目錄也是48個,並且與region一一對應。通過代碼棧跟蹤信息,發現shuffleMap的每個task需要從restore中獲取region信息,雖然第一個stage有96個task,但每兩個task都是基於同一個region進行key分割得到,因此這個兩個task需要讀取同一個region信息,
定位到HBase源碼中:
// The openSeqNum will always be increase even for read only region, as we rely on it to
// determine whether a region has been successfully reopend, so here we always need to update
// the max sequence id file.
if (RegionReplicaUtil.isDefaultReplica(getRegionInfo())) {
LOG.debug("writing seq id for {}", this.getRegionInfo().getEncodedName());
WALSplitter.writeRegionSequenceIdFile(fs.getFileSystem(), fs.getRegionDir(), nextSeqId - 1);
}
LOG.info("Opened {}; next sequenceid={}", this.getRegionInfo().getShortNameToLog(), nextSeqId);
該段代碼會在restore對應的region目錄中創建seqid文件,從代碼註釋可知,該文件是作爲當前region是否打開的標誌位,由於兩個task同屬於一個region,因此同時創建該標誌位會報錯。task報錯後,會重新執行(spark中會重試4次),第二次執行時,由於該標誌位已經創建,因此執行正常。因此,當hbase.mapreduce.splits.per.region設置大於1之後,在第一個stage中會出現部分task failed的情況。
爲了規避這個錯誤,對org.apache.hadoop.hbase.mapreduce.TableSnapshotInputFormatImpl.RecordReader進行了重新實現,在其中初始化scanner中進行了重試:
def initialize(split: InputSplit, conf: Configuration): Unit ={
this.scan = TableMapReduceUtil.convertStringToScan(split.getScan)
this.split = split
val htd = split.getHtd
val hri = this.split.getRegionInfo
val fs = CommonFSUtils.getCurrentFileSystem(conf)
scan.setIsolationLevel(IsolationLevel.READ_UNCOMMITTED)
scan.setCacheBlocks(false)
scan.setScanMetricsEnabled(true)
try {
this.scanner = new ClientSideRegionScanner(conf, fs, new Path(split.getRestoreDir), htd, hri, scan, null)
} catch {
case e: IOException =>
logger.error(s"create ClientSideRegionScanner error", e)
e match {
case e: RemoteException =>
if (e.getClassName.contains("AlreadyBeingCreatedException")) {
logger.info("try to create ClientSideRegionScanner again")
Thread.sleep(1000)
this.scanner = new ClientSideRegionScanner(conf, fs, new Path(split.getRestoreDir), htd, hri, scan, null)
}
case _ =>
}
}
}
在初始化scanner時,如果遇到AlreadyBeingCreatedException異常,則重新初始化scanner,由於這時標誌位已經創建好,重新初始化時不會報錯。
重寫了org.apache.hadoop.hbase.mapreduce.TableSnapshotInputFormat的ceateRecordReader方法:
class TraceTableSnapshotInputFormat extends TableSnapshotInputFormat{
override def createRecordReader(split: InputSplit, context: TaskAttemptContext): mapreduce.RecordReader[ImmutableBytesWritable, Result] = {
new TableSnapshotRegionRecordReader
}
}
修改之後,當hbase.mapreduce.splits.per.region大於1時,沒有出現task運行失敗的情況。