$unwind + $group 是 MongoDB 数组去重唯一可靠方案,需先展开数组、再分组保留首次出现元素、最后重组;$pull 无法实现内部去重,$addToSet 不保序且分片下结果不一致。

用聚合管道去重:$unwind + $group 是唯一可靠方案
原生 MongoDB 没有类似 $removeDuplicates 的原子操作,直接删数组内重复值必须走聚合管道。核心逻辑是“拆开 → 分组去重 → 重组”,不能靠 $pull 或 $pullAll 解决——它们只删匹配项,不解决数组内部去重。
常见错误是试图用 $pull 配合 $in 去“删掉后面出现的重复”,但 $pull 无法识别“第几次出现”,只会删掉所有匹配值,结果可能全删光或留错位置。
- 必须用
$unwind把数组展开成多条文档,否则$group无法按数组元素分组 -
$group阶段用$first保留第一次出现的元素(即去重后保留首项),不能用$addToSet——它不保证顺序,且在分片集群中可能因数据分散导致结果不一致 - 重组时用
$push聚回数组,注意字段名要和原始数组名一致,否则会新增字段而非覆盖 - 如果数组为空或不存在,
$unwind默认会丢弃整条文档;加preserveNullAndEmptyArrays: true参数可保留
示例(对 tags 数组去重):
db.collection.aggregate([
{ $set: { tags: { $ifNull: ["$tags", []] } } },
{ $unwind: { path: "$tags", preserveNullAndEmptyArrays: true } },
{ $group: { _id: "$_id", tags: { $push: "$tags" } } },
{ $addFields: { tags: { $reduce: {
input: "$tags",
initialValue: [],
in: { $cond: [
{ $in: ["$$this", "$$value"] },
"$$value",
{ $concatArrays: ["$$value", ["$$this"]] }
]
}
}
} } }
])
updateOne + 聚合管道:MongoDB 4.2+ 才支持的就地更新
4.2 之前只能先查再写回,4.2+ 可用 updateOne 直接执行聚合表达式,真正实现“查+改”原子操作。但要注意:这不是语法糖,而是底层调用聚合引擎,性能开销比普通 update 大得多。
容易踩的坑:
- 必须显式指定
arrayFilters或使用$[elem]通配符——但去重场景通常不需要,因为你要重构造整个数组 -
updateOne的聚合 pipeline 写法和aggregate()不同:第一阶段不能是$match,而是直接从$set开始 - 如果集合很大,单次更新可能超时;建议配合
maxTimeMS和分批处理逻辑
安全写法(保留原始结构,仅重写 data 字段):
db.collection.updateMany(
{ data: { $exists: true, $type: "array" } },
[{
$set: {
data: {
$reduce: {
input: "$data",
initialValue: [],
in: {
$cond: [
{ $in: ["$$this", "$$value"] },
"$$value",
{ $concatArrays: ["$$value", ["$$this"]] }
]
}
}
}
}
}],
{ multi: true }
)
分片集群下数组去重要额外防内存溢出
在分片集群上跑聚合去重,$group 阶段要把所有匹配文档拉到 mongos 节点归并,极易触发内存限制。错误信息通常是:Exceeded memory limit for $group, but didn't allow external sort。
这不是代码写错了,而是配置缺失:
- 必须在聚合命令末尾加
{ allowDiskUse: true },否则直接失败 - 不能在
$group的_id中用高基数字段(比如时间戳、ObjectId),会导致分组桶过多 - 优先在
$match阶段缩小数据集——例如加updatedAt: { $gte: ISODate("2026-01-01") }过滤条件 - 如果去重字段本身是分片键(如
{itemId: 1}),那每个分片独立处理即可,无需跨分片归并,性能会好很多
空数组残留字段要不要删?$unset 得手动配
$pull 或聚合去重后,数组可能变成 [],但字段依然存在。MongoDB 永远不会自动删除字段——哪怕它现在是空数组。
业务上是否需要删字段,取决于下游逻辑:
- 如果应用层用
if (doc.tags)判断字段是否存在,那留着空数组会误判为“有数据” - 如果字段是必填项(schema 有校验),空数组可能被拒绝写入,此时必须
$unset - 组合写法:先去重,再用
$unset清理空数组字段,但注意$unset是独立操作,不能塞进同一个聚合 pipeline(除非用$merge)
典型补救步骤:
db.collection.updateMany(
{ tags: { $size: 0 } },
{ $unset: { tags: "" } }
)
真正麻烦的不是怎么删,而是你得想清楚:这个数组字段语义上“空”和“不存在”是否等价。多数时候它们不等价,所以别急着 $unset。


















