真正危险的是无界数组破坏原子性,导致文档迁移拖慢写入、卡住同步;数组是否无界取决于业务语义,用户日志等须拆至独立集合,带_id或超1KB元素必须建新集合。

文档体积超16MB只是表象,真正危险的是无界数组破坏原子性保证
MongoDB单文档16MB限制不是性能瓶颈的起点,而是崩溃前的最后警告。无界数组让每次 $push 都可能触发文档迁移——因为BSON要求文档在磁盘上连续存储,一旦原位置放不下新数据,整个文档就得搬走、重写、更新所有索引项。这会直接拖慢写入吞吐,还可能卡住复制集同步线程。
更隐蔽的问题是:迁移失败时错误日志里往往只报 moveChunk failed 或 Document too large for the collection,但实际原因可能是某条用户文档的 logs 数组已膨胀到8MB,而你还在用应用层轮询方式往里追加。
数组增长 ≠ 数据增长,业务语义决定是否该拆
判断一个数组是否“无界”,不能只看它当前有多大,而要看它是否天然具备无限累积属性:
- 用户操作日志、设备心跳记录、API调用审计 —— 这些必须拆到独立集合,哪怕现在才几十条
- 用户收货地址列表、课程章节结构、订单商品快照 —— 这些有明确生命周期和访问模式,可安全嵌入
- 评论、点赞、收藏 —— 看查询场景:如果前端只加载最新20条且不翻页,
$push+$slice是合理选择;如果要按时间聚合或分页查全部,则必须引用
关键信号是:只要数组元素本身带 _id、需要单独更新(比如标记某条评论为“已审核”)、或单个元素超过1KB,就该立刻建新集合。
$addToSet 和 $push 的性能陷阱藏在数组长度里
$addToSet 不是免费的去重方案。它底层会遍历整个数组做全量比对,当数组已有5万条时,一次更新可能耗时数秒——而你根本没意识到这是自己埋的雷。
更糟的是,$addToSet 对嵌套文档无效:{ a: 1, b: 2 } 和 { b: 2, a: 1 } 被视为不同对象,照样重复插入。如果你真需要去重,优先考虑集合级唯一索引(如 { userId: 1, ip: 1 }),再配合应用层判重。
如果只是想保序+截断,用 $push + $each + $slice 更可靠:
{ $push: { ips: { $each: ["192.168.1.1"], $slice: -100 } } }这个操作是原子的,不会因并发写入导致数组超长。
监控“悄悄长胖”的文档比修复更重要
靠人工查 db.collection.findOne() 发现不了问题。等报错时,往往已有数百个文档逼近16MB红线。
必须建立自动化基线检查:
- 每天跑聚合统计:
db.collection.aggregate([ { $project: { size: { $bsonSize: "$$ROOT" } } }, { $group: { _id: null, avg: { $avg: "$size" }, max: { $max: "$size" } } } ]) - 对高风险字段(如
logs,history,events)单独加告警:当$bsonSize> 2MB 且数组长度 > 1000 时触发 - 迁移旧数据别用应用层循环:用
db.oldCollection.aggregate([{ $unwind: "$logs" }, { $project: { userId: 1, log: "$logs", _id: 0 } }]).forEach(...)效率极低;改用管道直写新集合
最常被忽略的一点:文档“变胖”很少是突然发生的,而是每天多几条、每周涨几百字节,直到某次上线新功能批量写入,直接压垮集群。提前踩住油门,比事后换胎省力得多。


















