MongoDB副本集扩缩容全攻略:从规划到故障排查
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 1votes 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 来减少投票成员数量。但我不建议这么做因为 votes0 的节点没有投票权却仍然算作复制集成员会影响大多数计算反而可能降低可用性。设 votes0 仅适用于某些特殊场景比如跨机房容灾时不想让该节点影响多数派。hidden 节点是一个很实用的角色。如果你需要一份全量数据跑分析任务或定期备份同时又不想让它接收应用端的读请求可以把它设为 hidden。hidden 节点必须设置priority:0且它在rs.status()中可以看到但通过db.hello()对客户端隐藏。这样应用端的readPreferencesecondary不会路由到它。如果你将来想让它转正只需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 只能缓解不能完全解决多数派问题因为 votes0 仍然计入节点数吗实际上 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。如果应用配置了readPreferencesecondary当新节点同步未完成时它不会处理读请求流量集中在旧节点上可能导致旧节点负载升高。所以扩容后要注意观察各节点流量分布必要时调整连接配置。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 做变更审查你会发现复制集的扩缩容并没有想象中那么危险只要每一步都验证到位就能稳稳地调整集群规模。