跳到正文

MongoDB 副本集与分片 ​

一句话:副本集用多数派选举换高可用,分片用分片键把数据摊到多个副本集换水平扩展;代价是跨分片查询要广播、跨分片事务要两阶段提交,而且分片键选错后调整成本很高。

适用版本:MongoDB 7.0 / 8.0;分片键变更能力自 4.4(refine)和 5.0(reshard)起不同,下面分别标注。

为什么需要 ​

单节点 MongoDB 有两个天花板:机器坏了就不可用;数据量和写入量超过单机就只能换更大的机器。副本集解决第一个问题:多副本 + 多数派选举,主节点故障后自动切换。分片解决第二个问题:按分片键把集合拆成 chunk,分散到多个 shard,每个 shard 本身又是一个副本集。

两者解决的是不同层面的问题:副本集保证可用性和数据冗余,分片保证容量和写入吞吐。生产上通常是「分片集群里每个 shard 都是副本集」。

核心机制 ​

副本集角色与选举 ​

角色职责数据投票
Primary唯一接受写,默认也接受读完整数据是
Secondary复制 oplog,可配置只读完整数据是
Arbiter只参与选举,不存数据无是
Delayed Secondary延迟复制,用于误删恢复落后 N 秒是
Hidden Secondary对客户端不可见,用于备份/报表完整数据是
  • 节点间心跳约每 2 秒一次;electionTimeoutMillis 默认 10000ms,超过后触发选举。
  • 候选人必须拿到多数派投票才能成为 Primary;投票时会比较 oplog 进度,数据更新的节点优先。
  • priority 影响选举倾向,priority:0 的节点不会主动竞选;rs.stepDown() 可主动让位。
  • 这套协议是 Raft 风格的多数派选举,不是「谁网络延迟低谁当选」。

3 个数据节点(P+2S)最多容忍 1 个节点故障;5 个节点最多容忍 2 个。Arbiter 能降低存储成本,但它不存数据、不能备份,PSA 拓扑在只剩一个数据节点时风险很高。

写关注、读关注与读偏好 ​

写关注(Write Concern):

配置含义适用
{w:0}不等待确认可丢的埋点
{w:1}主节点确认可重放数据;PSA 拓扑的默认值
{w:"majority"}多数派确认核心数据;PSS 拓扑自 5.0 起的默认值
{w:"majority", j:true}多数派 + journal 落盘支付、订单等不能丢的数据

MongoDB 5.0 起,PSS 副本集的隐式默认写关注是 {w:"majority"};writeConcernMajorityJournalDefault=true(默认)时等价于带 j:true。PSA 拓扑因为 Arbiter 不算数据节点,会退化为 {w:1}。wtimeout 默认 0,表示不超时;核心写操作应显式设置,避免节点不足时无限阻塞。

读关注(Read Concern):默认 local,读当前节点最新数据;majority 只读已多数派确认的数据;linearizable 只对主节点读有效,最强也最慢;snapshot 用于事务。写 majority + 读 majority 才能保证「读到自己刚写的已确认数据」。

读偏好(Read Preference):primary(默认)、primaryPreferred、secondary、secondaryPreferred、nearest。报表和离线任务可以走 secondary,但要接受复制延迟;核心读不要为了分流牺牲一致性。

分片集群的组件 ​

  • mongos:解析查询、提取分片键、查 config server 缓存、转发到目标 shard,并合并结果。它不存数据。
  • Config Server:必须是副本集(CSRS);存集群元数据。它挂了,集群不能做元数据变更,已有路由也可能受影响。
  • Shard:存实际数据;每个 shard 是一个副本集,有自己的 Primary/Secondary。

请求路由:查询条件包含分片键 → 精确路由到少量 shard;不包含 → 广播到所有 shard,再在 mongos 合并。分片键是否覆盖主要查询,直接决定分片是加速还是只增加了复杂度。

分片键与 chunk ​

三种策略:

  • 范围分片:{createdAt:1},范围查询友好,但单调递增字段会把写入集中到最后一个 shard。
  • 哈希分片:{userId:"hashed"},写入均匀,但范围查询要广播,且不能利用索引做范围裁剪。
  • 复合分片:{tenantId:1, createdAt:1},兼顾多租户隔离和范围查询;前缀字段决定分布。

选键的四条判据:基数足够高、写入不单调、覆盖主要查询、值不可变或可接受调整。低基数字段(status、gender)和数组字段都不适合做分片键。

分片键不是永远不能改:

  • 4.4+ 可用 sh.refineCollectionShardKey() 给现有分片键追加后缀字段,缓解 chunk 无法分裂的问题;它不会立即重分布数据。
  • 5.0+ 可用 sh.reshardCollection() 整体更换分片键;需要额外存储(约 1.2 倍集合大小,以官方文档为准),过程中有短暂写入阻塞,且提交后不能回滚。
  • 这两个操作都不等于「免费改键」;能一次选对仍然是最优解。

Chunk 默认大小 128MB(MongoDB 6.0 起;更早版本为 64MB)。chunk 超过阈值会分裂;balancer 比较各 shard 的 chunk 数,超过迁移阈值后搬迁。balancer 运行在 config server 副本集的 Primary 上(3.4 起),不是 mongos。迁移流程是:克隆数据 → 追 oplog → 进入 critical section → 更新 config server 元数据 → 删除源数据;不是「同时写两个 shard」。

实战场景 ​

一、PSA 拓扑上写了 w:"majority",锁等待却变成无限阻塞 ​

现象:三节点副本集用的是 Primary + Secondary + Arbiter(PSA)。核心写操作按规范配了 writeConcern: {w:"majority"}。某次 Secondary 因磁盘故障下线,写操作开始大面积超时——而 Arbiter 仍在,多数派在票数上“是够的”。更糟的是部分驱动调用没有设 wtimeout(默认 0,表示不超时),线程一直挂着。

排查:多数派按数据节点算,票数够不等于数据能确认。

定位层取证据判定
拓扑形态rs.conf() 的成员与 arbiterOnlyArbiter 不存数据、不能作备份
有效写关注hello/writeConcernCount 或驱动日志里的实际 wcPSA 下 majority 可能退化为 {w:1}
默认值来源MongoDB 5.0 起 PSS 的隐式默认是 {w:"majority"};writeConcernMajorityJournalDefault=true 时等价带 j:truePSA 不享有这个默认
wtimeout连接串是否设 wtimeoutMS默认 0 即无限阻塞
选举影响只剩一个数据节点时的选举结果PSA 在只剩一个数据节点时风险很高
复制滞后rs.printReplicationInfo() / rs.printSecondaryReplicationInfo()oplog 窗口不足会触发重同步

对症做法:

text
1. 核心业务用 PSS(Primary + 2 Secondary),不用 PSA:
   Arbiter 只省磁盘,代价是“数据节点只剩两个”,
   任一数据节点故障时写入确认能力就没了。
2. 写关注按数据分级,全部显式设置:
   - 支付、订单:{w:"majority", j:true} + wtimeoutMS
   - 可重放数据:{w:1}
   - 可丢埋点:{w:0}
3. wtimeout 必须设:写超时要变成可观测的失败,不是线程堆积。
4. oplog 大小要能覆盖“最长恢复时间 × 写入速率”,
   否则 Secondary 重启后追不上只能全量重同步(Initial Sync)。
javascript
// 驱动侧显式设置(Node 驱动示例)
const coll = db.collection("orders").withWriteConcern({ w: "majority", j: true, wtimeout: 5000 });

// 检查当前配置与真实确认能力
rs.conf();                                     // 看 members 里是否有 arbiterOnly
rs.printSecondaryReplicationInfo();            // 看 lag 与 oplog 窗口
db.getReplicationInfo();                        // 估算 oplog 能撑多久

复盘要点:判据是“生产副本集里 PSA 拓扑的核心业务占比为 0”,以及“所有写连接串都带 wtimeoutMS”。演练口径:kill 唯一 Secondary,断言核心写仍能在 wtimeout 内成功或明确失败,不出现线程挂死。这套协议是 Raft 风格的多数派选举,投票时比较 oplog 进度、数据更新的节点优先,不是“谁网络延迟低谁当选”——所以拓扑设计决定的是数据能否被确认,而不是选主快慢。

二、单调递增的分片键把写入打进一个分片 ​

现象:IoT 设备上报按 {device_id: 1} 升序作为分片键,写入压测正常,两周后监控出现明显不对称:最新的 chunk 全集中在一个 shard,该 shard 磁盘 IO 与 CPU 95%,其余 shard 空闲;balancer 频繁迁移, mongos 延迟升高,写入偶发超时。

排查:单调递增键让范围分片的写入永远落在最后一个 chunk。

定位层取证据判定
分片键单调性键是否时间戳/自增序列单调即写入集中
chunk 分布sh.status() 与 db.collection.getShardDistribution()某 shard 承载绝大多数数据即命中
balancer 活动迁移日志与 jumbo chunk 记录写入热点会被反复切分又集中
查询覆盖核心查询是否带分片键不带则广播到所有 shard
基数分片键取值数量与 shard 数比值低基数限制可分片粒度
唯一性需求是否依赖分片键保证唯一分片集合的唯一索引只在全局分片键上成立

对症做法:

javascript
// 1. 哈希分片键:写入均匀,代价是按值查询必须广播(适合设备上报这种写多读少)
db.device_data.createIndex({ device_id: "hashed" })
sh.splitAt("test.device_data", { device_id: NumberLong("...") })   // 预分片避免启动期集中

// 2. 复合键把单调性打散:高位放散列、低位放时间,兼顾均匀与范围查询
{ shard_bucket: NumberInt(<device_id % 64>), ts: -1 }

// 3. 用粗粒度 + 时间组合:既均匀又能按时间归档与删除
{ tenant_hash: NumberInt(...), day: 1 }

// 4. 预创建 chunk(presplit):按预期写入速率一次切够,
   避免 balancer 在写入高峰反复自动切分与迁移
javascript
// 查看分布与热点
db.device_data.getShardDistribution();
sh.status();                                  // chunk 数量与各 shard 区间
db.chunk_migrations.find().sort({ time: -1 }).limit(20);
text
5. 按时间范围分片适合归档,但要知道热点在最新 chunk——
   配合分桶或哈希前缀打散写入,再按 chunk 区间做 TTL/归档。
6. 查询维度与分片键不一致时,用“映射集合”或把多维检索交给搜索引擎,
   不要让 mongos 广播全部 shard 来支持运营查询。

复盘要点:判据是“各 shard 的数据量与写入速率标准差”,以及“balancer 在高峰时段不迁移”。分片键一旦选定,改键的代价等于重建集合(新集合 + 双写 + 迁移 + 切别名),所以它和设计索引一样是“上线前决定、上线后难改”的项;balancer 的窗口与 jumbo chunk 处置见 MongoDB 生产踩坑。

三、readPreference: secondary 让“写完读不到”,投诉集中在客服场景 ​

现象:为了分摊主节点压力,把查询设为 secondaryPreferred。用户提交退款后立即查看,页面显示“未提交”;客服在工作台连续操作同一单据时,两次查询结果互相矛盾。rs.printSecondaryReplicationInfo() 显示部分 Secondary 有几百毫秒到几秒的复制延迟。

排查:Secondary 可读是能力,读到旧数据是它的默认代价。

定位层取证据判定
复制延迟rs.printSecondaryReplicationInfo() 的 lag 分布有 lag 就可能读旧
会话保证是否开启 causal consistency / 逻辑会话无保证则 read-your-writes 不成立
读偏好配置连接串 readPreference 与是否为写后读单独设 primary全局 secondaryPreferred 是常见误配
读关注readConcern: local vs majority后者能避免读到未确认数据
隐藏成员用途Hidden Secondary 是否被误当作读节点它对客户端不可见,应留给备份/报表
Delayed Secondary延迟副本被误加入读池它会返回几分钟前的数据

对症做法:

javascript
// 1. 写后读用同一逻辑会话 + 因果一致性:驱动默认对会话内读做 oplog 水位等待
const session = client.startSession();
await coll.withWriteConcern({ w: "majority" }).insertOne(doc, { session });
// 同一会话内的后续读会等待读到自己的写
const mine = await coll.withReadConcern({ level: "majority" })
                       .find({ _id: doc._id }, { session }).toArray();

// 2. 按操作分级,而不是全局一个 readPreference
//    单据详情、状态确认 → primary;列表、报表、导出 → secondaryPreferred
const readColl = coll.withReadPreference(secondaryPreferred);   // 只用于分析类
text
3. 写连接和读连接分开配置,避免同一个池里两种语义混用;
   Delayed/Hidden 成员不进入读池,只承担恢复与备份。
4. 业务能接受旧数据的时间要写进接口文档:
   “本页面数据延迟 ≤ 5 秒”,比事后解释“读不到”更省事。
5. 需要“读己之写”但没有会话条件时,退化成写后短时间读主(与 MySQL 读写分离同理)。

复盘要点:判据是“写后立即读的路径必须落在 Primary 或带因果保证的会话内”,可以用一条端到端测试固化:写入后 100ms 内读,断言读到自己的写。默认写关注、读偏好与 wtimeout 这三组值决定事故形态,和 MySQL 读写分离里“默认值决定事故形态”是同一条规律;Change Stream 的一致性语义见 事务与 Change Stream。

最小上手 ​

bash
# 三个 mongod 实例分别启动后,在其中一个上初始化副本集
mongosh --port 27017
javascript
rs.initiate({
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017", priority: 2 },
    { _id: 1, host: "mongo2:27017" },
    { _id: 2, host: "mongo3:27017" }
  ]
})
rs.status()   // 期望一个 PRIMARY、两个 SECONDARY

// 分片集群(mongos 已启动)上启用分片
sh.enableSharding("app")
sh.shardCollection("app.orders", { customerId: "hashed" })
sh.status()
db.orders.getShardDistribution()

sh.status() 能同时看到分片键、chunk 分布和 balancer 状态;getShardDistribution() 用来验证写入是否均匀。

生产动作 ​

  • 拓扑:3 个数据节点跨可用区;不要用 Arbiter 替代第二个数据节点。5 节点用于跨机房或更高容灾等级。
  • 写关注:核心集合显式 {w:"majority", j:true, wtimeout:5000};不要依赖「默认值」在不同拓扑下可能变化。
  • oplog:Linux 64 位默认约为磁盘的 5%,最少 990MB(以官方文档为准);按「写入速率 × 最长允许断连时间 × 2」复核,rs.printSecondaryReplicationInfo() 的 lag 超过 30 秒告警。
  • 分片键验证:上线前用真实量级数据 bulk insert,检查各 shard 写入均匀;用主要查询 explain 确认是否只路由到目标 shard。
  • balancer 窗口:业务高峰期可关闭或限定窗口:
    javascript
    sh.setBalancerState(false)   // 临时停止
    db.settings.updateOne(
      { _id: "balancer" },
      { $set: { activeWindow: { start: "02:00", stop: "06:00" } } }
    )
  • 监控:sh.status() 看 chunk 分布与 jumbo chunk;db.currentOp() 看迁移和长查询;config server 的磁盘与选举状态单独监控。

边界 ​

  • 跨分片查询会广播:分片键不覆盖查询条件时,mongos 要等所有 shard 返回,延迟由最慢的 shard 决定。
  • 跨分片事务代价高:两阶段提交 + 锁,能按分片键把数据放在同一 shard 就不要跨片。
  • 哈希分片牺牲范围查询:等值路由很好,范围查询要扫所有 shard。
  • 单调递增分片键会热点:ObjectId、自增 ID、createdAt 做范围分片时,新写入集中在一个 shard。
  • reshard 不是无感操作:需要额外磁盘和 IO,提交阶段会短暂阻塞写入,必须提前演练。
  • Arbiter 不提供数据冗余:它只投票,不能备份,也不能在只剩一个数据节点时救场。

讲清楚 ​

副本集和分片解决的是两个问题。副本集用多数派选举保证主节点故障后自动切换,3 个数据节点最多挂 1 个;写关注决定「写到哪里才算成功」,5.0 起 PSS 默认是 majority,PSA 会退化成 w:1,所以核心数据要显式配置。分片用分片键把数据拆成 chunk 放到多个 shard,mongos 负责路由,config server 存元数据。

分片键是架构决策:要高基数、写入不单调、覆盖主要查询。选错了,4.4+ 可以 refine 追加后缀,5.0+ 可以 reshard 整体更换,但都要额外存储和停机窗口,不是免费操作。chunk 默认 128MB,balancer 在 config server 主节点上跑,迁移是克隆 + 追 oplog + 切元数据,不是双写。

把一门技术讲透到既懂也会用