• HDFS QJM原理分析


    前言


    最近在重新研读HDFS QJM的细节实现,所谓“温故而知新”,感觉还是收获不少。之前笔者曾简单地翻译过HDFS QJM的设计文档,感兴趣的同学可以点此链接:HDFS QJM的架构设计。本文笔者打算挑选其中的一些细节要点,进程阐述。

    背景


    在HDFS QJM出现之前,editlog的一种推荐存储方式是基于NAS网络存储设备。这种方式会带来一些局限性:

    • 对于特定硬件的要求。
    • 部署操作的复杂性。

    这么做的一个理由是它可以做到一定的高可用性。但是它的劣势是大于它所带来的好处的,于是社区提出了在软件层面来做这样的事,提出了以下3点基本要求:

    • 没有特定硬件的要求。
    • 软件层Fencing机理的实现。(Fencing的操作在这里是为了防止早些的writer对象进行写操作)。
    • 无单点问题,所有editlog都是完全高可用的。

    基于Quorum的管理方法


    QJM的全称是Quorum Journal Manager,管理的节点为JournalNode,NameNode往这些JournalNode上读/写editlog信息。在每次的写操作过程中,这些信息会发送到所有的JournalNode节点中,关键的一点是,它并不需要要求所有节点成功的回复信息,只需要多数以上(这里指半数以上)的成功信息即可。这就是Quorum原理的核心所在。

    Quorum Journal管理的主执行流程


    QuorumJournalManager内部控制editlog的写入步骤如下:

    1.停止之前的writer对象。当前写editlog的writer对象在写的时候,必须保证之前的writer对象没有在写editlog信息。
    2.恢复上一次未写入完全的editlog信息内容。因为存在一种可能性,早期的writer对象存在写失败的可能性,造成各个JournalNode上写入的editlog的内容长度不同,这里需要做一个数据同步恢复。这点会在下文中具体分析到。
    3.开始写入一个新的editlog数据片段。
    4.写入editlog信息片段。
    5.Finalize(确认)editlog信息成功写入。只要步骤4中的执行结果在多数JournalNode中为成功即可。

    下面我们来看其中的2个关键步骤的细节实现。

    Fencing Writers


    这个步骤的目的,笔者已经阐述过,是为了防止早期的writer对象继续写入editlog信息,造成脏数据的写入。那么问题来了,QJM内部是如何实现这点的呢?答案如下。

    每个writer对象变为活跃的时候,QJM为每个writer对象分配一个唯一的epoch数字,这个数字是单调递增的,在QJM的写入过程中,不允许epoch数字小于当前epoch数字的writer对象写入数据。

    所以,从上面的内容来看,这里提到了一个重要的变量:epoch数字。我们可以简单理解为“迭代轮次”的概念,比如说第一轮,第二轮,第三轮。。
    但是在每次epoch数字的迭代增加中,是有一番讲究的,里面会涉及到协商选择的,细节步骤如下:

    a.首先QJM会发送一条请求消息(getJournalState()请求),去获取每个JournalNode上当前的epoch数字值。这个值在JournalNode上被保存在名为lastPromisedEpoch的变量里。
    b.QJM收到所有JournalNode返回来的epoch数字后,取出其中的最大值,然后在此值上加1,以此作为新的epoch值。
    c.QJM以新的epoch值为内容,向各JournalNode发送newEpoch请求,每个JournalNode收到此请求时,比较自身保存的epoch值(即本地lastPromisedEpoch变量),如果比此值大,则更新本地值。

    本地epoch值更新后,会在每次的写操作中被用作其中的一个检测条件,代码如下:

    private synchronized void checkRequest(RequestInfo reqInfo) throws IOException {
        // 判断当前请求信息的epoch值是否小于当前的epoch值,如果是则抛出异常
        if (reqInfo.getEpoch() < lastPromisedEpoch.get()) {
          throw new IOException("IPC's epoch " + reqInfo.getEpoch() +
              " is less than the last promised epoch " +
              lastPromisedEpoch.get());
        } else if (reqInfo.getEpoch() > lastPromisedEpoch.get()) {
          // 否则用新的epoch值更新当前epoch值
          updateLastPromisedEpoch(reqInfo.getEpoch());
        }
        ...
    }

    在这里,我们对照代码再来看看QJM(QuorumJournalManager类)内部新的epoch值的生成过程:

    Map<AsyncLogger, NewEpochResponseProto> createNewUniqueEpoch()
          throws IOException {
        Preconditions.checkState(!loggers.isEpochEstablished(),
            "epoch already created");
    
        // 步骤1:向所有JournalNode发送getJournalState请求,获取它们的epoch值
        Map<AsyncLogger, GetJournalStateResponseProto> lastPromises =
          loggers.waitForWriteQuorum(loggers.getJournalState(),
              getJournalStateTimeoutMs, "getJournalState()");
    
        long maxPromised = Long.MIN_VALUE;
        // 步骤2:从这些epoch值中选出最大的值
        for (GetJournalStateResponseProto resp : lastPromises.values()) {
          maxPromised = Math.max(maxPromised, resp.getLastPromisedEpoch());
        }
        assert maxPromised >= 0;
    
        // 新的epoch值在最大的值基础上递增1
        long myEpoch = maxPromised + 1;
        // 步骤3:向JournalNode节点发送新的epoch值
        Map<AsyncLogger, NewEpochResponseProto> resps =
            loggers.waitForWriteQuorum(loggers.newEpoch(nsInfo, myEpoch),
                newEpochTimeoutMs, "newEpoch(" + myEpoch + ")");
    
        loggers.setEpoch(myEpoch);
        return resps;
    }

    异常/失败数据恢复


    这部分小节内容即上面的步骤2。社区在这块可是做了相当详细的设计,毕竟数据失败,延时这类的情况在普通硬件中是极有可能发生的,所以我们要在软件层做这样的容错处理。

    在QJM中,引入了lastWriterEpoch来保存最近一次writer写入对象的epoch值,定义如下:

    /**
     * The epoch number of the last writer to actually write a transaction.
     * This is used to differentiate log segments after a crash at the very
     * beginning of a segment. See the the 'testNewerVersionOfSegmentWins'
     * test case.
     */
    private PersistentLongFile lastWriterEpoch;

    此变量在每次开始写入新的数据片段时会被更新,

    public synchronized void startLogSegment(RequestInfo reqInfo, long txid,
          int layoutVersion) throws IOException {
        assert fjm != null;
        checkFormatted();
        checkRequest(reqInfo);
    
        ...
    
        long curLastWriterEpoch = lastWriterEpoch.get();
        // 更新当前最新writer值epoch值
        if (curLastWriterEpoch == reqInfo.getEpoch()) {
          LOG.info("Updating lastWriterEpoch from " + curLastWriterEpoch +
              " to " + reqInfo.getEpoch() + " for client " +
              Server.getRemoteIp());
          lastWriterEpoch.set(reqInfo.getEpoch());
        }
    
        ...
    }

    然后在每次的写操作中,会进行epoch值的检查,

    private synchronized void checkWriteRequest(RequestInfo reqInfo) throws IOException {
        checkRequest(reqInfo);
    
        if (reqInfo.getEpoch() != lastWriterEpoch.get()) {
          throw new IOException("IPC's epoch " + reqInfo.getEpoch() +
              " is not the current writer epoch  " +
              lastWriterEpoch.get());
        }
    }

    重新回到本小节前面提到的失败情况,举个例子,比如一个JournalNode节点突然crash了,其上的editlog就会出现落后的情况,当它重新启动的时候,就会从其它正常节点上同步好数据。下面我们来看QJM内部是如何执行的,主要分为以下几步:

    步骤1.决定哪个数据片段需要去恢复。这个步骤是紧接着newEpoch请求的,在向每个JournalNode节点发送newEpoch请求收到回复后,比较得出其中最大的数据片段id(事务ID),可意为写入的最新的数据段。
    步骤2.向JournalNode发送PrepareRecovery RPC请求。PrepareRecovery请求是为了告诉JournalNode准备针对指定事务id,进行数据恢复。PrepareRecovery请求的返回信息为当前各个JournalNode上的给定事务id内容的信息。因为每个JournalNode上对于指定待恢复的数据片段,可能会存在数据内容不一致的情况。
    步骤3.在获取针对给定事务id的数据片段信息后,QJM会针对各种情况对此选择一个理想的数据恢复源。
    步骤4.QJM将需要同步的数据和数据源地址封装到AcceptRecovery RPC请求中,发送给各个JournalNode用于数据恢复。
    步骤5.确认数据段恢复成功。

    下面我们针对代码,对照上面的步骤实现:

    public void recoverUnfinalizedSegments() throws IOException {
        Preconditions.checkState(!isActiveWriter, "already active writer");
    
        LOG.info("Starting recovery process for unclosed journal segments...");
        // 步骤1-1.发送newEpoch请求
        Map<AsyncLogger, NewEpochResponseProto> resps = createNewUniqueEpoch();
        LOG.info("Successfully started new epoch " + loggers.getEpoch());
    
        if (LOG.isDebugEnabled()) {
          LOG.debug("newEpoch(" + loggers.getEpoch() + ") responses:
    " +
            QuorumCall.mapToString(resps));
        }
    
        // 步骤1-2.从NewEpoch请求回复内容获取最大的txId,以此作为待恢复的数据片段
        long mostRecentSegmentTxId = Long.MIN_VALUE;
        for (NewEpochResponseProto r : resps.values()) {
          if (r.hasLastSegmentTxId()) {
            mostRecentSegmentTxId = Math.max(mostRecentSegmentTxId,
                r.getLastSegmentTxId());
          }
        }
    
        // On a completely fresh system, none of the journals have any
        // segments, so there's nothing to recover.
        if (mostRecentSegmentTxId != Long.MIN_VALUE) {
          // 开始进行数据片段的恢复
          recoverUnclosedSegment(mostRecentSegmentTxId);
        }
        isActiveWriter = true;
    }

    进入recoverUnclosedSegment方法,继续阅读接下来步骤的相关代码:

    private void recoverUnclosedSegment(long segmentTxId) throws IOException {
        Preconditions.checkArgument(segmentTxId > 0);
        LOG.info("Beginning recovery of unclosed segment starting at txid " +
            segmentTxId);
    
        // 步骤2.发送PrepareRecovery请求
        QuorumCall<AsyncLogger,PrepareRecoveryResponseProto> prepare =
            loggers.prepareRecovery(segmentTxId);
        Map<AsyncLogger, PrepareRecoveryResponseProto> prepareResponses=
            loggers.waitForWriteQuorum(prepare, prepareRecoveryTimeoutMs,
                "prepareRecovery(" + segmentTxId + ")");
        LOG.info("Recovery prepare phase complete. Responses:
    " +
            QuorumCall.mapToString(prepareResponses));
    
        // 根据返回结果,选择其中最优的JournalNode上的数据为数据源,里面会涉及到各种情况的比较,
        // 具体比较逻辑在SegmentRecoveryComparator比较器中
        Entry<AsyncLogger, PrepareRecoveryResponseProto> bestEntry = Collections.max(
            prepareResponses.entrySet(), SegmentRecoveryComparator.INSTANCE); 
        AsyncLogger bestLogger = bestEntry.getKey();
        PrepareRecoveryResponseProto bestResponse = bestEntry.getValue();
    
        ...
    
        SegmentStateProto logToSync = bestResponse.getSegmentState();
        assert segmentTxId == logToSync.getStartTxId();
    
        // Sanity check: none of the loggers should be aware of a higher
        // txid than the txid we intend to truncate to
        for (Map.Entry<AsyncLogger, PrepareRecoveryResponseProto> e :
             prepareResponses.entrySet()) {
          AsyncLogger logger = e.getKey();
          PrepareRecoveryResponseProto resp = e.getValue();
    
          if (resp.hasLastCommittedTxId() &&
              resp.getLastCommittedTxId() > logToSync.getEndTxId()) {
            throw new AssertionError("Decided to synchronize log to " + logToSync +
                " but logger " + logger + " had seen txid " +
                resp.getLastCommittedTxId() + " committed");
          }
        }
    
        URL syncFromUrl = bestLogger.buildURLToFetchLogs(segmentTxId);
    
        // 步骤4.给定同步的地址,执行恢复操作
        QuorumCall<AsyncLogger,Void> accept = loggers.acceptRecovery(logToSync, syncFromUrl);
        loggers.waitForWriteQuorum(accept, acceptRecoveryTimeoutMs,
            "acceptRecovery(" + TextFormat.shortDebugString(logToSync) + ")");
    
        // 步骤5.确认数据的恢复
        QuorumCall<AsyncLogger, Void> finalize =
            loggers.finalizeLogSegment(logToSync.getStartTxId(), logToSync.getEndTxId()); 
        loggers.waitForWriteQuorum(finalize, finalizeSegmentTimeoutMs,
            String.format("finalizeLogSegment(%s-%s)",
                logToSync.getStartTxId(),
                logToSync.getEndTxId()));
    }

    以上就是对于HDFS QJM的简单分析,笔者只是选取了个人认为比较重要的部分,细节内容读者朋友可阅读QJM的设计文档。

    参考资料


    [1].https://issues.apache.org/jira/browse/HDFS-3077. Quorum-based protocol for reading and writing edit logs

  • 相关阅读:
    MT【280】最小值函数
    MT【279】分母为根式的两个函数
    MT【278】二次齐次化
    selenium之 chromedriver与chrome版本映射表
    django使用数据库锁
    mysql添加行内锁
    Django 通过url 获取url名称
    Django Q的其他用法
    Django 的 QueryDict
    Django 获取带参数的url
  • 原文地址:https://www.cnblogs.com/bianqi/p/12183641.html
Copyright © 2020-2023  润新知