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

发布时间:2026/9/11 7:30:00
Mongoose findOneAndUpdate() 完全指南:原子更新、Upsert 与返回值控制 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属性但返回值中没有ageconst 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: afterconst 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 / returnOriginalnew与returnOriginal选项已被弃用统一改用returnDocumentreturnDocument: after取代new: true或returnOriginal: falsereturnDocument: 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自动剔除 undefinedMongoose 会自动从更新中移除值为undefined的字段。例如下面的调用中name被设为undefinedMongoose 会在发送更新前把它从$set中剔除因此name保持原值不变。如果你确实想删除某个属性请使用$unsetconst 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的处理以及setDefaultsOnInsertlib/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: falseupsert时返回null如果 upsert 恰好插入了一个新文档而你又要求返回更新前文档returnDocument: before那么没有更新前的文档可言此时返回null。测试 test/model.findOneAndUpdate.test.jsreturns null when doing an upsert newfalse gh-1533验证了这一行为。__v的处理upsert 插入新文档时 Mongoose 会补上版本键__v测试adds __v on upsert (gh-2122) (gh-4505)覆盖但如果是纯$set更新则不额外添加doesnt 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 驱动的原始结果对象ModifyResultconst 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为trueres.lastErrorObject.updatedExistingtrue表示更新了已有文档false表示这次是 upsert 插入的新文档res.lastErrorObject.upserted当发生插入时包含新文档的_idres.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 newfalse gh-7770test/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参数中包含__tMongoose 会自动将其从更新中移除。这是为了防止意外修改判别器键——尤其是当你把不可信的用户输入直接传给update参数时恶意用户可能试图把一条记录从一种事件类型篡改成另一种。如果需要显式修改判别器键可以设置overwriteDiscriminatorKey: truelet 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(Cant modify discriminator key ...)否则严格模式开启静默地delete obj[key]把该字段从更新中剔除。此外castUpdate.js开头还处理了另一种情况当设置了overwriteDiscriminatorKey: true且更新里包含判别器键时Mongoose 会根据目标判别器值切换用于转换cast的 Schemalib/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),仅供参考