news 2026/10/3 7:01:39

mongoose 更新数据不存在就新增:findOneAndUpdate 与 upsert 实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
mongoose 更新数据不存在就新增:findOneAndUpdate 与 upsert 实战指南

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 操作符用混了、索引没建对,一目了然。生产环境记得关掉,或者只对慢查询开。

代码写完不算完,跑一遍验证脚本,确认“查无记录 → 插入 → 再更新 → 总数不变”这条链路通了,再上线。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 7:01:08

基于ESP32-S3的智能语音助手开发实战:从硬件到云端全链路解析

1. 生态全景&#xff1a;从麦克风到云端的一条链路把 xiaozhi-esp32 刷进一块 ESP32-S3 板子&#xff0c;通电&#xff0c;喇叭里传出一句“你好&#xff0c;我是小智”&#xff0c;然后你可以对着它说“今天天气怎么样”&#xff0c;它真的能答上来。这个瞬间确实有点上头&…

作者头像 李华
网站建设 2026/10/3 6:59:55

Proteus 9.0安装与Keil联调全攻略:从环境搭建到仿真避坑

1. 为什么 Proteus 9.0 值得单独写一篇安装实录搞单片机仿真的人&#xff0c;电脑里基本都绕不开 Proteus 这个软件。从 51 单片机到 STM32&#xff0c;从简单的 LED 闪烁到带 I2C 的 OLED 显示&#xff0c;Proteus 最大的价值就是让你在没买开发板、没焊电路之前&#xff0c;先…

作者头像 李华
网站建设 2026/10/3 6:59:37

Proteus 9.0安装教程:许可证配置与Keil联调全解析

1. 为什么 Proteus 9.0 值得折腾&#xff0c;以及这次安装到底难在哪搞单片机开发的人&#xff0c;迟早绕不开 Proteus 这个软件。你可以把它理解成一个“电子实验室的沙盘”——不用真的焊板子、不用真的烧芯片&#xff0c;在电脑上就能把电路搭出来、把程序跑起来、把示波器波…

作者头像 李华
网站建设 2026/10/3 6:59:09

西门子PLC与Profinet从站IC芯片通讯配置与排故实战

做非标设备集成这些年&#xff0c;我最常被问的一句话就是“西门子PLC和变频器、远程IO这些从站到底是怎么对上的”。不管是ABB的Profinet选件&#xff0c;还是国产远程IO模块&#xff0c;拆开来看&#xff0c;核心都是同一类东西&#xff1a;一颗专门跑Profinet协议栈的IC芯片…

作者头像 李华
网站建设 2026/10/3 6:58:09

VS Code 搭配 Claude Code 安装配置全攻略

这两年AI编程工具迭代得飞快&#xff0c;我日常几乎离不开Claude Code和VS Code这对组合。很多人对 Claude Code 的印象还停留在"终端里跑的神秘命令行工具"&#xff0c;其实它和 VS Code 联动起来才是高效开发的正确打开方式——直接在编辑器里看它改了哪些文件、动…

作者头像 李华