做后端开发的迟早会和数据库的高可用打交道。早期单机 MongoDB 扛业务数据量上来、并发一高磁盘 I/O 和连接数先扛不住更别提机器宕机或者机房断电这种物理级事故——真碰上恢复数据全靠运气和备份时效。后来我把核心业务切到了三节点副本集累计稳定运行两年多中间经历过主节点被强制 kill、机房网络抖动、磁盘写满等状况都没丢失数据也没出现长时间不可用。这篇就把我在这套架构上的设计、部署、运维实践和踩坑记录完整写出来。所谓副本集本质就是一组 mongod 进程构成的主从复制集群通过内置的复制协议自动完成数据同步和故障切换。三节点是最小的高可用配置也是我在实际项目里认为性价比最高的形态。它能容忍一个节点完全宕机而不影响业务写入配合合理的读偏好设置还能横向分担一部分查询压力。1. 架构设计与副本集核心机制1.1 为什么选三节点而不是两节点或单机单机部署的问题很直接没有冗余mongod 进程崩溃等同于数据库不可用。两节点虽然多了一台机器但会引入一个非常尴尬的投票死局——两个节点各有一票一旦发生网络分区双方都认为自己是主节点各写各的脑裂风险极高如果加上仲裁节点实际上又变成了三节点的拓扑。所以生产环境我基本不会考虑两节点。三节点最标准的拓扑是一个主节点加两个副节点。三个节点各自持有一票任意节点想要成为主节点必须获得超过半数的投票也就是至少两票。这样在任何时刻整个集群有且只有一个主节点能够被多数派认可从机制上就消除了脑裂的可能性。三节点的优势还不止投票这个层面。两个副节点意味着我们有双份完整的数据副本当其中一个副节点因为硬件故障掉线时另一个仍然能够提供完整数据并且可以正常参与选举。如果只有一主一副副节点一挂主节点虽然还能工作但整个集群已经退化成单点既没有故障转移的能力也丧失了数据冗余这种情况下基本应该立刻介入修复。注意副本集的“高可用”是有损降级的高可用。三节点集群允许损失一个节点但集群健康度会从 GREEN 降到 YELLOW此时如果再出现第二个节点故障整个副本集将进入只读或完全不可用状态。这个风险阈值必须让所有人都清楚。1.2 副本集的核心写入与同步协议MongoDB 副本集的数据一致性依赖操作日志也就是 oplog。每次写入操作在主节点执行后都会被记录为一条 oplog 条目。副节点通过持续拉取 oplog 并重放这些操作异步地将自身数据推进到与主节点一致的状态。这里需要说明的是所有的写操作包括插入、更新、删除以及带事务的多文档操作都只会经过主节点。副节点直接收到的写请求会被主节点驳回除非设置了合适的 read preference 允许从副节点读——但即使从副节点读写还是必须走主节点。我习惯用一个不太精确但很好理解的类比主节点是编辑部内容在这里完成终审签发oplog 是排版好的版样副节点是印刷厂拿到版样后照单印刷。只要版样完整任何一个印刷厂都能印出同样的报纸。实际部署中我强烈建议用 SSD 承载 oplog 所在的存储池尤其是高写入负载的业务。oplog 的写入和读取都是顺序 I/OSSD 能显著降低副节点的复制延迟。机械硬盘在长时间高强度写入下很容易出现复制 lag 涨到几千秒的情况。1.3 选举机制与故障转移原理当主节点发生故障比如进程宕掉、网络不可达、心跳超时副节点会发起一次选举。整个选举过程遵循一个非常简单的原则能联络到多数派、拥有最新 oplog 数据的节点胜出。每个节点维护一个 term 计数器类似选举代次。每次选举都会使 term 加一。节点之间通过心跳消息交换各自的状态和 term。当一个节点发现无法联系到主节点它会将自身状态切换到 candidate并请求其他节点投票。其他节点会检查候选者的 oplog 是否足够新比较 lastOpTime以及自己当前有没有更优先的候选然后决定是否投出赞成票。有件事值得特别提醒在投票逻辑里数据新鲜度优先于节点优先级。也就是说哪怕一个节点配置了更高的 priority但只要它比另一个节点的 oplog 落后选举时高优先级也不会压过数据更新的低优先级节点。这个设计很合理因为高可用系统的第一目标是数据不丢而不是性能或角色偏好。等网络恢复、原主节点重新加入集群后它会发现自己当前 term 小于集群内的最新 term于是主动降级为从节点并同步追赶新主节点的数据。这个自我纠错的过程是自动完成的不需要人工干预。2. 环境规划与部署准备2.1 节点配置与磁盘规划建议三节点副本集的每一台机器承担的角色其实相同——既可以当主节点也可以当副节点所以三台机器配置最好保持一致不要出现一台 8 核 64G 带两台 2 核 4G 这种奇葩组合。副本集的高可用依赖于所有节点都足够健壮短板机器不仅容易成为故障点还会拖慢整个集群的同步效率。我当前这台生产环境的副本集是这样的配置节点角色云主机规格磁盘系统主节点4核 8GSSD 200G主流 Linux 发行版副节点 A4核 8GSSD 200G主流 Linux 发行版副节点 B4核 8GSSD 200G主流 Linux 发行版内存规划上MongoDB 默认的 WiredTiger 存储引擎会使用大多数可用内存作为缓存。这里要区分实际情况如果机器上只跑 MongoDB可以按默认的 50% 或更高设置 cache size如果机器上还要跑别的应用建议显式配置 storage.wiredTiger.engineConfig.cacheSizeGB防止 MongoDB 把内存吃光导致其他服务被 OOM。磁盘这块我需要多说一句。副本集所有节点上的数据是完全相同的所以三台机器需要同等大小的磁盘空间。很多人图省事给副节点分很小的磁盘结果主节点数据涨到一定规模后副节点复制直接失败集群变成一主一从这个坑我踩过一次。2.2 安装 MongoDB 并配置基础参数安装过程不复杂重点在于几个关键的配置参数。我直接贴一份生产可用的配置模板然后逐个解释每项参数的含义。systemLog: destination: file path: /var/log/mongodb/mongod.log logAppend: true logRotate: reopen verbosity: 0 storage: dbPath: /var/lib/mongo journal: enabled: true wiredTiger: engineConfig: cacheSizeGB: 4 processManagement: fork: true pidFilePath: /var/run/mongodb/mongod.pid timeZoneInfo: /usr/share/zoneinfo net: port: 27017 bindIp: 0.0.0.0 replication: replSetName: rs0 security: authorization: enabled keyFile: /etc/mongodb/rs-keyfile先看 replication.replSetName这个名称在同一个副本集里的所有节点上必须一致否则节点之间无法识别彼此属于同一个集群。我见过有人只改了一台机器的 replSetName 就直接 rs.add()结果报错 appeared to be a different replica set排查半天才发现是把名称写错了。再看 security.keyFile启用认证后副本集各节点之间需要用同一个 keyfile 进行内部通信认证。这个文件必须提前生成并分发到所有节点上权限要设为 600属主必须是运行 mongod 的用户。如果 keyfile 权限不对mongod 启动时会直接拒绝启动并报错 permissions on keyfile are too open 之类的话。cacheSizeGB 的配置要和硬件匹配。比如机器是 8G 内存MongoDB 之外还要跑监控 agent 和数据同步进程我就把缓存设成了 4G。这个值并不是越大越好要是把系统内存全占了会导致操作系统频繁 swap性能反而断崖式下跌。2.3 安全加固与访问控制生产环境绝对不能裸奔没有鉴权的 MongoDB 等于把数据裸奔在公网。我的做法是创建独立的业务账号和运维账号业务账号只授予目标库的读写权限运维账号才拥有 dbOwner 或更高的管理权限。账号职责分离避免一个账号被泄露后整个集群沦陷。// 在初始化副本集后执行 // 创建应用读写账号 db.getSiblingDB(appdb).createUser({ user: appUser, pwd: 强密码, roles: [ { role: readWrite, db: appdb }, { role: read, db: appdb_log } ] }); // 创建运维管理账号 db.getSiblingDB(admin).createUser({ user: opsAdmin, pwd: 另一个强密码, roles: [ { role: root, db: admin }, { role: clusterAdmin, db: admin } ] });业务侧统一走专有账号连接URI 格式采用副本集连接串mongodb://appUser:password192.168.1.11:27017,192.168.1.12:27017,192.168.1.13:27017/appdb?replicaSetrs0readPreferencesecondaryPreferred要注意 readPreference 这个参数。副本集默认所有读请求都打到主节点相当于副本集只提供了高可用没有扩展读能力。设置了 secondaryPreferred 后读流量会被优先分配到副节点只有当副节点全部不可用时才会走主节点这对读多写少的业务帮助很大。3. 副本集初始化与功能验证3.1 初始化副本集三个节点的 mongod 启动完成后任意挑一个节点连接上去执行 rs.initiate()。通常以第一个启动的节点或者我们希望它成为初始主节点的节点为准来执行初始化。// 连接第一台节点 mongosh --host 192.168.1.11 --port 27017 rs.initiate({ _id: rs0, members: [ { _id: 0, host: 192.168.1.11:27017, priority: 2 }, { _id: 1, host: 192.168.1.12:27017, priority: 1 }, { _id: 2, host: 192.168.1.13:27017, priority: 1 } ] });关于 priority 这里我解释一下。节点的 priority 决定它在选举中的权重默认都是 1。我给第一个节点设了 priority: 2这样只要它在线它被选为主节点的概率就会更高。这个配置在跨机房的部署里很有用可以把主节点固定在有业务写入倾向的那个机房或延迟更低的那台机器上。如果不想固定主节点三个节点都用默认 priority: 1 也完全可以选举机制会自动选出数据最新的节点来当主。执行 rs.initiate() 后副本集会在几秒钟内完成选举可以通过 rs.status() 查看状态rs.status().members.forEach(m { print(name: ${m.name}, stateStr: ${m.stateStr}, health: ${m.health}); });正常情况会看到三个节点中一个 PRIMARY两个 SECONDARY所有节点的 health 都是 1。如果出现某个节点一直显示 STARTUP 或 STARTUP2说明它还在同步初始数据等 oplog 同步完成后会自动变成 SECONDARY。3.2 验证主从复制与事务支持副本集初始化完成的头两件事我一向是验证写入复制和验证事务能力。先测复制// 在主节点写入一条测试数据 db.testcoll.insertOne({ _id: 1, ts: new Date() }); // 连接副节点查询 db.getMongo().setReadPref(secondary); db.testcoll.findOne({ _id: 1 });只要在副节点能查到 _id 为 1 的文档复制链路就没问题。注意查询前必须切换读偏好不能在副节点上做默认主读因为主节点上的数据不会自动反向广播到副节点——对副节点确实持有副本但默认读并不保证能命中副本数据。再测事务。MongoDB 副本集支持多文档事务但前提是存储引擎必须是 WiredTiger并且副本集成员全部启用副本集模式。我一般用一个两文档更新的小事务来验证const session db.getMongo().startSession(); session.startTransaction(); try { session.getDatabase(appdb).collA.updateOne( { _id: 1 }, { $set: { val: tx-test } } ); session.getDatabase(appdb).collB.updateOne( { _id: 1 }, { $set: { val: tx-test } } ); session.commitTransaction(); } catch (e) { session.abortTransaction(); throw e; }如果之前用的还是 MMAPv1 的旧版本一定要现在做计划升级到 WiredTiger。我印象非常深的一点是MMAPv1 不支持事务连单文档原子性都很受限这在现代业务场景下几乎没法用。3.3 延迟与数据一致性验证部署完副本集我不会马上把业务流量切过去。我做一轮完整的延迟测试核心指标就是写操作完成时间以及副节点复制延迟。复制延迟可以通过下面这种方式观察const primaryOpTime rs.printReplicationInfo(); const secondaryLag rs.status().members .filter(m m.stateStr SECONDARY) .map(m ({ name: m.name, lag: m.optimes ? m.optimes.appliedOpTime.ts - rs.status().members[0].optimes.lastCommittedOpTime.ts : null }));更常用的做法是看第二行的时间差。rs.printReplicationInfo() 会显示 oplog 的最大时间和当前时间如果两者差距不大说明复制基本是实时的。在有同机房副节点的情况下我看到的最长复制延迟也就是毫秒级别。但要注意写主节点的响应时间只能证明主节点写入成功并不代表副节点已经追上。所以真正生产环境中的一致性校验不能只看主节点必须要从副节点读出来比对。我们内部有一条不成文的规定重要数据写完以后 5 秒会用副节点读一条来交叉验证长期执行下来没发现过大分叉这才敢把线上读流量切到 secondaryPreferred。4. 日常运维与高可用演练4.1 必须盯住的监控指标副本集部署完成只是开始日常运维才是重头戏。我当前监控面板上的核心指标大致分四类指标分类具体指标告警阈值节点状态节点是否可达、主从角色30 秒无心跳即告警复制状态复制延迟、oplog 时间差延迟超过 60 秒告警系统资源CPU、内存、磁盘使用率、I/O 等待磁盘使用率超过 85% 告警连接状态当前连接数、连接池等待时间连接数达到上限 80% 告警系统资源里最容易被忽略的就是磁盘。副本集节点上的数据相同磁盘消耗完全同步增长一旦主节点磁盘告警副节点也会在同一时间附近告警。如果只剩 5% 空间oplog 可能立刻被截断比节点宕机还危险因为这个故障没有自动恢复方案必须手工扩容或清理数据。复制延迟指标的采集方式建议直接用驱动自带的监控命令或者平台监控脚本定死在每 15 秒一采。opslag 异常攀升往往预示着网络抖动或者副节点 I/O 饱和这个时候去看主节点压测得到的负载数据基本就能对上有问题的是哪台。4.2 备份策略与恢复演练副本集不等于备份。很多人以为副本集有多个副本就不需要备份了这是非常危险的误解。副本集解决的是高可用问题如果业务上有人误执行了 dropDatabase()这个删除操作会通过 oplog 同步到所有副节点所有副本上的数据都会消失。这种情况下唯一能救命的只有真正的备份。我当前采用的备份策略如下每天凌晨 2 点对副节点执行 mongodump备份整个业务库备份保留周期为 7 天同时每天导出一份到对象存储做异地留存每月做一次全量恢复演练验证备份数据可恢复性具体的备份命令如下一定注意要连副节点执行避免对主节点造成额外压力mongodump -h 192.168.1.12:27017 -u opsAdmin -p 密码 \ --authenticationDatabaseadmin \ -d appdb \ -o /backup/mongodb/appdb/$(date %Y%m%d%H%M)恢复命令则是对应上传mongorestore -h 192.168.1.11:27017 -u opsAdmin -p 密码 \ --authenticationDatabaseadmin \ -d appdb \ /backup/mongodb/appdb/202501020300需要指出的是mongodump 属于逻辑备份对于超大库会明显比物理文件拷贝慢。如果数据库超过 500G我建议改用文件系统快照方式备份副节点数据目录。用云厂商的快照能力做一致性快照速度快很多恢复时也能直接挂载读取。还有一点任何备份策略都要经过真实的恢复演练才能算数。我自己就遇到过备份文件损坏、mongorestore 到一半报错的情况都是靠定期演练提前发现的。不演练的备份真到事故面前就是摆设。4.3 高可用切换演练的具体步骤高可用系统的价值在故障时才能体现所以我们平时就要主动制造可控故障验证切换逻辑。我每次演练过程是这样组织的通知业务方和研发确认当前没有批量写任务在执行找到当前主节点记录其 host 和优先级执行 db.adminCommand({ shutdown: 1 })模拟主节点被动宕机观察剩余两个节点的选举过程验证新主节点是否接管业务写入恢复原主节点进程观察它重新加入集群并同步数据再次执行 rs.status() 确认所有节点健康整个切换过程通常只需要几秒到十几秒。这个过程中业务端需要做的就是正确配置驱动里的 replicaSet 参数以及处理好 readPreference。驱动会自己感知主节点切换并重连应用层最需要跨过的是网络重连和自动重试的异常处理。如果业务代码里没用连接池而是每次手动 new MongoClient则故障切换瞬间会有少量请求失败这个问题必须提前让研发同学知道。切换演练对副本集的意义不用多说。真正让团队所有人建立起信心的就是经历过几次这样的演练大家都清楚故障来临时数据库会怎样响应。5. 常见问题与排查实录5.1 节点加入副本集一直显示 STARTUP这个问题非常常见。新节点加入副本集后很长时间一直处于 STARTUP 或 STARTUP2 状态无法变为 SECONDARY。排查思路分三步走第一步检查 mongod 日志。最常见的原因是初始同步需要全量拷贝数据拷贝时间与数据量成正比。几 G 数据可能要几分钟几百 G 就要几十分钟甚至更久这属于正常现象耐心等待即可。第二步如果长时间毫无进展就要检查新节点的磁盘空间。初始同步期间MongoDB 会先拷贝数据文件再回放 oplog 追赶主节点。如果磁盘空间不足同步过程会反复失败节点会陷入反复重试。第三步检查网络连通性。副本集节点之间需要能够直连彼此的 27017 端口如果有防火墙挡着同步过程完全无法启动。5.2 主节点频繁切换如果集群里主节点频繁自动切换也就是 flapping业务会间歇性感知到写超时。这个问题的根源通常是网络质量。最常见的场景主节点和其他节点之间的网络有丢包或抖动导致心跳超时其他节点发起选举原主节点恢复后被迫降级短时间内又可能触发再次选举。另一个常见原因是 CPU 饱和。如果主节点的 CPU 使用率长期打满mongod 处理心跳消息就会延迟其他节点误以为主节点失联然后发起不必要的选举。针对这类问题我的建议是按顺序排查用业务自带的网络监控工具或 ping、mtr 检查节点间丢包率是否大于 0用 top 和 mongostat 确认各节点的 CPU 使用率如果网络质量没有问题那就考虑调整心跳间隔但这是兜底方案而不应该是首选方案5.3 复制延迟持续增大且无法追平复制延迟表现为副节点和主节点的数据差异持续拉大副节点永远追不上主节点。这种情况多半不是瞬时抖动而是某个副节点存在长期性能瓶颈。我在生产环境里遇到过的一种典型情况副节点同时承担了业务侧大量的读请求又承担了备份任务的负载导致 I/O 带宽耗尽oplog 拉取和重放都被拖慢。解决方向有两条第一将读流量和备份任务迁移到另一个副节点或者直接买性能更高的机器来跑副节点。第二种如果确认这个副节点永远追不上主节点不如想办法让它偶尔停掉同步在空闲时段再重新开启追赶比如在凌晨低峰期执行 rs.syncFrom()让它从另一台更健康的副节点同步。不过这里有个更关键的常识需要强调复制延迟的隐患并不是副节点读取的数据不够新而是如果主节点在这种状态下宕机那么拥有最新 oplog 数据的节点才有资格当选主节点。如果副节点落后太多它不仅追不上数据还可能在选举中被剥夺候选资格集群将退化为不可用状态。所以延迟监控必须尽快处理不能等着自我恢复。5.4 oplog 大小与容量规划oplog 是副本集复制的核心它的大小直接决定了复制链路的容错能力。默认的 oplog 大小是磁盘容量的 5%对于长期高写入的业务来说往往不够。如果某个副节点离线超过 oplog 覆盖的时间窗口它重新上线后无法从主节点增量同步只能做全量重同步耗时和风险都大。先看当前 oplog 情况rs.printReplicationInfo();输出里的 oplog first event time 和 oplog last event time 之间的时间跨度代表当前 oplog 能覆盖多长的时间窗口。如果这个窗口只有几个小时而你的日常维护或网络中断可能要持续大半天那就要考虑调整 oplog 大小了。调整 oplog 大小的方式在 4.0 以上版本可以直接在运行中执行db.adminCommand({ replSetResizeOplog: 1, size: 16384 // 单位 MB即 16G });执行之后新的大小会立即生效不需要重启。我一般建议将 oplog 设置为可以覆盖至少 12 小时到 24 小时的写入量极端写入密集的库甚至要留两天的余量。5.5 认证开启后节点间通信失败如果之前以无认证方式运行过副本集后来才启用 keyFile 认证最容易踩的坑是集群内部节点之间的认证没生效。现象是节点启动后一直处在 UNKNOWN 或者 REMOTE 状态日志报错 not authorized for admin command。排查办法是先确认所有节点使用了同一个 keyFile 内容然后确认所有节点的 keyFile 权限为 600。需要特别说明的是修改 keyFile 后必须重启 mongod 才能生效。副本集内部通信的认证是在进程启动时加载的只改文件不重启是不行的。我见过有人改了 keyFile然后把文件拷贝到所有节点却不重启进程导致节点间互相不信任整个集群差点瘫痪。6. 监控告警的落地配置样例前面讲了监控看什么指标这里给一份可落地的告警规则样例方便直接借鉴。我目前用的是开源监控方案 Prometheus配合社区维护的 MongoDB Exporter整体稳定性和灵活性都比较满意。关键的告警规则可以这样定义groups: - name: mongodb-replicaset rules: - alert: MongoDBNodeDown expr: mongodb_health_status 0 for: 1m labels: severity: critical annotations: summary: MongoDB 节点不可达 - alert: MongoDBReplicationLagHigh expr: mongodb_mongod_replset_member_optimes_seconds - on(instance, setname) mongodb_mongod_replset_member_optimes_seconds{statePRIMARY} 60 for: 2m labels: severity: warning annotations: summary: 副本集复制延迟超过 60 秒 - alert: MongoDBPrimaryFlapping expr: changes(mongodb_mongod_replset_member_state{statePRIMARY}[15m]) 3 labels: severity: warning annotations: summary: 主节点在 15 分钟内发生多次切换 - alert: MongoDBDiskUsageHigh expr: mongodb_disk_usage_percent 85 for: 5m labels: severity: warning annotations: summary: MongoDB 数据目录磁盘使用率超过 85%这些配置都是我日常在维护和迭代的。告警阈值这个东西别照搬网上的一定要结合自己集群的实际情况来定。比如复制延迟 60 秒对某些业务可能已经是严重的但对于一个夜间离线跑批的系统来说可能完全无所谓。因为告警的最终目的不是被发现而是帮助人快速定位和介入。另外我对告警完整性还有一点补充一定要做分钟级的数据保留和历史趋势留存。光看当前值很难判断是瞬时抖动还是持续恶化有了趋势图很多问题一眼就能看出来能省下大量排查时间。写在最后的一点经验从最开始单机 MongoDB 被业务写垮到现在这套三节点高可用副本集稳定扛下所有核心读写流量最大的感受是高可用不是说出来的是设计、验证和演练反复打磨出来的。最后分享两个我在运维过程中沉淀下来的习惯。第一个是每次故障恢复后一定要做一次复盘把时间线、根因、解决办法、预防措施四件事写清楚沉淀成团队的运维手册。第二个是每月至少做一次主节点手动切换演练没人知道真实的故障什么时候来但等到故障来了再手忙脚乱翻文档损失一定比演练成本大得多。副本集本身是一个非常成熟的技术方案文档也写得很全。但真正让它发挥价值靠的还是部署细节的严谨和日常运维的用心。希望这篇手册能帮你少踩几个坑把 MongoDB 副本集真正跑稳。