MongoDB 的 upsert 不是某个独立命令,而是 updateOne、updateMany、findOneAndUpdate、bulkWrite 等写入操作里的一个选项。开启 upsert 后,如果过滤条件没有匹配到文档,MongoDB 会插入一条新文档;如果匹配到了,就执行更新表达式。这个特性在配置初始化、指标落库、外部数据同步等场景中使用频率很高,但真正要写出稳定可靠的写入逻辑,需要先理解 upsert 插入时文档是如何拼出来的。

以 updateOne 为例,下面这段代码在 orderId 不存在时会插入一条带有 orderId、status 和 updatedAt 的新文档:
db.orders.updateOne(
{ orderId: "A1001" },
{ $set: { status: "pending", updatedAt: new Date() } },
{ upsert: true }
);
执行后,如果 orders 集合里没有 orderId 为 A1001 的文档,新文档会包含查询条件中的 orderId 字段,以及 $set 里的 status 和 updatedAt。这也是 upsert 最容易理解的一种形态。但需要注意,MongoDB 只会把查询条件中的等值字段带入插入文档,范围条件、正则等非等值条件不会自动写入。
upsert 的插入文档是如何生成的
MongoDB 在处理 upsert 时,会先尝试用 filter 条件做匹配。如果找不到任何文档,则以 filter 中的等值条件为基础构造一个文档,再在这个基础上应用 update 参数中的更新操作符。比如 filter 是 { orderId: "A1001", channel: "app" },update 是 { $set: { status: "pending" } },插入结果就是 { orderId: "A1001", channel: "app", status: "pending" }。
如果 filter 里带有范围查询,例如 { createdAt: { $gte: ISODate("2026-01-01") } },插入时无法根据范围条件确定一个精确的 createdAt 值,因此新文档中不会自动包含 createdAt 字段。此时如果业务上需要 createdAt,应该把它写进 $set 或 $setOnInsert,而不是依赖 filter 自动生成。这个细节在统计落库时尤其容易踩坑。
另外,如果 update 参数是一个替换式文档,比如 { status: "pending" } 而不是 { $set: { status: "pending" } },那么 upsert 插入的是 filter 等值字段与替换文档的合并结果。替换式写法在已有文档场景下会清空其他字段,日常开发中除非明确要整体替换,否则推荐使用 $set 和 $setOnInsert。
$set 与 $setOnInsert:控制初始化字段
在用 upsert 做初始化时,经常会出现一类字段:第一次创建时要写入,后续更新时不能覆盖。例如 createdAt、createdBy、默认角色、初始状态等。MongoDB 提供了 $setOnInsert 操作符,只在插入操作发生时生效,更新已有文档时会被直接忽略。
db.users.updateOne(
{ userId: "u_9001" },
{
$set: { lastLoginAt: new Date() },
$inc: { loginCount: 1 },
$setOnInsert: { createdAt: new Date(), role: "member", status: "active" }
},
{ upsert: true }
);
这段代码可以反复执行。用户存在时,lastLoginAt 会刷新,loginCount 通过 $inc 累加 1,而 createdAt、role、status 不会被修改。用户不存在时,插入文档会包含 userId、lastLoginAt、loginCount、createdAt、role、status 这些字段,其中 loginCount 初始为 1。$setOnInsert 与 $set 的职责分离,是避免 upsert 把初始化字段反复重置的关键。
还要注意 $inc 与 $set 的差异。如果上面把 loginCount 写成 $set: { loginCount: 1 },那么每次执行都会把计数重置为 1,无法实现累加。对于计数类字段,$inc 是 upsert 幂等写入之外的另一种常用手段;而对于确实需要固定为新值的字段,才使用 $set。
批量 upsert 与唯一索引冲突处理
当需要一次写入多组数据时,可以使用 bulkWrite 批量提交多个 updateOne 操作,每个操作单独设置 upsert 选项。这样做能减少网络往返次数,对批量同步和定时统计任务很有帮助。下面是一个按天累加 PV 和 UV 的例子:
db.metrics.bulkWrite([
{
updateOne: {
filter: { date: "2026-02-01", metric: "pv" },
update: { $inc: { total: 120 } },
upsert: true
}
},
{
updateOne: {
filter: { date: "2026-02-01", metric: "uv" },
update: { $inc: { total: 58 } },
upsert: true
}
}
]);
批量 upsert 的一个常见问题是重复键错误。假设 metrics 集合已经对 date 和 metric 建立了唯一索引,上述两个操作分别匹配不同文档,不会有冲突。但如果并发执行两个针对同一 date+metric 的 upsert 请求,MongoDB 可能在一个请求插入成功的同时,另一个请求也尝试插入相同唯一键,从而抛出 duplicate key error。为减少这类风险,应当先创建唯一索引让数据在数据库层面保持唯一,同时在应用层对错误码 11000 做捕获和重试。
创建唯一索引的语句如下:
db.metrics.createIndex({ date: 1, metric: 1 }, { unique: true });
如果业务允许少量重复请求,也可以在捕获到 duplicate key error 后,重新执行一次 updateOne 再写入增量。重试时因为文档已经存在,会直接走更新分支,不会再次插入。这个过程本质上就是把并发 upsert 转换为一次可恢复的更新。
返回结果判断和常见误区
判断一次 upsert 到底执行了插入还是更新,应当看返回对象里的 upsertedCount 和 upsertedId。比如:
const result = db.orders.updateOne(
{ orderId: "A1001" },
{ $set: { status: "pending" } },
{ upsert: true }
);
if (result.upsertedCount > 0) {
print(`inserted: ${result.upsertedId._id}`);
} else {
print(`updated: matched=${result.matchedCount}, modified=${result.modifiedCount}`);
}
这里有一个细节:matchedCount 表示匹配到的文档数,modifiedCount 表示实际发生变化的文档数。当 $set 设置的新值和原值完全相同时,matchedCount 可能为 1,而 modifiedCount 为 0。因此不能简单用 modifiedCount 是否为 0 来判断是否插入,应该优先使用 upsertedCount。
另一个常见误区是误用替换式更新。比如已经存在的商品文档包含 _id、name、desc、stock 四个字段,如果执行 db.products.updateOne({ _id: 1 }, { name: "NewName" }, { upsert: true }),已有的 desc 和 stock 会被删掉,整个文档只剩 _id 和 name。想只改名称,应写成 { $set: { name: "NewName" } }。还有人在 filter 中写了非等值条件,却期望插入文档自动带上该字段,这也是对 upsert 生成规则理解不透导致的。
总体来看,upsert 的核心不在于开启选项本身,而在于用对更新操作符、设计好唯一索引、正确读取返回结果。把这些细节处理清楚,MongoDB 的 upsert 才能在幂等写入、批量统计和数据初始化等场景里发挥稳定作用。
MongoDB upsert插入更新bulkWrite修改时间:2026-09-20 00:36:22