因为要对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运行失败的情况。