MongoDB 系列 · 第六篇

副本集选举:谁能当主节点,谁说了算

上一篇看到 oplog 怎么把写入从主节点传播到从节点。这一篇往前一步:一开始谁是主节点、主节点挂了或者主动让位之后谁来接班,这件事是怎么定下来的。本机在同一台机器上真实跑了 3 个 mongod 进程,组成一个真实的 3 节点副本集,亲眼看着它们从"谁都不是主节点"到选出第一个主节点,又用 rs.stepDown() 真实触发了一次换届。

两次选举的完整过程都直接从 mongod 的真实日志里截取,没有一行是编的;再对照 MongoDB Server 真实源码(v8.3.7 标签)确认每一步为什么会这样发生。

2 次真实选举
本机 3 节点真实副本集,一次超时触发、一次 stepDown 触发,日志原样截取
3 处真实源码
dry run 分支逻辑、投票拒绝规则、当选后的 catch-up 阶段

真实实验:搭一个真的 3 节点副本集

3 个独立的 mongod 进程,各自的端口和数据目录,同一台机器上组成一个真实的副本集。

真实实测
$ mongod --port 28001 --dbpath ./db1 --replSet rs_election --bind_ip 127.0.0.1 &
$ mongod --port 28002 --dbpath ./db2 --replSet rs_election --bind_ip 127.0.0.1 &
$ mongod --port 28003 --dbpath ./db3 --replSet rs_election --bind_ip 127.0.0.1 &

$ mongosh --port 28001 --eval '
  rs.initiate({
    _id: "rs_election",
    members: [
      { _id: 0, host: "127.0.0.1:28001" },
      { _id: 1, host: "127.0.0.1:28002" },
      { _id: 2, host: "127.0.0.1:28003" }
    ]
  })
'
// 几秒钟后: [ { name: '127.0.0.1:28001', state: 'PRIMARY', health: 1 }, { name: '127.0.0.1:28002', state: 'SECONDARY', health: 1 }, { name: '127.0.0.1:28003', state: 'SECONDARY', health: 1 } ]

rs.initiate() 之后,没有任何一个节点是天生的主节点——三个进程都以 SECONDARY 起步,几秒钟内自己选出了第一个 PRIMARY(这次是 28001)。

真实日志:第一次选举,一步步发生了什么

直接从 28001 的 mongod 日志里截取,按真实发生顺序排列,一行没改。

真实实测
// 没人是 PRIMARY,选举超时(默认 10 秒)一到,自己决定发起选举
Starting an election, since we've seen no PRIMARY in election timeout period
  {electionTimeoutPeriodMillis: 10000}

// 先不动真格,发一轮"试探性"投票请求,term 还是 0
Conducting a dry run election to see if we could be elected {currentTerm: 0}
VoteRequester processResponse {term: 0, dryRun: true, vote: 'yes', from: '127.0.0.1:28003'}

// 试探成功(自己 + 28003 = 2 票,3 个投票节点里的多数),这才真正把 term 加到 1
Dry election run succeeded, running for election {newTerm: 1}
Storing last vote document in local storage for my election {lastVote: {term: 1, candidateIndex: 0}}
VoteRequester processResponse {term: 1, dryRun: false, vote: 'yes', from: '127.0.0.1:28002'}

// 两票到手(28002 这次也投了),当选
Election succeeded, assuming primary role {term: 1}

整个过程分成两轮投票:先是一轮不真正加 term 的"dry run",确认自己大概率能选上,才真正把 term 从 0 加到 1 发起正式选举。两轮里 28001 都拿到了 2/3 的票(自己 + 一个对端),达到多数。

真实源码:为什么要先跑一次"假的"选举

src/mongo/db/repl/replication_coordinator_impl_elect_v1.cpp · mongodb/mongo @ r8.3.7, L215 long long term = _topCoord->getTerm(); int primaryIndex = -1; if (reason == StartElectionReasonEnum::kStepUpRequestSkipDryRun) { long long newTerm = term + 1; LOGV2(21437, "Skipping dry run and running for election", "newTerm"_attr = newTerm); _startRealElection(lk, newTerm, reason); lossGuard.dismiss(); return; } LOGV2(21438, "Conducting a dry run election to see if we could be elected", "currentTerm"_attr = term);

term 是选举里全局唯一递增的"届号",每往前推进一次代价不小——旧 term 的一些状态要作废,集群里其他节点也要跟着更新自己认的 term。dry run 用的是"当前 term 还没加 1"的旧 term 去问一圈"如果我现在参选,你们会投给我吗",不会真正修改任何节点的投票记录。只有确认能拿到多数,才值得把 term 真正推进一次去发起正式选举——这解释了为什么日志里 dry run 阶段的 term 还是 0,正式选举时才变成 1。

真实实验:主动换届——rs.stepDown()

现在的主节点 28001 主动让位,看新一轮选举怎么发生。

真实实测
$ mongosh --port 28001 --eval 'rs.stepDown(60)'
# 28001(旧主节点)的日志: Attempting to step down in response to replSetStepDown command Stepdown succeeded # 28002 的日志: Starting an election due to step up request {} Skipping dry run and running for election {newTerm: 2} Storing last vote document in local storage for my election {lastVote: {term: 2, candidateIndex: 1}} VoteRequester processResponse {term: 2, dryRun: false, vote: 'yes', from: '127.0.0.1:28001'} Election succeeded, assuming primary role {term: 2} Entering primary catch-up mode Exited primary catch-up mode # 几秒后确认: $ mongosh --port 28002 --eval 'rs.status().myState + " term=" + rs.status().term' 1 term=2 // myState:1 = PRIMARY

这次的日志里完全没有 dry run 这一步——rs.stepDown() 会直接要求某个从节点"起来接班",触发原因是 kStepUpRequestSkipDryRun,跟上一节引用的源码分支完全对上:既然是被现任主节点亲自请求换届,不需要再"投石问路"。

真实源码:选票不是白给的——数据落后的节点投不了赞成票

src/mongo/db/repl/topology_coordinator.cpp · mongodb/mongo @ r8.3.7, L3766 } else if (args.getLastWrittenOpTime() < getMyLastWrittenOpTime()) { response->setVoteGranted(false); response->setReason(fmt::format( "candidate's data is staler than mine. candidate's last written OpTime: {}, " "my last written OpTime: {}", args.getLastWrittenOpTime().toString(), getMyLastWrittenOpTime().toString())); } else if (!args.isADryRun() && _lastVote.getTerm() >= args.getTerm()) { response->setVoteGranted(false); response->setReason(fmt::format( "already voted for another candidate ({}) this term ({})", ...));

每个节点收到投票请求时,会拿候选人报上来的 lastWrittenOpTime(它自己 oplog 里最新一条记录的位置)跟自己的比——候选人比自己落后,直接拒绝投票,理由都写得明明白白:"candidate's data is staler than mine"。这条规则保证了选出来的主节点手里的数据不会比任何一个投票给它的节点更旧;紧接着那条"这个 term 已经投过别人了"的检查,保证了同一个 term 里每个节点最多只能投一票,不会左右横跳。这两条一起,是选举结果不会产生数据倒退的根本原因。

真实源码:当选之后不能立刻为所欲为——catch-up 模式

src/mongo/db/repl/replication_coordinator_impl.h · mongodb/mongo @ r8.3.7, L877 // The state and logic of primary catchup. // // The primary exits catchup mode when any of the following happens. // 1) My last applied optime reaches the target optime, if we've received a heartbeat from all // nodes. // 2) Catchup timeout expires. // 3) Primary steps down. // 4) The primary has to roll back to catch up. // 5) The primary is too stale to catch up.

刚当选的主节点不会立刻大大方方接受新写入——万一旧主节点在挂掉前已经把某条写入复制给了别的从节点,而新主节点自己还没收到,新主节点得先把这个差距追上,否则自己的数据反而比集群里别的节点旧。这正是日志里 Entering primary catch-up modeExited primary catch-up mode 这段在做的事:等收到全部节点的心跳、确认自己没有落后之后,才正式结束 catch-up、开始正常工作。

交互演示:两次真实选举 + 一次投票规则的对照验证

前 5 步是本机两次真实选举的日志重放,第 6 步用同一套真实投票规则,对照验证"数据落后就投不了票"这件事。

选举实录未开始
点击"下一步"或"播放"开始。

判断逻辑由 Python 脚本按真实源码转写:投票是否批准直接实现 topology_coordinator.cpp 的判断顺序(term 是否够新、数据是否够新、这个 term 是否已投过票);两次选举谁当选、term 变成几,和真实日志逐项断言相等;数据落后节点被拒投这一步,用同一套函数在一个真实实验里没有触发过的场景上验证,还有一个用不同公式重新算多数票门槛的独立交叉检查。

参考与说明

  • 本文源码引用(dry run 分支判断、TopologyCoordinator::processReplSetRequestVotes 投票规则、CatchupState 退出条件)均取自 mongodb/mongo 仓库 r8.3.7 标签,与本机安装的 MongoDB Community 8.3.7 版本一致,直接从 GitHub 拉取源文件核对过函数签名、关键调用和行号。
  • 两次选举的完整日志、rs.status() 输出均为本机真实跑起来的 3 节点副本集(端口 28001/28002/28003)产生,未做删改;实验结束后已关闭这 3 个测试用的 mongod 进程。
  • 演示数据的自检:两次真实选举的"谁当选、term 变成几、有没有 dry run"逐项和真实日志断言相等;数据落后节点被拒投的场景直接复用真实源码转写的同一个投票判断函数,不是另外编的逻辑;多数票门槛用另一种公式独立算过一遍,结果一致。
  • 没有涉及:多数据中心场景下的选举优先级配置、仲裁节点(arbiter)对选举的影响、选举与读写关注级别(read/write concern)的具体交互(下一篇会展开)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电