主题
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() 的成员与 arbiterOnly | Arbiter 不存数据、不能作备份 |
| 有效写关注 | hello/writeConcernCount 或驱动日志里的实际 wc | PSA 下 majority 可能退化为 {w:1} |
| 默认值来源 | MongoDB 5.0 起 PSS 的隐式默认是 {w:"majority"};writeConcernMajorityJournalDefault=true 时等价带 j:true | PSA 不享有这个默认 |
| 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 27017javascript
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 + 切元数据,不是双写。