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 秒)
- 新副本集初始化时
选举投票规则:
- 每个节点最多只能投一票
- 候选节点必须是 Secondary 且具有最新的 Oplog
- 节点拥有更高优先级的
priority值时更容易当选 - 获得超过半数(
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: false或w: 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 }
隐藏节点的典型用途包括:
- 专用备份源:避免备份操作影响业务 Secondary 的性能
- 数据分析/报表节点:跑重量级聚合而不占用业务节点资源
- 跨地域复制:将隐藏节点部署在异地数据中心,用于灾难恢复
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 数 | 触发迁移的差异阈值 |
|---|---|
| < 20 | 2 |
| 20 - 79 | 4 |
| >= 80 | 8 |
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
在生产环境中,推荐以下部署模式:
- 应用服务器同机部署:每个应用服务器上运行一个本地 mongos,通过 localhost 连接,减少网络跳数
- 独立 mongos 集群:部署一组 mongos,前端用 LVS / HAProxy 做负载均衡,应用连接负载均衡地址
- 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 的顺序:
- 关闭一个 Secondary,升级二进制文件,重启
- 等待其同步追上后,升级下一个 Secondary
- 对 Primary 执行
rs.stepDown()降级为 Secondary - 升级原 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 可以在大规模生产环境中稳健运行。理解这些机制背后的原理,才能在架构设计与故障排查时做出正确的决策。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。