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(隐藏节点) 否(priority=0) 备份 / 报表专用,不接收读流量
Delayed(延迟节点) 否(priority=0, 延迟 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 是什么?我老是听到这个词但不太理解。

大师:Oplog(Operation 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.yml
version: '3.8'
services:
  rs1:
    image: mongo:8.0
    container_name: mongo-rs1
    ports:
      - "27017:27017"
    command: mongod --replSet myRS --bind_ip_all --port 27017
    volumes:
      - ./data/rs1:/data/db
    networks:
      - rs-net
  rs2:
    image: mongo:8.0
    container_name: mongo-rs2
    ports:
      - "27018:27017"
    command: mongod --replSet myRS --bind_ip_all --port 27017
    volumes:
      - ./data/rs2:/data/db
    networks:
      - rs-net
  rs3:
    image: mongo:8.0
    container_name: mongo-rs3
    ports:
      - "27019:27017"
    command: mongod --replSet myRS --bind_ip_all --port 27017
    volumes:
      - ./data/rs3:/data/db
    networks:
      - rs-net

networks:
  rs-net:
    driver: bridge

3.2 分步实现

步骤一:初始化复制集

目标:启动 3 个 mongod 并初始化复制集。

# 启动容器
docker compose -f docker-compose-rs.yml up -d
docker compose ps

# 进入 rs1 初始化复制集
docker exec -it mongo-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)
// 确认当前是 Primary
use test_rs
db.products_rs.insertOne({
  name: "复制集测试商品",
  price: 199,
  createdAt: new Date()
})
print("写入成功,等待同步...")
// 连接到 Secondary(27018 或 27019),需要先允许从库读
// mongosh "mongodb://localhost:27018"

// 从库默认不允许读,需要设置
db.getMongo().setReadPref("secondary")
// 或者在连接串中:mongosh "mongodb://localhost:27018/?readPreference=secondary"

use test_rs
const doc = db.products_rs.findOne({ name: "复制集测试商品" })
print("从库中的商品:", doc ? doc.name : "未同步(稍等几秒)")
// 刚插入可能还没同步(< 50ms 延迟),稍等后应出现

步骤三:模拟 Primary 宕机 + 自动选举

目标:停止 Primary 容器,观察自动选主过程。

# 找到当前 Primary
docker exec mongo-rs1 mongosh --quiet --eval "rs.isMaster().primary" | grep -v "^$"

# 假设 Primary 是 rs1:27017
# 模拟宕机:停止 rs1 容器
docker stop mongo-rs1

# 查看 rs2 的复制集状态
docker exec mongo-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.log 2>/dev/null | grep "election" | tail -5

步骤四:恢复宕机节点——重新加入复制集

目标:将被 Kill 的 rs1 重新启动,观察它如何追回数据。

# 重启 rs1
docker start mongo-rs1

# 等待 10 秒,查看 rs1 的角色
docker exec mongo-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 = 不能成为 Primary
  hidden: 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

// 场景 1:w:1(只 Primary 确认,最快但不安全)
const t1 = Date.now()
db.orders_rs.insertOne(
  { orderNo: "WC_TEST_1", amount: 100 },
  { writeConcern: { w: 1 } }
)
print("w:1 耗时:", Date.now() - t1, "ms")
// 约 2-5ms

// 场景 2:w:"majority"(多数成员确认,安全但稍慢)
const t2 = Date.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(取决于副本之间的网络延迟)

// 场景 3:w:3(所有 3 个数据节点都确认,最慢)
const t3 = Date.now()
db.orders_rs.insertOne(
  { orderNo: "WC_TEST_3", amount: 300 },
  { writeConcern: { w: 3, j: true, wtimeout: 5000 } }  // j:true = 需要持久化到 Journal
)
print("w:3+j 耗时:", Date.now() - t3, "ms")

3.3 完整代码清单

文件 用途
mongodb-lab/replicaset/docker-compose-rs.yml 3 节点复制集 Docker 配置
mongodb-lab/replicaset/init-rs.js 复制集初始化脚本
mongodb-lab/replicaset/failover-test.js 故障切换验证脚本
mongodb-lab/replicaset/writeconcern-demo.js writeConcern 性能对比

3.4 测试验证

// 连接到 Primary 运行
// 1. 确认复制集状态
const status = rs.status()
print("复制集名称:", status.set)
print("成员数:", status.members.length)
print("Primary:", status.members.filter(m => m.stateStr === "PRIMARY").length === 1 ? "PASS" : "FAIL")

// 2. 验证写入复制
db.test_rs.validation.insertOne({ msg: "验证复制", ts: new Date() }, { writeConcern: { w: "majority" } })
// 在 Secondary 上验证
// mongosh "mongodb://localhost:27018/?readPreference=secondary" --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 -v

4. 项目总结

4.1 复制集 vs 单机 vs 分片集群

维度 单机 复制集 分片集群
高可用 自动故障切换 每分片独立高可用
数据冗余 多副本 每分片多副本
写性能 最高 略降(writeConcern 开销) 水平扩展
读性能 单机 可读 Secondary 分担 多分片并行读
部署复杂度
适用数据规模 < 100GB < 500GB 500GB+

4.2 适用场景

复制集适用

  1. 所有生产环境——单机仅限本地开发和 CI。
  2. 需要"业务无感知"的自动故障转移。
  3. 读多写少——读写分离,读流量分担到 Secondary。
  4. 异机房容灾——跨机房部署 Secondary 节点。

不适用场景

  1. 单机开发环境——复制集增加 3 倍资源消耗。
  2. 写入优先且可接受停机的批量处理——单机更快。

4.3 注意事项

注意事项 说明
奇数个选举节点 3/5/7 个有投票权的节点,避免偶数(需要过半票)
Oplog 大小 默认是磁盘的 5%,生产建议设为 24-48 小时的写入量
Secondary 读不是"免费"的 Secondary 追不上 Primary 时会产生读延迟
网络分区 被隔离的少数派节点无法选举,服务不可用
rs.stepDown() 不是重启 会触发安全的 Primary 降级,让出位置给其他节点

4.4 常见踩坑经验

故障案例一:偶数节点导致网络分区后无法选举

某团队部署了 4 节点(2 个完整节点 + 2 个 Arbiter),机房断网后 2+2 各在一侧,每一侧都只有 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 Secondary(2 节点),设置 writeConcern: majority。多数 = ceil(3/2)=2——需要两个节点都确认。其中一个节点挂了,写入因为多数无法达成直接报超时。根因:2 节点复制集的"多数"等于"全部",任意一个节点故障写入全停。解决:始终用至少 3 个数据节点;或用 writeConcern: 1 牺牲一致性保可用性。

4.5 思考题

  1. 如果 Primary 写入 Oplog 后立刻宕机(Oplog 还没传到任何 Secondary),新选出的 Primary 会丢失这些写入吗?对客户端而言,这些写入的返回结果是什么?
  2. Arbiter 节点不存数据,为什么也能参与选举?如果 Arbiter 被网络隔离到少数派一侧,会发生什么?

(答案将在第 18 章末尾揭晓)


上一章思考题答案

  1. 两个需要事务的跨集合场景:① 下单 + 库存预扣——创建订单和扣减库存必须在同一事务中,如果不用事务,替代方案是"先预扣库存(原子 $inc)+ 后创建订单,订单创建失败后补偿恢复库存"(Saga 模式),代码复杂且补偿失败有数据不一致风险。② 订单完成 + 更新用户统计——订单标记完成和用户总消费金额更新。替代方案是 Change Streams 异步更新统计数据,接受短暂的不一致。

  2. Spring Boot 自动管理索引(spring.data.mongodb.auto-index-creation=true + @Indexed)的优点:开发效率高,不需要手写 DDL 脚本;风险:① 启动时同步建索引会阻塞应用启动(大集合建索引需要数分钟);② 生产环境 @Indexed 注解的修改会意外地在数据库中创建非预期索引;③ 无法控制 background/partialFilterExpression 等高级参数。推荐方案:开发环境用自动建索引快速迭代,生产环境关闭自动建索引,通过变更管理流程执行索引变更脚本。

延伸阅读与资源

Python 3实战精进:从脚本到高并发订单引擎
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

posted on 2026-07-29 14:09  一天不进步,就是退步  阅读(0)  评论(0)    收藏  举报