1. 程式人生 > >MongoDB一次節點宕機引發的思考(原始碼剖析)

MongoDB一次節點宕機引發的思考(原始碼剖析)

目錄

  • 簡介
  • 日誌分析
  • 副本集 如何實現 Failover
    • 心跳的實現
    • electionTimeout 定時器
  • 業務影響評估
  • 參考連結

宣告:本文同步發表於 MongoDB 中文社群,傳送門:
http://www.mongoing.com/archives/26759

簡介

最近一個 MongoDB 叢集環境中的某節點異常下電了,導致業務出現了中斷,隨即又恢復了正常。
通過ELK 告警也監測到了業務報錯日誌。

運維部對於節點下電的原因進行了排查,發現僅僅是資源分配上的一個失誤導致。 在解決了問題之後,大家也對這次中斷的也提出了一些問題:

"當前的 MongoDB叢集 採用了分片副本集的架構,其中主節點發生故障會產生多大的影響?"
"MongoDB 副本集不是能自動倒換嗎,這個是不是秒級的?"

帶著這些問題,下面針對副本集的自動Failover機制做一些分析。

日誌分析

首先可以確認的是,這次掉電的是一個副本集上的主節點,在掉電的時候,主備關係發生了切換。
從另外的兩個備節點找到了對應的日誌:

備節點1的日誌

2019-05-06T16:51:11.766+0800 I REPL     [ReplicationExecutor] Starting an election, since we've seen no PRIMARY in the past 10000ms
2019-05-06T16:51:11.766+0800 I REPL     [ReplicationExecutor] conducting a dry run election to see if we could be elected
2019-05-06T16:51:11.766+0800 I ASIO     [NetworkInterfaceASIO-Replication-0] Connecting to 172.30.129.78:30071
2019-05-06T16:51:11.767+0800 I REPL     [ReplicationExecutor] VoteRequester(term 3 dry run) received a yes vote from 172.30.129.7:30071; response message: { term: 3, voteGranted: true, reason: "", ok: 1.0 }
2019-05-06T16:51:11.767+0800 I REPL     [ReplicationExecutor] dry election run succeeded, running for election
2019-05-06T16:51:11.768+0800 I ASIO     [NetworkInterfaceASIO-Replication-0] Connecting to 172.30.129.78:30071
2019-05-06T16:51:11.771+0800 I REPL     [ReplicationExecutor] VoteRequester(term 4) received a yes vote from 172.30.129.7:30071; response message: { term: 4, voteGranted: true, reason: "", ok: 1.0 }
2019-05-06T16:51:11.771+0800 I REPL     [ReplicationExecutor] election succeeded, assuming primary role in term 4
2019-05-06T16:51:11.771+0800 I REPL     [ReplicationExecutor] transition to PRIMARY
2019-05-06T16:51:11.771+0800 I REPL     [ReplicationExecutor] Entering primary catch-up mode.
2019-05-06T16:51:11.771+0800 I ASIO     [NetworkInterfaceASIO-Replication-0] Ending connection to host 172.30.129.78:30071 due to bad connection status; 2 connections to that host remain open
2019-05-06T16:51:11.771+0800 I ASIO     [NetworkInterfaceASIO-Replication-0] Connecting to 172.30.129.78:30071
2019-05-06T16:51:13.350+0800 I REPL     [ReplicationExecutor] Error in heartbeat request to 172.30.129.78:30071; ExceededTimeLimit: Couldn't get a connection within the time limit

備節點2的日誌

2019-05-06T16:51:12.816+0800 I ASIO     [NetworkInterfaceASIO-Replication-0] Ending connection to host 172.30.129.78:30071 due to bad connection status; 0 connections to that host remain open
2019-05-06T16:51:12.816+0800 I REPL     [ReplicationExecutor] Error in heartbeat request to 172.30.129.78:30071; ExceededTimeLimit: Operation timed out, request was RemoteCommand 72553 -- target:172.30.129.78:30071 db:admin expDate:2019-05-06T16:51:12.816+0800 cmd:{ replSetHeartbeat: "shard0", configVersion: 96911, from: "172.30.129.7:30071", fromId: 1, term: 3 }
2019-05-06T16:51:12.821+0800 I REPL     [ReplicationExecutor] Member 172.30.129.160:30071 is now in state PRIMARY

可以看到,備節點1在 16:51:11 時主動發起了選舉,併成為了新的主節點,隨即備節點2在 16:51:12 獲知了最新的主節點資訊,因此可以確認此時主備切換已經完成。
同時在日誌中出現的,還有對於原主節點(172.30.129.78:30071)大量心跳失敗的資訊。

那麼,備節點具體是怎麼感知到主節點已經 Down 掉的,主備節點之間的心跳是如何運作的,這對資料的同步複製又有什麼影響?
下面,我們挖掘一下 ** 副本集的 自動故障轉移(Failover)** 機制

副本集 如何實現 Failover

如下是一個PSS(一主兩備)架構的副本集,主節點除了與兩個備節點執行資料複製之外,三個節點之間還會通過心跳感知彼此的存活。

一旦主節點發生故障以後,備節點將在某個週期內檢測到主節點處於不可達的狀態,此後將由其中一個備節點事先發起選舉並最終成為新的主節點。 這個檢測週期 由electionTimeoutMillis 引數確定,預設是10s。

接下來,我們通過一些原始碼看看該機制是如何實現的:

<<來自 MongoDB 3.4原始碼>>

db/repl/replication_coordinator_impl_heartbeat.cpp
相關方法

  • ReplicationCoordinatorImpl::_startHeartbeats_inlock 啟動各成員的心跳
  • ReplicationCoordinatorImpl::_scheduleHeartbeatToTarget 排程任務-(計劃)向成員發起心跳
  • ReplicationCoordinatorImpl::_doMemberHeartbeat 執行向成員發起心跳
  • ReplicationCoordinatorImpl::_handleHeartbeatResponse 處理心跳響應
  • ReplicationCoordinatorImpl::_scheduleNextLivenessUpdate_inlock 排程保活狀態檢查定時器
  • ReplicationCoordinatorImpl::_cancelAndRescheduleElectionTimeout_inlock 取消並重新排程選舉超時定時器
  • ReplicationCoordinatorImpl::_startElectSelfIfEligibleV1 發起主動選舉

db/repl/topology_coordinator_impl.cpp
相關方法

  • TopologyCoordinatorImpl::prepareHeartbeatRequestV1 構造心跳請求資料
  • TopologyCoordinatorImpl::processHeartbeatResponse 處理心跳響應並構造下一步Action例項

下面這個圖,描述了各個方法之間的呼叫關係


圖-主要關係

心跳的實現

首先,在副本集組建完成之後,節點會通過ReplicationCoordinatorImpl::_startHeartbeats_inlock方法開始向其他成員傳送心跳:

void ReplicationCoordinatorImpl::_startHeartbeats_inlock() {
    const Date_t now = _replExecutor.now();
    _seedList.clear();

    //獲取副本整合員
    for (int i = 0; i < _rsConfig.getNumMembers(); ++i) {
        if (i == _selfIndex) {
            continue;
        }
        //向其他成員傳送心跳
        _scheduleHeartbeatToTarget(_rsConfig.getMemberAt(i).getHostAndPort(), i, now);
    }

    //僅僅是重新整理本地的心跳狀態資料
    _topCoord->restartHeartbeats();

    //使用V1的選舉協議(3.2之後)
    if (isV1ElectionProtocol()) {
        for (auto&& slaveInfo : _slaveInfo) {
            slaveInfo.lastUpdate = _replExecutor.now();
            slaveInfo.down = false;
        }

        //排程保活狀態檢查定時器
        _scheduleNextLivenessUpdate_inlock();
    }
}

在獲得當前副本集的節點資訊後,呼叫_scheduleHeartbeatToTarget方法對其他成員傳送心跳,
這裡_scheduleHeartbeatToTarget 的實現比較簡單,其真正發起心跳是由 _doMemberHeartbeat 實現的,如下:

void ReplicationCoordinatorImpl::_scheduleHeartbeatToTarget(const HostAndPort& target,
                                                            int targetIndex,
                                                            Date_t when) {
    //執行排程,在某個時間點呼叫_doMemberHeartbeat
    _trackHeartbeatHandle(
        _replExecutor.scheduleWorkAt(when,
                                     stdx::bind(&ReplicationCoordinatorImpl::_doMemberHeartbeat,
                                                this,
                                                stdx::placeholders::_1,
                                                target,
                                                targetIndex)));
}

ReplicationCoordinatorImpl::_doMemberHeartbeat 方法的實現如下:

void ReplicationCoordinatorImpl::_doMemberHeartbeat(ReplicationExecutor::CallbackArgs cbData,
                                                    const HostAndPort& target,
                                                    int targetIndex) {
    LockGuard topoLock(_topoMutex);

    //取消callback 跟蹤
    _untrackHeartbeatHandle(cbData.myHandle);
    if (cbData.status == ErrorCodes::CallbackCanceled) {
        return;
    }

    const Date_t now = _replExecutor.now();
    BSONObj heartbeatObj;
    Milliseconds timeout(0);

    //3.2 以後的版本
    if (isV1ElectionProtocol()) {
        const std::pair<ReplSetHeartbeatArgsV1, Milliseconds> hbRequest =
            _topCoord->prepareHeartbeatRequestV1(now, _settings.ourSetName(), target);
        //構造請求,設定一個timeout
        heartbeatObj = hbRequest.first.toBSON();
        timeout = hbRequest.second;
    } else {
        ...
    }

    //構造遠端命令
    const RemoteCommandRequest request(
        target, "admin", heartbeatObj, BSON(rpc::kReplSetMetadataFieldName << 1), nullptr, timeout);

    //設定遠端命令回撥,指向_handleHeartbeatResponse方法
    const ReplicationExecutor::RemoteCommandCallbackFn callback =
        stdx::bind(&ReplicationCoordinatorImpl::_handleHeartbeatResponse,
                   this,
                   stdx::placeholders::_1,
                   targetIndex);

    _trackHeartbeatHandle(_replExecutor.scheduleRemoteCommand(request, callback));
}

上面的程式碼中存在的一些細節:

  • 心跳的超時時間,在_topCoord.prepareHeartbeatRequestV1方法中就已經設定好了
    具體的演算法就是:

**hbTimeout=_rsConfig.getHeartbeatTimeoutPeriodMillis() - alreadyElapsed**

其中heartbeatTimeoutPeriodMillis是可配置的引數,預設是10s, 那麼alreadyElapsed是指此前連續心跳失敗(最多2次)累計的消耗時間,在心跳成功響應或者超過10s後alreadyElapsed會置為0。因此可以判斷,隨著心跳失敗次數的增加,超時時間會越來越短(心跳更加密集)

  • 心跳執行的回撥,指向自身的_handleHeartbeatResponse方法,該函式實現了心跳響應成功、失敗(或是超時)之後的流程處理。

ReplicationCoordinatorImpl::_handleHeartbeatResponse方法的程式碼片段:

void ReplicationCoordinatorImpl::_handleHeartbeatResponse(
    const ReplicationExecutor::RemoteCommandCallbackArgs& cbData, int targetIndex) {
    LockGuard topoLock(_topoMutex);

    // remove handle from queued heartbeats
    _untrackHeartbeatHandle(cbData.myHandle);
    ...

    //響應成功後
    if (responseStatus.isOK()) {
        networkTime = cbData.response.elapsedMillis.value_or(Milliseconds{0});
        const auto& hbResponse = hbStatusResponse.getValue();

        // 只要primary 心跳響應成功,就會重新排程 electionTimeout定時器
        if (hbResponse.hasState() && hbResponse.getState().primary() &&
            hbResponse.getTerm() == _topCoord->getTerm()) {

            //取消並重新排程 electionTimeout定時器
            cancelAndRescheduleElectionTimeout();
        }
    }
    ...
    //呼叫topCoord的processHeartbeatResponse方法處理心跳響應狀態,並返回下一步執行的Action
    HeartbeatResponseAction action = _topCoord->processHeartbeatResponse(
        now, networkTime, target, hbStatusResponse, lastApplied);
    ...
    //排程下一次心跳,時間間隔採用action提供的資訊
    _scheduleHeartbeatToTarget(
        target, targetIndex, std::max(now, action.getNextHeartbeatStartDate()));

    //根據Action 執行處理
    _handleHeartbeatResponseAction(action, hbStatusResponse, false);
}

這裡省略了許多細節,但仍然可以看到,在響應心跳時會包含這些事情的處理:

  • 對於主節點的成功響應,會重新排程 electionTimeout定時器(取消之前的排程並重新發起)
  • 通過_topCoord物件的processHeartbeatResponse方法解析處理心跳響應,並返回下一步的Action指示
  • 根據Action 指示中的下一次心跳時間設定下一次心跳定時任務
  • 處理Action指示的動作

那麼,心跳響應之後會等待多久繼續下一次心跳呢? 在 TopologyCoordinatorImpl::processHeartbeatResponse方法中,實現邏輯為:
如果心跳響應成功,會等待heartbeatInterval,該值是一個可配引數,預設為2s;
如果心跳響應失敗,則會直接傳送心跳(不等待)。

程式碼如下:

HeartbeatResponseAction TopologyCoordinatorImpl::processHeartbeatResponse(...) {
  
    ...

    const Milliseconds alreadyElapsed = now - hbStats.getLastHeartbeatStartDate();
    Date_t nextHeartbeatStartDate;

    // 計算下一次 心跳啟動時間
    // numFailuresSinceLastStart 對應連續失敗的次數(2次以內)
    if (hbStats.getNumFailuresSinceLastStart() <= kMaxHeartbeatRetries &&
        alreadyElapsed < _rsConfig.getHeartbeatTimeoutPeriod()) {
        // 心跳失敗,不等待,直接重試心跳
        nextHeartbeatStartDate = now;
    } else {
        // 心跳成功,等待一定間隔後再次傳送(一般是2s)
        nextHeartbeatStartDate = now + heartbeatInterval;
    }

    ...
    // 決定下一步的動作,可能發生 tack over(本備節點優先順序更高,且資料與主節點一樣新時)
    HeartbeatResponseAction nextAction;
    if (_rsConfig.getProtocolVersion() == 0) {
       ...
    } else {
        nextAction = _updatePrimaryFromHBDataV1(memberIndex, originalState, now, myLastOpApplied);
    }
    nextAction.setNextHeartbeatStartDate(nextHeartbeatStartDate);
    return nextAction;
}

electionTimeout 定時器

至此,我們已經知道了心跳實現的一些細節,預設情況下副本集節點會每2s向其他節點發出心跳(預設的超時時間是10s)。
如果心跳成功,將會持續以2s的頻率繼續傳送心跳,在心跳失敗的情況下,則會立即重試心跳(以更短的超時時間),一直到心跳恢復成功或者超過10s的週期。

那麼,心跳失敗是如何觸發主備切換的呢,electionTimeout 又是如何發揮作用?

在前面的過程中,與electionTimeout引數相關兩個方法如下,它們也分別對應了單獨的定時器:

  • ReplicationCoordinatorImpl::_scheduleNextLivenessUpdate_inlock 發起保活狀態檢查定時器
  • ReplicationCoordinatorImpl::_cancelAndRescheduleElectionTimeout_inlock 重新發起選舉超時定時器

第一個是 _scheduleNextLivenessUpdate_inlock這個函式,它的作用在於保活狀態檢測,如下:

void ReplicationCoordinatorImpl::_scheduleNextLivenessUpdate_inlock() {
    //僅僅支援3.2+
    if (!isV1ElectionProtocol()) {
        return;
    }

   
    // earliestDate 取所有節點中更新時間最早的(以儘可能早的發現問題)
    // electionTimeoutPeriod 預設為 10s
    auto nextTimeout = earliestDate + _rsConfig.getElectionTimeoutPeriod();
   
    // 設定超時回撥函式為 _handleLivenessTimeout
    auto cbh = _scheduleWorkAt(nextTimeout,
                               stdx::bind(&ReplicationCoordinatorImpl::_handleLivenessTimeout,
                                          this,
                                          stdx::placeholders::_1));
}

因此,在大約10s後,如果沒有什麼意外,_handleLivenessTimeout將會被觸發,如下:


void ReplicationCoordinatorImpl::_handleLivenessTimeout(...) {

    ...
    for (auto&& slaveInfo : _slaveInfo) {
        ...

        //lastUpdate 不夠新(小於electionTimeout)
        if (now - slaveInfo.lastUpdate >= _rsConfig.getElectionTimeoutPeriod()) {
            ...
            //在保活週期後仍然未更新節點,置為down狀態
            slaveInfo.down = true;

            //如果當前節點是主,且檢測到某個備節點為down的狀態,進入memberdown流程
            if (_memberState.primary()) {
 
                //呼叫_topCoord的setMemberAsDown方法,記錄某個備節點不可達,並獲得下一步的指示
               //當大多數節點不可見時,這裡會獲得讓自身降備的指示
                HeartbeatResponseAction action =
                    _topCoord->setMemberAsDown(now, memberIndex, _getMyLastDurableOpTime_inlock());
                //執行指示
                _handleHeartbeatResponseAction(action,
                                               makeStatusWith<ReplSetHeartbeatResponse>(),
                                               true);
            }
        }
    }
    //繼續排程下一個週期
    _scheduleNextLivenessUpdate_inlock();
}

可以看到,這個定時器主要是用於實現主節點對其他節點的保活探測邏輯:

當主節點發現大多數節點不可達時(不滿足大多數原則),將會讓自己執行降備

因此,在一個三節點的副本集中,其中兩個備節點掛掉後,主節點會自動降備。 這樣的設計主要是為了避免產生意外的資料不一致情況產生。


圖- 主自動降備

第二個是_cancelAndRescheduleElectionTimeout_inlock函式,這裡則是實現自動Failover的關鍵了,
它的邏輯中包含了一個選舉定時器,程式碼如下:

void ReplicationCoordinatorImpl::_cancelAndRescheduleElectionTimeout_inlock() {

    //如果上一個定時器已經啟用了,則直接取消
    if (_handleElectionTimeoutCbh.isValid()) {
        LOG(4) << "Canceling election timeout callback at " << _handleElectionTimeoutWhen;
        _replExecutor.cancel(_handleElectionTimeoutCbh);
        _handleElectionTimeoutCbh = CallbackHandle();
        _handleElectionTimeoutWhen = Date_t();
    }

    //僅支援3.2後的V1版本
    if (!isV1ElectionProtocol()) {
        return;
    }
    //僅備節點可執行
    if (!_memberState.secondary()) {
        return;
    }
    ...
    //是否可以選舉
    if (!_rsConfig.getMemberAt(_selfIndex).isElectable()) {
        return;
    }

    //檢測週期,由 electionTimeout + randomOffset
    //randomOffset是隨機偏移量,預設為 0~0.15*ElectionTimeoutPeriod = 0~1.5s
    Milliseconds randomOffset = _getRandomizedElectionOffset();
    auto now = _replExecutor.now();
    auto when = now + _rsConfig.getElectionTimeoutPeriod() + randomOffset;
  
    LOG(4) << "Scheduling election timeout callback at " << when;
    _handleElectionTimeoutWhen = when;

    //觸發排程,時間為 now + ElectionTimeoutPeriod + randomOffset
    _handleElectionTimeoutCbh =
        _scheduleWorkAt(when,
                        stdx::bind(&ReplicationCoordinatorImpl::_startElectSelfIfEligibleV1,
                                   this,
                                   StartElectionV1Reason::kElectionTimeout));
}

上面程式碼展示了這個選舉定時器的邏輯,在每一個檢測週期中,定時器都會嘗試執行超時回撥,
而回調函式指向的是_startElectSelfIfEligibleV1,這裡面就實現了主動發起選舉的功能,
如果心跳響應成功,通過cancelAndRescheduleElectionTimeout呼叫將直接取消當次的超時回撥(即不會發起選舉)
如果心跳響應遲遲不能成功,那麼定時器將被觸發,進而導致備節點發起選舉併成為新的主節點!

同時,這個回撥方法(產生選舉)被觸發必須要滿足以下條件:

  1. 當前是備節點
  2. 當前節點具備選舉許可權
  3. 在檢測週期內仍然沒有與主節點心跳成功

這其中的檢測週期略大於electionTimeout(10s),加入一個隨機偏移量後大約是10-11.5s內,猜測這樣的設計是為了錯開多個備節點主動選舉的時間,提升成功率。
最後,將整個自動選舉切換的邏輯梳理後,如下圖所示:


圖-超時自動選舉

業務影響評估

副本集發生主備切換的情況下,不會影響現有的讀操作,只會影響寫操作。 如果使用3.6及以上版本的驅動,可以通過開啟retryWrite來降低影響。
但是如果主節點是屬於強制掉電,那麼整個 Failover 過程將會變長,很可能需要在Election定時器超時後才被副本集感知並恢復,這個時間視窗會在12s以內。
此外還需要考慮客戶端或mongos對於副本集角色的監視和感知行為。但總之在問題恢復之前,對於原主節點的任何讀寫都會發生超時。
因此,對於極為重要的業務,建議最好在業務層面做一些防護策略,比如設計重試機制。

參考連結

https://docs.mongodb.com/manual/replication/#automatic-failover
https://www.percona.com/blog/2016/05/25/mongodb-3-2-elections-just-got-better/
https://www.percona.com/blog/2018/10/10/mongodb-replica-set-scenarios-and-internals/