第18章:Mongo复制集运维——故障切换、节点扩容与数据重同步
1. 项目背景
业务场景:复制集上线一个月,本地生活电商扛住了两次服务器故障。但新问题接踵而至——A 城市的机房要停电检修 4 小时,需要把 Primary 手动切到 B 机房的节点;DBA 发现从库的磁盘快满了,要在不中断服务的前提下加入一个新节点并顺利完成数据同步;运营实习生某天手滑又误删了 500 条评论,幸运的是有一个延迟从库(Delayed Secondary),需要在延迟从库的数据被覆盖之前把数据抢救回来。
痛点:复制集不是"搭好了就万事大吉"。日常运维中的高频操作——节点扩容、滚动升级、数据中心切换、延迟节点恢复、Initial Sync 故障处理——都需要运维团队熟练掌握。但多数人只会用rs.status()看一眼,不知道数据重同步的风险有多大,也不清楚rs.stepDown()的内部流程,更不懂怎么在延迟节点上做"时间旅行式"的数据恢复。
2. 项目设计
小胖(看着监控大屏):大师,我们深圳机房今晚停电 4 小时,大屏上 Primary 在深圳机房。怎么把它"搬到"广州机房?
大师:这叫"计划内故障转移"(Planned Failover)。用一条命令就能搞定:
rs.stepDown(60)// Primary 主动退位,60 秒内不参与新一轮选举stepDown会让当前 Primary 主动降级为 Secondary,然后其他符合条件的节点立即发起选举,选出一个新 Primary。60 秒内原 Primary 不能再竞选,确保新 Primary 有足够时间稳定下来。
小胖:那这不就跟皇帝退位让贤一样——“我退下来,你们选一个新皇帝,我 60 秒内不来抢”。
大师:形象。但要注意,stepDown会先等待所有已写入 Oplog 的操作被至少一个 Secondary 赶上(catchup),确保退位时数据不丢。如果 catchup 时间太长,可以用stepDown(60, 30)——最多等 30 秒 catchup 期限。
技术映射:rs.stepDown(stepDownSecs, catchUpSecs)的参数分别控制"退位后多久不能再竞选"和"退位前最多等多久追上数据"。
小白:那节点扩容呢?我们磁盘快满了,怎么加一个新节点?加节点的时候会不会影响正在跑的查询?
大师:加节点分两步——先启动一个空的 mongod 实例(加入复制集),然后它会自动做 Initial Sync(初始同步)把数据从已有节点拷过来。Initial Sync 期间会有几个影响:
- 做 Initial Sync 的源节点(Sync Source)会增加读负载;
- 新节点的 Initial Sync 可能持续数小时(取决于数据量);
- 但是!对客户端的读写完全没有影响——因为 Initial Sync 不用锁库,读取 Secondary 的快照。
技术映射:Initial Sync 分两阶段——① 克隆所有数据库(复制每个集合的文档);② 在克隆期间产生的增量 Oplog 重放(追赶)。克隆阶段读取的是源节点的一致快照。
小胖:那 Oplog 窗口不够大的话,Initial Sync 是不是就失败了?
大师:对。Initial Sync 的第二步(Oplog 重放)需要源节点的 Oplog 中还保留着克隆开始时那个时间点的记录。如果克隆耗时 3 小时但 Oplog 窗口只有 2 小时——第二步就会因为找不到起始 Oplog 而失败,新节点必须从头再来。
技术映射:Initial Sync 对 Oplog 窗口有硬性要求——窗口必须大于克隆数据库的总时长。当数据库很大时(TB 级),建议用文件系统快照的方式完成 Initial Sync(把源节点的数据目录直接拷贝过去,比逻辑克隆快得多)。
小白:那延迟节点(Delayed Secondary)怎么用来救命?我同事说它就是 MongoDB 的"回收站"。
大师:精准!延迟节点故意慢 Primary 一段时间(如 1 小时),它上面的数据是 1 小时前的快照。如果有人误删了数据,你可以在延迟节点上查到被删之前的数据,然后在 Primary 上恢复。恢复步骤:
- 找到延迟节点:
rs.conf().members.find(m => m.secondaryDelaySecs > 0) - 关掉延迟节点:让它在误删操作执行前停下来。
- 从延迟节点的数据中导出被删的文档。
- 导回到 Primary。
大师(总结):复制集运维三件事——手动切换用stepDown;扩容加节点关注 Oplog 窗口够不够;误删恢复用延迟节点的"时间旅行"能力。
3. 项目实战
3.1 环境准备
沿用第 17 章的 3 节点复制集。
3.2 分步实现
步骤一:手动 Primary 切换——stepDown
目标:练习安全的 Primary 切换流程。
// 在 Primary 节点上操作// 1. 确认当前 PrimaryconstprimaryInfo=rs.isMaster()print("当前 Primary:",primaryInfo.primary)// 2. 查看所有节点的同步延迟rs.status().members.forEach(m=>{constlag=m.optimeDate?(Date.now()-m.optimeDate.getTime())/1000:"N/A"print(`${m.name}|${m.stateStr}| 延迟:${lag}s`)})// 3. 执行 stepDownrs.stepDown(60,30)// PRIMARY 降级为 SECONDARY,30 秒后触发选举# 观察选举过程# 在该节点降级后,连接另一个节点dockerexecmongo-rs2 mongosh--quiet--eval' const status = rs.status(); print("新 Primary:", status.members.find(m => m.stateStr === "PRIMARY")?.name || "选举中..."); '步骤二:新增节点——扩容复制集
目标:启动第 4 个 mongod 并加入复制集。
# 1. 在 docker-compose-rs.yml 中添加 rs4 服务# 2. 启动新节点dockercompose-fdocker-compose-rs.yml up-drs4# 3. 在 Primary 上将 rs4 加入复制集dockerexecmongo-rs1 mongosh--quiet--eval' rs.add({ host: "rs4:27017", priority: 1 }) '// 监控 Initial Sync 进度// 在 rs4 上查看db.currentOp({"desc":{$regex:/initial sync/i}})// 或查看日志// docker logs -f mongo-rs4// 查看 rs4 的同步状态rs.status().members.find(m=>m.name.includes("rs4"))// stateStr 会经历:// STARTUP → STARTUP2(正在 Initial Sync)→ RECOVERING → SECONDARY// 整个 Initial Sync 完成后 stateStr = "SECONDARY"步骤三:移除节点——缩容复制集
目标:从复制集中安全移除一个节点。
// 在 Primary 上执行// 移除节点前确保它不当前 Primary,且移除后仍有足够的投票节点// 1. 查看配置constconf=rs.conf()print("当前成员数:",conf.members.length)// 2. 移除 rs4(非 Primary 节点)rs.remove("rs4:27017")// 注意:host 字符串必须与 rs.conf() 中完全一致// 验证print("移除后成员数:",rs.conf().members.length)步骤四:修改节点优先级和角色
目标:在线修改节点属性而不重启。
// 获取当前配置constcfg=rs.conf()// 修改 rs2 的优先级(priority 越高当选 Primary 概率越大)cfg.members.find(m=>m._id===1).priority=3// 将 rs3 设为 Hidden 节点(不接受客户端读流量)constmember3=cfg.members.find(m=>m._id===2)member3.priority=0member3.hidden=true// 应用配置更新rs.reconfig(cfg,{force:false})// force: false = 需要多数节点在线才能重配(安全)// force: true = 允许在少数节点在线时强制作(危险,仅在恢复场景使用)步骤五:Oplog 管理与窗口监控
目标:调整 Oplog 大小并监控 Oplog 窗口。
// 查看当前 Oplog 信息constoplogStats=db.getSiblingDB("local").oplog.rs.stats()print("Oplog 大小:",(oplogStats.maxSize/1024/1024).toFixed(0),"MB")print("Oplog 使用:",(oplogStats.size/1024/1024).toFixed(0),"MB")// 查看 Oplog 窗口(最早和最晚记录的时间差)constoplog=db.getSiblingDB("local").oplog.rsconstlast=oplog.find().sort({$natural:-1}).limit(1).next()constfirst=oplog.find().sort({$natural:1}).limit(1).next()if(first&&last){constwindowHours=(last.ts.getHighBits()-first.ts.getHighBits())/3600print("Oplog 窗口:",windowHours.toFixed(1),"小时")}// 修改 Oplog 大小(所有节点上都要执行)// 方法:先删掉旧 oplog,MongoDB 重建时指定大小// 1. 通过 rs.stepDown() 确保当前节点不是 Primary// 2. 重启 mongod 时加入 --oplogSize 4096(单位 MB)// 或在 docker-compose 的 command 中: --oplogSize 4096步骤六:利用延迟节点恢复误删数据
目标:演示用 Delayed Secondary 做时间旅行恢复。
// 模拟:在 Primary 上插入数据use local_life db.delay_recovery.insertOne({docId:"IMPORTANT_001",data:"这条数据很重要",createdAt:newDate()})constinsertTime=newDate()print("插入时间:",insertTime)// 等待 5 秒后,模拟误删db.delay_recovery.deleteOne({docId:"IMPORTANT_001"})print("已删除(模拟误删)")# 数据恢复流程(在延迟节点上操作)# 1. 在 Primary 上停止延迟节点的同步# (延迟节点的数据停留在 insertTime 附近,还没有收到 delete 操作)# 方法:在延迟节点上执行dockerexecmongo-rs5 mongosh--quiet--eval' // 使延迟节点停止同步(临时变成 standalone 模式) db.adminCommand({ replSetFreeze: 9999 }) '# 2. 从延迟节点导出被删数据dockerexecmongo-rs5 mongodump\--db=local_life--collection=delay_recovery\--query='{"docId":"IMPORTANT_001"}'\--out=/tmp/recovery# 3. 将数据恢复到 Primarydockerexecmongo-rs1 mongorestore\--db=local_life--collection=delay_recovery\/tmp/recovery/local_life/delay_recovery.bson// 验证恢复use local_lifeconstrecovered=db.delay_recovery.findOne({docId:"IMPORTANT_001"})print("恢复成功:",recovered?"PASS":"FAIL")3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/replicaset/stepdown-test.js | stepDown 切换演练 |
mongodb-lab/replicaset/add-node-test.js | 新增/移除节点脚本 |
mongodb-lab/replicaset/oplog-monitor.js | Oplog 窗口监控 |
mongodb-lab/replicaset/delayed-restore.js | 延迟节点数据恢复流程 |
3.4 测试验证
// 在 Primary 上运行// 1. stepDown 验证constprePrimary=rs.isMaster().primary rs.stepDown(30,10)sleep(15000)constpostPrimary=rs.isMaster().primaryprint("Primary 切换:",prePrimary!==postPrimary?"PASS (已切换)":"FAIL")// 2. 节点加入验证constmemberCount=rs.status().members.lengthprint("成员数:",memberCount>=3?"PASS":"FAIL")// 3. Oplog 窗口验证constfirst=db.getSiblingDB("local").oplog.rs.find().sort({$natural:1}).limit(1).next()constlast=db.getSiblingDB("local").oplog.rs.find().sort({$natural:-1}).limit(1).next()constwindow=(last.ts.getHighBits()-first.ts.getHighBits())/3600print("Oplog 窗口:",window.toFixed(1),"小时",window>1?"PASS":"⚠ 建议增大Oplog")4. 项目总结
4.1 运维操作速查表
| 操作 | 命令 | 注意事项 |
|---|---|---|
| 计划内切换 | rs.stepDown(secs, catchupSecs) | 先确认 Secondary 同步状态 |
| 加数据节点 | rs.add("host:port") | 确保 Oplog 窗口 > Initial Sync 耗时 |
| 加 Arbiter | rs.addArb("host:port") | Arbiter 不存数据,省磁盘 |
| 移除节点 | rs.remove("host:port") | 确保移除后至少有 2 个数据节点 |
| 修改优先级 | cfg.members[n].priority = X; rs.reconfig(cfg) | 仅影响下一次选举 |
| 长 Oplog | --oplogSize 4096(重启时设置) | 需逐个节点滚动重启 |
| 紧急恢复 | rs.reconfig(cfg, {force: true}) | 仅当多数节点不可用时使用 |
| 冻结节点 | rs.freeze(seconds) | 冻结期间不参与选举 |
4.2 适用场景
本章操作适用:
- 机房停电/搬迁——通过 stepDown 做计划内 Primary 迁移。
- 节点磁盘告急——加入新节点扩容量后移除旧节点。
- 版本升级——逐个节点滚动升级 mongod 版本。
- 误删恢复——从延迟节点抢救数据。
- Oplog 优化——监控窗口大小并动态调整。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
rs.reconfig必先获取最新 config | 不要用旧的rs.conf()修改后再 reconfig——中间可能已被别人改过 |
| Initial Sync 不要从 Secondary 做 | 如果源 Secondary 的 Oplog 不如 Primary 新,可能导致 Initial Sync 后不一致 |
| 延迟节点不能无限延迟 | secondaryDelaySecs太大(> 24h)可能导致 Oplog 覆盖、从库永远追不上 |
| 移除节点≠回收磁盘 | rs.remove只是从复制集配置中踢出,该节点上的磁盘数据仍在 |
force: true是双刃剑 | 强制定会导致"[term: X, version: Y]"的配置冲突,事后需手动校验所有节点配置一致 |
4.4 常见踩坑经验
故障案例一:Initial Sync 无限循环
某节点因 Oplog 窗口不够导致 Initial Sync 失败后自动重试,每次重试都是从头克隆 + Oplog 追不上 → 失败 → 重试,陷入无限循环。根因:Oplog 窗口 < 克隆全量数据的时间。解决:临时增加到 4 倍正常大小的 Oplog,等新节点 sync 完成后再调回去。
故障案例二:rs.reconfig后节点被踢出
某运维修改了rs.conf()中一个节点的 host 名,从 IP 改为域名。结果该节点因"host 名不匹配"被复制集自动踢出。根因:MongoDB 的复制集成员身份由_id和host共同来确定,修改 host 等于"踢掉旧节点,加入新节点",而不是重命名。解决:如果必须改 host 名,先rs.remove再rs.add。
故障案例三:延迟节点时间设置错误导致数据丢失
某团队把secondaryDelaySecs设为86400(24 小时),以为可以用作"昨天的数据备份"。结果 Primary 写入速度峰值期间 Oplog 窗口缩小到 8 小时——延迟节点等待 24 小时后,发现 Oplog 里已经没有了 24 小时前的记录,同步永久中断。解决:secondaryDelaySecs必须小于 Oplog 窗口的最差情况,建议设为 Oplog 窗口的 30%-50%。
4.5 思考题
- 如果复制集的 3 个数据节点全部宕机,恢复时应该先启动哪个节点?顺序重要吗?
- Hidden 节点和 Delayed 节点的数据都不直接为客户端查询服务,但两者在 Oplog 拉取行为上有何本质区别?
(答案将在第 19 章末尾揭晓)
上一章思考题答案:
Primary 写入 Oplog 后立刻宕机,Secondary 还没拉到这条 Oplog——这些写入会丢失。但从客户端视角,如果
writeConcern: w:1(只等 Primary 确认),客户端收到成功响应后数据丢失了——这叫"已被确认但未传至副本的写入丢失"。如果writeConcern: majority,Primary 在至少一个 Secondary 也确认之前不会返回成功——不会丢失。所以高价值数据务必使用writeConcern: majority。Arbiter 能参与选举是因为 Raft 协议不要求"存数据"才能投票——Arbiter 被赋予投票权,只需检查候选者的 term 和 log index 是否足够新。如果 Arbiter 被网络隔离到少数派一侧,它会和少数派一起无法联系多数派——少数派无法获得过半票数,无法选举出新 Primary;多数派不受影响。但极端情况下:如果 Arbiter 和 Primary 在一起被隔离,而 2 个 Secondary 在另一侧——只有 Primary+Arbiter 一侧有 2 票,Secondary 一侧也有 2 票,谁也选不出来(3 号票没到位)。
延伸阅读与资源
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析