基於QJM/Qurom Journal Manager/Paxos的HDFS HA原理及代碼分析

轉載 http://yanbohappy.sinaapp.com/?p=205

HDFS HA的解決方案可謂百花齊放,Linux HA, VMware FT, shared NAS+NFS, BookKeeper, QJM/Quorum Journal Manager, BackupNode等等。目前普遍採用的是shard NAS+NFS,因爲簡單易用,但是需要提供一個HA的共享存儲設備。而社區已經把基於QJM/Quorum Journal Manager的方案merge到trunk了,clouderea提供的發行版中也包含了這個feature,這種方案也是社區在未來發行版中默認的HA方案。本文從代碼的角度分析這種方案的實現。

關於HDFS源代碼中HA機制的整體框架實現,Active NN, Standby NN兩種角色各自的代碼執行流程,client如何做failover,爲什麼要fencing,每個DN要向Active和Standby NN都要report block,這樣才能保證hot standby等等類似的問題,可以參考前面的文章: http://yanbohappy.sinaapp.com/?p=50  和 http://yanbohappy.sinaapp.com/?p=55 。

在HA具體實現方法不同的情況下,HA框架的流程是一致的。不一致的就是如何存儲和管理日誌。在Active NN和Standby NN之間要有個共享的存儲日誌的地方,Active NN把EditLog寫到這個共享的存儲日誌的地方,Standby NN去讀取日誌然後執行,這樣Active和Standby NN內存中的HDFS元數據保持着同步。一旦發生主從切換Standby NN可以儘快接管Active NN的工作(雖然要經歷一小段時間讓原來Standby追上原來的Active,但是時間很短)。

說到這個共享的存儲日誌的地方,目前採用最多的就是用共享存儲NAS+NFS。缺點有:1)這個存儲設備要求是HA的,不能掛掉;2)主從切換時需要fencing方法讓原來的Active不再寫EditLog,否則的話會發生brain-split,因爲如果不阻止原來的Active停止向共享存儲寫EditLog,那麼就有兩個Active NN了,這樣就會破壞HDFS的元數據了。對於防止brain-split問題,在QJM出現之前,常見的方法就是在發生主從切換的時候,把共享存儲上存放EditLog的文件夾對原來的Active的寫權限拿掉,那麼就可以保證同時至多隻有一個Active NN,防止了破壞HDFS元數據。

Clouera爲解決這個問題提出了QJM/Qurom Journal Manager,這是一個基於Paxos算法實現的HDFS HA方案。QJM的結構圖如下所示:

QJM的基本原理就是用2N+1臺JournalNode存儲EditLog,每次寫數據操作有大多數(>=N+1)返回成功時即認爲該次寫成功,數據不會丟失了。當然這個算法所能容忍的是最多有N臺機器掛掉,如果多於N臺掛掉,這個算法就失效了。這個原理是基於Paxos算法的,可以參考 http://en.wikipedia.org/wiki/Paxos_(computer_science) 。

用QJM的方式來實現HA的主要好處有:1)不需要配置額外的高共享存儲,這樣對於基於commodity hardware的雲計算數據中心來說,降低了複雜度和維護成本;2)不在需要單獨配置fencing實現,因爲QJM本身內置了fencing的功能;3)不存在Single Point Of Failure;4)系統魯棒性的程度是可配置的(QJM基於Paxos算法,所以如果配置2N+1臺JournalNode組成的集羣,能容忍最多N臺機器掛掉);5)QJM中存儲日誌的JournalNode不會因爲其中一臺的延遲而影響整體的延遲,而且也不會因爲JournalNode的數量增多而影響性能(因爲NN向JournalNode發送日誌是並行的)。

1, NameNode格式化和啓動

關於HDFS NN的元數據管理邏輯,FSImage和EditLog相關的源代碼分析請參考:http://yanbohappy.sinaapp.com/?p=84 和http://yanbohappy.sinaapp.com/?p=101,NN的這部分代碼在不同的HA解決方案中是一樣的。先格式化HDFS,生成存放FSImage和EditLog的目錄,目錄初始化,把文件系統元數據持久化到文件。然後在啓動的時候加載最新的FSImage和在那之後的EditLog。

NN存放FSImage和EditLog的目錄用NNStorage這個類來管理。看FSImage的構造函數,傳進去兩個URI的集合,分別是存放FSImage和EditLog的地方。我們一直在說的NN HA解決的是EditLog的共享存儲問題,不包括FSImage,除非我們配置把這兩個東西存儲到一個地方,但是一般是不會這麼做的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protectedFSImage(Configuration conf,
                    Collection<URI> imageDirs,
                    List<URI> editsDirs)
      throwsIOException {
    this.conf = conf;
    //注意此時的storage對象中storageDirs變量只存放File目錄,不存放bookkeeper,qjournal目錄。
    //bookkeeper,qjournal目錄是後面通過調用fsImage.getEditLog().initJournalsForWrite()來初始化bookeeper或者qjournal目錄的。
    storage = newNNStorage(conf, imageDirs, editsDirs);
    if(conf.getBoolean(DFSConfigKeys.DFS_NAMENODE_NAME_DIR_RESTORE_KEY,
                       DFSConfigKeys.DFS_NAMENODE_NAME_DIR_RESTORE_DEFAULT)) {
      storage.setRestoreFailedStorage(true);
    }
    this.editLog = newFSEditLog(conf, storage, editsDirs);
    archivalManager = newNNStorageRetentionManager(conf, storage, editLog);
  }

就像前面說的,EditLog的管理相對FSImage要複雜很多。所以接下來就是fsImage.getEditLog().initJournalsForWrite()來初始化存放日誌的地方。這個在FSEditLog.initJournals()中完成,對於基於File的共享存儲(NFS)來說,就是創建了一個用於管理這個設備和其中EditLog文件的FileJournaManger,然後加入EditLog.journalSet集合統一管理;而對於其他類型的共享存儲(BookKeeper,QJM,BackupNode)則是創建對應的JournalManager對象。對於我們的QJM來說,就是創建了一個QuorumJournalManager對象。

2,構造QuroumJournalManager

QuromJournalManager的構造函數初始化了一些變量,比如這個QJM的uri,nsinfo等。注意我們使用URI來區分不同的JournalNode集羣,JournalNode集羣的URI表示類似於Zookeeper,這裏有個例子,我們在hdfs-site.xml中會通過以下的形式配置使用QJM:

1
2
3
4
<property>
  <name>dfs.namenode.shared.edits.dir</name>
  <value>qjournal://node1.example.com:8485;node2.example.com:8485;node3.example.com:8485/mycluster</value>
</property>

然後構造一個AsyncLoggerSet對象用於管理該QuromJournalManager向多個JournalNode的連接。這個AsyncLoggerSet對象裏面有個存放AsyncLogger接口(真正的實現類是IPCLoggerChannel,每個IPCLoggerChannel管理QJM與一個JournalNode的連接和異步通信)的List。

this.loggers = new AsyncLoggerSet(createLoggers(loggerFactory));

進一步調用

static List<AsyncLogger> createLoggers (Configuration conf,

      URI uri, NamespaceInfo nsInfo, AsyncLogger.Factory factory)

創建一系列的AsyncLogger用於寫Log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
staticList<AsyncLogger> createLoggers(Configuration conf,
      URI uri, NamespaceInfo nsInfo, AsyncLogger.Factory factory)
          throwsIOException {
    List <AsyncLogger> ret = Lists.newArrayList();
    //從uri中解析出對應的2N+1臺JournalNode節點的ip+port
    List <InetSocketAddress> addrs = getLoggerAddresses(uri);
    //從uri中解析出JournalId,我們的例子中解析出來的應該是mycluster
    String jid = parseJournalId(uri);
    for(InetSocketAddress addr : addrs) {
    //返回爲每一個JN創建的IPCLoggerChannel類對象,IPCLoggerChannel繼承自AsyncLogger
      ret.add(factory.createLogger(conf, nsInfo, jid, addr));
    }
    returnret;
  }

3,格式化qjournal

New出這個FSImage對象之後就該初始化了,FSImage.format()->editLog.formatNonFileJournals (ns) 負責format非File的EditLog存放URI。進一步會調用

QuorumJournalManager.format(NamespaceInfo nsInfo)會格式化qjournal。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
publicvoid format(NamespaceInfo nsInfo) throwsIOException {
    // AsyncLoggerSet這個wrapper類會依次調用其內部的AsyncLogger.format(),發送format RPC,然後註冊callback函數。
    QuorumCall<AsyncLogger,Void> call = loggers.format(nsInfo);
    try{
       //在這個函數裏循環等待有足夠的success response或者exception或者time out。對於format請求,要求所有JN(不是大多數)返回成功才行。
      call.waitFor( loggers.size(), loggers.size(), 0, FORMAT_TIMEOUT_MS,
          "format");
    }catch(InterruptedException e) {
      thrownew IOException( "Interrupted waiting for format() response");
    }catch(TimeoutException e) {
      thrownew IOException( "Timed out waiting for format() response");
    }
 
    if(call.countExceptions() > 0) {
      call.rethrowException("Could not format one or more JournalNodes");
    }
  }

這裏就涉及到QJM的核心原理Paxos算法了。關於這個算法的原理大家可參考wikipedia,簡單說就是Active NN把日誌寫到2N+1個JournalNode上,每次寫日誌的操作只要其中a quorumof JNs(即大多數,大於等於N+1臺JN)返回成功即認爲這次操作是成功的。但是這個format操作是比較特殊的,要求所有的JN返回都是成功的才行,因爲它相當於是做了個初始化的工作。在後面的寫數據的過程中,只要大多數success response就認爲這次寫成功了。

QuorumCall這個類包裝了整個異步調用的過程:每次QuorumJournalManager對象向2N+1臺JN發送寫日誌請求都是異步的,發出之後不是同步等待每個JN的返回值,而是註冊一個callback函數,每當有一個返回,就把response計數加1(如果返回是success,把success計數加1;如果返回是failure,把failure計數加1)。這樣QuorumJournalManager這端只需要發出去請求,然後循環檢測時候有足夠的success response或者足夠的exception或者是time out。

上面在代碼中提到了RPC,QJM的RPC主要就一個協議類:QuorumJournalManager與多個JournalNode通信的協議QJournalProtocol。那麼RPC的通信雙方的實體類分別是哪個呢?客戶端(QuorumJournalManager)是QJournalProtocolTranslatorPB;服務器端(JournalNode)是JournalNodeRpcServer。

看看這個format命令到了JN端做了哪些事情?

先是根據journalId創建了Journal對象,然後調用Journal.format()。接下來就是創建本地存儲目錄,創建Journal元數據,寫元數據到目錄等。由於JournalNode管理本地的數據採用的是FileJournalManager對象,所以後面的邏輯跟使用FileJournalManager的NN很像了。

4,NN發生主從切換

接下來就該看看一個Standby NN由Standby變成Active時,需要執行哪些操作:

1) fencing原來Active NN的寫。

2) recover in-progress logs。原來Active NN寫EditLog過程中發生了主從切換,那麼處在不同JournalNode上的EditLog的數據可能不一致,需要把不同JournalNode上的EditLog同步一致,並且finalized。(這個過程類似於HDFS append中的recover lease的過程)

3) startLogSegment。不一致的EditLog都同步一致且finalized,那麼原來的Standby NN正式行駛正常的Active NN的寫日誌功能。

4) write edits

5) finalizeLogSegment

4.1,fencing原來Active NN的寫

前面說過,基於QJM的HA不需要處理fencing問題。這是怎麼做到的呢?解決這個問題靠的是epoch number,這個和Paxos算法中選主(master election)所做的工作類似。

當Active 和Standby NN 發生主從切換時,原來的Standby NN需要執行:

NameNode.startActiveServices()->FSNamesystem.startActiveServices()->FSEditLog.recoverUnclosedStreams()->JournalSet.recoverUnfinalizedSegments()->QourumJournalManager.recoverUnfinalizedSegment()。這個過程說白了就是給原來的Active NN擦屁股,也可以算作是Standby要接管qjournal寫權利的開始。這裏面就出現了我們所說的brain-split的問題,Standby NN怎麼保證原來的Active NN已經不再往qjournal上寫數據了。看看QourumJournalManager.recoverUnfinalizedSegment()是怎麼實現的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
publicvoid recoverUnfinalizedSegments() throwsIOException {
    Preconditions.checkState(!isActiveWriter,"already active writer");
 
    LOG.info("Starting recovery process for unclosed journal segments...");
    //這句話解決了brain-split問題,也就是fencing writer
    Map<AsyncLogger, NewEpochResponseProto> resps = createNewUniqueEpoch();
    LOG.info("Successfully started new epoch " + loggers.getEpoch());
 
    if(LOG.isDebugEnabled()) {
      LOG.debug("newEpoch("+ loggers.getEpoch() + ") responses:\n" +
        QuorumCall.mapToString(resps));
    }
    //找出最後一塊edit log segment,因爲只有最後一塊有可能是不完整的。
    longmostRecentSegmentTxId = 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) {
      //把不完整的log segment恢復完整,這個過程在後面會具體講
      recoverUnclosedSegment(mostRecentSegmentTxId);
    }
    isActiveWriter = true;
  }

Epoch解決了我們所說的問題,Standby NN向每個JournalNode發送getJournalState RPC請求,JN返回自己的lastPromisedEpoch。QuorumJournalManager收到大多數JN返回的lastPromisedEpoch,在其中選擇最大的一個,然後加1作爲當前QJM的epoch,同時通過發送newEpoch RPC把這個新的epoch寫到qjournal上。因爲在這之後每次QuorumJournalManager在向qjournal執行寫相關操作(startLogSegment(),logEdits(),finalizedLogSegment()等)的時候,都要把自己的epoch作爲參數傳遞過去,寫相關操作到達每個JournalNode端會比較如果傳過來的epoch如果小於JournalNode端存儲的lastPromisedEpoch,那麼這次寫相關操作會被拒絕。如果大多數JournalNode都拒絕了這次寫相關操作,這次操作就失敗了。回到我們目前的邏輯中,在主從切換時,原來的Standby NN把epoch+1了之後,原來的Active NN的epoch就肯定比這個小了,那麼如果它再向qjournal寫日誌就會被拒絕。因爲qjournal不接收比lastPromisedEpoch小的QJM寫日誌。

看看JN收到newEpoch RPC之後怎麼辦:JN檢查來自QJM的這個epoch和自己存儲的lastPromisedEpoch:如果來自writer的epoch小於lastPromisedEpoch,那麼說明不允許這個writer向JNs寫數據了,拋出異常,writer端收到異常response,那麼達不到大多數的success response,就不會有寫qjournal的權限了。(其實這個過程就是Paxos算法裏面選主的過程)

4.2 recover in-progress logs

接着上面的代碼,Standby已經通過createNewUniqueEpoch()來fencing原來的Active,這個RPC請求除了會返回epoch,還會返回最後一個log segment的txid。因爲只有最後一個log segment可能需要恢復。這個recover算法就是Paxos算法的一個實例(instance),目的是使得分佈在不同JN上的log segment的數據達成一致。

接下來就開始recoverUnclosedSegment()恢復算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
privatevoid recoverUnclosedSegment(longsegmentTxId) throwsIOException {
    Preconditions.checkArgument(segmentTxId > 0);
    LOG.info("Beginning recovery of unclosed segment starting at txid " +
        segmentTxId);
// Step 1. Prepare recovery
    //QJM向JNs問segmentTxId對應的segment的長度和finalized/in-progress狀況;JNs返回這些信息。(對應Paxos算法的Phase 1a和Phase 1b)
    QuorumCall<AsyncLogger,PrepareRecoveryResponseProto> prepare =
        loggers.prepareRecovery(segmentTxId);
    Map<AsyncLogger, PrepareRecoveryResponseProto> prepareResponses=
        loggers.waitForWriteQuorum(prepare, prepareRecoveryTimeoutMs,
            "prepareRecovery("+ segmentTxId + ")");
    LOG.info("Recovery prepare phase complete. Responses:\n" +
        QuorumCall.mapToString(prepareResponses));
    //在每個JN的返回信息中通過SegmentRecoveryComparator比較,選擇其中最好的一個log segment作爲後面同步log的標準。
    //如何選擇更好的Log segment後面有詳細解釋。
    Entry<AsyncLogger, PrepareRecoveryResponseProto> bestEntry = Collections.max(
        prepareResponses.entrySet(), SegmentRecoveryComparator.INSTANCE);
    AsyncLogger bestLogger = bestEntry.getKey();
    PrepareRecoveryResponseProto bestResponse = bestEntry.getValue();
 
    // Log the above decision, check invariants.
    if(bestResponse.hasAcceptedInEpoch()) {
      LOG.info("Using already-accepted recovery for segment " +
          "starting at txid " + segmentTxId + ": " +
          bestEntry);
    }elseif (bestResponse.hasSegmentState()) {
      LOG.info("Using longest log: " + bestEntry);
    }else{
      //prepareRecovery RPC沒有返回任何指定txid的segment,原因可能如下:
      //有3個JNs: JN1,JN2,JN3。原來的Active NN 在JN1上開始寫segment 101,
      //然後原來Active NN掛了,主從切換,此時segment 101在JN2和JN3上並不存在,
      //newEpoch RPC,因爲我們看到了JN1上的segment 101,所以決定recover的是segment 101
      //在prepareRecovery之前,JN1掛了,那麼prepareRecovery RPC只能發向JN2和JN3了,RPC返回的結果是沒有segment 101
      //這種情況下是不需要recover的,因爲segment 101並沒有寫成功(沒有達到大多數)
      for(PrepareRecoveryResponseProto resp : prepareResponses.values()) {
        assert!resp.hasSegmentState() :
          "One of the loggers had a response, but no best logger " +
          "was found.";
      }
 
      LOG.info("None of the responders had a log to recover: " +
          QuorumCall.mapToString(prepareResponses));
      return;
    }
    SegmentStateProto logToSync = bestResponse.getSegmentState();
    assertsegmentTxId == 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()) {
        thrownew AssertionError("Decided to synchronize log to " + logToSync +
            " but logger " + logger + " had seen txid " +
            resp.getLastCommittedTxId() + " committed");
      }
    }
    //同步log的數據源JN找到後,構造URL用於其他JN讀取EditLog(JN端有HTTP server通過servlet形式提供HTTP讀)
    URL syncFromUrl = bestLogger.buildURLToFetchLogs(segmentTxId);
 //向JNs發送acceptRecovery RPC請求(對應Paxos算法的Phase 2a)
    //JN收到這個acceptRecovery RPC之後,使自己的log與syncFromUrl同步,並持久化這個logsegment和epoch
    //如果收到大多數的JNs的success response,那麼這個同步操作成功。(對應Paxos算法的Phase 2b)
    QuorumCall<AsyncLogger,Void> accept = loggers.acceptRecovery(logToSync, syncFromUrl);
    loggers.waitForWriteQuorum(accept, acceptRecoveryTimeoutMs,
        "acceptRecovery("+ TextFormat.shortDebugString(logToSync) + ")");
 
    // If one of the loggers above missed the synchronization step above, but
    // we send a finalize() here, that's OK. It validates the log before
    // finalizing. Hence, even if it is not "in sync", it won't incorrectly
// finalize.
    //EditLog既然已經同步完了,那麼就應該正常finalized了。
    QuorumCall<AsyncLogger, Void> finalize =
        loggers.finalizeLogSegment(logToSync.getStartTxId(), logToSync.getEndTxId());
    loggers.waitForWriteQuorum(finalize, finalizeSegmentTimeoutMs,
        String.format("finalizeLogSegment(%s-%s)",
            logToSync.getStartTxId(),
            logToSync.getEndTxId()));
  }

代碼中留給我們一個問題,就是什麼樣的log segment是更好的,在recover的過程中被選爲同步源呢。詳細的設計可以參考Todd寫的<<Quorum-Journal Design>>https://issues.apache.org/jira/secure/attachment/12547598/qjournal-design.pdf 的2.9和2.10。在代碼中的實現是SegmentRecoveryComparator類。

簡單描述下原理就是:有finalized的不用in-progress的;如果有多個finalized必須length一致;沒有finalized的看誰的epoch更大;如果前面的都一樣就看誰的最後一個txid更大。

在<<Quorum-Journal Design>>中有具體的例子。我看完這塊之後感覺和HDFS append的block recover過程中選擇同步源的思路有異曲同工之妙。

經歷了上面的QourumJournalManager.recoverUnfinalizedSegment()過程,不完整的log segment都是完整的了,接下來就是調用NameNode.startActiveServices()->FSNamesystem.startActiveServices()->EditLogTailer.catchupDuringFailover()->EditLogTailer.doTailEdits(),原來Standby NN先去和原來Active NN同步EditLog,然後把EditLog執行,這時兩臺NN內存數據才真正一致。這裏會調用QuorumJournalManager.selectInputStreams()從JNs中讀取EditLog。而且目前HDFS中只有finalized edit log才能被Standby NN讀取並執行。在Standby NN從JNs讀取EditLog時,首先向所有的JN節點發送getEditLogManifest() RPC去讀取大於某一txid並且已經finalized edit log segment,收到大多數返回success,則把這些log segment整合成一個RedundantEditLogInputStream,然後Standby NN只要向其中的一臺JN讀取數據就行了。

至此原來的Standby NN所做的擦屁股的工作就結束了,那麼它就正式變成了Active NN,接下來就是正常的記錄日誌的工作了。

4.3 startLogSegment

NameNode.startActiveServices()->FSNamesystem.startActiveServices()->FSEditLog.openForWrite()->FSEditLog.startLogSegmentAndWriteHeaderTxn()->FSEditLog.startLogSegment()->JournalSet.startLogSegment()->JournalSet.startLogSegment()->QuorumJournalManager.startLogSegment()。QJM向JNs發送startLogSegment RPC調用,如果收到多數success response則成功,用這個AsynaLogSet構造QuorumOutputStream用於寫log。

4.4 write edits

寫EditLog的過程:FSEditLog.logEdit()->QuorumOutputStream.write()把Log寫到QuorumOutputStream的double buffer裏面。

Log持久化的過程:FSEditLog.logSync()->EditLogOutputStream.flush()->QuorumOutputStream.flushAndSync(),在這個函數裏通過AsyncLoggerSet.sendEdits()調用Journal RPC把對應的日誌寫到JNs,同樣是大多數success response即認爲成功。如果大多數返回失敗的話,這次logSync操作失敗,那麼NameNode會abort,因爲沒法正常寫日誌了。

4.5 finalizedLogSegment

流程和startLogSegment基本一樣。

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