不能靠 distinct 或单次 update 实现“去重并留最新”,必须用聚合管道 $group + $sort + $limit 配合 $out/$merge 写入新集合,或应用层分页读取、内存去重后批量 replaceOne。

直接结论:不能靠 distinct 或单次 update 实现“去重并留最新”,必须用聚合管道 + $group + $sort + $limit 配合写入新集合,或用 find + 应用层去重再批量替换——没有原子化的一行命令能同时完成“识别重复+选最新+删旧”。
为什么 distinct 不能用来“保留最新一条”
distinct 只返回字段值的唯一集合,不带任何文档上下文,也不返回原始文档本身。它返回的是一个数组,比如 ["user_123", "user_456"],你根本不知道哪条是最新、哪条对应哪个时间戳或 _id。更关键的是,distinct 不做任何删除或更新动作,纯属只读查询。线上误以为加个 query 参数就能筛出“最新”,结果发现连时间字段都拿不到。
真正可行的两步法:聚合分组 + 覆盖写入
核心思路是把“按某字段去重”转化为“按该字段分组,每组取 _id 或 timestamp 最大的那条”。MongoDB 4.4+ 支持在 $group 阶段用 $max 提取最大值,但要拿到整条文档,得配合 $sort 和 $first(注意不是 $max):
- 先
$sort确保最新文档排第一(例如按created_at降序) - 再
$group按去重字段(如user_id)分组,用$first: "$$ROOT"拿到每组首条文档 - 最后用
$out写入新集合,或$merge替换原集合(慎用,会清空原集合)
示例命令(保留每个 user_id 下 created_at 最新的完整文档):
db.logs.aggregate([
{ $sort: { user_id: 1, created_at: -1 } },
{ $group: {
_id: "$user_id",
doc: { $first: "$$ROOT" }
}
},
{ $replaceRoot: { newRoot: "$doc" } },
{ $out: "logs_deduped" }
])
用 $merge 替换原集合时的三个硬坑
$merge 看起来比 $out 更安全(支持 on、whenMatched 等),但实际踩坑率极高:
- 目标集合必须已存在,且不能是空集合——否则
$merge会静默失败,不报错也不写入 -
on: ["_id"]是默认行为,但如果你去重依据是user_id,就必须显式写on: ["user_id"],否则每条文档都当新文档插入,重复翻倍 -
whenMatched: "replace"会完整替换整条文档,如果原集合有其他字段(比如运维打的标记字段processed_by),会被一并抹掉
高频场景下更稳的选择:应用层控制 + 批量 replaceOne
当去重逻辑复杂(比如要比较嵌套字段、或需调用外部服务校验)、或数据量不大(百万级以下)、或不允许停写时,别硬刚聚合管道。改用程序读一批、内存去重、再批量替换更可控:
- 用
find({}).sort({created_at: -1}).limit(1000)分页拉数据,避免内存爆掉 - Python 里用
dict按user_id去重:latest[user_id] = doc,天然覆盖旧值 - 构造
replaceOne批量操作列表,每批最多 1000 条(MongoDB 单次 bulkWrite 上限) - 关键点:
upsert: true必须开,否则遇到不存在的user_id会跳过
这种做法绕开了聚合阶段的内存限制和 $merge 的隐式行为,适合需要人工审核中间结果、或与 Kafka/Spark 流水线对接的场景。
最易被忽略的一点:去重依据字段(如 user_id)必须建索引,否则 $sort 和 $group 会全表扫描,千万级数据可能卡住整个副本集。别等超时了才想起加 db.logs.createIndex({user_id: 1, created_at: -1})。











