月初帮业务团队扩容一套 MongoDB 复制集,需求描述只有一句话:“加一台新机器进复制集,扛一下读流量。”我反问了一句:“你打算怎么加?”对方很自信:“rs.add() 啊,一行命令的事。”我当场就把计划文档里的坑位清单摆到了他面前。
MongoDB 复制集的扩容与缩容,命令确实只有一两行,但真正的困难从来不在命令本身,而在节点加入前后的投票权、优先级、oplog 追平、连接串更新这一整套连锁反应。节点加进去不等于任务结束,节点删掉也不等于机器可以关机走人。这篇文章我想把动态调整复制集规模这件事,从原理到实操再到一次真实的选主事故,完整讲清楚。适合正在维护 3 节点以上复制集、准备通过扩容分摊读压力,或者已经被 rs.remove() 坑过的同学。
1. 复制集扩缩容到底解决的是什么:先分清需求和手段
1.1 加节点不等于加容量
很多人一脑热就扩容,理由是“集群容量不够了”。这里有个绕不开的事实:复制集每个节点持有全量数据,加一台 secondary 只是多了一份完整的副本,并不会让每台机器上的数据变少。
打个比方,复制集像是每个仓库都存着一整套货,加仓库不能改变单个仓库的存量上限。如果你的瓶颈是磁盘不足,单纯扩容只会让预算增加,问题原封不动。加节点真正解决的是三类问题:读压力分摊、高可用冗余、容灾能力扩展。
所以扩容前先问自己一个问题:我是缺读吞吐,还是缺高可用,还是缺容量?只有前两者适合靠复制集加节点解决。如果是容量问题,要么换更大磁盘,要么考虑分片集群,那是另一个维度的架构变更。
1.2 读分流要付出的代价:一致性权衡
把读流量切到 secondary 之前,必须想清楚业务对数据一致性的容忍度。主节点写入后,日志复制到 secondary 需要时间,哪怕延迟只有几十毫秒,客户端读到的也可能是旧数据。
我在线上环境见过真实案例。某团队给报表接口配置了 secondaryPreferred 读偏好,结果每天凌晨大促结束后,报表数据总是比主库少几条。排查到最后才发现,报表节点同步延迟最高到过 30 秒,而报表刚好在这 30 秒内查了一次数据。
如果业务真的对一致性敏感,读偏好就用 primary 或者 primaryPreferred。如果业务能容忍秒级延迟,比如榜单、日志、非核心查询,那才适合把读流量分给 secondary。复制集扩读能力的方式很多,不是加一台节点就能无脑均摊。
1.3 什么时候应该缩容
缩容的场景没有扩容那么“喜庆”,常见原因包括:机器下架和成本压缩、节点数量过多导致网络与心跳开销上升、以及某个机房整体退服。
有一种缩容容易被忽略:复制集从 3 节点扩成 5 节点之后,发现半数节点在同一机房,跨机房容灾反而被削弱。这种情况我就干过——加节点时没考虑故障域,三个节点挤在一个可用区,结果机房一抖动,整个复制集差点选不出主。后来果断缩掉一台,把复制集重新分布到两个机房。
缩容不是简单的 reduce 操作,本质上是一次容量规划和故障域调整。动手之前,先画一遍拓扑图:谁在哪个机房、谁是 primary、谁在同步数据、谁只是凑票的。画不清楚就别动。
2. 动手之前:把投票权、优先级和 oplog 窗口算明白
2.1 votes、priority、slaveDelay 三个字段决定节点命运
复制集成员配置里,有三个字段在扩缩容时最容易埋雷。
| 字段 | 默认值 | 作用 | 常见坑 |
|---|---|---|---|
| votes | 1 | 节点持有的选票数,决定能否参与选主 | 新节点一上线就带选票,数据没追平也能影响选举结果 |
| priority | 1 | 节点竞选 primary 的优先级,数值越高越优先 | 全部节点优先级相同,网络抖动时谁都能被选主 |
| slaveDelay | 0 | 数据延迟同步秒数,常用于误删恢复 | 延迟节点不能参与常规读写路由,优先级必须设为 0 |
这三个字段组合起来,基本决定了一个节点加入复制集后的“政治地位”。我见过太多人 rs.add() 之后什么都不管,结果新节点第二天就当选了 primary,而它还落后主节点十几分钟的数据。
2.2 新节点追数据的过程:initial sync 的两阶段原理
MongoDB 新节点加入复制集,会先做一次 initial sync。这个过程不是简单地复制一份数据文件,而是分两个阶段:
第一阶段,新节点会向集群发起 listDatabases、listCollections,然后逐个集合拷贝数据。这个阶段会占用大量网络带宽,尤其数据量大时,可能对主节点产生明显压力。
第二阶段,新节点在拷贝数据的同时,会持续收集复制集 oplog 里的新写入操作,拷贝完成后重放这些操作,直到自己的 oplog 追平当前主节点的时间点。
这里的关键在于:initial sync 的耗时不能超过 oplog 的保留窗口。如果数据量太大,同步时间超过了 oplog 能覆盖的时间范围,新节点就会陷入“永远追不上”的死循环,不断重新同步。
2.3 动手前先算一笔账:同步时间与 oplog 窗口
在扩容前,我强烈建议先跑一次这个命令,看看 oplog 到底能覆盖多久:
db.getReplicationInfo()输出里的 time 字段就是 oplog 窗口时长。再估算一下你的全量数据同步时间:数据总量除以实际带宽,再加上 oplog 重放的时间。如果估算出来的同步时间接近甚至超过 oplog 窗口,就绝不能直接 rs.add()。
这种情况下有两种解法。第一种是提前把某台机器的数据文件通过快照或物理拷贝的方式复制到新节点所在机器上,再把它加入复制集,这样 initial sync 只需要追一小段 oplog,时间会大幅缩短。第二种是临时调大 oplog 容量,等新节点追平之后再调回去。
3. 扩容实操:从 rs.add() 到把新节点“养熟”
3.1 安全添加:先让新节点闭嘴,再让它干活
直接 rs.add({ _id: 4, host: "172.16.3.14:27017" }) 对不对?对,但不推荐。
正确的姿势是先把新节点的 votes 和 priority 都设为 0,让它以一个“不参与选举的透明人”身份加入集群:
rs.add({ _id: 4, host: "172.16.3.14:27017", votes: 0, priority: 0 })这样做的好处是,新节点在数据同步期间没有资格当选 primary,不会因为一次意外选举就把整个集群带进坑里。等它数据追上之后,再把它“转正”。
3.2 等待新节点追上数据:状态确认是扩容的必修课
添加节点之后的等待过程,很多人会焦虑地盯着终端发呆。其实只需要轮询几个状态字段,就能判断它是否已经具备转正条件。
rs.status().members.filter(m => m.name.includes("172.16.3.14")).forEach(m => { print(`state=${m.stateStr}, health=${m.health}, optime=${m.optimeDate}`); });重点关注三件事:stateStr 是否变成了 SECONDARY、health 是否为 1、optimeDate 与主节点的时间差是否已经缩小到几秒以内。
还有一个更直接的命令可以看同步延迟:
db.printSlaveReplicationInfo()它会列出每个 secondary 距离主节点的同步延迟。我习惯等延迟稳定在 0 到 2 秒之间,并且持续几分钟不变,才进行下一步转正操作。
3.3 转正操作:把新节点正式变成投票成员
数据追平之后,再执行 reconfig 把 votes 和 priority 恢复成正常值:
cfg = rs.conf(); cfg.members[3].votes = 1; cfg.members[3].priority = 1; rs.reconfig(cfg);这里有个细节:执行 rs.reconfig() 之前,务必确认复制集内大多数节点在线。我遇到过有人在一个 secondary 离线状态下强行 reconfig,结果剩余的多数派配置被改写,离线节点重新上线后发现自己和集群的配置对不上,直接进入 RECOVERING 状态。
4. 扩容之后最容易忽略的一步:重新审视优先级策略
4.1 默认 priority=1 会在特定时刻引爆问题
所有节点的 priority 都保持默认 1,看起来“众生平等”,实际上是隐患。MongoDB 选主时,如果两个节点优先级相同,会通过对比 oplog 位置和心跳来竞争。一旦网络分区发生,位于不同机房的节点各自认为自己有机会当选,就可能把数据落后的节点选成主。
我在生产环境里吃过这个亏,后面会详细讲。这里先给出结论:扩容之后,必须重新审视整个复制集的优先级策略,而不是让所有节点维持默认值。
4.2 主动设计优先级:让 primary 尽量留在该留的地方
合理的做法是,让主机房的关键节点 priority 设置得高一点,其他节点设置成 1 甚至 0。
cfg = rs.conf(); cfg.members[0].priority = 2; // 主可用区节点,优先当选 cfg.members[1].priority = 1; // 同机房或同可用区节点 cfg.members[2].priority = 0; // 异地容灾节点,平时不参与选主 cfg.members[3].priority = 1; // 新扩容节点 rs.reconfig(cfg);通过差异化优先级,可以避免“主节点漂移”带来的网络延迟和跨机房流量成本。我现在的习惯是:每次扩容结束后,趁维护窗口顺手把整份配置 review 一遍,而不是只盯着新节点。
4.3 rs.reconfig 的 force 参数:能用但最好永远别用
rs.reconfig(cfg, { force: true }) 这个参数能在多数派不可用的情况下强制下发配置。听起来很强大,但副作用也很明显:它可能绕过心跳检查,在集群状态异常时强行推进配置版本,导致部分节点出现配置分叉。
正常扩容缩容根本不需要 force。如果你发现自己想用 force,先停下来问一句:集群是不是已经不正常了?如果回答是,那你应该先解决集群健康问题,而不是急着改配置。
5. 缩容的正确姿势:从 secondary 到 primary,顺序决定生死
5.1 缩容 secondary:标准流程与注意事项
如果目标节点不是 primary,缩容就简单多了:
rs.remove("172.16.3.14:27017")命令执行之后,节点会脱离复制集,心跳停止,但本地数据文件不会被自动删除。机器上遗留的数据仍占着磁盘,如果这张机器要回收给其他用途,记得人工清理 MongoDB 数据目录。
这里有个容易忽略的操作:确认要移除的节点真的是 secondary。明明想删的是 secondary,结果手里的 IP 写成了 primary 的,删除命令会直接触发强制选举,后果不堪设想。我的习惯是执行前先 rs.status() 看清楚成员列表,把 host 和角色对应上。
5.2 缩容 primary:必须先 stepDown,再 remove
移除 primary 是高危操作,绝不能直接 rs.remove()。正确流程是先在 primary 节点上执行降级:
rs.stepDown(300)300 表示当前节点在 300 秒内不参与 primary 竞选。执行后要立刻观察集群状态,确认新 primary 已经产生,旧 primary 已经变成 SECONDARY 角色,然后才能执行 rs.remove()。
我见过有人 stepDown 之后没等新主选出来就直接 remove,结果集群短暂进入了“无主”状态,所有写入全部报错。虽然几十秒后新主会自动选举出来,但线上业务对这几秒的不可用完全不能接受。
5.3 缩容后的遗留事项:连接串、监控、备份
节点移除干净了,别急着关机。以下几件事必须一并处理:
- 应用连接串:如果客户端用的是 mongodb://host1:27017,host2:27017... 这种写死列表,移除节点后的连接串更新要立即跟上,否则应用会一直尝试连接已失效的节点,产生无谓超时和重试。用 mongodb+srv 协议可以从根上避免这个问题。
- 监控与告警:移除节点后,监控平台里往往还残留着这台机器的指标采集任务,会一直报“连接失败”的告警。记得同步清理监控项。
- 备份任务:如果备份任务是挂在某个 secondary 节点上执行的,移除前必须把备份任务迁移到其他节点,否则备份会静默中断。
- 防火墙白名单:节点下线后,安全组里对应的规则最好一并移除,保持网络安全策略干净。
6. 特殊角色节点的增删:仲裁、隐藏、延迟的边界
6.1 仲裁节点:加它容易,删它要格外小心
仲裁节点是复制集里最“轻量”的角色,不存业务数据,只参与投票。添加命令很简单:
rs.addArb("172.16.3.15:27017")或者:
rs.add({ host: "172.16.3.15:27017", arbiterOnly: true })但删除的时候,有一个很坑的场景:如果当前复制集只有两个数据节点加一个仲裁节点,你一心想把仲裁节点删掉,删完之后投票成员就变成了偶数。此时任意一个数据节点宕机,剩余节点都无法形成多数派,写入直接停摆。
正确的缩容思路是先规划好最终投票成员数量。想让复制集从“2数据+1仲裁”变成“2数据”,更稳妥的方式是先加一个数据节点进去,再移除仲裁节点,最后再评估是否需要缩掉多余的数据节点。顺序错了,一次简单的缩容就会搞出一次事故。
6.2 隐藏节点与延迟节点:扩缩容中的实用场景
隐藏节点 priority 为 0,对客户端不可见,适合做备份、报表分析等非实时业务。延迟节点通过 slaveDelay 字段控制数据同步延迟,专门用于误删数据恢复。
这两种节点加入复制集的流程和普通 secondary 一样,但要注意:隐藏节点的 votes 可以保留为 1,也可以设为 0,取决于你是否需要它参与投票。而延迟节点的 priority 必须保持为 0,否则它永远处于延迟状态,根本不可能竞选 primary,一旦参与选举反而会造成主节点位置混乱。
延迟节点在缩容时是最没有存在感的角色,直接 rs.remove() 就行,因为它不参与心跳选主,移除风险几乎为零。不过要提醒一句:延迟节点缩容后会带走它本地保存的历史数据,如果你依赖它做误删恢复,移除之前最好确认有没有其他恢复手段。
7. 一次真实选主风暴的完整排查链路
7.1 事故现象:扩容两天后,业务开始大面积报错
那次事故发生在一次“成功”扩容后的第三天。上午还一切正常,中午业务方突然报了一片告警:复制集没有 primary、写入失败、not primary 错误刷屏。
当时第一反应是主节点宕机了。但登录到原主节点上一看,进程明明活着,只是角色已经由 PRIMARY 变成了 SECONDARY。再看新 primary 是谁,竟然是我们前两天刚扩容进去的那台新节点。
7.2 排查过程:从 rs.status() 到日志取证
第一步,执行 rs.status(),逐个成员看 stateStr、health、lastHeartbeat。发现新节点当选了 primary,而它的 optimeDate 落后原主节点大约 2 分钟。
第二步,去原主节点日志里搜关键词,发现它经历了网络抖动,触发了新一轮选举。按默认优先级,每个节点都有平等的竞选资格,新节点很快就赢了选举成为主。
第三步,对比新旧主节点的 oplog 位置,确认新主在当选前的数据并不是最新的。这直接导致了部分写入被回滚,业务看到的“数据丢失”实际上是选主阶段的正常回滚机制在起作用。
7.3 根因复盘与修复动作
查下来根因很明确:扩容时直接用了默认的 votes=1 和 priority=1,新节点同步完成不到两天,它的 oplog 一直没有完全追平主节点,却因为网络抖动被动参与了选举。
修复分三步执行:
- 用 rs.reconfig() 将新节点 priority 临时调整为 0,确保它短期内不会再被选为主;
- 等它完全追平所有延迟后,再把 priority 恢复为 1;
- 调整复制集 settings 里的 heartbeatTimeoutSecs 参数,适度降低网络抖动带来的误判概率。这个参数默认是 10 秒,可以适当调大一些,但不要调得过大,否则故障感知时间会变长,业务要承受更久的不可用。
复盘之后,我把团队扩缩容流程里明文加了一条铁律:所有新节点必须以 votes=0 和 priority=0 的身份接入集群,数据确认追平后再转正。这个习惯一直保留到现在。
说实话,这套教训是用一下午的告警和一部分回滚数据换来的。我现在给自己定了两条规矩:任何扩缩容操作前,先把 rs.conf() 导出一份存档;操作过程中把每一步都记录下来,完成后对比配置版本号和成员状态。扩缩容的大忌就是想当然地以为 MongoDB 默认配置就够用,至少在复制集动态调整这个场景里,默认值通常意味着把命运交给了网络抖动。