MongoDB聚合管道在4.2版本引入了$merge阶段,这算是聚合框架里一个比较实用的能力。它解决了之前$out只能写全新集合的问题,让我们可以把管道的计算结果直接合并进一个已有的集合,无论是插入新文档、更新旧文档还是替换旧文档,都能在一段聚合操作里完成。对于需要定期跑批、维护汇总表或者做数据回填的场景,$merge能让代码简洁不少。

先看一下$merge的基础语法。它必须出现在聚合管道的最后一个阶段,和$out的位置一样,因为聚合管道本身就是逐段处理数据流,最后一步才允许写库。基本写法是在管道末尾追加一个包含$merge字段的阶段,核心参数有into、on、whenMatched、whenNotMatched等。比如下面这个最简单的示例,把订单集合按用户分组统计总金额,然后合并到一个叫user_order_summary的集合里:
db.orders.aggregate([
{
$group: {
_id: "$userId",
totalAmount: { $sum: "$amount" }
}
},
{
$merge: {
into: "user_order_summary",
on: "_id",
whenMatched: "replace",
whenNotMatched: "insert"
}
}
])
这段代码会把每个用户的总金额写进user_order_summary,如果该用户已经存在就整体替换,不存在就插入新文档。和$out相比,$merge最明显的优势就是不会每次运行都清空目标集合,适合做增量维护。当然$out也有它的场景,比如临时生成报表然后整体替换,这里就不展开了。
$merge的核心参数:into、on、whenMatched和whenNotMatched
into参数指定目标集合,可以是字符串表示同一个数据库里的集合,也可以写成{ db: "xxx", coll: "yyy" }的形式跨库输出。需要注意的是,目标库必须和源库在同一个副本集或者分片集群里,不能跨集群写入。如果没有特殊权限,目标集合不存在时会自动创建,但创建出来的集合没有继承源集合的索引,所以如果后续要做基于on字段的匹配,最好提前手动建好唯一索引。
on参数用来告诉MongoDB用哪些字段做匹配,相当于关系数据库里的JOIN键。如果省略on,默认用_id字段。这个参数可以是一个字段名,也可以是一个数组包含多个字段。不过要小心,如果目标集合在on指定的字段上没有唯一索引,那么whenMatched为merge或replace时,MongoDB无法保证只匹配到一条文档,可能会产生重复更新或者报错。官方文档建议在on字段上建唯一索引,尤其是使用merge模式时,否则性能和数据一致性都会有风险。
whenMatched控制当聚合结果中的文档和目标集合中已有的文档匹配成功时做什么,可选值有replace、keepExisting、merge、fail和pipeline。replace是直接用新文档替换旧文档,但会保留旧文档的_id;keepExisting表示保留目标集合里已有的文档,忽略聚合结果中的对应文档;merge则是把新文档的字段合并到旧文档里,新字段覆盖旧字段,旧文档里没有的字段保留;fail表示如果匹配成功就报错并回滚整个操作;pipeline是4.4之后支持的,可以对匹配到的目标文档再跑一段聚合管道,做更灵活的更新。
whenNotMatched控制当没有匹配到目标文档时的行为,可选值有insert和discard。insert会把聚合结果中的文档插入目标集合,discard则是直接丢弃。需要注意whenNotMatched为insert时,聚合结果中的文档必须包含_id字段,否则会报错,因为插入文档不能没有_id。如果聚合管道里没有显式输出_id,MongoDB会自动生成一个ObjectId,这一点和普通插入行为一致。
用$merge实现月度汇总表:一个实际案例
假设电商平台每天有大量订单写入orders集合,业务方想看每个用户每月的消费总额和订单数。直接对原始订单做聚合查询虽然可行,但数据量大的时候会很慢。常见的做法是维护一张monthly_user_stats汇总表,每天定时跑批,把当天订单数据聚合后合并进去。用$merge就能很好地完成这件事。
先看数据结构。orders集合里每条文档包含userId、orderDate、amount等字段,orderDate是日期类型。我们想统计每个用户在每个自然月的总消费额和订单数,目标集合monthly_user_stats以userId和month作为唯一标识。首先给目标集合建一个复合唯一索引:
db.monthly_user_stats.createIndex(
{ userId: 1, month: 1 },
{ unique: true }
)
然后编写聚合管道。第一阶段把当天的订单按userId和month分组,month可以用$dateToString把orderDate格式化成yyyy-MM。分组之后得到每个用户当月的部分统计数据,最后用$merge写入monthly_user_stats,匹配键是userId和month两个字段,匹配成功时用merge把当天数据累加到已有的月统计上,匹配失败说明这个用户这个月还没有记录,直接插入。代码如下:
db.orders.aggregate([
{
$match: {
orderDate: { $gte: new Date("2025-02-01T00:00:00Z"), $lt: new Date("2025-02-02T00:00:00Z") }
}
},
{
$group: {
_id: {
userId: "$userId",
month: { $dateToString: { format: "%Y-%m", date: "$orderDate" } }
},
totalAmount: { $sum: "$amount" },
orderCount: { $sum: 1 }
}
},
{
$project: {
_id: 0,
userId: "$_id.userId",
month: "$_id.month",
totalAmount: 1,
orderCount: 1
}
},
{
$merge: {
into: "monthly_user_stats",
on: ["userId", "month"],
whenMatched: "merge",
whenNotMatched: "insert"
}
}
])
这里有几个细节值得注意。第一,$group之后生成的_id是一个嵌套对象,如果直接拿这个_id作为合并后的文档_id,虽然也能存进去,但后续查询不太直观,所以加了一个$project把userId和month提出来作为普通字段,同时去掉_id,让目标集合自动生成新的ObjectId。第二,on指定了数组["userId", "month"],这就要求目标集合在这两个字段上有唯一索引,前面已经建好了。第三,whenMatched设为merge,意味着如果目标文档里已经存在totalAmount和orderCount字段,新值会直接覆盖旧值;如果目标文档里还有其他字段比如lastUpdated,merge不会动它,只有出现在聚合结果里的字段才会被更新。如果需要每天跑批时把orderCount累加而不是覆盖,就需要把whenMatched改成pipeline,在pipeline里对orderCount做$add。
再来看whenMatched为pipeline的用法。假设我们想让orderCount累加而不是覆盖,totalAmount也累加,那么在$merge里这样写:
{
$merge: {
into: "monthly_user_stats",
on: ["userId", "month"],
whenMatched: [
{
$set: {
totalAmount: { $add: ["$totalAmount", "$$new.totalAmount"] },
orderCount: { $add: ["$orderCount", "$$new.orderCount"] }
}
}
],
whenNotMatched: "insert"
}
}
注意pipeline里要引用聚合结果中的新文档字段,得用$$new前缀,引用目标集合里的旧文档字段直接用$字段名。这个写法在MongoDB 4.4以上版本可用,比merge模式灵活得多,但也要注意管道里不能包含$merge或$out这种写阶段,否则会报错。
$merge的常见报错与性能注意点
第一个高频报错是“Performing an update on the path '_id' would modify the immutable field '_id'”。出现这个错误通常是因为whenMatched设为merge,而聚合结果里包含了_id字段,并且这个_id与目标文档的_id不同,MongoDB尝试去更新_id字段导致冲突。解决办法很简单:在$merge之前的$project阶段把_id字段去掉,让目标集合自己保留原有的_id,就像前面例子那样。
第二个常见问题是权限不足。$merge需要目标集合的insert、update权限,如果目标集合不存在还需要createCollection权限。很多生产环境的MongoDB用户只给了源集合的读权限和某个数据库的读写权限,但跨库写的时候会因为目标库权限不够而失败。所以部署之前先确认好账号权限,不然在跑批的时候突然报错,排查起来比较费时间。
性能方面,$merge的效率和on字段上的索引关系很大。如果目标集合在on字段上没有唯一索引,当whenMatched为merge或replace时,MongoDB会退化成对每条结果文档做全表扫描来匹配,数据量一大就会非常慢。建了唯一索引之后,匹配操作变成索引查找,性能能提升好几个数量级。另外在分片集群环境下,如果目标集合是分片的,$merge的写入会涉及跨分片路由,尽量让on字段和分片键保持一致,可以减少不必要的性能损耗。还有一点,$merge每次只能处理一个管道,如果多个任务并发写同一个目标集合,需要自己做好并发控制,比如用事务或者分布式锁,否则可能出现数据不一致。
总的来说,$merge是MongoDB聚合管道里一个很实用的写库工具,尤其适合做汇总表维护、数据清洗回写、跨集合数据同步。理解清楚whenMatched和whenNotMatched的取值,配合唯一索引和合理的投影,可以避免大多数坑。如果遇到whenMatched的merge模式不能满足累加需求,就切换到pipeline模式,用$$new引用新文档字段来做增量计算。掌握了这些,日常处理MongoDB数据流转会顺手很多。
MongoDB聚合管道$merge数据合并修改时间:2026-10-04 05:14:56