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值处理、原子性、upsert、includeResultMetadata与判别器键(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
new与returnOriginal选项已被弃用,统一改用returnDocument:
returnDocument: 'after'取代new: true或returnOriginal: false;returnDocument: 'before'取代new: false或returnOriginal: 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 中有明确文档说明。注意:returnDocument与returnOriginal不能同时设置,否则会抛出MongooseError。
findByIdAndUpdate 只是语法糖
findByIdAndUpdate(id, update, options)等价于findOneAndUpdate({ _id: id }, update, options),其实现直接委托:
return this.findOneAndUpdate({ _id: id }, update, options);(见 lib/query.js)。两者共享全部选项,包括下文提到的upsert、includeResultMetadata与overwriteDiscriminatorKey。
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 会将filter与update合并后插入一个新文档:
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 Character为true);res.lastErrorObject.updatedExisting:true表示更新了已有文档,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 去转换更新字段,确保写入的数据符合目标判别器的结构约束。
这个选项同样适用于findByIdAndUpdate、findOneAndReplace、updateOne等写操作(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),仅供参考