主题
MongoDB 生产踩坑
一句话:MongoDB 的默认值和自动机制在多数场景下够用,但写关注、分片键、文档大小、cache 和 oplog 这五个地方一旦配错,故障往往在切换或数据量上来时才暴露。
适用版本:MongoDB 7.0 / 8.0。默认值随版本变化,排查前先用命令确认实际配置。
为什么需要
MongoDB 的默认值面向「单机或常规负载下先跑起来」,不是面向故障切换和十亿级数据。写关注决定主节点故障时丢不丢最近写入;分片键决定数据会不会全压在一个 shard;BSON 的 16MB 是硬限制;WiredTiger cache 只装得下热数据;oplog 是有上限的环形缓冲。这五个地方配错,平时可能完全正常,一到主从切换、数据量上涨或消费断连就集中暴露。
核心机制
五类问题共用一条因果链:默认值或数据模型与真实负载不匹配,故障在边界条件被触发。
| 触发条件 | 底层机制 | 典型后果 |
|---|---|---|
| 主节点故障 | 写关注 w:1 只等主节点落盘,未等多数派复制 | 未复制的最近写入丢失 |
| 写入持续增长 | 单调分片键让新 chunk 全落在最后一个 shard | 单 shard 热点、迁移成本高 |
| 数组持续追加 | BSON 单文档上限 16MB | 写入直接失败 |
| 无索引大范围扫描 | 冷页挤占 WiredTiger cache | 热数据被换出,实例整体变慢 |
| 写入快于消费/断连过久 | oplog 环形覆盖旧记录 | 从节点反复 initial sync、Change Stream token 失效 |
最小上手
javascript
// mongosh 7.0/8.0:先确认实际生效的配置,而不是假设默认值
db.orders.getWriteConcern()
rs.status().members.map(m => ({ name: m.name, state: m.stateStr }))
rs.printReplicationInfo()
db.serverStatus().wiredTiger.cache["bytes currently in the cache"]
db.orders.find({ status: "paid" }).explain("executionStats")预期:getWriteConcern() 显示集合实际写关注;printReplicationInfo() 给出 oplog 的时间窗口;explain 中若出现 COLLSCAN,说明该查询正在把冷页读进 cache。
一、写关注不足 → 主从切换丢最近写入
现象:主节点故障,从节点升主后发现最近几秒的写入没了。
根因:写关注是 {w:1}(显式配置,或 PSA 拓扑的默认退化),主节点写完就返回,数据还没复制到多数派;主节点一挂,未复制的写入丢失。
先确认:db.orders.getWriteConcern() 看集合实际生效的写关注。
注意:MongoDB 5.0 起 PSS 副本集的默认写关注已是 majority,不要再假设「默认就是 w:1」;PSA(带 Arbiter)才会退化为 w:1。
处置:核心集合显式 {w:"majority", j:true, wtimeout:5000};日志/埋点等可重放数据才接受 w:1。切换后丢数据不是 bug,是写关注和复制模式的直接结果。
二、分片键选错 → 热点与迁移成本
现象:某个 shard 的 CPU、磁盘和写入量是其他 shard 的数倍。
根因:分片键单调递增(ObjectId、自增 ID、created_at),新写入全部落在最后一个 chunk;或基数太低,chunk 无法继续分裂。
纠正一个过时说法:分片键不是绝对不能改。
- 4.4+:
sh.refineCollectionShardKey()可追加后缀字段,缓解 chunk 无法分裂; - 5.0+:
sh.reshardCollection()可整体更换分片键。
两者都需要额外存储(约 1.2 倍集合大小,以官方文档为准)、IO 和切换窗口,reshard 提交阶段还会短暂阻塞写入,不是免费操作。
处置:上线前用真实量级数据验证写入分布和主要查询路由;分片键要高基数、非单调、覆盖主要查询。已经选错时,先评估 refine 是否够用,再考虑 reshard 或重建。
三、内嵌数组无限增长 → 文档超过 16MB
现象:写入报错 document exceeds maximum allowed size 16MB。
根因:把会持续追加的数据(物流轨迹、评论、日志)内嵌进父文档。16MB 是 BSON 硬限制,不能调大。
处置:
- 会无限增长的关联数据改为引用;
- 需要首页展示的,用 Subset 只嵌最近 N 条,全量另存;
- 监控
db.collection.stats().avgObjSize和最大文档大小。
四、全表扫描把 WiredTiger Cache 打翻
现象:一个查询突然变慢,随后所有查询都变慢,实例整体延迟上升。
根因:无索引查询触发 COLLSCAN,从磁盘读入大量冷页,把索引和热数据挤出 WiredTiger cache;其他查询被迫回磁盘,形成连锁反应。
处置:
- 用
db.currentOp()抓运行中的 COLLSCAN; - 用
system.profile/ 慢日志定位查询,explain确认缺哪个索引; - 先 kill 长扫描止血,再补索引;不要靠加内存掩盖。
五、oplog 窗口不够 → 从节点追不上 / Change Stream 断点失效
现象 A:从节点长期 RECOVERING,或 initial sync 反复失败。
现象 B:Change Stream 报 resume token not found。
根因:oplog 是有上限的环形缓冲,写入太快或断连太久,旧记录被覆盖。从节点 initial sync 期间如果 oplog 被覆盖,只能重新开始;Change Stream 的 resume token 指向已被覆盖的位置,也只能重刷。
术语纠正:从节点不是「加载 RDB」(那是 Redis 的术语)。MongoDB 从节点走 initial sync:复制集合数据、建索引、再追 oplog。进程崩溃恢复才是 checkpoint + journal replay,两者不要混为一谈。
处置:
- Linux 64 位 oplog 默认约磁盘 5%、最少 990MB(以官方文档为准),这是下限不是建议值;按「写入速率 × 最长断连时间 × 2」复核;
rs.printReplicationInfo()看 oplog 时间窗口,rs.printSecondaryReplicationInfo()看 lag;- Change Stream 消费者持久化 token,并让 oplog 窗口覆盖最长消费中断。
生产动作
- 先确认默认值:
db.collection.getWriteConcern()、rs.status()、sh.status()、db.serverStatus().wiredTiger.cache。 - 慢查询:
db.currentOp()抓长操作;system.profile按millis和planSummary聚合。 - 复制:
rs.printReplicationInfo()看窗口,rs.printSecondaryReplicationInfo()看延迟。 - 分片:
sh.status()看 chunk 分布、jumbo chunk 和 balancer 窗口。
边界
- 这些坑的共同点是「配置和模型问题」,不是靠加机器能根治;先改配置/模型,再扩容量。
w:"majority"降低丢数据风险,但不等于强一致读;需要读己之写还要配readConcern:"majority"和primary读偏好。- reshard 能改分片键,但有成本;不要把它当成日常调参手段。
- oplog 调大只是延长窗口,不能替代备份和 Change Stream 的幂等消费。
讲清楚
MongoDB 生产事故大多不是代码写错,而是默认值和数据模型在故障时才暴露。写关注不足会在主从切换时丢最近写入,先看集合实际生效的写关注;分片键单调递增会造成热点,4.4+ 能 refine、5.0+ 能 reshard,但都要额外存储和窗口;内嵌数组无限长会撞 16MB;无索引查询会把 cache 打翻,拖慢整个实例;oplog 窗口不够会让从节点反复 initial sync、让 Change Stream 的 token 失效。
排查顺序是:先用命令确认实际配置,再看 slow query / currentOp / rs.status / sh.status,最后才考虑扩容。把「从节点加载 RDB」这类 Redis 术语套到 MongoDB 上,说明还没分清 initial sync、journal 恢复和 Change Stream 三件事。