1. 复制集扩容前的思路梳理
我不是第一次处理 MongoDB 复制集的扩容和缩容,但每次接到这类需求,我都会先按捺住直接敲rs.add的手。因为复制集扩缩容最大的风险从来不是命令本身,而是对现有集群状态、业务流量和数据分布缺乏清晰判断,导致操作完才发现主节点切换引发抖动、同步追不上、甚至脑裂。动集群这种活,先把思路理清楚,往往比执行命令更重要。
1.1 先想清楚:扩容到底是为了什么
很多人一听说“集群性能不够了”就急着加节点,但复制集扩容并不能像分片那样线性提升写性能。MongoDB 复制集的核心职责是数据冗余和高可用,所有写操作默认都集中在 primary 节点,读操作虽然可以分发到 secondary,但默认情况下客户端仍从 primary 读取。所以加一个 secondary 节点,能带来的是数据冗余能力增强、读能力扩展(开启 secondary 读后)、以及故障切换时可用的投票成员增加,但写吞吐不会因为节点变多而自动变高。
在动手之前,我会先确认扩容的真实目的:是为了扛更高的读 QPS?是为了满足数据安全要求,增加一份完整副本?还是为了在物理机房迁移时临时增加一个观察节点?目的不同,新节点的角色配置、优先级、甚至所在机房网络规划都会完全不同。比如只为了备份,可以加入一个priority:0且hidden:true的节点;为了读写分离,则要配置合理的readPreference,并评估 secondary 的数据延迟是否能满足业务需求;如果只是为了让选举更稳,那么增加的是 arbiter 而不是数据节点。这些区分,决定了后续每一步操作的价值。
1.2 成员角色与节点规划
复制集的每个成员在选举和数据复制上有不同权重,扩容前需要先规划好新成员的角色。最常见的角色是普通的secondary,参与投票、可以被选举为 primary、也承担数据同步。还有一种priority:0的成员,它拥有投票权但永远不会成为 primary,主要用于承载只读流量或作为备份节点。hidden节点本质上也是 priority 0,但对客户端不可见,适合跑报表或备份,不会接到应用直连的读请求。arbiter不存数据,只参与投票,常用于奇数节点个数不足时打破选举平局。
规划节点时有个简单原则:生产环境复制集成员数尽量保持奇数,比如 3、5、7。原因是 MongoDB 选举需要大多数成员在线,如果成员总数是偶数,网络分区时可能出现两边票数相等,导致无法选出 primary。比如 4 节点复制集,某个分区内有 2 个节点,另一个分区也有 2 个,两边都无法获得超过半数的投票,整个集群就不可写了。这是很多新手容易忽略的地方。所以扩容时,如果从 2 节点加到 3 节点,是合理的;从 4 节点加到 5 节点,也合理。但如果从 3 节点直接加到 4 节点,我一般会建议改成加一个 arbiter,或者干脆加到 5 个数据节点,避免偶数问题。
1.3 从网络、硬件到版本的前置检查
新成员加进来后,要和其他节点建立心跳检测,并同步全量数据,因此网络质量直接决定了扩容能否顺利完成。我曾经遇到过跨机房加节点,结果由于两机房之间延迟超过 200ms,新节点同步 oplog 时一直追不上主节点,反复处于 STARTUP2 状态。所以扩容前,至少要在新节点上执行mongosh --host <已有节点IP> --eval "db.hello()"测一下连通性,再用ping和iperf看一下延迟和带宽。复制集内部的心跳默认每 2 秒一次,如果心跳丢失超过 10 秒,会被其他节点标记为不可达。延迟过高会导致心跳不稳定,严重的会引发误判切换。
硬件方面,新节点磁盘容量不能低于主节点当前数据量的一定余量。简单估算方法是:db.stats().dataSize加上索引大小db.stats().indexSize,再乘以 1.2 到 1.5 的安全系数,同时留出 oplog 的空间。很多人忽略 oplog,复制集的 oplog 默认是磁盘空间的 5%(对于 WiredTiger),如果新节点同步慢,oplog 又不够大,可能永远追不上。版本方面,务必保证新节点的 MongoDB 版本与主节点一致或高于主节点,对复制集而言低版本成员加入高版本集群可能直接导致同步异常。我在 4.4 集群中试着加入一个 4.2 节点,结果新节点反复报replication错误,后来升级到同版本才解决。所以安装 MongoDB 之前,先看官网版本对应关系,别拍脑袋装。
2. 扩容实操:从单节点到多节点,一步步加入新成员
理清了规划和检查项,下面进入实际操作。这里我以最常见的场景为例:现有复制集有 3 个数据节点,需要扩展到 5 个节点,新节点分别部署在两台新服务器上。整个过程分为环境准备、添加成员、观察同步三个阶段。
2.1 新节点环境准备与安装
新节点的 MongoDB 安装,建议使用官方源或者下载官方 tar 包,不要用系统自带的旧版本。安装完成后,先不要急着启动服务,把配置文件写好。配置里需要包含replication.replSetName,值必须和现有复制集名称完全一致,否则节点无法加入。另外建议在配置中固定net.port、net.bindIp,并设置完整的systemLog.path和storage.dbPath。很多安装失败案例都是因为 dbPath 目录没有写权限、日志路径不存在,或者 selinux 拦截了端口导致的。
启动新节点后,它暂时是独立的实例,无法直接与复制集通信。此时我建议先用mongosh --port <新节点端口>连上去,执行rs.status()确认它的角色和状态。正常情况下应该显示为STARTUP,并带有空的members数组。如果这里就报错,说明复制集名称或配置有问题,先排查修复,不要急着加入集群。
2.2 用 rs.add 添加成员
在 primary 节点上执行rs.add()是最常用的方式。假设新节点 IP 是10.0.1.10,端口 27017,那么执行:
rs.add({ host: "10.0.1.10:27017", priority: 1, votes: 1 })如果不指定配置对象,可以直接rs.add("10.0.1.10:27017"),这样新成员会使用默认配置:priority 1,votes 1,对客户端可见。如果只想让它作为只读备份节点,可以设置priority: 0, hidden: true。添加后,执行rs.status()能看到新成员的状态,一般会经历STARTUP2(全量同步阶段)到RECOVERING(追 oplog 阶段),最后变成SECONDARY。这个过程中,新节点会从 primary 拉取全量数据,数据量大的场景耗时较长。
这里我强调一个细节:rs.add和rs.addArb是复制集扩容最常用的两个命令,但执行时尽量在业务低峰期。因为全量同步会占用主节点的磁盘读 IO 和网络带宽,如果主节点 IO 能力不强,可能导致现有业务请求延迟上升。我曾经在白天高峰期加节点,结果同步期间 primary 的 iowait 飙升到 80%,用户侧出现大量慢查询。后来我养成了习惯,扩容前看一眼mongostat,如果 IO 已经很高,就推迟操作。
2.3 优先级、投票权与隐藏节点的配置建议
添加成员时,很多人习惯不配置 priority 和 votes,但这在复杂场景下会埋坑。默认 priority 都是 1,意味着每个 secondary 都有资格成为 primary。如果你有 5 个节点,业务上希望固定某个节点成为主,那么需要把其他节点的 priority 调低。比如希望10.0.1.10成为主,可以把它的 priority 设为 2,其他节点设为 1。但要注意,priority 越高不代表一定变成主,选举时会综合比较优先级、oplog 进度、网络分区等因素。如果你只打算让新节点承担读流量或备份,直接给它priority:0即可,避免它在任何情况下被选为主,影响主节点的稳定性。
votes 参数控制成员参与选举的票数,默认为 1,可设为 0 到 1 之间的整数。注意:只有投票成员才有资格参与 primary 选举。通常我们不需要改动 votes,除非你遇到偶数节点问题,想通过把某个节点的 votes 设为 0 来减少投票成员数量。但我不建议这么做,因为 votes=0 的节点没有投票权,却仍然算作复制集成员,会影响大多数计算,反而可能降低可用性。设 votes=0 仅适用于某些特殊场景,比如跨机房容灾时不想让该节点影响多数派。
hidden 节点是一个很实用的角色。如果你需要一份全量数据跑分析任务或定期备份,同时又不想让它接收应用端的读请求,可以把它设为 hidden。hidden 节点必须设置priority:0,且它在rs.status()中可以看到,但通过db.hello()对客户端隐藏。这样应用端的readPreference=secondary不会路由到它。如果你将来想让它转正,只需rs.reconfig()改掉优先级和 hidden 即可,不用重新加节点。
2.4 数据同步的观察与验证
新成员加入后,最忌讳的是加完就跑,第二天才发现节点根本没同步上。我一般会持续观察一段时间,具体做法是:
- 执行
rs.status()查看新成员stateStr,持续跟踪变化。 - 进入新节点实例,执行
db.printReplicationInfo()查看它的 oplog 最早时间和当前时间,确认 oplog 在持续推进。 - 比较新节点与 primary 的
optimeDate,差值应逐渐缩小并稳定在一个很小的范围。
如果新节点长时间停留在STARTUP2,说明全量同步还没完成,可能是数据量大或网络带宽不足。如果长时间停留在RECOVERING,大概率是复制过程中出现了错误,需要查看日志。常见错误比如集合数据损坏、唯一索引冲突、文档过大等。WiredTiger 引擎对于同步失败的容忍度低,如果 oplog 被覆盖导致无法追同步,只能删掉新节点 dbPath 目录,重新全量同步。所以为确保一次成功,我会提前调大 oplog 大小。修改 oplog 大小的方法是:在 primary 上执行rs.reconfig修改配置项members[].secondaryDelaySecs不是唯一办法,更直接的可以在副本集本地后台执行db.adminCommand({replSetResizeOplog: 1, size: 16384}),单位是 MB。这个命令不需要停机,在线调整。数值建议至少能覆盖 2 小时以上的写入量。保守估算方式:查看db.getReplicationInfo()显示的时间范围,如果初始只有 1 小时,就扩容到 6 小时以上。
当新成员状态变为SECONDARY,并且optimeDate与 primary 相差几百毫秒以内,扩容的数据层面就成功了。接下来可以临时把应用连接串改成包含新节点地址,测试读请求是否正常分发,确认无误后再更新正式连接配置。
3. 缩容实操:安全移除节点的完整路径
有扩容自然也有缩容。缩容的典型场景包括:业务量下降、机房下线、节点硬件频繁故障、或者当初为了应急临时加的节点不再需要。缩容比扩容更需要谨慎,因为移除数据节点意味着减少数据副本数量,如果同时移除多个节点,可能导致复制集无法构成大多数,从而失去写入能力。
3.1 什么时候应该缩容
我遇到的缩容需求大致分三类:一是业务确确实实减少了,比如只保留了核心交易,日志、报表类数据迁移到了其他存储,原来 7 个节点的复制集现在只需要 3 个节点就能支撑;二是节点所在的主机硬件频繁报警,比如磁盘坏道、温度过高,运维想先撤下那台机器;三是架构调整,准备把某些节点挪到分片集群里,或者迁移到新机房,需要从旧集群中摘除。
无论哪种原因,我建议遵循“先降级、后移除”的原则。所谓降级,就是先把目标节点的 priority 改成 0,让它不再可能成为 primary,同时观察一段时间。如果这个节点本身是 primary,强行 remove 会导致触发选举,切换过程中可能有一小段时间不可写,业务侧需要有重试机制。把目标节点 priority 降为 0 并 rs.stepDown() 主动让位,再执行移除,能最大程度降低对业务的影响。
3.2 rs.remove 和强制移除的姿势
常规移除操作很简单,在 primary 上执行:
rs.remove("10.0.1.11:27017")执行后,复制集的配置会更新,目标节点会从 members 列表中被移除。目标节点自身会感知到配置变化,但它的进程不会自动停止,仍然是一个独立的 MongoDB 实例,只是不再属于复制集。此时如果它想连接原来的复制集,会发现无法同步,状态可能变为REMOVED或ROLLBACK。正确做法是移除后手动停掉目标节点的服务,并在目标节点上执行一次彻底的数据清理,或者保留数据以便将来重新加入时快速同步。
有一种情况不能直接rs.remove:当复制集只剩 2 个节点时,移除其中一个会导致只剩下 1 个数据节点,无法形成多数派,复制集只能以只读模式运行。此时如果确需缩到单节点,那么建议先停掉全部业务写入,在 primary 上执行:
cfg = rs.conf() cfg.members = [cfg.members[0]] // 保留单节点 rs.reconfig(cfg, { force: true })force 参数可以在只剩单个节点时强制应用配置。但要明确,这不再是标准的复制集高可用模式,后续如果需要重新加入节点,必须重新初始化或从备份恢复。所以缩容前,一定要想清楚是暂时缩容还是永久缩容。
还有一种“强制移除”的场景:旧节点已经彻底挂掉,连不上、也无法执行 rs.remove。这时可以在 primary 上通过rs.reconfig手动构造新的 members 列表,排除不可达节点。注意force: true会跳过多数派检查,但也会打断其他节点的正常配置同步。操作前务必备份rs.conf()的完整输出,以免误操作。
3.3 调整优先级与投票配置防止脑裂
缩容后,节点数变化可能打破奇数原则,因此需要重新评估投票配置。比如从 5 节点缩到 3 节点,并没有问题;但如果从 3 节点缩到 2 节点,就会面临偶数节点带来的选举僵局风险。此时建议添加一个 arbiter,或者把其中一个节点的 votes 调为 0,但调 votes 为 0 只能缓解,不能完全解决多数派问题,因为 votes=0 仍然计入节点数吗?实际上 MongoDB 计算“大多数”是基于参与投票的成员数,即 votes 的总和。如果 A、B 两个节点 votes 都是 1,总和是 2,大多数需要 2。其中一个挂掉,剩下 1 个无法构成大多数,复制集仍然只读。所以真正解决办法是加 arbiter,让总和变成 3,大多数是 2,某个数据节点挂掉后,另一个数据节点与 arbiter 一起可以构成大多数。
缩容后我还习惯检查每个节点的 priority 分布。比如原来有 5 个节点,缩容移除的恰好是 priority 最高的节点,剩下的节点 priority 都相同,那么下次选举时大家机会均等,可能导致主节点在多个节点间飘忽不定。如果业务上希望稳定在某个节点,就主动调整 priority 让某几个节点优先级更高。
3.4 数据迁移与确认清理
移除节点后,目标节点可能存有大量数据。如果这台机器还要继续使用,比如重新部署其他服务,需要清空 MongoDB 数据目录,避免残留数据占用磁盘,也防止将来误启动导致两个节点使用相同 replSetName 引发冲突。清理前最好先停服务,再删除 dbPath 下的所有文件。需要注意,如果目标节点曾经是从节点,它本地可能还有一些历史 oplog 文件,保留这些文件没有意义,因为重新加入时会被当作“新节点”重新全量同步。
数据副本减少后,还要确认剩余节点的数据一致性。可以定期执行db.collection.validate()抽查关键集合,同时检查备份策略是否仍然满足 RPO。比如原本 3 个数据节点,缩到 2 个数据节点加 1 个 arbiter,这时候如果唯一的数据节点磁盘故障,在没有备份的情况下可能丢数据。所以在缩容前,我会和业务方确认备份和容灾要求,必要的时候把单点数据节点替换为多副本,而不是盲目缩容。
4. 常见问题与排查技巧实录
操作例子和经验说完了,这部分我整理了扩容和缩容中经常踩到的问题,给出具体的定位方法和解决思路,希望能帮大家少走弯路。
4.1 新节点一直处于 RECOVERING/STARTUP2 怎么办
先说 STARTUP2。这个状态意味着正在做 initial sync,也就是全量拷贝数据。它慢的常见原因是全量数据总量太大、磁盘 IO 能力不够、网络带宽受限。定位方法:在新节点上执行db.serverStatus().metrics.repl.apply.batches.totalMillis,观察增量变化,同时用mongostat看 qps 和磁盘 util。如果发现 util 一直 100%,可能需要临时限制复制带宽,比如调整replication参数,或者干脆换性能更好的机器。如果网络是瓶颈,可以用sar -n DEV 1查看网卡流量,如果持续跑满,需要优化网络拓扑或使用专用同步网络。
RECOVERING 通常出现在 initial sync 完成或者收到 shutdown 命令后,节点尝试追 oplog 时发生。常见原因:
- oplog 太小,新节点还没追上主节点,主节点的 oplog 已经覆盖了它所需的起点。
- 存在重复键错误或文档校验失败,导致复制线程停止。
- 新节点与主节点版本不一致,出现协议不兼容。
对于 oplog 太小的问题,最好的办法是避免让它一直处于追日志状态,直接把新节点数据目录删除,重新做一次全量同步,同时调大 oplog 大小。如果有重复键错误,去日志里找具体命名空间和 _id 值,排查数据中是否有超过索引限制的历史数据。如果版本不一致,升级新节点到相同版本即可。
4.2 扩容后写入延迟升高,如何定位
扩容后如果发现 primary 的写入延迟升高,很可能是全量同步对 IO 和网络造成了压力。虽然是后台同步,但 MongoDB 的 initial sync 是通过拷贝数据文件的方式做的,对源节点的读 IO 和网络有一定消耗。此时可以先查看 primary 的db.currentOp(),过滤op: "query"和ns: "local.oplog.rs"相关的操作,确认是否还有同步任务占用资源。也可以看新节点是否仍处于 STARTUP2,如果是,可以临时降低新节点的同步优先级,比如限制初始同步的网络带宽。实际操作中,如果业务真的很敏感,我建议把新节点在不同机房或者不同存储池,错峰扩容。
还有一种情况:扩容时添加了 hidden 节点并设置priority:0,它仍然会从 primary 同步数据,所以读压力可能没减少,但写压力仍在 primary。如果应用配置了readPreference=secondary,当新节点同步未完成时,它不会处理读请求,流量集中在旧节点上,可能导致旧节点负载升高。所以扩容后要注意观察各节点流量分布,必要时调整连接配置。
4.3 缩容后出现主节点选举抖动
缩容后,往往因为 members 数组顺序变化或 priority 配置问题,导致触发重新选举。比如之前 5 个节点,主节点是某个 secondary 上的临时由 arbiter 决定,移除节点后,由于 priority 排序变化,复制集可能会在下一次心跳检查时重新选举一个 priority 更高的主。如果业务没有处理主从切换的逻辑,这类抖动可能导致连接中断。避免方法:在移除节点前,把目标节点 priority 改为 0 并观察稳定后,再移除。同时检查剩余节点的 priority 设置,仅保留一个节点 priority 最高,其他保持相同或更低,降低选举导致的主节点漂移概率。
如果缩容后确实出现了短时间内多次选举,可以立即查看rs.status()中各节点的lastHeartbeatMessage和electionCandidateMetrics,判断触发选举的原因。通常都是由于大多数节点短暂不可达,导致有心跳超时触发。要恢复稳定,可以重启那些状态异常的 mongod,或者rs.stepDown()强制主节点让位一次,让整个集群重新收敛。
4.4 磁盘容量与复制集扩缩容的关系
一个常被忽略的坑:复制集扩容后,磁盘容量需求并不是简单加一份数据,oplog 也会随时间增长,而且如果开启了 profiling 或日志,也会占用空间。在缩容时,节点上的旧数据可能残留大量日志和临时文件,需要清理。磁盘扩容一般涉及纵向扩容,即把单块磁盘换成更大的,或者加数据盘。这部分属于系统层面,与 MongoDB 本身关系不大,但操作前建议先停止复制集节点服务,或者对节点进行优雅下线,避免在磁盘操作过程中发生复制中断。对于复制集来说,如果仅仅只是磁盘不足,又不想动节点架构,可以临时rs.add一个节点,把旧节点数据迁出,再移除旧节点,这是比较安全的方式。但注意副本集数据迁移最好通过新增节点 + 移除节点的方式,不要直接对数据文件做拷贝,否则很容易因为dbPath不一致导致节点无法启动。
5. 动态调整集群规模的经验总结
写到这,基本把复制集扩缩容的操作流程和问题都过了一遍。最后分享一些我个人在实际操作中的体会,尤其是一些没写在官方文档里的细节。
5.1 我踩过的一些坑
第一次做大集群扩容时,我犯过一个低级错误:直接用rs.add()添加了一台配置完全一致的新节点,但忘了改 host 名称,结果成员列表里出现了两个相同的 host:port,导致复制集配置校验失败。后来才意识到,新节点的 host 必须对 primary 节点可解析,且不能与其他节点重复。所以在添加之前,最好先在新节点执行hostname -f,并保证其他节点能解析这个名字。如果使用 IP,就统一用 IP,不要混用。
另一个坑是关于 fast sync 的移植。我曾在测试环境尝试通过拷贝 primary 的 dbPath 文件来加速新节点初始同步,结果启动后新节点数据校验失败。原因是没有正确备份和恢复,同时节点启动时发现数据文件中的 replSet 配置和当前集群不一致。后来我放弃了手动拷贝数据文件的做法,改用官方推荐的方式:直接增加一个带最近备份的新节点,或者用mongorestore先把全量数据恢复到新节点,再启动复制。虽然步骤多一点,但更安全。
5.2 监控与自动化扩展的建议
复制集的扩缩容,不应该是一次性的临时操作,最好纳入监控和自动化体系。我通常会在复制集节点上配置mongod_exporter,把rs.status里的state、optimeDate、members数量、replicationLag等指标采集到 Prometheus,设置告警。比如:
- 当 secondary 的 replication lag 超过 30 秒,告警。
- 当复制集的投票成员数量变化,告警。
- 当节点状态不是 PRIMARY/SECONDARY/ARBITER 的时间持续超过 5 分钟,告警。
对于频繁的扩容缩容,我建议把操作脚本化。比如用 Python 脚本通过 pymongo 连接 primary,执行 rs.status 获取当前成员列表,然后根据传入参数选择 add 或 remove。脚本里要加入显式检查:
- 如果成员数量加上新成员后不是奇数,给出警告。
- 如果新成员 host 不在节点列表里,给出提示。
- 如果移除的节点是 primary,自动触发 stepDown 之后再 remove。
- 每次操作前导出当前的
rs.conf()到备份文件,方便回滚。
此外,MongoDB 本身也支持通过replSetResizeOplog动态调整 oplog 大小,这为“先扩容节点容量,再扩容集群规模”提供了不错的过渡手段。总体上,动态调整集群规模的关键在于:提前规划、细粒度监控、小步验证。不要一次性对所有节点做大动作,每次只操作一个节点,确认稳定后再操作下一个。
最后再分享一个实用小技巧:无论扩容还是缩容,操作完成后都建议立刻执行一次rs.config()备份,并和操作前的配置做 diff。MongoDB 的复制集配置是集群的命根子,一次错误的 reconfig 就可能导致全集群故障。把这些配置归档到代码仓库里,配合 CI/CD 做变更审查,你会发现复制集的扩缩容并没有想象中那么危险,只要每一步都验证到位,就能稳稳地调整集群规模。