天天看點

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

導語:MongoDB的副本集協定是一種raft-like協定,即基于raft協定的理論思想實作,并且對之進行了一些擴充。本文嘗試從源碼層面,以主節點的視角切入分析副本集選舉的整個過程,并給出了MongoDB副本集協定與raft的主要差別。 (PS:本文代碼和分析基于源碼版本v4.0.3。水準有限,文章中有錯誤或了解不當的地方,還望指出,共同學習)

一、背景

MongoDB的副本集協定(内部稱為

pv1

),是一種raft-like協定,即基于raft協定的理論思想實作,并且對之進行了一些擴充。

閱讀本文之前建議先了解一下副本集相關的基礎知識,比如官方文檔replication等。

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

replicate set.png

二、選舉過程

2.1 主要涉及檔案

  • db/repl/replication_coordinator_impl_elect_v1.cpp

  • db/repl/replication_coordinator_impl.cpp

2.2 主要流程

選舉的大緻流程和函數調用鍊如下:

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

選舉調用鍊.png

一個典型的選舉過程primary節點的日志如下:

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

典型選舉過程的主節點日志.png

2.3 函數及代碼

PS:覺得這部分比較枯燥的童鞋,可以不看,直接跳到下一段

從觸發選舉開始看起。

_startElectSelfIfEligibleV1()

會根據傳遞進來的選舉原因,判斷本節點是否滿足能成為選舉的candidate(具體可以參考

becomeCandidateIfElectable()

)。然後調用

_startElectSelfV1_inlock()

進行接下來的内容。

可以看到發生選舉的原因有以下幾種:

1. timeout,逾時。【最常見原因】,即副本集内10s内沒有主節點;

2. priority takeover,優先級搶占。即設定了某個節點更高優先級;

3. stepup,主動提升等級。與

stepdown

對應。還有一個

skip dry run

的,表示跳過預選舉,後面會介紹關于預選舉(

dry run

)相關的内容;

4. catchup takeover,追趕搶占。即primary處于catchup階段時發生了takeover,後面也會詳細介紹;

5. single node election,單節點選舉。即單節點模式下需要其選舉并成為主節點

switch (reason) {
        case TopologyCoordinator::StartElectionReason::kElectionTimeout:
            log() << "Starting an election, since we've seen no PRIMARY in the past "
                  << _rsConfig.getElectionTimeoutPeriod();
            break;
        case TopologyCoordinator::StartElectionReason::kPriorityTakeover:
            log() << "Starting an election for a priority takeover";
            break;
        case TopologyCoordinator::StartElectionReason::kStepUpRequest:
        case TopologyCoordinator::StartElectionReason::kStepUpRequestSkipDryRun:
            log() << "Starting an election due to step up request";
            break;
        case TopologyCoordinator::StartElectionReason::kCatchupTakeover:
            log() << "Starting an election for a catchup takeover";
            break;
        case TopologyCoordinator::StartElectionReason::kSingleNodePromptElection:
            log() << "Starting an election due to single node replica set prompt election";
            break;
    }           

複制

_startElectSelfV1_inlock()

首先要擷取目前副本集的任期(

term

),對于除了

kStepUpRequestsSkipDryRun

之外的所有選舉原因,都需要進行一次預選舉(dry run),然後走

_processDryRunResult(term)

的邏輯。

主要邏輯如下:

// 擷取目前term
long long term = _topCoord->getTerm();
// 如果需要跳過預選舉,則term自增,并且開始真正的選舉
if (reason == TopologyCoordinator::StartElectionReason::kStepUpRequestSkipDryRun) {
        long long newTerm = term + 1;
        log() << "skipping dry run and running for election in term " << newTerm;
        _startRealElection_inlock(newTerm);
        lossGuard.dismiss();
        return;
    }

// 否則都需要進行預選舉(dry run)
log() << "conducting a dry run election to see if we could be elected. current term: " << term;
// 選舉準備(通過心跳heartbeat實作)
_voteRequester.reset(new VoteRequester);
// 預選舉
_processDryRunResult(term);           

複制

接下來是預選舉階段

_processDryRunResult(term)

接收目前的

term

值作為參數,通過

_voteRequester

擷取結果,判斷自身是否滿足發起真正選舉的條件,如果滿足的話,再自增term并調用

_startRealElection_inlock(newTerm)

;否則列印預選舉失敗原因并退出。

long long newTerm = originalTerm + 1;
log() << "dry election run succeeded, running for election in term " << newTerm;

_startRealElection_inlock(newTerm);           

複制

失敗的原因可能包括:

  1. kInsufficientVotes:獲得的選票不足
  2. kStaleTerm:自身term過期
  3. kPrimaryRespondedNo:主節點拒絕投票

真正的選舉階段

首先給自己投一票,然後等待本次選舉過程中來自其他節點的投票結果。這裡得到的投票結果跟預投票時可能得到的結果要少一個,不可能為

kPrimaryRespondedNo

,因為這種可能性在經過預投票之後被排除了。

一切正常的話,該節點會進入成員狀态變更的邏輯。

// 先投自己一票
_topCoord->voteForMyselfV1();
...
// 給其他人節點發送請求投票結果的RPC
_startVoteRequester_inlock(lastVote.getTerm());
...
// 得到投票結果并處理
_onVoteRequestComplete(newTerm)
...
// 狀态機變更,傳遞的是選舉勝利狀态
_performPostMemberStateUpdateAction(kActionWinElection);           

複制

狀态變更階段

_performPostMemberStateUpdateAction

的實作中,我們隻關注選舉獲勝(

kActionWinElection

)分支的處理:

  1. 首先要處理選舉獲勝的結果
  2. 更新節點狀态,對于選舉成功的節點,這裡将從

    SECNONDAY

    變成

    PRIMARY

    狀态
  3. 再次更新狀态機到

    kActionCloseAllConnections

  4. 通知副本集内所有的從節點選舉勝利
  5. 剛選舉出來的primary節點進入追趕模式

    catchup

switch (action) {
        case kActionWinElection: {
            _electionId = OID::fromTerm(_topCoord->getTerm());
			...
            auto ts = LogicalClock::get(getServiceContext())->reserveTicks(1).asTimestamp();
			// 處理選舉獲勝的結果
            _topCoord->processWinElection(_electionId, ts);
			// 擷取下一個階段,正常的話會從kActionWinElection變更到kActionCloseAllConnections
            const PostMemberStateUpdateAction nextAction =
                _updateMemberStateFromTopologyCoordinator_inlock(nullptr);
            lk.unlock();
			// 再次更新狀态機,往下一階段前進
            _performPostMemberStateUpdateAction(nextAction);
            lk.lock();
            // 通過心跳通知副本集内所有的從節點選舉勝利的好消息
            _restartHeartbeats_inlock();
			...
			//進入追趕模式
			_catchupState = stdx::make_unique<CatchupState>(this);
            _catchupState->start_inlock();

            break;
        }
	}           

複制

_updateMemberStateFromTopologyCoordinator_inlock()

中,狀态機的更新部分對于不同的節點狀态會走不同的分支邏輯傳回不同的下一階段。(關于rollback和其他狀态節點的邏輯已略去)

PostMemberStateUpdateAction result;
    if (_memberState.primary() || newState.removed() || newState.rollback()) {
        // Wake up any threads blocked in awaitReplication, close connections, etc.
        _replicationWaiterList.signalAll_inlock();
        // Wake up the optime waiter that is waiting for primary catch-up to finish.
        _opTimeWaiterList.signalAll_inlock();
        // _canAcceptNonLocalWrites 為false,表示無法接受非local的寫入
        invariant(!_canAcceptNonLocalWrites);

        serverGlobalParams.validateFeaturesAsMaster.store(false);
		// 主節點會傳回這個
        result = kActionCloseAllConnections;
    } else {
		//從節點會傳回這個
        result = kActionFollowerModeStateChange;
    }           

複制

還有一點值得注意的是,對于将要變更成primary狀态的主節點而言,此時是無法接受非local的寫入的,其

_canAcceptNonLocalWrites

為false的狀态;

而對于已經成為primary的主節點而言,這裡會将之設定為

canAcceptWrites

也就是true的狀态。當然這裡會在本函數的下一次調用時

(drain mode)

發生

{
        // We have to do this check even if our current and target state are the same as we might
        // have just failed a stepdown attempt and thus are staying in PRIMARY state but restoring
        // our ability to accept writes.
        bool canAcceptWrites = _topCoord->canAcceptWrites();
        if (canAcceptWrites != _canAcceptNonLocalWrites) {
            // We must be holding the global X lock to change _canAcceptNonLocalWrites.
            invariant(opCtx);
            invariant(opCtx->lockState()->isW());
        }
        _canAcceptNonLocalWrites = canAcceptWrites;
    }           

複制

追趕(catchup)階段

大緻的調用鍊如下:

// 開始catchup,如果catchup的逾時設定為0(後面會提到這一設定參數)就跳過catchup階段直接傳回了
_catchupState->start_inlock();
// 排程
ReplicationExecutor::scheduleWorkAt() 
// 通過心跳和oplog來追趕,此函數用于處理心跳傳回結果
_handleHeartbeatResponse()
// 判斷是否追上了
CatchupState::signalHeartbeatUpdate_inlock()
//結束追趕模式
CatchupState::abort_inlock() 結束catchup模式           

複制

其中,判斷是否追上的條件就是目标的oplog時間是否小于等于我的最新的oplog time。相等就表示追上了哈~

// We've caught up.
    if (*targetOpTime <= _repl->_getMyLastAppliedOpTime_inlock()) {
        log() << "Caught up to the latest optime known via heartbeats after becoming primary.";
        abort_inlock();
        return;
    }           

複制

primary drain階段

drain為排水的意思,此處可以了解為 收尾階段

大緻的調用鍊如下:

//進入drain階段
_enterDrainMode_inlock()
// 結束背景同步
BackgroundSync::stop()
//退出drain階段
signalDrainComplete()
// 銷毀追趕狀态
_catchupState.reset()           

複制

我們重點關注一下

signalDrainComplete()

,其中完成了向真正primary狀态的過渡,此時的primary才是可以接受用戶端寫入的主節點。

//擷取全局寫鎖
Lock::GlobalWrite globalWriteLock(opCtx);

// 斷言目前的節點狀态和 接受寫入狀态
invariant(_getMemberState_inlock().primary());
invariant(!_canAcceptNonLocalWrites);

{
    lk.unlock();
    AllowNonLocalWritesBlock writesAllowed(opCtx);
    OpTime firstOpTime = _externalState->onTransitionToPrimary(opCtx, isV1ElectionProtocol());
    lk.lock();
    auto status = _topCoord->completeTransitionToPrimary(firstOpTime);
    if (status.code() == ErrorCodes::PrimarySteppedDown) {
        log() << "Transition to primary failed" << causedBy(status);
        return;
    }
    invariant(status);
}
// Must calculate the commit level again because firstOpTimeOfMyTerm wasn't set when we logged
// our election in onTransitionToPrimary(), above.
_updateLastCommittedOpTime_inlock();
// 再次調用這個函數,此時由于節點已經是primary狀态,會将`_canAcceptNonLocalWrites`更新為true,即此時主節點已經可以接受非local的寫入了 _updateMemberStateFromTopologyCoordinator_inlock(opCtx);
log() << "transition to primary complete; database writes are now permitted" << rsLog;           

複制

因為drain模式禁止複制寫操作,隻有搶到全局X鎖才有可能退出drain模式,而該鎖會阻塞所有其他線程的寫操作。從設定

_canAcceptNonLocalWrites

到此方法傳回(釋放全局X鎖)的這段時間段内,MongoDB不會處理任何外部寫入。

三、關鍵流程

3.1 預選舉(pre-vote)

為何需要多出一個預選舉階段呢?

我們先來看看沒有預選舉階段的raft協定在下面這種場景下有什麼問題。

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

一種異常場景.png

如圖所示,一個3-節點叢集,其中S2暫時與S1和S3不互通。

1) 在raft協定中,對于S2這一節點而言,每次達到選舉逾時的時候它都會發起一次選舉并自增term;由于并不能連接配接到S1和S3,選舉會失敗,如此反複,term會增加到一個相對比較大的值(圖中為57);

2)由于S1和S3滿足大多數條件,不妨結社選擇S1成為叢集新的主節點,term變為2;

3)當網絡連接配接恢複,S2又可以重新連接配接到S1和S3之後,其term會通過心跳傳遞給S1和S3,而這會導緻S1 step down成為從節點;

4)選舉逾時時間過後,叢集會重新觸發一次選舉,無論是S1還是S3成為新的主(S2由于落後是以不可能),其term值會變成58;

上面描述的場景有什麼問題呢?

兩個:

1) term跳變;

2) 網絡恢複後多了一次無意義的選舉;而從step down 到新一輪選舉完成的過程中叢集是無主的(不可寫狀态)

預選舉就是為了解決上述問題的。

在嘗試自增term并發起選舉之前,S2會看看自己有沒有可能獲得來自S1和S3的選票。如若不滿足條件則不會發起真正的選舉。

3.2 追趕階段(catchup)

新選出來的primary為何要進入這個階段?

同樣的,我們可以分析一下沒有這個階段的話在下面這個場景有什麼問題。

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

rollback場景.png

如圖所示,一個3-節點叢集,分别為S1,S2,S3。

(a)時刻,S1為primary,序号為2的日志還沒來得及複制到S2和S3上;

(b)時刻,S2挂掉,觸發叢集重新選舉;

(c)時刻,S3成為新的primary,term變為2;

(d)時刻,S1恢複,但是發現自己比primary節點有更新的日志,觸發復原(rollback)操作

(e)時刻,叢集恢複正常,新的寫入成功

上面的場景有什麼問題?

準确來說沒什麼問題,符合raft協定的強一緻性原則,但是存在一次復原過程。

catchup就是為了盡量避免復原的出現而誕生的。

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

選舉的catchup流程.png

如圖所示,如果S1在(c)時刻就恢複,這裡的時刻應該再細化一下,是在S3獲得了S2的投票成為primary狀态之後,而不是在獲得投票結果之前(否則的話S1不會投票給S3,本輪選舉失敗,等待下一輪選舉,S1會重新成為主)。S1進入catchup狀态,看看有沒有哪個從節點存在比自己更新的日志,發現S1有,然後就同步到自己這邊并送出,再“真正”成為主節點,支援外部的寫入;然後整個副本集一切恢複正常。

注意到這一過程中是沒有復原操作的。叢集通過副本集協定保留了序号為2的寫入日志并且自愈。

對于MongoDB而言,還有其他手段可以用來盡量避免復原操作的出現,比如設定

writeConcern

majority

(對于3節點叢集而言,也可直接設定為

2

)來避免圖中(a)情況的出現。確定新選出來的primary包含舊primary挂掉前的最新資料。
MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料
writeConcern為2時的寫入流程.png

從前面的代碼分析中,我們可以知道catchup是利用節點間的心跳和oplog來實作的。這一時間段的長短取決于舊primary挂之前超前的資料量。對于新的primary而言,“追趕”可以說是很形象了。總而言之,catchup階段可以避免部分場景下復原的出現。

當然,官方貼心地提供了兩個可調整的設定參數用來控制catchup階段,分别是:

  • settings.catchUpTimeoutMillis

    (機關為ms)

預設為-1,表示無限的追趕等待時間。即新選舉出的primary節點,嘗試去副本集内其他節點擷取(追趕catchup)更新的寫入并同步到自身,直到完全同步為止。

設定為0表示關閉catchup功能。

  • settings.catchUpTakeoverDelayMillis

    (機關為ms)

    預設為30000,即30s。表示允許新選出來的primary在被接管(takeover)前處于追趕(catchup)階段的時間。

設定為-1表示關閉追趕搶占(catchup takeover)功能。

當追趕時間為無限,且關閉了追趕搶占功能時,也可通過

replSetAbortPrimaryCatchUp

指令來手動強制終止catchup階段,完成向primary的過渡。

以上兩個參數在MnogoDB3.6以上的版本生效

四、總結

4.1 MongoDB副本集協定與raft協定是有差別的

具體差別可以參考下面的表:

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

MongoDB副本集協定與raft對比.png

關于表中

writeConcern

,

arbiter

等參數及配置,細節可以參考mongodb 官方文檔。

其中,關于MongoDB副本集的oplog拉取流程可以參考下面這張圖,會更清晰一些:

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

oplog拉取流程.png

4.2 副本集有主到能提供服務(支援寫入)需要一定的時間來catchup

如果在這段時間内發起寫入操作,會發生什麼?

用戶端會收到MongoDB傳回的錯誤

(NotMaster) not master

,該錯誤一般出現在嘗試對seconday節點進行寫操作時。

于是,你檢查你通路的

ip:port

,确認就是副本集的主節點無疑。然後感到奇怪:為什麼明明是primary節點,你卻告訴我not master呢?

PS:早期mongodb有master-slave版本,副本集後沿用了master的概念,這裡的

not master

應了解為

not priamry

更合理。

IsMaster

指令同理。

如果清楚選舉裡還有catchup這一階段的話,就不會有這樣的疑惑了。也就不會嘗試在叢集剛選舉結束後就立馬進行寫入了:)

補充

感興趣的小夥伴可以再來考慮raft協定中這樣一種邊界情況:

MongoDB核心:副本集選舉過程分析一、背景二、選舉過程三、關鍵流程四、總結補充參考資料

另一種異常場景.png

如圖所示,同樣的一個3-節點叢集,分别為S1,S2,S3。

(a)時刻, S1為leader,它們的日志内容都保持一緻(完全同步而且接下來不會有寫入操作);

(b)時刻,S1和S2之間的連結由于某種原因不互通,但是他們和S3之間的網絡都是ok的;

(c)時刻,已經過去了選舉逾時時間,S2發現叢集中沒有leader,于是發起選舉,S3會投同意票,然後S2成為叢集中的新主;

(d)時刻,S1與S2之間依然不互通,S1通過心跳與S3互動時,發現S3的term比自己大,然後S1退位為follower狀态。

(e)時刻,又過了選舉逾時時間,S1又發現叢集中沒有leader,于是再一次發起選舉,S3同樣會投同意票,然後S1再次成為叢集中的新主;

(f)然後,這種leader翻轉(filp-flopping)會持續出現,直到S1與S2之間的網絡恢複,或者叢集出現新的寫入操作。

上面的場景有什麼問題?

頻繁且不必要的選舉,影響業務長連結。

聰明的你一定也想到了這個問題的解決方案:

引入“忠誠”(又或者叫“粘性”)的概念。既然S3像一個渣男一樣忽左忽右,牆頭草,兩邊倒;那麼我們可以部分限制它投同意票的能力。當它認為目前的主節點處于正常工作的狀态時,那麼它就沒理由給新的主節點投同意票(當他跟現女友相處得很好的時候,就沒有理由出去勾搭其他妹子)。

在場景中具體解釋如下:

(c)時刻,S2發現叢集中沒有leader,發起選舉,S3這時在收到投票請求的時候,不會隻檢查term和oplog就投同意票了,而是再檢查一下目前primary是否正常,如果正常而且也滿足叢集大多數條件的話,就給S2投否決票。S2選舉失敗,S1繼續承擔主節點的責任。

這樣就避免了leader翻轉的情況。

那麼問題來了,MongoDB中有實作這樣的解決方案麼?如果有,你覺得實作在那一階段呢?

歡迎評論區讨論~

參考資料

PS:前兩個均為PDF檔案(論文),想深入研究的童鞋可以讀一下~

Consensus: Bridging Theory and Practice

4-modifications-for-Raft-consensus

MongoDB doc replication

MongoDB doc replica-configuration

一文搞懂raft算法

MongoDB高可用複制集内部機制:Raft協定