Mongoose findOneAndUpdate() 完全指南:原子更新、Upsert 与返回值控制
2026/9/11 7:29:15 网站建设 项目流程

Mongoose findOneAndUpdate() 完全指南:原子更新、Upsert 与返回值控制

【免费下载链接】mongooseMongoDB object modeling designed to work in an asynchronous environment.项目地址: https://gitcode.com/GitHub_Trending/mo/mongoose

本教程以 Mongoose 官方文档 docs/tutorials/findoneandupdate.md 为骨架,结合 lib/query.js 与 test/model.findOneAndUpdate.test.js 的源码与测试实现,系统讲解findOneAndUpdate()的签名、返回值语义(returnDocument)、undefined值处理、原子性、upsertincludeResultMetadata与判别器键(discriminator key)更新等核心能力。读完你不仅能正确使用该 API,还能理解它底层如何通过findAndModify命令工作,以及何时该用它、何时该用save()

何时应该使用findOneAndUpdate()

Mongoose 官方一贯的建议是:能使用save()更新文档就优先使用save(),因为它能获得更完整的校验(validation)与中间件(middleware)支持。findOneAndUpdate()底层执行的是 MongoDB 的findAndModify命令(见 lib/query.js 的 API 注释),它跳过文档级校验与save中间件,换取的是原子性与单次往返。

因此,当出现以下场景时,findOneAndUpdate()是更合适的选择:

  • 更新需要原子性,不允许读取与写入之间存在竞态窗口;
  • 需要upsert(匹配不到就插入);
  • 需要更新后立即拿到文档,而不想额外发一次查询;
  • 对性能敏感,希望单条命令完成"查找 + 修改 + 返回"。

官方教程还提到一个典型误区:如果 MongoDB 中根本没有匹配的文档,findOneAndUpdate()会返回null(除非开启upsert),这一点与save()的行为不同,需要在使用时判空。

Getting Started:签名与默认返回值

findOneAndUpdate()的函数签名如下:

function findOneAndUpdate(filter, update, options) {}

它找到第一个匹配filter的文档,应用update,然后返回该文档。默认情况下返回的是更新前的文档(即update尚未应用时的状态)。在下面的示例中,doc初始只有name_id属性,findOneAndUpdate()添加了age属性,但返回值中没有age

const Character = mongoose.model('Character', new mongoose.Schema({ name: String, age: Number })); const _id = new mongoose.Types.ObjectId('0'.repeat(24)); let doc = await Character.create({ _id, name: 'Jean-Luc Picard' }); doc; // { name: 'Jean-Luc Picard', _id: ObjectId('000000000000000000000000') } const filter = { name: 'Jean-Luc Picard' }; const update = { age: 59 }; // The result of `findOneAndUpdate()` is the document _before_ `update` was applied doc = await Character.findOneAndUpdate(filter, update); doc; // { name: 'Jean-Luc Picard', _id: ObjectId('000000000000000000000000') } doc = await Character.findOne(filter); doc.age; // 59

如果你希望返回更新后的文档,需要设置returnDocument: 'after'

const filter = { name: 'Jean-Luc Picard' }; const update = { age: 59 }; // `doc` is the document _after_ `update` was applied because of // `returnDocument: 'after'` const doc = await Character.findOneAndUpdate(filter, update, { returnDocument: 'after' }); doc.name; // 'Jean-Luc Picard' doc.age; // 59

与 MongoDB 驱动返回值的区别

Mongoose 的findOneAndUpdate()与 MongoDB Node.js 驱动的同名方法不同:它直接返回文档本身(或null),而不是驱动层的结果对象(ModifyResult)。从源码看,Mongoose 在_findOneAndUpdate()中通过const doc = !options.includeResultMetadata ? res : res.value;将驱动结果解包为文档(lib/query.js),再交给_completeOne()进行 hydrate 与投影处理。

returnDocument 与弃用的 new / returnOriginal

newreturnOriginal选项已被弃用,统一改用returnDocument

  • returnDocument: 'after'取代new: truereturnOriginal: false
  • returnDocument: 'before'取代new: falsereturnOriginal: true

在源码中,new/returnOriginal会被convertNewToReturnDocument(options)转换(lib/query.js),并在 lib/mongoose.js 中对mongoose.set('returnOriginal', ...)打出弃用警告。测试 test/model.findOneAndUpdate.test.js 大量使用returnDocument: 'after'验证更新后语义。

全局默认:mongoose.set('returnDocument', ...)

除了每次调用传入选项,你还可以通过mongoose.set('returnDocument', 'after')全局改变所有findOneAndUpdate()findByIdAndUpdate()findOneAndReplace()的默认返回语义(默认'before')。该全局选项在Query.prototype.findOneAndUpdate中会被读取并应用(lib/query.js),并在 lib/mongoose.js 中有明确文档说明。注意:returnDocumentreturnOriginal不能同时设置,否则会抛出MongooseError

findByIdAndUpdate 只是语法糖

findByIdAndUpdate(id, update, options)等价于findOneAndUpdate({ _id: id }, update, options),其实现直接委托:

return this.findOneAndUpdate({ _id: id }, update, options);

(见 lib/query.js)。两者共享全部选项,包括下文提到的upsertincludeResultMetadataoverwriteDiscriminatorKey

Undefined Values in Updates:自动剔除 undefined

Mongoose 会自动从更新中移除值为undefined的字段。例如下面的调用中,name被设为undefined,Mongoose 会在发送更新前把它从$set中剔除,因此name保持原值不变。如果你确实想删除某个属性,请使用$unset

const filter = { name: 'Jean-Luc Picard' }; const update = { $set: { name: undefined, age: 59 } }; const doc = await Character.findOneAndUpdate(filter, update, { returnDocument: 'after' }); doc.name; // 'Jean-Luc Picard' doc.age; // 59

这一行为有测试直接覆盖:test/model.findOneAndUpdate.test.js中的'accepts undefined'用例对{ time: undefined, base: undefined }调用findOneAndUpdate({}, ..., {}),不会抛错也不会写入这些字段。这与save()的行为保持一致——undefined值不会被持久化,从而避免"传入了undefined就把已有数据清空"的隐患。如果你需要显式清除字段,$unset是唯一可靠的方式。

Atomic Updates:原子更新与 save() 的竞态

除了未加索引的 upsert之外,findOneAndUpdate()是原子的:你可以假定 MongoDB 在"找到文档"与"更新文档"之间,文档不会发生变化(upsert 场景除外,详见下一节)。

作为对比,save()模式存在经典的竞态窗口:先用findOne()把文档加载进内存,再在某个时刻调用save()写回。如果在两次操作之间 MongoDB 中的文档被其他请求修改,save()会用旧数据覆盖新数据:

const filter = { name: 'Jean-Luc Picard' }; const update = { age: 59 }; let doc = await Character.findOne({ name: 'Jean-Luc Picard' }); // Document changed in MongoDB, but not in Mongoose await Character.updateOne(filter, { name: 'Will Riker' }); // This will update `doc` age to `59`, even though the doc changed. doc.age = update.age; await doc.save(); doc = await Character.findOne(); doc.name; // Will Riker doc.age; // 59

如上例所示,另一个请求把名字改成了'Will Riker',但本地的doc并不知道,save()依然把age写成了 59。对很多业务来说这个竞态无伤大雅,但如果更新必须基于"读取时的那一刻"的状态,就需要findOneAndUpdate()(或在多文档场景下使用事务)来保证原子性——findOneAndUpdate()把"查找 + 更新"合并为 MongoDB 服务端的单个原子操作。

从实现上看,_findOneAndUpdate()在真正执行前还会做一系列准备工作:条件转换(_castConditions)、更新转换(_castUpdate)、版本键装饰(decorateUpdateWithVersionKey,即乐观锁__v的处理)以及setDefaultsOnInsert(lib/query.js)。这些步骤保证了更新语句符合 Schema 定义,并维持 Mongoose 的版本控制语义。

Upsert:匹配不到就插入

通过upsert: true选项,可以把findOneAndUpdate()变成一次 find-and-upsert 操作:如果找到匹配filter的文档,行为与普通更新一致;如果没有匹配的文档,MongoDB 会filterupdate合并后插入一个新文档

const filter = { name: 'Will Riker' }; const update = { age: 29 }; await Character.countDocuments(filter); // 0 const doc = await Character.findOneAndUpdate(filter, update, { new: true, upsert: true // Make this update into an upsert }); doc.name; // Will Riker doc.age; // 29

关于 upsert 需要注意几点:

  • 原子性的例外:官方文档明确指出,原子性保证不适用于"依赖唯一索引的 upsert"。多个并发 upsert 相同filter时可能出现重复键错误,需要结合唯一索引与错误重试来处理。
  • new: false+upsert时返回null:如果 upsert 恰好插入了一个新文档,而你又要求返回更新前文档(returnDocument: 'before'),那么没有"更新前的文档"可言,此时返回null。测试 test/model.findOneAndUpdate.test.js('returns null when doing an upsert & new=false gh-1533')验证了这一行为。
  • __v的处理:upsert 插入新文档时 Mongoose 会补上版本键__v(测试'adds __v on upsert (gh-2122) (gh-4505)'覆盖),但如果是纯$set更新则不额外添加('doesn't add __v on upsert if$set(gh-4505) (gh-5973)')。
  • 默认值与 immutable 字段:upsert 场景下,Schema 中定义的默认值(setDefaultsOnInsert)会在插入时应用;不可变属性(immutable)会被移到$setOnInsert中,仅在新插入时生效。

如何判断是"插入"还是"更新"?

默认情况下,Mongoose 会解包返回值只给你文档,你无法区分这次操作到底是更新了已有文档还是插入了新文档。此时就需要includeResultMetadata选项。

The includeResultMetadata Option:获取原始结果

Mongoose 默认会对findOneAndUpdate()的结果做转换:只返回更新后的文档。这会带来一个问题——很难判断文档是否被 upsert。为了同时拿到更新后的文档以及"是否插入了新文档"等元信息,可以设置includeResultMetadata: true,让 Mongoose 返回 MongoDB 驱动的原始结果对象(ModifyResult):

const filter = { name: 'Will Riker' }; const update = { age: 29 }; await Character.countDocuments(filter); // 0 const res = await Character.findOneAndUpdate(filter, update, { new: true, upsert: true, // Return additional properties about the operation, not just the document includeResultMetadata: true }); res.value instanceof Character; // true // The below property will be `false` if MongoDB upserted a new // document, and `true` if MongoDB updated an existing object. res.lastErrorObject.updatedExisting; // false

上面的res对象结构如下:

{ lastErrorObject: { n: 1, updatedExisting: false, upserted: 5e6a9e5ec6e44398ae2ac16a }, value: { _id: 5e6a9e5ec6e44398ae2ac16a, name: 'Will Riker', __v: 0, age: 29 }, ok: 1 }

关键字段说明:

  • res.value:更新后的文档(经 Mongoose hydrate,因此instanceof Charactertrue);
  • res.lastErrorObject.updatedExistingtrue表示更新了已有文档,false表示这次是 upsert 插入的新文档;
  • res.lastErrorObject.upserted:当发生插入时,包含新文档的_id
  • res.ok:命令执行状态,1表示成功。

从源码看,设置includeResultMetadata: true后,_findOneAndUpdate()不再解包res.value,而是把整个res返回(lib/query.js);同时_completeOne()中也有对应的空值判断逻辑(if (!doc && !this.options.includeResultMetadata),lib/query.js),保证在"未匹配且未 upsert、返回 null"的情况下,元数据仍然可以正常返回。测试'return includeResultMetadata when doing an upsert & new=false gh-7770'(test/model.findOneAndUpdate.test.js)专门验证了该选项与new: false组合时的行为。

includeResultMetadata同样适用于findOneAndDelete()(lib/query.js)与findOneAndReplace(),是判断"删除/替换是否真的命中文档"的推荐方式。

Updating Discriminator Keys:默认禁止修改判别器键

Mongoose 默认禁止通过findOneAndUpdate()修改判别器键(discriminator key)。判别器键是 Mongoose 判别器(discriminators)用来区分不同子模型的字段,默认名为__t。例如有如下判别器模型:

const eventSchema = new mongoose.Schema({ time: Date }); const Event = db.model('Event', eventSchema); const ClickedLinkEvent = Event.discriminator( 'ClickedLink', new mongoose.Schema({ url: String }) ); const SignedUpEvent = Event.discriminator( 'SignedUp', new mongoose.Schema({ username: String }) );

如果update参数中包含__t,Mongoose 会自动将其从更新中移除。这是为了防止意外修改判别器键——尤其是当你把不可信的用户输入直接传给update参数时,恶意用户可能试图把一条记录从一种事件类型篡改成另一种。

如果需要显式修改判别器键,可以设置overwriteDiscriminatorKey: true

let event = new ClickedLinkEvent({ time: Date.now(), url: 'google.com' }); await event.save(); event = await ClickedLinkEvent.findByIdAndUpdate( event._id, { __t: 'SignedUp' }, { overwriteDiscriminatorKey: true, new: true } ); event.__t; // 'SignedUp', updated discriminator key

底层实现:castUpdate 中的判别器保护

从源码看,这一保护实现在 lib/helpers/query/castUpdate.js:当遍历更新操作符时,如果路径是判别器键且schema.discriminatorMapping.value !== obj[key],并且没有设置overwriteDiscriminatorKey,则:

  • 若 Schema 的strict模式为'throw',直接抛出Error('Can't modify discriminator key ...')
  • 否则(严格模式开启)静默地delete obj[key],把该字段从更新中剔除。

此外,castUpdate.js开头还处理了另一种情况:当设置了overwriteDiscriminatorKey: true且更新里包含判别器键时,Mongoose 会根据目标判别器值切换用于转换(cast)的 Schema(lib/helpers/query/castUpdate.js),即用目标子模型的 Schema 去转换更新字段,确保写入的数据符合目标判别器的结构约束。

这个选项同样适用于findByIdAndUpdatefindOneAndReplaceupdateOne等写操作(lib/model.js 等处的文档均有说明),默认值为false

小结:最佳实践速查

场景推荐做法
需要完整校验与中间件优先save(),先findOne()再改字段再保存
需要原子更新 / 避免竞态使用findOneAndUpdate(),必要时配合事务
需要更新后的文档returnDocument: 'after'(替代已弃用的new: true
需要更新前的文档保持默认或显式returnDocument: 'before'
匹配不到就插入upsert: true,注意"未加索引的 upsert 不保证原子性"
需要区分更新 vs 插入includeResultMetadata: true,读lastErrorObject.updatedExisting
需要清除某个字段$unset,不要依赖undefined值(会被自动剔除)
需要修改判别器键__t必须显式设置overwriteDiscriminatorKey: true

相关代码与测试可以继续深入研读:Query.prototype.findOneAndUpdate 实现、_findOneAndUpdate 执行链路、castUpdate 判别器保护、完整测试套件。若你希望进一步理解查询链式调用与lean()的取舍,可参考 docs/queries.md 与 docs/tutorials/lean.md。

【免费下载链接】mongooseMongoDB object modeling designed to work in an asynchronous environment.项目地址: https://gitcode.com/GitHub_Trending/mo/mongoose

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询