子文档数量稳定且≤20可嵌入,如收货地址;orders等无上限数据必须引用,否则易触documenttoolarge错误并引发性能与维护问题。

子文档数量是否稳定且 ≤20?看 addresses 而不是 orders
内嵌只在「一对少、增长可控」时安全。比如用户收货地址,平均每人 3–8 条,历史月增不到 1 条,db.users.findOne().addresses.length 查出来方差小、最大值 ≤20 —— 这类可以嵌入。
但 orders、comments、logs 这类天然增长无上限的字段,哪怕当前平均只有 5 条,也要立刻拒绝嵌入:业务跑三个月可能就涨到 200+,触发 DocumentTooLarge 错误(16MB 限制),且无法在线收缩。
- 用
db.users.aggregate([{$project: {cnt: {$size: "$addresses"}}}, {$group: {_id: null, avg: {$avg: "$cnt"}, max: {$max: "$cnt"}, std: {$stdDevSamp: "$cnt"}}}])快速验证分布 - 若子文档含二进制字段(如
thumbnail、transcript),哪怕只有 3 条,也建议引用——单文档体积膨胀比数量更致命 - 别信“现在不多”,查最近 90 天日志里该字段的新增频率,按线性外推 6 个月
是否需要按子文档条件查询或排序?警惕 $elemMatch 和内存排序
嵌入后一旦要查“所有 status 为 pending 的订单”,就得写 db.users.find({ "orders.status": "pending" }) 或更重的 $elemMatch。这类查询无法跳过数组中不匹配项,性能随数组长度线性下降,且 "orders.status" 索引效果极差。
同样,想“取最新 5 条订单”,嵌入方案必须把整个 orders 数组拉出来,在内存里 .sort() 再 .slice(5);而引用方案可直接 db.orders.find({ user_id: ObjectId("...") }).sort({ createdAt: -1 }).limit(5) 走索引。
- 聚合中用
$unwind拆嵌入数组,数据量 >10k 就容易 OOM 或超时 - 只要存在任意一个按子文档字段筛选/分页/聚合的需求,优先选引用
-
$lookup配合{ from: "orders", localField: "_id", foreignField: "user_id", as: "orders" }+db.orders.createIndex({ user_id: 1, createdAt: -1 })是标准解法
子文档是否有独立生命周期或被多方引用?检查 _id 类型和索引
如果子数据要单独更新、审计、被支付系统或通知服务直连消费,它就不是附属,而是实体。硬嵌入会破坏单一数据源原则,导致状态不一致。
例如一条日志被 3 个报警规则触发,嵌入无法表达这种多对一关系;而引用只需在 alert_rules 集合里存 log_id: ObjectId("...") 即可。
- 确保引用字段是
ObjectId类型,不是字符串"507f1f77bcf86cd799439011",否则db.orders.find({ user_id: ... })变成全表扫描 - 所有引用字段必须建索引:
db.orders.createIndex({ user_id: 1 }),否则$lookup性能断崖下跌 - 事务需求明确(如“扣款 + 更新订单 + 发通知”需原子性)时,MongoDB 多文档事务只支持跨集合引用模型
读取模式是否总是“父+全部子”一起加载?别高估缓存收益
嵌入唯一不可替代的优势,是单次查询返回完整视图,省掉一次网络往返。但这只在两种情况下真正划算:
- 前端页面每次打开都必须展示全部子数据(如用户资料页固定显示全部 5 个标签)
- 子文档极小(纯字符串/数字)、无二进制、且数量稳定
其他情况,所谓“省一次查询”的收益常被掩盖:N+1 问题可通过批量 $lookup、连接池复用、应用层缓存缓解;而嵌入带来的文档膨胀、更新锁竞争、索引失效风险却是实打实的。
最容易被忽略的是:当子文档字段本身需要频繁局部更新(比如只改某条订单的 tracking_number),嵌入意味着重写整个用户文档,WiredTiger 页面级锁会阻塞其他字段更新——这比一次额外查询代价高得多。











