1. 从一次线上事故说起:为什么“更新不存在就新增”这么容易翻车
先说个真实场景。你写了一个用户签到接口,逻辑是“今天没签到就创建签到记录,签过就更新签到时间”。代码大概长这样:
const record = await CheckinModel.findOneAndUpdate( { userId, date: today }, { $set: { checkinAt: new Date() } }, { upsert: true } );本地测试一切正常,上线后某天运营反馈:签到记录里出现了大量checkinAt为空的脏数据,还有用户同一天出现两条记录。你回头一看日志,发现并发请求下upsert触发了重复插入,而返回的record有时候是旧文档,前端拿到的checkinAt还是上一次的值。
这就是findOneAndUpdate配合upsert最典型的三个坑:返回值新旧不分、默认值不填充、并发写入重复插入。这三个问题不解决,你的“不存在则新增”逻辑就是一颗定时炸弹。
findOneAndUpdate是 Mongoose 里一个“查找并更新”的原子操作,它把findOne和updateOne合并成一次数据库往返。加上upsert: true之后,如果查询条件没匹配到任何文档,MongoDB 会直接插入一条新文档。听起来很美好,但默认行为和你想象的往往不一样。
这篇内容适合谁:正在用 Node.js + Mongoose 做业务开发,需要实现“有则更新、无则插入”逻辑的同学;被upsert返回值坑过的同学;想搞清楚new、setDefaultsOnInsert、runValidators这几个参数到底怎么配的同学。我会从 Schema 配置讲到可复制的参数组合,再到验证步骤和报错排查,尽量让你看完就能直接抄进项目。
核心检索词先明确:mongoose findOneAndUpdate upsert 不存在则新增,这是全文的主线。下面所有代码都基于 Mongoose 7.x 和 MongoDB 6.x,低版本我会标注差异。
2. 前置准备:TaoToken 接入与 Mongoose 环境搭建
在写业务代码之前,先把模型调用和数据库环境准备好。这里分两块:一块是如果你要用大模型辅助生成或调试 Mongoose 代码,可以通过 TaoToken 统一接入;另一块是本地 Mongoose 环境。
先说 TaoToken 这块。它的作用是把不同模型厂商的接口统一成一个 OpenAI 兼容格式,你在写代码时不用为每个厂商改一遍 SDK。对于调试 Mongoose 这种偏工程的问题,我习惯用模型对话来快速验证一段聚合查询或update语法是否正确。
接入方式很简单,拿到 API Key 后,Base URL 填https://taotoken.net/api,模型 ID 按你选的填。如果你只是偶尔问几个语法问题,用模型对话就够了;如果你要长期跑 Agent 做代码生成,可以看 Coding Plan;Key 的管理在 console 的 api-keys 页面。
# 环境变量里配置,避免硬编码 export TAOTOKEN_API_KEY="你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"需要说明的是,TaoToken 在这里的角色是“帮你更快写出正确代码的辅助工具”,不是替代你的数据库。Mongoose 的upsert行为最终由 MongoDB 决定,模型只能帮你查文档、生成片段、解释报错。
再说 Mongoose 环境。初始化一个最小项目:
mkdir mongoose-upsert-demo && cd mongoose-upsert-demo npm init -y npm install mongoose连接数据库:
// db.js const mongoose = require('mongoose'); async function connect() { await mongoose.connect('mongodb://127.0.0.1:27017/upsert_demo', { serverSelectionTimeoutMS: 5000, }); console.log('MongoDB connected'); } module.exports = { connect };如果你本地没有 MongoDB,用 Docker 起一个最省事:
docker run -d --name mongo-upsert -p 27017:27017 mongo:6到这里环境就绪。接下来进入正题,先定义 Schema,因为upsert的很多坑都和 Schema 配置直接相关。
3. 可复制配置:Schema 定义与 upsert 参数组合
这一节是全文的核心,我会给出一个完整的 Schema 和几组参数组合,你可以直接复制。
先看 Schema。假设我们做一个“设备心跳记录”模型,需求是:同一设备同一分钟只保留一条记录,不存在就新增,存在就更新最后心跳时间。
// models/Heartbeat.js const mongoose = require('mongoose'); const heartbeatSchema = new mongoose.Schema( { deviceId: { type: String, required: true, index: true, }, minuteKey: { type: String, required: true, }, lastBeatAt: { type: Date, default: Date.now, }, beatCount: { type: Number, default: 1, }, status: { type: String, enum: ['online', 'offline'], default: 'online', }, }, { timestamps: true, versionKey: false, } ); // 关键:复合唯一索引,防止并发 upsert 重复插入 heartbeatSchema.index({ deviceId: 1, minuteKey: 1 }, { unique: true }); module.exports = mongoose.model('Heartbeat', heartbeatSchema);注意那个复合唯一索引。很多人以为upsert本身能保证不重复,其实不是。upsert在并发下会先查后插,两个请求同时查不到就会同时插入。唯一索引是最后一道防线,配合upsert才能既保证“不存在则新增”,又保证不重复。
接下来是参数组合。findOneAndUpdate的签名是:
Model.findOneAndUpdate(filter, update, options)options里和 upsert 相关的关键参数有这些:
| 参数 | 作用 | 推荐值 |
|---|---|---|
upsert | 没匹配到就插入 | true |
new | 返回更新后的文档 | true |
setDefaultsOnInsert | 插入时应用 Schema 默认值 | true |
runValidators | 更新时跑 Schema 校验 | true |
includeResultMetadata | 返回原始结果元数据 | 按需 |
最常用的组合:
const options = { upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true, };这里重点说new和setDefaultsOnInsert。
new: true决定返回值是更新前还是更新后的文档。默认是false,也就是返回旧文档。如果你不写new: true,第一次插入时返回的是插入后的文档(因为旧文档不存在),但第二次更新时返回的是更新前的文档。这个不一致行为是很多 bug 的根源。所以只要你在业务里要用返回值,就统一写new: true。
setDefaultsOnInsert决定插入时是否应用 Schema 里的default。Mongoose 5 之后这个默认是true,但如果你显式传了false或者用了某些旧版本,插入的文档可能缺少默认值。我建议显式写上,避免版本差异。
还有一个容易忽略的点:update对象里如果用了$setOnInsert,它只在插入时生效,更新时被忽略。这个操作符适合放“创建时间”这类只在新增时写入的字段。
const update = { $set: { lastBeatAt: new Date(), status: 'online' }, $inc: { beatCount: 1 }, $setOnInsert: { createdAt: new Date() }, };注意$setOnInsert和timestamps可能冲突。如果你开了timestamps: true,Mongoose 会自动管理createdAt和updatedAt,这时候再手动写$setOnInsert: { createdAt }可能报错或覆盖。二选一即可。
完整的调用:
const Heartbeat = require('./models/Heartbeat'); async function upsertHeartbeat(deviceId, minuteKey) { const filter = { deviceId, minuteKey }; const update = { $set: { lastBeatAt: new Date(), status: 'online' }, $inc: { beatCount: 1 }, }; const options = { upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true, }; const doc = await Heartbeat.findOneAndUpdate(filter, update, options); return doc; }这段代码就是“不存在则新增、存在则更新”的最小可用版本。下一节我们来验证它到底有没有按预期工作。
4. 验证请求:先查无记录,再执行更新,最后检查结果
光写代码不够,得实际跑一遍看结果。我按“查询确认无记录 → 执行 upsert → 检查新增结果 → 再执行一次看更新行为”这个顺序来。
先写一个验证脚本:
// verify.js const mongoose = require('mongoose'); const { connect } = require('./db'); const Heartbeat = require('./models/Heartbeat'); async function run() { await connect(); const deviceId = 'dev-001'; const minuteKey = '2024-06-01T10:30'; // 第一步:确认无记录 const before = await Heartbeat.findOne({ deviceId, minuteKey }); console.log('查询前:', before); // 应该是 null // 第二步:执行 upsert const options = { upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true, }; const after = await Heartbeat.findOneAndUpdate( { deviceId, minuteKey }, { $set: { lastBeatAt: new Date(), status: 'online' }, $inc: { beatCount: 1 }, }, options ); console.log('第一次 upsert 返回:', after); // 第三步:检查数据库里的实际文档 const check = await Heartbeat.findOne({ deviceId, minuteKey }); console.log('数据库实际文档:', check); // 第四步:再执行一次,验证是更新而非新增 const second = await Heartbeat.findOneAndUpdate( { deviceId, minuteKey }, { $set: { lastBeatAt: new Date(), status: 'online' }, $inc: { beatCount: 1 }, }, options ); console.log('第二次 upsert 返回:', second); const count = await Heartbeat.countDocuments({ deviceId, minuteKey }); console.log('记录总数:', count); // 应该是 1 await mongoose.disconnect(); } run().catch(console.error);运行node verify.js,预期输出:
查询前: null 第一次 upsert 返回: { _id: ..., deviceId: 'dev-001', minuteKey: '2024-06-01T10:30', lastBeatAt: ..., beatCount: 1, status: 'online', createdAt: ..., updatedAt: ... } 数据库实际文档: { ... beatCount: 1 ... } 第二次 upsert 返回: { ... beatCount: 2 ... } 记录总数: 1几个关键观察点:
第一,第一次 upsert 返回的文档里beatCount是 1,说明$inc在插入时也生效了。这是 MongoDB 的行为,$inc对不存在的字段会从 0 开始加。
第二,status是online,说明$set在插入时生效。但如果你把status放在$setOnInsert里,更新时就不会被覆盖。
第三,第二次 upsert 返回的beatCount是 2,且记录总数还是 1,说明更新逻辑正确,没有重复插入。
第四,createdAt和updatedAt都存在,说明timestamps正常工作。
如果你把new改成false再跑一遍,会发现第二次返回的beatCount是 1(旧值),这就是new参数的影响。所以再次强调:要用返回值就写new: true。
再验证一下默认值填充。把status从 update 里去掉,只留$inc:
const after = await Heartbeat.findOneAndUpdate( { deviceId: 'dev-002', minuteKey }, { $inc: { beatCount: 1 } }, options ); console.log(after.status); // 应该是 'online',来自 Schema default如果输出undefined,说明setDefaultsOnInsert没生效,检查你的 Mongoose 版本和参数。
到这里,正常流程验证完毕。下一节看踩坑和报错。
5. 常见报错与坑点排查:401、E11000、返回值异常
这一节按真实报错来组织,每个都给出原因和修法。
坑一:并发下 E11000 duplicate key error
报错长这样:
MongoServerError: E11000 duplicate key error collection: upsert_demo.heartbeats index: deviceId_1_minuteKey_1 dup key: { deviceId: "dev-001", minuteKey: "..." }原因:两个请求同时执行 upsert,都查不到记录,都尝试插入,唯一索引拦下了第二个。这是upsert的固有竞态,不是 bug。
修法有三种。第一种是加唯一索引后捕获 E11000 并重试:
async function safeUpsert(filter, update, options, retries = 3) { for (let i = 0; i < retries; i++) { try { return await Heartbeat.findOneAndUpdate(filter, update, options); } catch (err) { if (err.code === 11000 && i < retries - 1) { continue; // 重试,这次会走更新分支 } throw err; } } }第二种是用updateOne配合upsert,它同样有竞态,但配合唯一索引重试更自然。第三种是从业务上避免并发,比如用队列串行化同一设备的写入。
坑二:返回值是 null
明明upsert: true,为什么返回 null?检查两点:一是new没设成true且是更新场景,旧文档可能因为某些原因查不到;二是filter和update里有冲突字段,导致 MongoDB 报错但被吞掉。最稳妥的排查方式是打开 Mongoose 调试:
mongoose.set('debug', true);这样能看到实际发给 MongoDB 的命令,一眼就能看出 filter 和 update 长什么样。
坑三:默认值没填充
插入的文档缺少 Schema 里定义的default字段。原因通常是setDefaultsOnInsert为false,或者你用了$setOnInsert覆盖了默认值。修法是显式传setDefaultsOnInsert: true,并检查 update 对象里有没有同名字段。
坑四:runValidators 不生效
runValidators: true只对$set、$unset等更新操作符生效,对$inc这类不跑校验。而且它默认只校验被更新的字段,不是整个文档。如果你需要全文档校验,得用context: 'query'或者改用save()。
坑五:401 类错误
如果你在调试时通过 TaoToken 的模型对话让模型帮你生成代码,遇到 401,通常是 Key 没配对或者 Base URL 写错了。检查https://taotoken.net/api是否完整,Key 是否放在Authorization: Bearer头里。这类问题和 Mongoose 本身无关,但排查思路一样:先确认请求发对了地方。
坑六:OAuth / local proxy failed
这两个报错一般出现在你用某些工具链间接调用模型接口时。local proxy failed说明本地转发层没起来,OAuth相关说明鉴权流程没走完。这类问题建议直接看接入文档,按文档里的 curl 示例先跑通最小请求,再回到代码里。
坑七:reading choices 报错
Cannot read properties of undefined (reading 'choices')通常是你解析模型返回时,返回体结构和你预期的不一样。先打印原始 response,确认字段路径,再取值。这跟 Mongoose 无关,但调试时经常一起出现。
排查完这些,你的 upsert 逻辑基本就稳了。最后说下长期使用的建议。
6. 长期编码与 Agent 场景:把 upsert 逻辑沉淀成可复用工具
单次写完findOneAndUpdate不难,难的是在几十个模型里保持一致的 upsert 行为。我的做法是抽一个通用工具函数,把参数组合固定下来。
// utils/upsert.js function buildUpsertOptions(extra = {}) { return { upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true, ...extra, }; } async function upsertOne(Model, filter, update, extra = {}) { return Model.findOneAndUpdate(filter, update, buildUpsertOptions(extra)); } module.exports = { upsertOne, buildUpsertOptions };这样每个模型调用都统一,不会有人漏写new: true。配合唯一索引和 E11000 重试,基本能覆盖大部分“不存在则新增”的场景。
如果你在用 Claude Code 这类工具做长期编码,可以把这套约定写进项目规范,让 Agent 生成代码时自动带上。TaoToken 的 Coding Plan 适合这种需要持续跑 Agent 的场景,模型对话适合临时问语法,接入文档里有完整的 Base URL 和 Key 配置说明。
最后给一个实用技巧:在开发环境打开mongoose.set('debug', true),把实际执行的 MongoDB 命令打出来。upsert 的问题十有八九能在日志里直接看到——filter 写错了、update 操作符用混了、索引没建对,一目了然。生产环境记得关掉,或者只对慢查询开。
代码写完不算完,跑一遍验证脚本,确认“查无记录 → 插入 → 再更新 → 总数不变”这条链路通了,再上线。