1. 什么场景逼着你手动切换:不只是机器要挂那么简单
去年某天凌晨两点,值班手机把我在睡梦中吵醒。某核心业务库的 primary 所在宿主机磁盘剩余空间不到 2%,再过半小时可能就写不进去了。业务方在群里说绝对不能停,必须在十分钟内把主切到另外一台已经准备好的从节点上。等我打开运维文档,发现手动主从切换那一节只有一句“执行 rs.stepDown()”。当时对着 mongosh 敲命令的时候,心里是真没底。从那以后我就把 MongoDB 手动主从切换列入了必须定期演练的清单,今天这篇内容就是那几次演练沉淀下来的完整过程。
很多人觉得 MongoDB 复制集的自动故障转移已经解决了高可用问题,手动切换根本用不上。这个想法大错特错。自动选举解决的是“节点进程挂了”的场景,但运维中有一大堆情况是进程没挂、心跳正常、自动选举永远不会触发,可你就是得把主切走。下面列几个我实际遇到的场景。
1.1 磁盘告警与硬件维护:最常见的切换触发条件
宿主机磁盘空间告警是头号原因。日志涨得快、慢查询日志、备份文件、mongod 的 journal,任何一个把磁盘塞满,写操作就会开始报错。磁盘快满的时候 MongoDB 不会自动把 primary 让给别人,它只是在那边硬扛,直到写不进去业务雪崩。同样的情况还有宿主机内存 ECC 报错、CPU 型号不一致导致性能差异过大、网卡固件要升级、内核要打补丁。这些统统不会触发自动选举,只能手动切。
还有一种很容易被忽略的情况:机房割接。你所在公司可能用某家云厂商,也可能自建机房,不管哪种,只要底层网络设备要维护、机柜要迁移、供电要改造,都意味着某个节点要下电。下电前必须把 primary 位置挪走,否则等你把机器关了才发现集群没有主,那真是叫天天不应。
1.2 让主节点回到更适合的位置:资源水位与角色错位
还有一种常见情况是角色错位。比如你新采购了一台高性能机器,或者某台从节点所在宿主机特别空闲,你希望 primary 落在资源更充裕的节点上。虽然 MongoDB 可以通过设置 priority 来自动促成这种结果,但 priority 机制生效需要一次选举,而正常运行时除非当前主挂掉或退位,否则不会重新选。这时候你依然需要手动 stepDown 一次,让集群重新选举,把主“弹”到目标节点。如果你拖着不处理,高性能节点空转还无所谓,真正的问题是低性能节点因为承载写入而持续高负载,早晚拖垮整个集群。
1.3 从 primary 上赶走异常流量:应用层故障隔离
还有一个我遇到过的场景,某个应用侧发了 bug,写入流量翻了几十倍,把 primary 的 CPU 和 IO 全部打满。这时候开发在紧急修复,业务又不能停,我们就得先手动把 primary 切到另一台负载较低的从节点上。注意,stepDown 并不是负载均衡器,切换之后异常流量会跟着打到新 primary 上,所以这只能算缓兵之计,真正解决问题的还是把应用的异常写入挡住。但它确实能给从节点之间争取一些喘息时间,尤其在你想快速恢复只读能力的时候,非常管用。
1.4 自动故障转移的盲区:为什么高可用不等于高可用演练
最关键的认知是:MongoDB 自动故障转移依赖心跳和选举,但很多“看起来不正常但进程没死”的情况,它根本不会自动处理。比如 primary 节点网络延迟飙升到几百毫秒,但心跳仍然能通过;比如磁盘 IO 等待时间很长,写入已经慢到不可接受,但进程还活着;比如出现死锁或长事务把某个关键操作卡死,但节点状态仍是 PRIMARY。这些场景下自动选举不会触发,必须有预案、有脚本、有人敢在凌晨执行这一步。高可用系统的价值恰恰在于:你能在它需要切换的时候,知道切换会发生什么、会丢多少数据、对应用造成多大影响。这些只能靠演练来验证,靠文档凭空想象是没有用的。
2. 演练前吃透 rs.stepDown():它到底替你做了什么事
2.1 stepDown 的三段式执行过程
rs.stepDown() 并非简单地把节点降级了事,它背后有一套协调流程。以命令rs.stepDown(60, 15)为例,整段执行大致分为三个阶段。
第一阶段,当前 primary 会停止接受新的写入,在客户端视角里它开始表现为“即将退位”。但这和进程被杀不一样,它不会立刻放弃自己的主身份,而是先检查集群中是否有从节点的 oplog 已经追平到自己的最新位置。
第二阶段是等待从节点追平。如果发现还没有从节点追平,它会启动一个等待计时,也就是第二个参数secondaryCatchUpPeriodSecs控制的窗口。在这个窗口内,从节点会尽力追赶 primary 的 oplog,追得越接近,后续切换的数据丢失窗口就越小。如果从节点在窗口内成功追上,切换会相对平滑;如果没追上,窗口时间一到,它仍然会执行降级。
第三阶段是触发选举。当前节点广播自己已经变成 SECONDARY,复制集成员重新发起 leader 选举,获得多数派票数的新节点成为 PRIMARY。而原 primary 在第一个参数seconds指定的时间内不能再次参与选举,这个冷却期就是为了防止它刚退下来又被立刻选回去,导致集群角色来回抖动。
2.2 两个参数到底怎么设置
seconds指退位冷却时间,默认为 60 秒。运维演练时我一般设 60 到 120 秒。如果设置太短,比如 10 秒,可能出现切换完成后集群刚稳定,原主又参与选举并胜出,造成二次切换。这个过程中应用的连接池会再次经历一次拓扑刷新,两波抖动叠加起来,业务侧会很痛苦。
secondaryCatchUpPeriodSecs指等待从节点追平 oplog 的时间上限,默认是 10 秒。建议演练时设 15 秒左右。这个值不是越大越好,因为如果从节点本身复制延迟已经很大,等再久也追不上,反而拖延整个切换的完成时间。10 到 15 秒对于常规健康集群已经足够。
我整理了一个参考表,方便后续演练时快速决策。
| 参数 | 默认值 | 推荐演练值 | 备注 |
|---|---|---|---|
| seconds | 60 | 60 - 120 | 冷却期越长,避免反复切回 |
| secondaryCatchUpPeriodSecs | 10 | 10 - 15 | 给从节点追 oplog 的时间 |
2.3 与 kill -9 自动故障转移的本质区别
很多人觉得手动切换和直接杀死 primary 进程是一样的,反正都会触发选举。差别非常大。直接 kill 掉 primary,集群立刻进入无主状态,从节点们会马上发起选举,但新上任的 primary 的 oplog 位置可能落后于旧 primary,旧节点上最近写入的一部分数据可能在新主上不存在。
而rs.stepDown()给了一次优雅切换的机会:它在退位之前先让从节点尽量追平 oplog。虽然这不保证数据绝对不丢,但至少把丢失窗口压到了最小。真正决定数据丢不丢的其实是 writeConcern。如果应用写入时用的是writeConcern: majority,那这条数据必须被多数节点确认才返回,切换时多数节点里至少有一个持有该数据,所以不会丢。如果应用用的是w=1甚至不关心写确认,那最近的一部分写入只存在于旧 primary 内存和 oplog 里,无论优雅 stepDown 还是 kill,切换后都可能消失,这是很多业务埋下的雷,平时看不出来,一切换就炸。所以我一直强调:演练前先检查应用的写入确认级别,别等到切完再去找数据。
2.4 一个容易忽略的细节:priority 和回切
stepDown 的冷却期结束后,原 primary 会恢复参选资格。如果它的 priority 还是默认的 1,而且它的 oplog 是最新的,那么在下一轮选举中它很可能会再次胜出,变成 primary。这意味着如果你执行完 stepDown 后没有及时处理,集群可能“自动回切”到你头上。演练时如果只想让某个节点暂时退出主角色,更稳妥的做法是临时把它的 priority 改成 0,等维护完成后再改回来。但注意修改 priority 本身可能触发选举,顺序上必须先确认目标节点已经追平 oplog,再操作。
3. 完整演练过程:预检、切换、验证、回切的一个完整闭环
3.1 演练前的预检清单
磨刀不误砍柴工。我每次演练前都要过一遍检查项,任何一个不满足就直接取消演练,绝不硬上。
第一,复制集至少要有三个数据节点,或者两个数据节点加一个仲裁节点。无论如何要保证多数派可用,因为 stepDown 之后必须能选出新主。如果一个从节点已经挂了,这时候不要做演练,先把挂的节点修复或剔除。
第二,用rs.status()查看每个成员的状态。重点关注health是否等于 1,stateStr是否是 PRIMARY 或 SECONDARY,以及各节点的optimeDate差距。我在 mongosh 里常用下面这段脚本快速检查:
// 在 mongosh 中对复制集任一节点执行 rs.status().members.forEach(m => { print(m.name, m.stateStr, "health:", m.health, "lagMs:", Date.now() - (m.optimeDate ? m.optimeDate.getTime() : 0)); });第三,检查 oplog 窗口和复制延迟。用rs.printReplicationInfo()看 oplog 总量和时间窗口,用rs.printSecondaryReplicationInfo()看从节点复制延迟,正常情况下应该是 0 秒或非常接近 0。如果从节点落后太多,先暂停演练,排查复制延迟原因。
第四,检查应用的 MongoDB 连接串。必须确认 URI 是完整的三节点副本集形式,类似mongodb://node1:27017,node2:27017,node3:27017/?replicaSet=rs0&retryWrites=true,并且驱动版本支持自动拓扑刷新。如果发现连接串只写了单节点地址,演练前必须先让应用修改配置,否则切完应用必挂。
第五,确认业务处于低峰期。虽然 MongoDB 复制集设计上能承受切换,但任何切换都有抖动窗口,选在低峰期演练,既是对业务负责,也方便从容处理意外。
3.2 执行 stepDown:命令的正确姿势与预期输出
预检通过后,登录当前主节点,先执行rs.isMaster()确认角色,再执行切换。我习惯用完整的 admin 命令格式,便于日后写脚本:
// 连接当前 primary db.adminCommand({ replSetStepDown: 60, secondaryCatchUpPeriodSecs: 15 });执行这条命令后,mongosh 通常会卡住一小会儿,时间取决于从节点追平速度,然后会返回一个对象,常见输出是{ok: 1}。接着你在这个会话里再执行rs.isMaster(),会发现自己已经变成 SECONDARY。有些版本下命令执行期间连接会短暂断开,这也是正常的,因为服务端已经把自己降级了。
我建议在另一台终端上同时执行监控命令,实时观察集群状态变化:
// 在任一从节点上循环观察 while (true) { const s = rs.status(); const primary = s.members.find(m => m.stateStr === "PRIMARY"); print(new Date().toISOString(), primary ? primary.name : "NO PRIMARY"); sleep(1000); }切换完成后,新 primary 会在几秒内出现。正常情况下整个选主过程在 10 秒内完成,加上 catch-up 等待,整体应该在 20 秒左右。如果超过 30 秒还没有新主,说明有异常,先看rs.status()里各节点的stateStr和lastHeartbeatMessage。
3.3 切换后的业务验证清单
角色切过去不代表演练成功,业务验证才是关键。我会按顺序做下面几件事。
第一,在应用服务器上用驱动自带的健康检查接口确认能够写读。最简单的方式是直接跑一条 insert 和 find,确认新主可写、从节点可读。如果有多个从节点,也要轮流检查一遍连通性。
第二,看应用日志和监控告警。重点看有没有连接异常报错、超时、NotPrimary 之类的关键字。驱动侧如果配置了 retryWrites,切换瞬间的写失败会自动重试,账面上可能无感,但日志里可能有重试记录,这很正常。
第三,观察新 primary 的资源水位。CPU、内存、磁盘 IO、连接数都要看,确认新主能够扛住现有流量。如果新主是老旧机器,配置明显低于原来的主,那这个切换可能不适合长期维持。
第四,让切换后的状态稳定运行至少 10 到 20 分钟,再决定是否回切。如果演练目标是“切走并保持”,那就保留新主,把原主降为从节点观察。如果只是验证切换能力,完成后要执行回切。
3.4 把主切回去:回切也要按流程来
回切操作本质上是再执行一次 stepDown,只不过方向相反。比如现在新 primary 是 node2,你想把主还给 node1,就登录 node2 执行同样的命令:
db.adminCommand({ replSetStepDown: 60, secondaryCatchUpPeriodSecs: 15 });前提是 node1 已经追平 oplog,并且 priority 没有被改成 0。回切完成后,同样要走一遍业务验证流程。这里有个容易踩的坑:回切太急。如果刚切完不到几分钟就立刻回切,集群会在短时间内经历两次切换,业务侧的连接池和客户端拓扑缓存会连续抖动,一些老驱动甚至会因此进入不健康状态。我的做法是两次切换之间至少间隔 10 分钟,让连接池完全稳定下来再说。
下面是我每次演练都会打印的一份检查清单表格,你可以直接拿去改。
| 阶段 | 检查项 | 预期结果 |
|---|---|---|
| 切换前 | 复制集多数派可用 | 至少 3 个健康节点 |
| 切换前 | 从节点复制延迟 | 0 秒或接近 0 |
| 切换前 | 应用连接串 | 包含 replicaSet 和 retryWrites |
| 切换中 | stepDown 返回值 | ok: 1 |
| 切换后 | 新 primary 出现时间 | 30 秒内 |
| 切换后 | 业务写读验证 | 成功 |
| 切换后 | 应用日志 | 无持续报错 |
| 回切前 | 稳定运行观察 | 至少 10 分钟 |
| 回切后 | 再次业务验证 | 成功 |
4. 我在演练中踩过的坑:排查链路和补救方法
4.1 应用连接池不刷新,持续报 NotPrimary
我遇到过最典型的问题:集群本身切得好好的,新 primary 也确认了,但某个应用服务一直报NotPrimaryNoSecondaryOk或NotWritablePrimary错误,日志里刷屏。第一反应会觉得是 MongoDB 的问题,其实问题出在应用侧连接串。
排查链路是这样的。先确认集群状态:用rs.isMaster()在应用服务器上验证新主地址可达。然后看应用日志,发现它一直在尝试连接旧 primary 的 IP。最后翻驱动配置,发现 URI 写死成了mongodb://old_primary_ip:27017/db,既没带replicaSet参数,也没带上其他节点地址。这种配置下驱动压根不知道这是一个副本集,只会傻傻地连一个节点,切换之后当然连不上。
解决办法是把 URI 改成完整的副本集连接串,加上replicaSet=rs0&retryWrites=true,重新发布应用。排查过程中最耗时的其实是定位,因为一开始大家都在怀疑 MongoDB 选主出了问题。所以现在我把“验证应用连接串”列进了演练前预检清单的第一优先级,宁可多花五分钟检查,也不想等切换完再被业务方找上门。
4.2 stepDown 之后集群短暂没有主:投票配置和 priority 的问题
另一次演练中,我执行 stepDown 后,集群竟然整整 40 秒没有出现新主。当时第一反应是选举被卡住了,立刻值班查看rs.status(),结果发现一个非常隐蔽的配置问题:复制集里一个数据节点的priority被设成了 0,另一个是hidden: true,而我在集群里总共只有两个数据节点加一个仲裁节点。表面看有三个投票成员,实际上真正的候选者只有一个,而那个候选者追 oplog 花了点时间,加上选举超时配置偏大,整体拉长了无主时间。
排查链路是先看rs.status()的每个成员stateStr,再看配置里priority和votes字段,最后对比选举相关的settings.electionTimeoutMillis。解决办法是把候选节点的 priority 调成正常值,确认配置后重新选举。这一步给我的教训是:复制集成员的 priority 和 votes 配置不能想当然,尤其经历过其他人调整后,必须每次演练前都验证一遍。
4.3 长事务或慢写拖住 stepDown,命令长时间不返回
还有一次执行 stepDown 时,命令迟迟不返回,像是卡死了一样。当时第一直觉是网络问题,后来在另一个会话里运行db.currentOp(),发现当前 primary 上有一个长时间运行的聚合操作,一直占用着资源。stepDown 过程中需要等节点达到一个相对安静的状态,这个慢操作导致降级流程迟迟走不完。
排查链路是这样的:新开一个 mongosh 连接,执行db.currentOp()过滤出长时间运行的写操作和事务,结合appName或client字段确认是哪个应用发起的操作。确认后和业务沟通,或者直接db.killOp(opid)终止该操作。kill 之后再次执行 stepDown,命令立刻返回,切换顺利完成。
这里必须提醒一句:killOp是双刃剑,杀错业务操作可能造成数据不一致或业务报错。所以遇到这种情况我优先联系业务方确认操作是否重要,实在联系不上才会在风险评估后再做决定。演练前先看看 currentOp,能提前避免这个麻烦。
4.4 单节点或二节点复制集上执行 stepDown:把自己玩没了
这个坑发生在一次测试环境里。某同事在一套单节点复制集上执行了rs.stepDown(),结果整个复制集没有 primary,集群直接不可写。单节点复制集本来就没有任何人能参与选举,stepDown 之后没有任何节点能顶上来,必须人工干预才能把主“救”回来。二节点复制集也一样,除非有仲裁节点,否则 stepDown 后也大概率选不出主,因为多数派票数凑不够。
正确的做法是:测试环境最差也要用“两个数据节点加一个仲裁节点”的结构,生产环境至少三节点。演练前用rs.status()数一下健康节点数,少于三直接取消操作。这个坑一旦踩了,恢复过程非常难看,甚至可能要去底层手动修改 local 库的 replication 配置,风险极高。
4.5 seconds 设太短导致的反复弹回
有一段时间我把 seconds 参数设成 10 秒做演练,想着快点结束。结果第一次切换完成后,原主在冷却期一过立刻重新参与选举,而且因为它数据最新、priority 又一样,顺利再次当选。于是集群在不到一分钟内又切了回去,应用侧经历了两次切换,产生了大量连接重连告警。后来我把 seconds 固定为至少 60 秒,回切时至少间隔 10 分钟以上再操作,这个反复弹回的问题就再也没有出现过。
5. 让演练成为常规动作:复盘模板和自动化雏形
5.1 每次演练都要有记录:复盘模板比命令更值钱
演练的意义不只是练一次手,而是通过每次演练发现周边配套系统的问题。我建议每次演练后都要填写一张简单的复盘表,至少包含以下字段。
| 字段 | 示例 |
|---|---|
| 演练日期 | 2024-xx-xx 02:00 |
| 演练目的 | 验证 stepDown 流程与监控告警 |
| 执行人 | 值班 DBA |
| 切换前 primary | node1 |
| 切换后 primary | node2 |
| 是否回切 | 是 / 否 |
| 无主窗口时长 | 约 8 秒 |
| 业务影响 | 无 / 少量重试 |
| 应用日志异常 | 无 / 连接重试 |
| 发现的问题 | 连接串未配置 retryWrites |
| 改进项 | 应用侧修改 URI 并重发 |
这张表的价值在于:半年后你再看到某次切换时发现“无主窗口 40 秒”,就能立刻回忆起是 priority 配置问题,而不是临时查一堆日志。复盘记录是运维团队最宝贵的资产。
5.2 演练频率和触发节点
我建议至少每季度做一次完整的手动切换演练,并且在以下几种情况下强制加练一次:复制集拓扑变更后、MongoDB 大版本升级后、机房或网络架构调整后、应用连接串统一改造后。每次演练都选在业务低峰期,提前和业务方、开发团队同步时间窗口,争取大家的配合。
5.3 自动化切换脚本的思路与边界
说到自动化,我的态度是“半自动”就好,不建议全自动。原因很简单:stepDown 本身只是一条命令,但切换前后的预检、验证、告警确认都需要人来判断。如果盲目把整个流程自动化,一旦漏掉某个前置条件(比如从节点落后、priority 配置异常、连接串单点),就是生产事故。
可以做的是把固定、机械的部分包装成脚本。比如切换命令的封装:
#!/bin/bash # 传入当前 primary 的地址,执行 stepDown 并输出结果 PRIMARY_HOST="$1" mongosh "mongodb://${PRIMARY_HOST}:27017/admin" --quiet \ --eval 'const res = db.adminCommand({replSetStepDown: 60, secondaryCatchUpPeriodSecs: 15}); printjson(res);'再比如切换后的角色确认脚本:
// 等待新 primary 出现,最多等 60 秒 const deadline = Date.now() + 60000; let found = false; while (Date.now() < deadline) { const s = rs.status(); const p = s.members.find(m => m.stateStr === "PRIMARY"); if (p) { print("New PRIMARY:", p.name); found = true; break; } sleep(1000); } if (!found) { print("ERROR: no PRIMARY within 60s"); }这两个脚本组合起来,基本覆盖了“切换 + 验证”的主干。但前置预检、业务验证、告警确认这些环节,我仍然坚持人工把关。
5.4 最后分享一点个人经验
演练的次数多了之后,我发现真正能反映团队运维水平的不是 stepDown 命令敲得有多熟练,而是切换前后那一整套配套动作是否完整:预检是否做足、监控是否覆盖、连接池是否能感知拓扑变化、回切是否克制、复盘是否到位。每次演练完我都会把发现的问题修复掉,而不是记录完就丢到一边。下次演练前再翻一遍上一次的复盘表,很多坑就不会再踩第二遍。如果你所在的团队还没有做过一次完整的手动主从切换演练,建议从今天开始补上,至少先从测试环境完整走一遍流程。