spark读取Hbase的优化

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章