$unwind 一次只能展开一层数组,因其设计为单层操作,仅作用于指定字段的直接元素,不递归处理嵌套数组;需按层级顺序使用多个 $unwind,并显式配置 preserveNullAndEmptyArrays: true 以避免文档丢失。

为什么 $unwind 一次只能展开一层数组?
$unwind 的设计就是单层展开:它只作用于指定字段的直接元素,不会递归进入子对象里的数组。比如文档里有 orders 数组,每个 order 里又有 items 数组,$unwind: "$orders" 后,orders.items 仍是数组,不是扁平结构。
常见错误是以为加多个 $unwind 就能“自动穿透”,结果发现第二层展开失败——往往因为某条数据里 orders 是空数组、null 或缺失,触发了默认的 preserveNullAndEmptyArrays: false 行为,整条文档被丢弃。
- 必须显式控制空/缺失值处理,用
{ path: "$orders", preserveNullAndEmptyArrays: true } - 嵌套越深,
$unwind阶段越多,文档爆炸风险越高(1 个 order × 5 items × 3 tags = 15 倍膨胀) - 顺序不能错:先展开外层数组,再展开内层,否则字段路径无效(如
$unwind: "$orders.items"在orders还没展开前会报错Path not found)
如何安全展开 orders → items → tags 三层结构?
假设集合中文档形如:{ _id: 1, orders: [ { items: [ { name: "A", tags: ["x","y"] } ] } ] },目标是让每条 tag 单独成一行。
关键不是堆 $unwind,而是每步都补上容错和路径校验:
[
{ $unwind: { path: "$orders", preserveNullAndEmptyArrays: true } },
{ $unwind: { path: "$orders.items", preserveNullAndEmptyArrays: true } },
{ $unwind: { path: "$orders.items.tags", preserveNullAndEmptyArrays: true } }
]
- 三个
$unwind必须按层级顺序写,路径必须完整("$orders.items"不能简写为"$items") - 每一步都设
preserveNullAndEmptyArrays: true,否则遇到orders: []或orders: [ { items: null } ]就静默丢数据 - 如果某层字段名可能不存在(如部分订单没
tags),可先用$set统一补默认值:{ $set: { "orders.items.tags": { $ifNull: ["$orders.items.tags", []] } } }
性能崩了怎么办?避免 N×M×K 爆炸的实用技巧
三层 $unwind 后文档数可能从 100 条变成几十万条,聚合卡住或内存溢出很常见。这不是配置问题,是数据结构和查询目标不匹配。
- 先用
$facet或$count估算膨胀率:{ $count: "before" }放在第一个$unwind前,再放一个$count在最后,对比数量级 - 如果只是要统计(如“每个 tag 出现几次”),把
$unwind换成$reduce+$map手动摊平,避免中间文档生成:{ $reduce: { input: { $reduce: { input: "$orders", initialValue: [], in: { $concatArrays: ["$$value", "$$this.items"] } } }, initialValue: [], in: { $concatArrays: ["$$value", "$$this.tags"] } } } - 真正需要逐行分析时,考虑提前在应用层或 ETL 阶段物化扁平表,而不是每次聚合硬算
为什么 $unwind 后字段名突然变空或报 undefined?
典型现象:$unwind: "$orders" 后,$orders.items 取不到值,日志显示 undefined。根本原因不是语法错,而是数据不一致。
- 检查原始数据:用
db.collection.findOne({ "orders.0.items.0.tags.0": { $exists: true } })确认是否存在符合路径的样本 - 字段类型混用:有些
orders是数组,有些是对象(如误存为{ orders: { items: [...] } }),$unwind对非数组字段直接返回空文档 - 路径大小写敏感:
"$Orders"和"$orders"是不同字段;MongoDB 不会自动转换 - 聚合阶段执行顺序影响:如果前面有
$project删除了orders字段,后续$unwind就失效
最省事的排查方式是加一个 $addFields 阶段临时打印路径:{ $addFields: { debug_orders: "$orders", debug_items: "$orders.items" } },然后看输出是否符合预期。











