第17章:复制集基础——从单机到高可用
1. 项目背景业务场景本地生活电商上线三个月单台 MongoDB 开始力不从心。某天凌晨 2 点服务器突然宕机——只是普通的内存故障但因为只有一台 MongoDB整个系统直接不可用直到早上 8 点运维重启服务器才恢复。6 小时的停服直接导致 3000 个订单丢失、用户投诉爆表。CTO 拍板必须做高可用。但团队的困扰在于——“MongoDB 的主从复制怎么做一个 Primary 两个 Secondary 够不够如果 Primary 挂了客户端怎么自动切到新 Primary应用代码需要改吗”痛点没有复制集Replica Set的单机 MongoDB任意故障进程崩溃、系统重启、磁盘满、网络断都会导致业务全部中断。更糟糕的是——很多人第一次搭复制集就踩坑节点之间网络不通导致选举死锁writeConcern: majority不懂含义导致写入性能和一致性之间做错取舍误以为读 Secondary 能分摊读压力结果延时导致数据不一致Arbiter 节点不懂其作用直接部署了 3 个数据节点花冤枉钱。2. 项目设计小胖拿着高可用架构图大师我听说过主从复制——一个主库写入从库同步数据。但 MongoDB 不是还搞了个什么复制集吗和 MySQL 的主从复制有啥不同大师区别蛮大的。MySQL 的主从复制是单向的——主库写从库只读主库死了你需要手动把从库提成主库。MongoDB 的复制集是自管理的——节点之间通过心跳Heartbeat互相监控Primary 挂了剩下的节点自动投票选举出新的 Primary整个过程无需人工干预。小胖自动选举这不是跟选班长一样吗——班长不在剩下的同学投票选新班长大师贴切。选举遵循 Raft 协议——MongoDB 的复制集本质上是一个基于 Raft 的分布式一致性集群。每个节点都有 term任期号每次选举 term 加 1获得多数票过半数的节点当选 Primary。技术映射MongoDB 复制集是自管理的分布式状态机——Primary 负责所有写入Oplog 记入日志Secondary 通过拉取 Oplog 不断地重放操作来追赶 Primary 的状态。当 Primary 不可达时存活的节点通过 Raft 共识协议选举新 Primary。小白那复制集有哪几种节点角色我知道 Primary 和 Secondary还有什么 Arbiter、Hidden、Delayed 的各有啥用大师MongoDB 复制集中的 5 种角色角色存储数据参与选举用途Primary是是唯一接收写入的节点Secondary (priority 0)是是同步数据 可参与选举Arbiter仲裁节点否是只投票不存数据打破平票僵局Hidden隐藏节点是否priority0备份 / 报表专用不接收读流量Delayed延迟节点是否priority0, 延迟 slaveDelay 秒误删恢复的后悔药小胖Arbiter 不存数据只投票那不是省了一个服务器的钱我直接用 Arbiter 组一主一从一 Arbiter 不就好了大师Arbiter 确实省钱但也有代价。Arbiter 不存数据意味着——如果 Primary 挂了Secondary 当选但如果 Primary 挂了且 Arbiter 也不可用了Secondary 独自得不到多数票无法成为 Primary。所以 Arbiter 只是用低成本凑足 3 票选举需要超过半数但不增加数据的容灾副本数。技术映射复制集选举要求获得 大多数选票。3 节点2 数据 1 Arbiter的多数是 2任意 1 个数据节点挂掉仍能选举。但 2 个数据节点都挂掉Arbiter 独木难支——因为 Raft 要求 quorum 中必须有包含最新日志的节点。小白追问Oplog 是什么我老是听到这个词但不太理解。大师OplogOperation Log是 MongoDB 复制的核心。你可以把它理解成主节点的施工清单——Primary 执行的每一个写操作insert/update/delete都会生成一条 Oplog 条目Secondary 不断地拉取这个清单在自己的数据库上按顺序重放一遍。Oplog 是一个固定大小的 Capped Collection环形缓冲区大小通过oplogSizeMB设置。如果 Secondary 断开连接太久Oplog 窗口里已经没有它错过的那些操作——此时 Secondary 无法追赶必须做 Initial Sync全量重新同步。技术映射Oplog 窗口 Oplog 大小 / 写入速率。比如 Oplog 2GB写入速率 100MB/h窗口就是 20 小时。因此写入高峰期 Oplog 窗口会缩短需要监控。大师总结复制集是 MongoDB 高可用的最小单元——3 个数据节点PSS 或 PSA是最常见的生产配置。今天记住三点自动选举靠 Raft 协议写入复制靠 Oplog 拉取数据一致性靠 writeConcern 控制。3. 项目实战3.1 环境准备用 Docker Compose 搭建 3 节点复制集PSS 架构# mongodb-lab/replicaset/docker-compose-rs.ymlversion:3.8services:rs1:image:mongo:8.0container_name:mongo-rs1ports:-27017:27017command:mongod--replSet myRS--bind_ip_all--port 27017volumes:-./data/rs1:/data/dbnetworks:-rs-netrs2:image:mongo:8.0container_name:mongo-rs2ports:-27018:27017command:mongod--replSet myRS--bind_ip_all--port 27017volumes:-./data/rs2:/data/dbnetworks:-rs-netrs3:image:mongo:8.0container_name:mongo-rs3ports:-27019:27017command:mongod--replSet myRS--bind_ip_all--port 27017volumes:-./data/rs3:/data/dbnetworks:-rs-netnetworks:rs-net:driver:bridge3.2 分步实现步骤一初始化复制集目标启动 3 个 mongod 并初始化复制集。# 启动容器dockercompose-fdocker-compose-rs.yml up-ddockercomposeps# 进入 rs1 初始化复制集dockerexec-itmongo-rs1 mongosh--eval rs.initiate({ _id: myRS, members: [ { _id: 0, host: rs1:27017, priority: 2 }, { _id: 1, host: rs2:27017, priority: 1 }, { _id: 2, host: rs3:27017, priority: 1 } ] }) # 期望输出: { ok: 1 }// 查看复制集状态rs.status()// 关键字段// members[*].stateStr → PRIMARY / SECONDARY// members[*].health → 1// members[*].optime → 该节点的最新 Oplog 时间步骤二观察复制行为目标在 Primary 上写入数据观察 Secondary 是否实时同步。// 连接到 Primary可能是 rs1 的 27017// 确认当前是 Primaryuse test_rs db.products_rs.insertOne({name:复制集测试商品,price:199,createdAt:newDate()})print(写入成功等待同步...)// 连接到 Secondary27018 或 27019需要先允许从库读// mongosh mongodb://localhost:27018// 从库默认不允许读需要设置db.getMongo().setReadPref(secondary)// 或者在连接串中mongosh mongodb://localhost:27018/?readPreferencesecondaryuse test_rsconstdocdb.products_rs.findOne({name:复制集测试商品})print(从库中的商品:,doc?doc.name:未同步稍等几秒)// 刚插入可能还没同步 50ms 延迟稍等后应出现步骤三模拟 Primary 宕机 自动选举目标停止 Primary 容器观察自动选主过程。# 找到当前 Primarydockerexecmongo-rs1 mongosh--quiet--evalrs.isMaster().primary|grep-v^$# 假设 Primary 是 rs1:27017# 模拟宕机停止 rs1 容器dockerstop mongo-rs1# 查看 rs2 的复制集状态dockerexecmongo-rs2 mongosh--quiet--eval const status rs.status(); print(Primary:, status.members.find(m m.stateStr PRIMARY)?.name || 无); # 期望rs2 或 rs3 中的一个被选举为 Primary# 选举时间通常在 5-12 秒之间// 查看选举日志docker exec mongo-rs2 cat/var/log/mongodb/mongod.log2/dev/null|grepelection|tail-5步骤四恢复宕机节点——重新加入复制集目标将被 Kill 的 rs1 重新启动观察它如何追回数据。# 重启 rs1dockerstart mongo-rs1# 等待 10 秒查看 rs1 的角色dockerexecmongo-rs1 mongosh--quiet--eval const status rs.status(); const me status.members.find(m m.name.includes(rs1)); print(rs1 状态:, me.stateStr); print(rs1 健康:, me.health); # 期望rs1 以 SECONDARY 身份重新加入原 Primary 已降级步骤五配置节点的优先级和延迟目标设置 Hidden 节点和 Delayed 延迟节点。// 在 Primary 上执行// 添加 Hidden 节点不接收客户端读流量rs.add({host:rs4:27017,priority:0,// 0 不能成为 Primaryhidden:true// 隐藏节点})// 添加 Delayed 节点同步延迟 1 小时误删恢复用rs.add({host:rs5:27017,priority:0,hidden:true,secondaryDelaySecs:3600// 1 小时延迟})// 查看配置rs.conf().members.forEach(m{print(${m.host}: priority${m.priority}, hidden${m.hidden||false}, delay${m.secondaryDelaySecs||0}s)})步骤六writeConcern 实战——对比写入确认级别use test_rs// 场景 1w:1只 Primary 确认最快但不安全constt1Date.now()db.orders_rs.insertOne({orderNo:WC_TEST_1,amount:100},{writeConcern:{w:1}})print(w:1 耗时:,Date.now()-t1,ms)// 约 2-5ms// 场景 2w:majority多数成员确认安全但稍慢constt2Date.now()db.orders_rs.insertOne({orderNo:WC_TEST_2,amount:200},{writeConcern:{w:majority,wtimeout:5000}})print(w:majority 耗时:,Date.now()-t2,ms)// 约 10-50ms取决于副本之间的网络延迟// 场景 3w:3所有 3 个数据节点都确认最慢constt3Date.now()db.orders_rs.insertOne({orderNo:WC_TEST_3,amount:300},{writeConcern:{w:3,j:true,wtimeout:5000}}// j:true 需要持久化到 Journal)print(w:3j 耗时:,Date.now()-t3,ms)3.3 完整代码清单文件用途mongodb-lab/replicaset/docker-compose-rs.yml3 节点复制集 Docker 配置mongodb-lab/replicaset/init-rs.js复制集初始化脚本mongodb-lab/replicaset/failover-test.js故障切换验证脚本mongodb-lab/replicaset/writeconcern-demo.jswriteConcern 性能对比3.4 测试验证// 连接到 Primary 运行// 1. 确认复制集状态conststatusrs.status()print(复制集名称:,status.set)print(成员数:,status.members.length)print(Primary:,status.members.filter(mm.stateStrPRIMARY).length1?PASS:FAIL)// 2. 验证写入复制db.test_rs.validation.insertOne({msg:验证复制,ts:newDate()},{writeConcern:{w:majority}})// 在 Secondary 上验证// mongosh mongodb://localhost:27018/?readPreferencesecondary --eval // const doc db.getSiblingDB(test_rs).validation.findOne({msg:验证复制});// print(doc ? PASS: 数据已同步 : FAIL: 未同步);// // 3. 验证自动选举// 记下当前 Primary停掉它// 等待 15 秒在另一个节点查看应有新 Primary// 4. 清理// docker compose -f docker-compose-rs.yml down -v4. 项目总结4.1 复制集 vs 单机 vs 分片集群维度单机复制集分片集群高可用无自动故障切换每分片独立高可用数据冗余无多副本每分片多副本写性能最高略降writeConcern 开销水平扩展读性能单机可读 Secondary 分担多分片并行读部署复杂度低中高适用数据规模 100GB 500GB500GB4.2 适用场景复制集适用所有生产环境——单机仅限本地开发和 CI。需要业务无感知的自动故障转移。读多写少——读写分离读流量分担到 Secondary。异机房容灾——跨机房部署 Secondary 节点。不适用场景单机开发环境——复制集增加 3 倍资源消耗。写入优先且可接受停机的批量处理——单机更快。4.3 注意事项注意事项说明奇数个选举节点3/5/7 个有投票权的节点避免偶数需要过半票Oplog 大小默认是磁盘的 5%生产建议设为 24-48 小时的写入量Secondary 读不是免费的Secondary 追不上 Primary 时会产生读延迟网络分区被隔离的少数派节点无法选举服务不可用rs.stepDown()不是重启会触发安全的 Primary 降级让出位置给其他节点4.4 常见踩坑经验故障案例一偶数节点导致网络分区后无法选举某团队部署了 4 节点2 个完整节点 2 个 Arbiter机房断网后 22 各在一侧每一侧都只有 2 票需要过半 3 票——两侧都无法选举 Primary导致服务完全中断。根因偶数节点 对称分布导致分裂脑Split Brain。解决始终使用奇数个投票节点关键节点放在奇数侧。故障案例二Oplog 窗口太小导致从库追不上某促销期间订单量暴增 10 倍写入速率从 50MB/h 飙升 500MB/h。Oplog 2GB 的窗口从 40 小时压缩到 4 小时。一个做全量备份后重启的从库发现自己需要追 6 小时前的数据但 Oplog 只保留了 4 小时的——必须做 Initial Sync耗时 3 小时。解决增大 Oplog 至 10GB监控 Oplog 窗口时间低于 12 小时告警。故障案例三writeConcern: majority在 2 节点复制集下写不进去某团队用 1 Primary 1 Secondary2 节点设置writeConcern: majority。多数 ceil(3/2)2——需要两个节点都确认。其中一个节点挂了写入因为多数无法达成直接报超时。根因2 节点复制集的多数等于全部任意一个节点故障写入全停。解决始终用至少 3 个数据节点或用writeConcern: 1牺牲一致性保可用性。4.5 思考题如果 Primary 写入 Oplog 后立刻宕机Oplog 还没传到任何 Secondary新选出的 Primary 会丢失这些写入吗对客户端而言这些写入的返回结果是什么Arbiter 节点不存数据为什么也能参与选举如果 Arbiter 被网络隔离到少数派一侧会发生什么答案将在第 18 章末尾揭晓上一章思考题答案两个需要事务的跨集合场景①下单 库存预扣——创建订单和扣减库存必须在同一事务中如果不用事务替代方案是先预扣库存原子$inc 后创建订单订单创建失败后补偿恢复库存Saga 模式代码复杂且补偿失败有数据不一致风险。②订单完成 更新用户统计——订单标记完成和用户总消费金额更新。替代方案是 Change Streams 异步更新统计数据接受短暂的不一致。Spring Boot 自动管理索引spring.data.mongodb.auto-index-creationtrueIndexed的优点开发效率高不需要手写 DDL 脚本风险① 启动时同步建索引会阻塞应用启动大集合建索引需要数分钟② 生产环境Indexed注解的修改会意外地在数据库中创建非预期索引③ 无法控制background/partialFilterExpression等高级参数。推荐方案开发环境用自动建索引快速迭代生产环境关闭自动建索引通过变更管理流程执行索引变更脚本。延伸阅读与资源python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析