$group + $first 用于取唯一文档而非删重,适合只读场景;$group + $addtoset + foreach 才用于批量删除重复数据;真正防重应靠写入端唯一索引或 upsert。

用 $group + $first 直接提取去重后的一条文档
这不是“删重复”,而是“取唯一”——适合导出、报表、ETL 等只读场景。核心是把目标字段设为 _id,再用 $first: "$$ROOT" 拿到该组第一条完整文档。
比如按 email 去重并保留最早插入的记录:
db.users.aggregate([
{ $group: { _id: "$email", doc: { $first: "$$ROOT" } } },
{ $replaceRoot: { newRoot: "$doc" } }
])
-
$$ROOT是当前文档全量,$first在 ObjectId 时间有序前提下 ≈ 最早插入(因 ObjectId 前4字节是时间戳) - 必须加
$replaceRoot,否则结果是{_id: "a@b.com", doc: {...}}这种嵌套结构,不便于后续使用 - 若需保留最新一条,改用
$last;但注意:聚合阶段不保证输入顺序,$last只在 pipeline 显式排序($sort)后才可靠
用 $group + $addToSet + forEach 批量删除重复文档
这是线上清理存量重复数据最常用的方式,尤其适用于无法加唯一索引的历史集合。关键点在于:先聚合同字段的 _id 到数组,再剔除一个留用,其余全删。
以 name 和 phone 联合判重为例:
db.users.aggregate([
{ $group: {
_id: { name: "$name", phone: "$phone" },
ids: { $addToSet: "$_id" }
}
},
{ $match: { "ids.1": { $exists: true } } }
]).forEach(function(doc) {
doc.ids.shift(); // 剔除第一个,保留它
db.users.deleteMany({ _id: { $in: doc.ids } });
})
-
$addToSet比$push更安全,避免同一_id因并发被多次加入(虽极少见) -
"ids.1": { $exists: true }是比count: { $gt: 1 }更快的写法,跳过单条记录的$sum计算 - 务必在生产环境加
{ allowDiskUse: true }参数,百万级数据不加会报errmsg: "Sort exceeded memory limit"
为什么不能直接用 distinct + insert 替换整个集合?
这种做法看似一劳永逸,但实际在线上几乎等同于停服+数据丢失风险。
-
db.collection.distinct("email")只返回字符串数组,原始文档里的createdAt、status、嵌套对象全丢了 - 清空再插:执行
db.users.deleteMany({})期间所有写请求失败;即使加事务(4.0+),跨文档事务也无法覆盖整个集合清空操作 - 如果表上有其他唯一索引(如
{userId: 1, type: 1}),新插入顺序不对可能触发DuplicateKeyError,中断流程 - 没有原子性保障:insert 过程中崩溃,集合就处于“半空”状态,业务不可用且难以回滚
真正防重复,应该在写入端做而不是查完再删
聚合去重是救火,不是基建。95% 的重复问题,根源在写入逻辑没约束。
- 新集合上线前,立刻建唯一索引:
db.users.createIndex({ email: 1 }, { unique: true })。后续重复insertOne会抛DuplicateKeyError,捕获后可跳过或告警 - 已有集合但不敢动索引?用
updateOne+upsert: true替代insertOne:db.users.updateOne({ email: "a@b.com" }, { $setOnInsert: doc }, { upsert: true })—— 注意$setOnInsert不会覆盖已有字段,比盲目$set安全 - 绝对别用
save():它已被弃用,行为不明确(存在则$set全字段,不存在则insert),极易掩盖重复逻辑
聚合阶段的去重能力再强,也补不上写入端松散设计带来的数据熵增。留着 $group 救急可以,但别把它当默认方案。











