MongoDB 复制集与分片集群高可用实战

MongoDB 复制集与分片集群是文档数据库做高可用和水平扩展的两根支柱:复制集解决单点故障与读扩展,分片集群解决单机容量与写入吞吐瓶颈。很多团队上线时只跑单实例,等数据涨到几百 GB、QPS 打满磁盘 IO 才想起改架构,此时迁移成本极高。本文按生产路径把两件事讲透:先把三节点复制集搭起来并演练故障转移,再判断什么时候该分片、分片键怎么选,最后给出一份踩过坑的配置清单。

一、先分清复制集与分片集群的职责

这两个概念经常被混着说,但解决的问题完全不同。复制集是把同一份数据在多个节点上保存多份副本;分片是把一份大数据按规则切成多段,分散到不同的复制集上。生产环境的标准形态是「分片集群的每个分片本身就是一个复制集」。

维度复制集 Replica Set分片集群 Sharded Cluster
解决问题高可用、读扩展、数据冗余存储容量、写入吞吐水平扩展
最小节点3 个(1 主 2 从,或 1 主 1 从 1 仲裁)config 复制集 + 2 分片 + mongos
数据分布每个节点存全量数据每个分片存一部分数据
写入能力只有主节点可写,写不扩展多分片并行写,随分片数线性扩展
运维复杂度低,建议所有生产环境必配高,数据量小于 1TB 通常不必上
典型触发点任何线上业务单分片数据超 1–2TB 或写入打满磁盘

结论很直接:复制集是必选项,分片是按需项。如果你现在还是单实例,第一步永远是补复制集,而不是急着分片。这个判断逻辑和关系型数据库一致,可以对照我们之前写过的 MySQL 主从复制与读写分离:高可用架构实战 一起看,两者在选主、延迟、读写分离上的取舍非常相似。

二、三节点复制集搭建:从配置到初始化

2.1 配置文件与启动

三台机器使用同一个 replSetName,并开启 keyFile 内部认证(生产必须开,否则任意节点可加入集群)。先在任一节点生成密钥并同步到三台:

# 生成集群内部认证密钥(三节点必须完全一致)
openssl rand -base64 756 > /etc/mongo/keyfile
chmod 400 /etc/mongo/keyfile
chown mongod:mongod /etc/mongo/keyfile

# 分发到另外两台
scp /etc/mongo/keyfile root@10.0.0.12:/etc/mongo/keyfile
scp /etc/mongo/keyfile root@10.0.0.13:/etc/mongo/keyfile

三台节点的 /etc/mongod.conf 内容一致,只需保证 bindIp 能被其他节点访问:

storage:
  dbPath: /var/lib/mongo
  wiredTiger:
    engineConfig:
      # 建议设为物理内存的 50%,不要贪心占满
      cacheSizeGB: 8
net:
  port: 27017
  bindIp: 0.0.0.0
security:
  authorization: enabled
  keyFile: /etc/mongo/keyfile
replication:
  replSetName: rs0
  # oplog 决定从节点能落后多久还能追上,生产建议 20-50GB
  oplogSizeMB: 20480
operationProfiling:
  mode: slowOp
  slowOpThresholdMs: 200

2.2 初始化复制集并设置优先级

三台都启动后,连到计划作为主节点的那台执行初始化。priority 控制选主倾向,hidden + priority:0 可以做只备份不参与选主的隐藏节点:

rs.initiate({
  _id: "rs0",
  members: [
    { _id: 0, host: "10.0.0.11:27017", priority: 10 },
    { _id: 1, host: "10.0.0.12:27017", priority: 5 },
    // 隐藏节点:只做备份和离线分析,不接客户端读,也不会被选为主
    { _id: 2, host: "10.0.0.13:27017", priority: 0, hidden: true, votes: 1 }
  ],
  settings: {
    // 心跳超时,网络抖动频繁的机房可适当放大到 15
    electionTimeoutMillis: 10000
  }
})

// 查看状态:关注 stateStr(PRIMARY/SECONDARY)与 optimeDate 差值
rs.status().members.forEach(m =>
  print(m.name, m.stateStr, m.optimeDate)
)

// 复制延迟一屏看清
rs.printSecondaryReplicationInfo()

初始化成功后必须马上建管理员账号,否则开了 authorization 却没有用户,重连就进不去了:

use admin
db.createUser({
  user: "root",
  pwd: passwordPrompt(),
  roles: [ { role: "root", db: "admin" } ]
})

// 业务库单独授权,禁止业务方直接用 root
use appdb
db.createUser({
  user: "app_rw",
  pwd: passwordPrompt(),
  roles: [ { role: "readWrite", db: "appdb" } ]
})

三、故障转移演练:别等真宕机才验证

复制集搭好只是第一步,真正决定可用性的是「主节点挂掉后业务多久恢复写入」。上线前必须演练一次,而且要在有真实流量压测的情况下演练。手动触发切主:

# 在当前主节点执行:主动让位,60 秒内不再参与选主
rs.stepDown(60)

# 观察选举耗时(正常 2-12 秒完成新主选举)
watch -n1 'mongosh --quiet --eval "rs.status().members.map(m=>m.name+\":\"+m.stateStr)"'

# 更真实的演练:直接 kill 主节点进程
systemctl stop mongod

演练时重点观察三件事:选举耗时是否在 SLA 内、应用是否自动重连有没有写丢失。第三点取决于 writeConcern 配置。连接串必须写全部节点并带 replicaSet 参数,驱动才能感知拓扑变化:

# 正确写法:列出所有可路由节点 + replicaSet + 写关注 + 重试写
mongodb://app_rw:pwd@10.0.0.11:27017,10.0.0.12:27017/appdb?replicaSet=rs0&w=majority&retryWrites=true&readPreference=primaryPreferred&maxPoolSize=100

# 错误写法:只写单节点,主挂了应用直接全线报错,不会自动切换
mongodb://app_rw:pwd@10.0.0.11:27017/appdb

3.1 写关注与读偏好的取舍

这两个参数直接决定「数据安全」和「延迟」的平衡点,是复制集调优里最容易配错的地方:

参数取值效果与代价建议场景
writeConcernw:1主节点写成功即返回,主挂可能丢最后几条日志、埋点等可容忍丢失的数据
writeConcernw:”majority”多数节点确认才返回,延迟增加但不丢,配合 retryWrites 可安全重试订单、支付、账务(推荐默认)
writeConcernj:true额外要求刷盘日志,延迟再增金融强一致场景
readPreferenceprimary强一致,但读压力全在主写后立即读的业务
readPreferencesecondaryPreferred读打到从节点,可能读到旧数据报表、列表页、搜索
readPreferenceprimaryPreferred主可用走主,主挂降级读从通用默认,可用性优先

一个高频误区:以为配了 secondaryPreferred 就等于读扩展翻倍。实际上从节点还要执行 oplog 回放,本身也有 IO 开销;如果查询没走索引,从节点只会更慢。读扩展的前提永远是索引先做对,这部分可以参考 MongoDB 索引优化:复合索引、覆盖查询与慢查询诊断

四、什么时候该上分片

分片会带来 mongos 路由层、config 元数据集群、chunk 迁移、跨分片聚合等一整套复杂度。满足以下任一条件再考虑,否则先做索引优化和垂直扩容:

  • 单个复制集数据量接近或超过 1–2TB,备份恢复窗口已经不可接受
  • 工作集(热数据)远大于内存,WiredTiger cache 命中率长期低于 90%,磁盘随机 IO 打满
  • 写入 QPS 已经把主节点 CPU 或磁盘写带宽压满,加索引和批量写都救不回来
  • 业务有明确的按租户、按时间隔离归档需求,分片能顺带解决数据生命周期管理

五、分片集群落地:分片键决定生死

分片集群由三部分组成:mongos 无状态路由、config server 复制集存元数据、若干 shard(每个都是复制集)。搭建顺序是 config → shard → mongos:

# 1) config server 复制集(配置文件加 clusterRole)
# sharding:
#   clusterRole: configsvr
mongosh --port 27019 --eval 'rs.initiate({
  _id:"cfg0", configsvr:true,
  members:[{_id:0,host:"10.0.1.11:27019"},{_id:1,host:"10.0.1.12:27019"},{_id:2,host:"10.0.1.13:27019"}]
})'

# 2) 每个分片各自初始化为复制集(clusterRole: shardsvr)

# 3) 启动 mongos 指向 config 复制集
mongos --configdb cfg0/10.0.1.11:27019,10.0.1.12:27019,10.0.1.13:27019 \
       --bind_ip 0.0.0.0 --port 27017 --keyFile /etc/mongo/keyfile --fork \
       --logpath /var/log/mongos.log

# 4) 通过 mongos 把分片加入集群
mongosh --port 27017 --eval '
sh.addShard("shard1/10.0.2.11:27017,10.0.2.12:27017");
sh.addShard("shard2/10.0.3.11:27017,10.0.3.12:27017");
sh.status();'

5.1 分片键选择:三个必须满足的条件

分片键一旦选错,后果是数据倾斜到单个分片(等于没分片)或所有查询都要广播到全部分片。好的分片键要同时满足高基数写入分布均匀与高频查询条件匹配

# 开启数据库分片
sh.enableSharding("appdb")

# 反例:用自增时间做范围分片键 —— 新数据全部涌向最后一个 chunk,热点分片
sh.shardCollection("appdb.events", { createdAt: 1 })

# 反例:纯哈希 _id —— 分布均匀但按租户查询会广播到所有分片
sh.shardCollection("appdb.orders", { _id: "hashed" })

# 推荐:复合分片键,前缀是高频查询维度,后缀提供高基数打散
sh.shardCollection("appdb.orders", { tenantId: 1, orderId: 1 })

# 时序数据推荐:租户 + 哈希打散,避免时间热点
sh.shardCollection("appdb.events", { tenantId: 1, deviceId: "hashed" })

# 检查数据分布是否倾斜(关注各分片 chunk 数与 size 是否接近)
db.orders.getShardDistribution()

分片键的设计其实是文档模型设计的延续——先想清楚查询模式,再决定字段结构和键顺序。如果模型阶段就没规划好访问路径,分片键几乎必然会选错,建议先回看 MongoDB 文档建模:嵌入式 vs 引用式设计与实战

5.2 balancer 与 chunk 迁移窗口

MongoDB 会自动在分片间迁移 chunk 做均衡,但迁移本身消耗 IO 和网络。生产环境应把 balancer 限制在业务低峰执行:

use config
// 只允许凌晨 1:00-5:00 迁移
db.settings.updateOne(
  { _id: "balancer" },
  { $set: { activeWindow: { start: "01:00", stop: "05:00" } } },
  { upsert: true }
)

// 大促前临时关闭均衡,结束后再打开
sh.stopBalancer()
sh.startBalancer()

// 查看是否正在迁移
sh.isBalancerRunning()

六、生产避坑清单

  • 偶数投票节点:两节点复制集无法完成多数选举,主挂即全集群只读。至少三个投票成员,或补一个 arbiter(但 arbiter 不存数据,会削弱 w:majority 的安全性,能用数据节点就别用 arbiter)。
  • oplog 太小:从节点停机维护稍久就超出 oplog 窗口,只能全量重同步。用 rs.printReplicationInfo() 确认 oplog 覆盖时长至少大于最长维护窗口的 2 倍。
  • 忘记 keyFile:只开 authorization 不配 keyFile,节点间认证失败会导致复制集反复选主。
  • 连接串只写一个节点:驱动无法感知新主,切换后应用持续报错,这是故障放大的头号原因。
  • 跨分片聚合未优化$lookup、无分片键前缀的 sort 会退化为 scatter-gather,延迟随分片数上升。用 explain("executionStats") 确认是否走 SHARD_MERGE。
  • 没有监控延迟:复制延迟和 chunk 分布必须进监控面板,否则从节点落后几小时都没人知道。指标接入可参考 Prometheus + Grafana 监控面板实战,用 mongodb_exporter 采集 mongodb_mongod_replset_member_replication_lag
  • 用分片替代索引:慢查询根因是缺索引时,加分片只会把慢查询复制到每个分片,问题不解决还更贵。

七、总结

MongoDB 高可用的落地顺序是清晰的:先三节点复制集 + w:majority + retryWrites,把可用性和数据安全兜住;再用监控确认瓶颈到底在索引、内存还是磁盘;只有确认是容量或写入吞吐触顶,才引入分片,并且把分片键设计当成和表结构同等重要的决策来做。多数团队真正需要的是把复制集配对、把索引建对,而不是过早引入分片集群的运维负担。上线前务必做一次带流量的故障转移演练——没演练过的高可用,等于没有高可用。

上一篇 前端工程化进阶:pnpm Monorepo 与微前端实战
下一篇 Spring 事务传播机制深度解析:7 种行为实战