MongoDB内核:副本集选举过程分析

时间:2022-07-26
本文章向大家介绍MongoDB内核:副本集选举过程分析,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

导语:MongoDB的副本集协议是一种raft-like协议,即基于raft协议的理论思想实现,并且对之进行了一些扩展。本文尝试从源码层面,以主节点的视角切入分析副本集选举的整个过程,并给出了MongoDB副本集协议与raft的主要区别。 (PS:本文代码和分析基于源码版本v4.0.3。水平有限,文章中有错误或理解不当的地方,还望指出,共同学习)

一、背景

MongoDB的副本集协议(内部称为pv1),是一种raft-like协议,即基于raft协议的理论思想实现,并且对之进行了一些扩展。

阅读本文之前建议先了解一下副本集相关的基础知识,比如官方文档replication等。

replicate set.png

二、选举过程

2.1 主要涉及文件

  • db/repl/replication_coordinator_impl_elect_v1.cpp
  • db/repl/replication_coordinator_impl.cpp

2.2 主要流程

选举的大致流程和函数调用链如下:

选举调用链.png

一个典型的选举过程primary节点的日志如下:

典型选举过程的主节点日志.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协议在下面这种场景下有什么问题。

一种异常场景.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为何要进入这个阶段?

同样的,我们可以分析一下没有这个阶段的话在下面这个场景有什么问题。

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就是为了尽量避免回滚的出现而诞生的。

选举的catchup流程.png

如图所示,如果S1在(c)时刻就恢复,这里的时刻应该再细化一下,是在S3获得了S2的投票成为primary状态之后,而不是在获得投票结果之前(否则的话S1不会投票给S3,本轮选举失败,等待下一轮选举,S1会重新成为主)。S1进入catchup状态,看看有没有哪个从节点存在比自己更新的日志,发现S1有,然后就同步到自己这边并提交,再“真正”成为主节点,支持外部的写入;然后整个副本集一切恢复正常。

注意到这一过程中是没有回滚操作的。集群通过副本集协议保留了序号为2的写入日志并且自愈

对于MongoDB而言,还有其他手段可以用来尽量避免回滚操作的出现,比如设置writeConcernmajority(对于3节点集群而言,也可直接设置为2)来避免图中(a)情况的出现。确保新选出来的primary包含旧primary挂掉前的最新数据。

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副本集协议与raft对比.png

关于表中writeConcern,arbiter等参数及配置,细节可以参考mongodb 官方文档。

其中,关于MongoDB副本集的oplog拉取流程可以参考下面这张图,会更清晰一些:

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协议中这样一种边界情况:

另一种异常场景.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协议