MongoDB的聚合管道中,$unwind是一个使用频率非常高的阶段,它能把数组字段拆分成多条独立文档,方便后续做分组统计或者关联查询。但不少人在实际使用时会遇到一个诡异的现象:明明集合里有100条文档,经过$unwind之后再统计,数量却变成了几十条。原因往往就出在空数组上——当数组字段为空数组[],或者字段压根不存在时,$unwind默认会把这条文档直接丢弃。这篇文章就来把这个问题讲透,并给出几种可靠的应对方案。

$unwind的默认行为:为什么空数组会导致文档丢失
先看$unwind的定义:它接收一个数组字段,将数组中的每个元素生成一条新文档,原数组字段被替换为单个元素值。如果数组有5个元素,输出就是5条文档;如果数组只有1个元素,输出1条文档。问题出在边界情况上。
官方文档对$unwind默认行为有明确说明:如果指定字段的值为null、字段不存在,或者字段是一个空数组,那么这条输入文档不会出现在输出结果中。也就是说,$unwind对这些文档执行的不是“展开为零条”并保留逻辑,而是直接静默丢弃。
用一个简单的集合来验证,插入三条测试数据:
db.orders.insertMany([
{ _id: 1, item: "键盘", tags: ["数码", "外设"] },
{ _id: 2, item: "鼠标", tags: [] },
{ _id: 3, item: "显示器" }
])
执行最基础的展开查询:
db.orders.aggregate([
{ $unwind: "$tags" }
])
结果只返回_id为1的两条文档,id为2和3的文档彻底消失了。在订单标签统计这类场景中,这意味着“没有标签的商品”不会进入统计口径,如果业务上需要统计所有商品的数量,结果就会出错。
preserveNullAndEmptyArrays参数:官方推荐的标准解法
要解决文档丢失问题,最直接的方式是使用$unwind的完整语法形式,开启preserveNullAndEmptyArrays选项。它的作用是:当字段为null、不存在或为空数组时,仍然保留这条文档,只是数组字段会以缺失(不存在)的形式输出。
db.orders.aggregate([
{
$unwind: {
path: "$tags",
preserveNullAndEmptyArrays: true
}
}
])
执行后三条文档全部保留,id为2和3的文档中tags字段消失了,而不是变成null或空字符串,这一点在后续阶段处理时需要注意。如果后续用$group按tags分组,这两条文档会被归到_id为null的分组里,通常还需要再做一层处理,比如在分组前用$ifNull给个默认值:
db.orders.aggregate([
{
$unwind: {
path: "$tags",
preserveNullAndEmptyArrays: true
}
},
{
$group: {
_id: { $ifNull: ["$tags", "无标签"] },
count: { $sum: 1 }
}
}
])
需要权衡的一点是,开启该参数后索引利用和文档量都会受到影响。保留下来的文档会进入后续所有管道阶段,如果后面跟着昂贵的$lookup或者$group,这些“本不该存在”的文档也会参与计算。所以是否开启,取决于业务口径:统计分母包含全部数据时必须开,只统计有标签的数据时则不必。
预处理方案:用$addFields和$ifNull主动补默认值
除了官方参数,还有一种更灵活的做法:在$unwind之前先对数组字段做预处理,把空数组、null和缺失字段统一补成一个有内容的数组。这种方式的优点是语义完全由自己控制,比如可以给空数组补一个占位元素。
db.orders.aggregate([
{
$addFields: {
tags: {
$cond: [
{ $gt: [{ $size: { $ifNull: ["$tags", []] } }, 0] },
"$tags",
["未分类"]
]
}
}
},
{ $unwind: "$tags" }
])
上面这段逻辑是:先用$ifNull把缺失字段补成空数组,再用$size判断长度,如果为零就替换成包含“未分类”的数组。这样展开后每条文档都至少有一条输出,而且tags字段始终有值,后续分组统计不需要再处理null的情况。
如果MongoDB版本在3.4以上,还可以用更简洁的$concatArrays写法,把默认值直接拼接上去,但要注意拼接后的数组会包含默认值,可能需要在后续阶段过滤掉。相比之下,$cond判断的写法逻辑更清晰,推荐优先使用。
两种方案的对比与选择建议
下面对比一下两种方案的特点:
| 对比项 | preserveNullAndEmptyArrays | 预处理补默认值 |
|---|---|---|
| 代码简洁度 | 一个参数,简洁 | 需要额外管道阶段,稍复杂 |
| 空数组文档的输出形态 | 文档保留,字段缺失 | 文档保留,字段为默认值 |
| 后续处理成本 | 需处理null分组 | 可直接分组 |
| 灵活性 | 固定行为,不可定制 | 可任意定义默认值 |
实际选型时可以遵循几个原则。如果只是单纯不想丢文档,对字段形态没有要求,用preserveNullAndEmptyArrays: true最省事;如果后续要按该字段分组统计,且希望空值有自己的分组名,预处理补默认值更合适;如果是$unwind之后还要跟$lookup这种重操作,要仔细评估保留的文档量,必要时在unwind前后用$match控制数据规模。
另外提醒一个容易踩的坑:$unwind默认输出中,空数组文档被丢弃的行为在嵌套数组场景下会被放大。比如先展开外层数组再展开内层数组,只要某一层出现空数组,整条分支就会断掉,排查起来相当麻烦。养成在展开前用$project或$addFields检查数据形态的习惯,或者在开发环境先跑一遍$group统计数量核对,能有效避免这类隐性数据丢失问题。
MongoDB聚合管道$unwind空数组preserveNullAndEmptyArrays修改时间:2026-09-05 20:40:48