MongoDB 副本集与分片架构:从选举到槽迁移的高可用之路

深入 MongoDB 高可用架构:副本集选举机制、分片策略与 Chunk 平衡、mongos 路由与读写关注级别

MongoDB 作为文档型数据库的代表,其灵活的 Schema 设计与丰富的查询能力深受开发者青睐。然而,在生产环境中,单点 MongoDB 实例面临数据丢失风险与扩展瓶颈。MongoDB 的高可用体系建立在两个核心基石之上——副本集(Replica Set)实现数据冗余与故障自动转移,分片(Sharding)实现海量数据的水平扩展。本文将从底层原理出发,深入剖析副本集选举机制、分片策略设计、mongos 路由逻辑,并辅以 Docker Compose 实战演练,帮助读者构建一条完整的 MongoDB 高可用部署路径。

一、副本集架构设计

副本集是 MongoDB 高可用方案的基石,它由一组维护相同数据集的 mongod 实例组成。通过多节点数据冗余,副本集既能承受节点故障,也能支持读写分离以分散负载。

1.1 PSS 与 PSA 架构模型

生产环境中最常见的副本集架构有两种范式:PSS(Primary-Secondary-Secondary)与 PSA(Primary-Secondary-Arbiter)。

PSS 架构 由 1 个 Primary 节点和 2 个 Secondary 节点组成,总计 3 个数据节点:

// rs.conf() 典型的 PSS 配置输出
{
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017", priority: 2 },
    { _id: 1, host: "mongo2:27017", priority: 1 },
    { _id: 2, host: "mongo3:27017", priority: 1 }
  ]
}

PSS 的核心优势在于完整的双副本冗余。任意一个节点宕机后,其余两个节点仍可形成多数派完成选举,且不会丢失已提交的数据。不足之处在于成本——三个节点均需存储完整数据,存储成本是单节点部署的 3 倍。

PSA 架构 由 1 个 Primary、1 个 Secondary 和 1 个 Arbiter 组成:

// rs.conf() 典型的 PSA 配置
{
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017" },
    { _id: 1, host: "mongo2:27017" },
    { _id: 2, host: "arbiter:27017", arbiterOnly: true }
  ]
}

Arbiter(仲裁节点)不存储业务数据,仅参与投票。PSA 用最低的额外存储成本获得了多数派机制带来的高可用性。但缺陷同样明显:如果 Secondary 宕机,只剩下 Primary 与 Arbiter,此时若 Primary 也宕机,系统将无法选举出新的 Primary,因为 Arbiter 不持有数据,无法成为主节点。此外,PSA 架构下 Rollback(回滚)场景的风险更高,因为仅有一份数据副本在正常运行。

架构选择建议:

维度PSS(推荐)PSA
数据冗余度2 份完整副本1 份完整副本
存储成本高(3x)低(2x)
可用性可承受双节点故障仅可承受单节点故障
选举成功率中等(依赖 Arbiter)
适用场景核心生产库、财务系统开发测试、成本敏感的非核心库

对于关键业务数据,强烈建议采用 PSS 架构,甚至可以扩展为 PSSS(4 数据节点 + 1 Arbiter)或更大的架构以提升容错能力。

1.2 选举机制详解

MongoDB 副本集的选举基于 Raft-like 的候选人机制,核心原则是多数派获胜

触发场景:

  • Primary 节点主动降级(如 rs.stepDown()
  • Primary 与大多数节点失去网络连接超过 electionTimeoutMillis(默认 10 秒)
  • 新副本集初始化时

选举投票规则:

  1. 每个节点最多只能投一票
  2. 候选节点必须是 Secondary 且具有最新的 Oplog
  3. 节点拥有更高优先级的 priority 值时更容易当选
  4. 获得超过半数(N/2 + 1)的票数才能成为 Primary
// 查看副本集状态和选举信息
rs.status()

// 典型输出中的关键字段
{
  "set": "rs0",
  "myState": 1,              // 1 = PRIMARY, 2 = SECONDARY, 7 = ARBITER
  "term": 3,                 // 当前任期号,每次选举递增
  "heartbeatIntervalMillis": 2000,
  "members": [
    {
      "_id": 0,
      "name": "mongo1:27017",
      "stateStr": "PRIMARY",
      "optime": { "ts": Timestamp(1755052800, 1), "t": 3 },
      "lastHeartbeatMessage": "",
      "electionTime": Timestamp(1755052700, 1),
      "configVersion": 1
    },
    {
      "_id": 1,
      "name": "mongo2:27017",
      "stateStr": "SECONDARY",
      "syncSource": "mongo1:27017"
    }
  ]
}

脑裂防护: MongoDB 采用 majority voting 来避免脑裂。如果一个 Primary 与多数节点失联,它会自动降级为 Secondary,而剩余的 Secondary 们若能形成多数派,则会发起新的选举。这确保了任何时刻最多只有一个 Primary 能接收写操作。

优先级与选举策略:

// 设置节点优先级——高优先级的 Secondary 更有可能成为 Primary
rs.reconfig({
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017", priority: 10 },
    { _id: 1, host: "mongo2:27017", priority: 5 },
    { _id: 2, host: "mongo3:27017", priority: 0 }    // priority 0 永远不会当选 Primary
  ]
})

注意:如果某节点 priority 设为 0,它将永远处于被动 Secondary 状态,这种设计常用于隐藏节点或备份专用节点。

1.3 Oplog:同步的命脉

Oplog(Operations Log)是 MongoDB 副本集同步的核心机制,它是一个有上限的集合(capped collection),位于每个 mongod 实例的 local 数据库中。

// 查看 Oplog 状态
db.getReplicationInfo()

// 等价于直接查询 oplog.rs
db.oplog.rs.find().sort({ $natural: -1 }).limit(5)

Oplog 中每条记录都包含以下关键字段:

{
  "ts": Timestamp(1755052800, 1),    // 操作时间戳
  "t": Long("3"),                    // 选举任期号
  "h": Long("-123456789012345678"),  // 操作哈希值
  "v": 2,                            // 版本号
  "op": "i",                         // 操作类型:i=insert, u=update, d=delete, c=command
  "ns": "mydb.users",                // 命名空间
  "ui": UUID("..."),                 // 集合的 UUID
  "o": { "_id": ObjectId("..."), "name": "Alice" },  // 操作文档
  "wall": ISODate("2026-08-13T10:00:00.000Z")         // 操作发生时间
}

Secondary 节点通过长轮询(tailable cursor)不断从 Primary(或其他 Secondary)拉取 Oplog 并重放,以此保持数据同步。这个过程称为同步链(Sync Chain)。Secondary 并不强制从 Primary 同步——如果另一个 Secondary 的 Oplog 比 Primary 更新(在网络分区场景下可能发生),它会自动选择最优的同步源。

Oplog 大小规划至关重要。 默认大小取决于存储引擎和操作系统(通常为磁盘空间的 5%),但生产环境应显式配置:

# mongod.conf
replication:
  oplogSizeMB: 5120    # 5GB,可根据业务写入量调整

如果 Oplog 窗口(从 newest 到 oldest 的时间跨度)小于全量同步所需的时间,那么一个落后的 Secondary 将无法通过增量同步追赶上 Primary,必须触发昂贵的初始同步(Initial Sync)。判断 Oplog 窗口是否充足:

// 计算 Oplog 窗口
db.getReplicationInfo().logLengthStart.toString()
// 理想情况下,Oplog 窗口应至少是计划维护窗口的 2-3 倍

1.4 Write Concern 写关注

Write Concern 定义了写操作返回成功前,MongoDB 需要满足的确认条件。合理配置 Write Concern 是在性能与数据持久性之间取得平衡的关键。

// 连接级别设置
MongoClient.connect(uri, {
  writeConcern: {
    w: "majority",       // 要求大多数节点确认
    j: true,             // 要求写入 journal(持久化到磁盘)
    wtimeout: 5000       // 等待超时时间(毫秒)
  }
})

// 单条语句级别设置
db.users.insertOne(
  { name: "Alice", age: 30 },
  { writeConcern: { w: 3, j: true, wtimeout: 5000 } }
)

Write Concern 策略矩阵:

w 值含义数据安全性写入延迟
0不等待任何确认(fire-and-forget)最低最低
1仅 Primary 确认(默认)中等
"majority"大多数数据节点确认高(防止回滚)中等
N精确 N 个节点确认可定制
"tagged"指定标签的节点确认按地域/机架隔离视网络而定

j: true 选项要求写入操作先记录到 journal(日志文件)再返回确认。Journal 默认每 100ms 刷盘一次,开启 j: true 虽然增加了延迟,但能确保即使 mongod 进程崩溃,已确认的数据也不会丢失。

生产环境推荐配置:

  • 金融交易、订单系统w: "majority", j: true,确保零数据丢失
  • 普通业务系统w: "majority", j: falsew: 1, j: true,在大多数场景下平衡性能与安全
  • 日志、埋点等可容忍少量丢失的系统w: 1, j: false 甚至 w: 0

1.5 Read Preference 读偏好

Read Preference 控制驱动从哪个节点读取数据。对于读多写少的场景,合理的 Read Preference 能显著提升读吞吐量

// Node.js 驱动示例
const collection = db.collection('users');

// 优先从 Secondary 读取(可能读到过期数据)
const docs = await collection.find({})
  .readPref('secondary', [{ datacenter: "east" }])
  .toArray();

// 从最近的节点读取(基于网络延迟)
const doc = await collection.findOne({ _id: userId })
  .readPref('nearest');

五种 Read Preference 模式:

模式读取目标适用场景注意事项
primary仅 Primary(默认)强一致性要求的场景无法分担主节点读压力
primaryPreferred优先 Primary,不可用时 fallback 到 Secondary强一致性为主但要求高可用故障切换期间可能读到旧数据
secondary仅 Secondary报表、分析型查询可能返回过期数据( eventual consistency )
secondaryPreferred优先 Secondary,不可用时 fallback 到 Primary读多写少的一般业务最为均衡的选择
nearest网络延迟最低的节点地理分布式部署不区分 primary/secondary,可能读到旧数据

最大读取延迟控制(Max Staleness):

// 只读取延迟不超过 90 秒的 Secondary
db.collection('users').find({})
  .readPref('secondary', [], { maxStalenessSeconds: 90 })

如果所有 Secondary 的延迟都超过 maxStalenessSeconds,驱动会抛出异常或 fallback(视配置而定)。这是避免在极端情况下读到严重过期数据的有效手段。

1.6 延迟节点与隐藏节点

延迟节点(Delayed Secondary) 是一种特殊的 Secondary,它有意滞后 Primary 一定的时长:

// 配置延迟节点(延迟 1 小时)
rs.reconfig({
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017" },
    { _id: 1, host: "mongo2:27017" },
    { _id: 2, host: "mongo3:27017", slaveDelay: 3600, priority: 0 }
  ]
})

延迟节点的核心价值在于防止人为误操作造成的数据灾难。如果 DBA 不小心执行了 db.collection.drop() 或批量更新错误,延迟节点还保留着操作前的数据状态,可以从该节点恢复。但需注意,延迟节点不能作为正常的 failover 目标(因设置了 priority: 0),且额外的存储空间开销与常规 Secondary 相同。

隐藏节点(Hidden Node) 对客户端完全不可见(驱动不会向其发送任何请求):

// 配置隐藏节点
{ _id: 3, host: "backup:27017", hidden: true, priority: 0 }

隐藏节点的典型用途包括:

  1. 专用备份源:避免备份操作影响业务 Secondary 的性能
  2. 数据分析/报表节点:跑重量级聚合而不占用业务节点资源
  3. 跨地域复制:将隐藏节点部署在异地数据中心,用于灾难恢复

1.7 仲裁节点设计考量

Arbiter 的设计理念是在不增加存储成本的前提下提供投票能力。但滥用 Arbiter 会带来隐患。

何时应该使用 Arbiter:

  • 双数据中心部署时,为了保持多数派在主机房(1 Primary + 2 Secondary in DC-A, 1 Arbiter in DC-B)
  • 开发/测试环境成本极度受限时
  • 偶数节点副本集需要打破平局的场景

何时应该避免 Arbiter:

  • 数据安全优先级高于成本的场景(缺少第二份数据副本)
  • 网络分区频繁且跨多个可用区部署的环境(Arbiter 可能成为不可预测的投票因素)
  • 需要 Write Concern w: 2 或更高保证的业务(PSA 架构下实际等同于 w: 1,因为只有一份 Secondary 数据副本)
// Arbiter 日志通常很少,几乎不需要资源
// 但 Arbiter 仍应部署在独立的服务器上,避免与 Primary/Secondary 共享故障域

最佳实践: 替代 PSA 的一个方案是使用分布式 PSA——Primary 在 Zone-A,Secondary 在 Zone-B,Arbiter 在 Zone-C。这样任意一个可用区完全故障,剩余两个可用区仍能保持多数派。

二、分片架构设计

当单节点存储或计算能力达到瓶颈(通常数据量超过 1-2TB 或写入 QPS 超过 10K)时,分片(Sharding)成为必然选择。MongoDB 的分片是**范围分区(Range-based Partitioning)**的实现,数据按片键(Shard Key)的值被划分到不同的分片(Shard)上。

2.1 核心组件

一个完整的分片集群包含三类角色:

mongos(路由层)

  • 客户端的单一入口,负责将请求路由到正确的分片
  • 缓存 Config Server 中的集群元数据(chunk 分布信息)
  • 支持查询合并(scatter-gather)与结果聚合
  • 轻量级进程,可水平扩展,通常部署多个实例做负载均衡

config servers(配置服务器)

  • 以副本集方式运行(自 MongoDB 3.4 起强制要求 3 节点副本集)
  • 存储整个集群的元数据:chunk 位置、分片列表、平衡器状态、zone 配置等
  • 如果 Config Server 副本集不可用,整个分片集群的数据迁移与 chunk 分裂会暂停,但已有数据的读写仍可正常进行

shards(分片)

  • 每个分片本身是一个独立的副本集(Replica Set)
  • 存储实际的数据 chunk
  • 建议每个分片的存储容量不超过 2TB,以控制恢复时间
┌─────────────────────────────────────────────────────────────┐
│                         客户端                               │
│                     (mongos 地址列表)                          │
└───────────────────────┬───────────────────────────────────────┘
                        │
        ┌───────────────┼───────────────┐
        ▼               ▼               ▼
   ┌─────────┐    ┌─────────┐    ┌─────────┐
   │ mongos  │    │ mongos  │    │ mongos  │   ← 路由层(可扩展)
   │ 路由 1   │    │ 路由 2   │    │ 路由 3   │
   └────┬────┘    └────┬────┘    └────┬────┘
        └───────────────┼───────────────┘
                        │
                        ▼
               ┌─────────────────┐
               │  Config Server  │   ← 元数据(3 节点副本集)
               │   副本集: CSRS   │
               └────────┬────────┘
                        │
        ┌───────────────┼───────────────┐
        ▼               ▼               ▼
   ┌──────────┐  ┌──────────┐  ┌──────────┐
   │ Shard A  │  │ Shard B  │  │ Shard C  │   ← 数据层(每个 Shard 是独立副本集)
   │ 副本集: RS0│  │ 副本集: RS1│  │ 副本集: RS2│
   │ [P][S][S]│  │ [P][S][S]│  │ [P][S][S]│
   └──────────┘  └──────────┘  └──────────┘

2.2 片键选择策略

片键(Shard Key)是分片集群中最重要的设计决策,一旦选定不可更改。错误的片键会导致热点分片、查询分散(scatter-gather)性能劣化等顽疾。

Hashed 分片

基于片键值的哈希值进行范围分区,能将写入均匀分布到所有分片。

// 创建 hashed 分片
db.collection.createIndex({ user_id: "hashed" });

sh.shardCollection("mydb.users", { user_id: "hashed" });

// 查看 chunk 分布
sh.status()

优势:

  • 写入绝对均匀,不存在单调递增的热点问题
  • 适合写入密集、读取以单点查询为主的场景

劣势:

  • 范围查询效率极差(同一个范围的文档可能散布在所有分片上)
  • 无法利用片键进行排序优化

适合场景: 用户会话、日志流水、事件埋点等以 _id 或 UUID 为查询条件的写入密集型集合。

Ranged 分片

基于片键的实际值范围进行分区,天然支持范围查询的高效路由。

// 创建 ranged 分片(默认方式)
db.orders.createIndex({ created_at: 1 });

sh.shardCollection("mydb.orders", { created_at: 1 });

// 手动预分片,控制初始 chunk 边界
sh.splitAt("mydb.orders", { created_at: ISODate("2026-01-01") });
sh.splitAt("mydb.orders", { created_at: ISODate("2026-04-01") });
sh.splitAt("mydb.orders", { created_at: ISODate("2026-07-01") });

优势:

  • 范围查询可以定位到少数分片甚至单一分片
  • 可以利用片键排序避免内存排序
  • 与 Zone 策略结合可实现数据地理分布

劣势:

  • 单调递增的片键(如时间戳、自增 ID)会导致持续热点——所有写入都落在最后一个 chunk 上
  • 需要精心设计预分片策略

应对热点的方法——复合片键:

// 复合片键:{ region: 1, user_id: 1 }
// 先按 region 分区,同一 region 内按 user_id 分散
sh.shardCollection("mydb.orders", { region: 1, user_id: 1 });

复合片键的第一个字段应该具有高基数和查询过滤频率,第二个字段用于分散热点。

Zone(区域)分片

Zone 分片允许将特定范围的 chunk 绑定到指定的分片或分片组上,典型用途是数据隔离地理本地化

// 定义 Zone:将欧洲用户数据固定在欧洲分片
sh.addShardToZone("shard0000", "EU");
sh.addShardToZone("shard0001", "US");
sh.addShardToZone("shard0002", "ASIA");

// 为集合关联 Zone 范围
sh.updateZoneKeyRange(
  "mydb.users",
  { region: "EU", _id: MinKey },
  { region: "EU", _id: MaxKey },
  "EU"
);

sh.updateZoneKeyRange(
  "mydb.users",
  { region: "US", _id: MinKey },
  { region: "US", _id: MaxKey },
  "US"
);

sh.updateZoneKeyRange(
  "mydb.users",
  { region: "ASIA", _id: MinKey },
  { region: "ASIA", _id: MaxKey },
  "ASIA"
);

Zone 分片典型应用场景:

场景Zone 策略效果
GDPR 数据合规欧洲用户数据限定在欧洲分片满足数据不离境要求
冷热数据分离近 3 个月数据在高性能 SSD 分片,历史数据在冷存储分片降低存储成本
多租户隔离大客户独占分片,小客户共享分片保障 SLA

2.3 Chunk 拆分与平衡器

Chunk 是 MongoDB 分片中的数据迁移单位,每个 chunk 代表片键空间的一个连续范围。默认情况下,单个 chunk 超过 64MB 时,mongos 或分片 Primary 会触发自动拆分(Split)

// 查看集合的 chunk 分布
sh.status()

// 或精确查看
db.chunks.find({ ns: "mydb.users" }).pretty()

chunk 的元数据结构如下:

{
  "_id": "mydb.users-user_id_MinKey",
  "ns": "mydb.users",
  "min": { "user_id": MinKey },
  "max": { "user_id": MaxKey },
  "shard": "shard0000",
  "lastmod": Timestamp(1, 0),
  "lastmodEpoch": ObjectId("...")
}

平衡器(Balancer) 是一个后台进程,定期比较各分片的 chunk 数量差异。当差异超过迁移阈值(由 chunk 总数决定,通常为 2 或 8)时,平衡器会自动将 chunk 从数量多的分片迁移到数量少的分片上。迁移期间chunk会被锁定,读取不受影响,但写入该chunk的操作会被短暂阻塞。

// 查看平衡器状态
sh.getBalancerState()       // 是否启用
sh.isBalancerRunning()      // 是否正在运行

// 临时关闭平衡器(执行维护操作时建议关闭)
sh.stopBalancer()
// ... 维护操作 ...
sh.startBalancer()

// 设置平衡器运行时间窗口(仅在业务低峰期运行)
use config
db.settings.updateOne(
  { _id: "balancer" },
  {
    $set: {
      activeWindow: {
        start: "02:00",
        stop: "06:00"
      }
    }
  },
  { upsert: true }
)

迁移阈值表:

总 Chunk 数触发迁移的差异阈值
< 202
20 - 794
>= 808

Jumbo Chunk 问题: 当某个 chunk 中存在不可拆分的超大文档(超过 16MB 限制的单文档,或因片键值完全相同的多个大文档),该 chunk 会被标记为 jumbo。jumbo chunk 无法被平衡器迁移,会导致分片间数据极度不均衡。

// 查找 jumbo chunk
sh.status(true)  // verbose 模式显示 jumbo 标记

// 查看具体 chunk 大小
db.chunks.find({ ns: "mydb.users", jumbo: true })

解决 jumbo chunk 的根本方法是重新设计片键或使用_id等唯一字段使数据能进一步拆分。有时也需要手动对数据进行重新分片(通过 mongodump / mongorestore 到新的分片集合)。

2.4 配置服务器

配置服务器存储了整个分片集群的元数据。自 MongoDB 3.4 起,配置服务器必须部署为 CSRS(Config Server Replica Set),即一个 3 节点副本集。

# Config Server 启动参数
mongod --configsvr --replSet configRS --dbpath /data/configdb --port 27019

Config Server 上存储的关键集合包括:

use config

// 所有分片信息
db.shards.find().pretty()

// 所有数据库及其主分片
db.databases.find().pretty()

// 所有集合的分片状态
db.collections.find().pretty()

// 所有 chunk 信息
db.chunks.find().pretty()

// 平衡器锁
db.locks.find({ _id: "balancer" }).pretty()

Config Server 不可用时的行为:

  • 已有数据的读写不受影响(mongos 缓存了 chunk 分布)
  • 新的 chunk 分裂和迁移会暂停
  • 如果 mongos 重启或新的 mongos 启动,将无法获取集群元数据,导致路由失败

因此,Config Server 虽然压力不大(主要是小文档的元数据操作),但其可用性对整个集群的运维操作至关重要。

三、mongos 路由机制与性能考量

3.1 路由原理

mongos 维护一份内存中的 chunk 映射表(从 Config Server 加载并在变更流中增量更新)。当收到客户端请求时,mongos 根据查询条件中的片键值确定目标分片:

定向操作(Targeted Operation):

// 查询包含完整的片键前缀——路由到单一分片
db.orders.find({ user_id: "u123", order_id: "o456" })

// 更新指定 _id——路由到单一分片
db.users.updateOne({ _id: ObjectId("...") }, { $set: { name: "Bob" } })

广播操作(Scatter-Gather):

// 查询不包含片键——需要向所有分片广播
db.orders.find({ status: "pending" })

// 不带片键条件的聚合——全分片聚合后合并
db.orders.aggregate([
  { $group: { _id: "$status", count: { $sum: 1 } } }
])

定向操作只涉及单个分片的连接和查询,延迟最低。广播操作则需要在所有分片上执行查询并在 mongos 上做结果合并,延迟与最慢的分片相当,且 mongos 需要额外的 CPU 和内存进行结果合并。

3.2 mongos 的扩展与部署模式

mongos 是无状态进程,可以随时增删:

# docker-compose 中的 mongos 服务示例
mongos:
  image: mongo:7.0
  command: mongos --configdb configRS/configsvr1:27019,configsvr2:27019,configsvr3:27019 --port 27017 --bind_ip_all

在生产环境中,推荐以下部署模式:

  1. 应用服务器同机部署:每个应用服务器上运行一个本地 mongos,通过 localhost 连接,减少网络跳数
  2. 独立 mongos 集群:部署一组 mongos,前端用 LVS / HAProxy 做负载均衡,应用连接负载均衡地址
  3. Kubernetes Sidecar 模式:每个应用 Pod 中运行 mongos 容器,通过共享网络命名空间直接访问

mongos 数量对性能的影响:

  • 每个 mongos 缓存元数据,大约需要 10-100MB 内存(视 chunk 数量而定)
  • 大量 mongos 同时连接 Config Server 和分片会产生更多连接开销
  • 一般建议每个应用实例或每 2-3 个应用实例共享一个 mongos

3.3 路由性能优化技巧

1. 查询必须包含片键前缀:

// 片键是 { region: 1, user_id: 1 }

// 优秀:包含片键前缀,定向路由
db.orders.find({ region: "US", user_id: "u123" })

// 尚可:只包含前缀第一个字段,定向到 region 分片组的少数 chunk
db.orders.find({ region: "US" })

// 糟糕:不包含片键前缀,广播到所有分片
db.orders.find({ user_id: "u123" })

2. 利用 $in 优化多值查询:

// 对于单字段片键,使用 $in 仍可保持定向
db.users.find({ user_id: { $in: ["u1", "u2", "u3"] } })
// mongos 会将此查询拆分为多个定向查询并行执行

3. 避免频繁更新的非片键字段导致文档迁移:

如果更新操作导致文档的片键值发生变化,MongoDB 需要将该文档从原分片删除并插入到新分片,这个代价极高(尤其是在事务中)。4.2+ 版本允许更新不可变片键的部分字段,但如果片键值本身变化,仍然需要文档迁移。

4. 合理设计索引覆盖查询:

// 创建覆盖索引
db.orders.createIndex({ user_id: 1, status: 1, amount: 1 });

// 查询只从索引获取数据,不需要访问文档本身
db.orders.find(
  { user_id: "u123" },
  { _id: 0, status: 1, amount: 1 }
)

在分片环境下,覆盖索引查询可以显著减少网络传输和 mongos 合并压力。

四、实战:Docker Compose 搭建 3 节点副本集

下面通过 Docker Compose 搭建一个完整的 MongoDB 3 节点 PSS 副本集,并演示基本的运维操作。

4.1 项目结构

mongodb-replica/
├── docker-compose.yml
├── mongo1.key       # 副本集认证密钥
└── init-scripts/
    └── init-replica.js

4.2 docker-compose.yml

version: "3.8"

services:
  mongo1:
    image: mongo:7.0
    container_name: mongo1
    hostname: mongo1
    restart: unless-stopped
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: admin123
    volumes:
      - mongo1_data:/data/db
      - ./mongo.key:/data/mongo.key:ro
      - ./init-scripts:/docker-entrypoint-initdb.d:ro
    command: >
      mongod
      --replSet rs0
      --bind_ip_all
      --port 27017
      --auth
      --keyFile /data/mongo.key
      --oplogSize 512
    networks:
      - mongo-network
    healthcheck:
      test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/admin -u admin -p admin123 --quiet
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  mongo2:
    image: mongo:7.0
    container_name: mongo2
    hostname: mongo2
    restart: unless-stopped
    ports:
      - "27018:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: admin123
    volumes:
      - mongo2_data:/data/db
      - ./mongo.key:/data/mongo.key:ro
    command: >
      mongod
      --replSet rs0
      --bind_ip_all
      --port 27017
      --auth
      --keyFile /data/mongo.key
      --oplogSize 512
    networks:
      - mongo-network
    depends_on:
      - mongo1

  mongo3:
    image: mongo:7.0
    container_name: mongo3
    hostname: mongo3
    restart: unless-stopped
    ports:
      - "27019:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: admin123
    volumes:
      - mongo3_data:/data/db
      - ./mongo.key:/data/mongo.key:ro
    command: >
      mongod
      --replSet rs0
      --bind_ip_all
      --port 27017
      --auth
      --keyFile /data/mongo.key
      --oplogSize 512
    networks:
      - mongo-network
    depends_on:
      - mongo1

volumes:
  mongo1_data:
  mongo2_data:
  mongo3_data:

networks:
  mongo-network:
    driver: bridge

4.3 生成认证密钥

# 生成 756 字节的密钥文件(MongoDB 要求)
openssl rand -base64 756 > mongo.key
chmod 400 mongo.key

注意:在容器环境中,密钥文件权限必须设置为 400,否则 mongod 会拒绝启动。

4.4 初始化副本集脚本

// init-scripts/init-replica.js
rs.initiate({
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017", priority: 2 },
    { _id: 1, host: "mongo2:27017", priority: 1 },
    { _id: 2, host: "mongo3:27017", priority: 1 }
  ]
});

// 等待副本集初始化完成
while (true) {
  const status = rs.status();
  const primary = status.members.find(m => m.stateStr === "PRIMARY");
  if (primary) {
    print("Replica set initialized. Primary:", primary.name);
    break;
  }
  sleep(1000);
}

4.5 启动与验证

# 启动所有节点
docker-compose up -d

# 等待几秒钟让副本集初始化完成
sleep 10

# 连接到 Primary 节点验证状态
docker exec -it mongo1 mongosh -u admin -p admin123 --authenticationDatabase admin --eval "rs.status()"

# 连接到 Secondary 节点验证同步(需要设置 rs.secondaryOk())
docker exec -it mongo2 mongosh -u admin -p admin123 --authenticationDatabase admin --eval "rs.secondaryOk(); db.version()"

4.6 故障转移测试

# 停止 Primary 容器
docker stop mongo1

# 等待 10-15 秒后,查看剩余节点的状态——其中一个会成为新的 Primary
docker exec -it mongo2 mongosh -u admin -p admin123 --authenticationDatabase admin --eval "rs.status()"

# 重新启动原 Primary
docker start mongo1

# 它的状态会恢复为 SECONDARY,并从新的 Primary 同步数据
docker exec -it mongo1 mongosh -u admin -p admin123 --authenticationDatabase admin --eval "rs.status()"

4.7 创建应用用户与读写分离测试

// 以管理员连接到 Primary
mongosh "mongodb://admin:admin123@localhost:27017/admin?replicaSet=rs0"

// 创建应用数据库和用户
use shop
db.createUser({
  user: "appuser",
  pwd: "app123",
  roles: [
    { role: "readWrite", db: "shop" }
  ]
});

// 创建带 TTL 的集合
db.orders.createIndex({ created_at: 1 }, { expireAfterSeconds: 604800 });  // 7天后过期

// 插入测试数据
db.orders.insertMany([
  { order_id: "o1", user_id: "u1", amount: 100, status: "paid", created_at: new Date() },
  { order_id: "o2", user_id: "u2", amount: 200, status: "pending", created_at: new Date() },
  { order_id: "o3", user_id: "u1", amount: 150, status: "shipped", created_at: new Date() }
]);
// Node.js 应用连接示例(读写分离)
const { MongoClient, ReadPreference } = require('mongodb');

const uri = 'mongodb://appuser:app123@localhost:27017,localhost:27018,localhost:27019/shop?replicaSet=rs0';

const client = new MongoClient(uri, {
  readPreference: ReadPreference.SECONDARY_PREFERRED,
  retryWrites: true,
  w: 'majority',
  wtimeoutMS: 5000
});

async function run() {
  await client.connect();
  const db = client.db('shop');

  // 写入操作自动路由到 Primary
  const writeResult = await db.collection('orders').insertOne({
    order_id: 'o4',
    user_id: 'u3',
    amount: 300,
    status: 'paid',
    created_at: new Date()
  });
  console.log('Inserted:', writeResult.insertedId);

  // 读取操作优先从 Secondary 获取
  const orders = await db.collection('orders')
    .find({ user_id: 'u1' })
    .toArray();
  console.log('Orders for u1:', orders);

  await client.close();
}

run().catch(console.error);

4.8 监控与告警

// 副本集健康检查脚本
const primary = rs.isMaster().ismaster;
const members = rs.status().members;
const healthySecondaries = members.filter(m =>
  m.stateStr === "SECONDARY" && m.health === 1
).length;

if (!primary) {
  print("CRITICAL: NO PRIMARY IN REPLICA SET");
} else if (healthySecondaries < 1) {
  print("WARNING: ONLY PRIMARY AVAILABLE, NO HEALTHY SECONDARY");
} else {
  print("OK: Replica set is healthy");
}

// 检查 Oplog 窗口(应至少为 24 小时)
const oplogInfo = db.getReplicationInfo();
const windowHours = oplogInfo.timeDiffHours;
print("Oplog window:", windowHours, "hours");
if (windowHours < 24) {
  print("WARNING: Oplog window is less than 24 hours!");
}

// 检查复制延迟
rs.status().members.forEach(m => {
  if (m.stateStr === "SECONDARY" && m.optimeDate) {
    const lag = new Date() - m.optimeDate;
    print(`Secondary ${m.name} lag: ${lag}ms`);
  }
});

五、维护操作与常见问题

5.1 滚动升级

升级 MongoDB 版本时,采用先 Secondary 后 Primary 的顺序:

  1. 关闭一个 Secondary,升级二进制文件,重启
  2. 等待其同步追上后,升级下一个 Secondary
  3. 对 Primary 执行 rs.stepDown() 降级为 Secondary
  4. 升级原 Primary
// 降级 Primary 触发选举
rs.stepDown(60)   // 60秒内不参与选举

5.2 扩容副本集

// 添加新节点
rs.add({ host: "mongo4:27017", priority: 0, votes: 1 })

// 等待新节点同步完成后,再调整优先级
rs.reconfig({
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017", priority: 2 },
    { _id: 1, host: "mongo2:27017", priority: 1 },
    { _id: 2, host: "mongo3:27017", priority: 1 },
    { _id: 3, host: "mongo4:27017", priority: 1 }
  ]
})

5.3 强制重新同步

当 Secondary 的 Oplog 过期无法继续同步时,需要重新同步:

# 在 Secondary 上停止 mongod,清空数据目录,重启
docker exec mongo2 mongosh -u admin -p admin123 --authenticationDatabase admin --eval "db.shutdownServer()"
docker exec mongo2 rm -rf /data/db/*
docker restart mongo2

# 新启动的 Secondary 会自动触发 Initial Sync

5.4 分片集群初始化概览

虽然分片集群的完整 Docker Compose 篇幅较大,以下是核心命令速查:

# 1. 启动 Config Server 副本集(3节点)
# 2. 启动各 Shard 的副本集(每个 Shard 3节点)
# 3. 启动 mongos,指定 Config Server
mongos --configdb configRS/config1:27019,config2:27019,config3:27019 --port 27017

# 4. 在 mongos 上添加分片
mongosh mongos:27017/admin
sh.addShard("shard0rs/shard0a:27018,shard0b:27018,shard0c:27018")
sh.addShard("shard1rs/shard1a:27018,shard1b:27018,shard1c:27018")

# 5. 启用数据库分片并配置片键
sh.enableSharding("mydb")
sh.shardCollection("mydb.users", { user_id: "hashed" })

# 6. 验证分片状态
sh.status()

六、最佳实践总结

MongoDB 的高可用架构设计需要在数据安全、操作灵活性与成本之间做出权衡。以下是核心原则:

副本集层:

  • 生产环境首选 PSS(3 数据节点)架构,避免对 Arbiter 的过度依赖
  • 合理配置 Write Concern——核心数据用 majority + journal,非核心数据可适当放宽
  • Oplog 大小至少保留 24-48 小时的写入窗口,给初始同步和维护操作留出余量
  • 使用延迟节点作为防误操作的最后一道防线
  • 隐藏节点用于备份和重度分析查询,隔离对业务节点的影响

分片层:

  • 片键选择是架构中最重要的决策,权衡写入均匀性与查询定位效率
  • 对单调递增字段(时间戳、自增 ID)绝不要直接使用 ranged 分片,应配合哈希或复合片键
  • Jumbo Chunk 是慢性毒药,在片键设计阶段就要预防
  • 平衡器设置时间窗口,避免在业务高峰期进行 chunk 迁移

路由层:

  • mongos 应与应用服务同机部署或通过负载均衡器代理,提供高可用的入口
  • 确保所有查询条件都包含片键前缀,这是分片集群性能的生命线
  • 监控 mongos 的 CPU 和内存使用,过多广播查询意味着分片策略需要优化

监控与告警:

  • 监控复制延迟(Secondary Lag),超过 10 秒即应告警
  • 监控各分片的磁盘使用率差异,防止数据倾斜
  • 监控 Config Server 的可用性,虽然数据读写不依赖它,但管理操作会受阻
  • 监控 Oplog 窗口,确保维护窗口内有充足余量

通过副本集保障数据冗余与故障自愈,通过分片实现水平扩展,再配合 Zone 策略与 Read/Write Concern 的精细化调度,MongoDB 可以在大规模生产环境中稳健运行。理解这些机制背后的原理,才能在架构设计与故障排查时做出正确的决策。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 缓存架构演进之路:从单机 Redis 到亿级分布式多级缓存体系
  2. Redis 7.x 重大新特性与架构升级深度解析
  3. Redis 消息队列深度对比:Pub/Sub、Streams 与 Kafka/RabbitMQ 选型指南