轉載 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
|
protected FSImage(Configuration
conf, Collection<URI>
imageDirs, List<URI>
editsDirs) throws IOException
{ this .conf
= conf; //注意此時的storage對象中storageDirs變量只存放File目錄,不存放bookkeeper,qjournal目錄。 //bookkeeper,qjournal目錄是後面通過調用fsImage.getEditLog().initJournalsForWrite()來初始化bookeeper或者qjournal目錄的。 storage
= new NNStorage(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
= new FSEditLog(conf,
storage, editsDirs); archivalManager
= new NNStorageRetentionManager(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
|
static List<AsyncLogger>
createLoggers(Configuration conf, URI
uri, NamespaceInfo nsInfo, AsyncLogger.Factory factory) throws IOException
{ 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)); } return ret; } |
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
|
public void
format(NamespaceInfo nsInfo) throws IOException
{ //
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) { throw new
IOException( "Interrupted
waiting for format() response" ); } catch (TimeoutException
e) { throw new
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
|
public void
recoverUnfinalizedSegments() throws IOException
{ 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,因爲只有最後一塊有可能是不完整的。 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) { //把不完整的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
|
private void
recoverUnclosedSegment( long segmentTxId)
throws IOException
{ 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); } else if
(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(); 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" ); } } //同步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基本一樣。