$push不加限制会导致文档无界增长,引发整文档重写、索引膨胀和缓存污染;应使用$push+$each+$slice原子截断,或拆分为独立集合。

为什么$push不加限制会让文档悄悄变大又变慢
数组无界增长不是“数据多”的问题,而是模型错配——MongoDB把本该分散的“一对多”关系硬塞进单文档,每次$push都可能触发整文档重写、索引膨胀、内存缓存污染。典型症状不是立刻报错,而是查询延迟随天数线性上升、moveChunk failed、Document too large for the collection等错误在某天突然爆发。
- 文档体积超16MB时写入直接失败,但更常见的是:5万条日志塞进
user.events数组后,一次$addToSet更新卡住几秒(它要遍历整个数组比对) - WiredTiger缓存里
bytes currently in the cache持续逼近上限,但热数据占比很低——因为每次读user.profile都得把整个含500条历史记录的文档加载进内存 - 复制延迟升高:主节点写完,从节点还在重放那个2MB文档的完整重写操作
用$push+$each+$slice原子截断,别靠$pop或$slice事后补救
已有大数组时,$pop或单独$slice只是临时削掉尾巴,下次$push仍会突破上限。真正安全的做法是用$push带$each和$slice组合,在一次原子操作中完成追加+截断。
- 只保留最近100条登录IP:
{ $push: { ips: { $each: ["192.168.1.1"], $slice: -100 } } } - 保留最近7天操作日志:
{ $push: { actions: { $each: [newAction], $slice: -500 } } }(预估每天约70条) - 注意
$slice: -N保留末尾N项,$slice: N保留开头N项;负数更符合“最新N条”场景 - 这个操作是原子的,不会出现中间态文档超限
什么时候必须拆成独立集合,而不是硬撑嵌套
判断标准很直白:只要数组元素本身有独立ID、需要单独查/改/聚合,或者单个元素超过1KB,就必须拆。MongoDB不是JSON桶,它是为“合理粒度文档”设计的。
- 反例:
user.orders数组里塞几十个订单详情(每个含商品列表、地址、支付信息) - 正例:建
orders集合,字段含userId、createdAt、total等核心字段,加索引{ userId: 1, createdAt: -1 } - 迁移别用应用层逐条读写——用
db.users.aggregate([{$unwind: "$orders"}, {$project: {...}}]).forEach(...)或直接管道写入新集合,避免网络往返开销 - 删除用户时,必须同步执行
db.orders.deleteMany({ userId: "xxx" }),否则引用失效变成脏数据
如何提前发现“悄悄长胖”的文档
靠人工查不到问题,等报错就晚了。关键是建立体积基线并自动告警,而不是等Document too large才动手。
- 定期运行
db.collection.stats().size / db.collection.stats().count算平均文档大小,设置阈值(比如>50KB触发告警) - 用
db.collection.find().sort({ _id: -1 }).limit(1).explain("executionStats")看单个大文档实际加载体积 - 监控
db.serverStatus().wiredtiger.cache["bytes currently in the cache"]与maximum bytes configured比值,持续>85%且热数据少,大概率是大文档污染缓存 - 字段级检查:
db.collection.aggregate([{$project: {len: {$strLenCP: {$toString: "$$ROOT"}}}}], {allowDiskUse: true})找出异常大的原始文档
真正麻烦的不是单次修复,而是业务逻辑里那些“默认追加、从不清理”的隐式假设——它们藏在代码角落,直到某天某个数组突破临界点,整个集合开始抖动。











