mongoose的versionkey默认仅在save()中生效,updateone等直接命令跳过校验;需显式用{ _id, __v: old }查询并$inc __v,再检查matchedcount是否为1。

为什么 versionKey 默认不生效
很多人加了 versionKey: 'version' 或保留默认的 __v,却发现并发更新时版本号没拦住重复写入——根本原因是:Mongoose 只在 save() 操作中自动检查并递增 __v,而 updateOne()、findOneAndUpdate() 等直接发给 MongoDB 的命令默认跳过版本校验。
这意味着你用 Model.updateOne({ _id, __v: 2 }, { $set: { name: 'x' } }) 时,即使文档当前 __v 已是 3,请求仍会成功,__v 也不会自增。
要让它起作用,必须显式把版本号作为更新条件,并手动递增:
const result = await User.updateOne(
{ _id: userId, __v: expectedVersion },
{ $set: { name: 'new name' }, $inc: { __v: 1 } }
)
如果 matchedCount === 0,说明版本已变,发生了并发冲突。
如何在 save() 中安全使用乐观锁
save() 是唯一由 Mongoose 自动管理 __v 的方法,但前提是:文档必须是从数据库查出来的“带版本的实例”,且中间没被手动改过 __v。
常见陷阱包括:
- 用
new User({...})创建新实例后直接save()—— 此时__v是 0,但 MongoDB 中可能已是 5,导致覆盖 - 从 API 接收 JSON 后用
Object.assign(doc, req.body)覆盖字段,却漏掉了传来的__v,导致下次save()用旧版本号 - 调用
doc.set(...)后未触发doc.markModified('__v')(其实不用,Mongoose 内部会处理,但容易误以为要手动干预)
正确做法是:先 findById() 拿到完整文档,再修改字段,最后 save():
const user = await User.findById(userId);
if (user.__v !== clientProvidedVersion) {
throw new Error('Version mismatch');
}
user.name = 'updated';
await user.save(); // Mongoose 自动检查 __v 并 +1
findOneAndUpdate() 怎么加乐观锁
这个方法最常用也最容易出错。它不自动读取或校验 __v,必须靠查询条件和更新操作配合实现。
关键点:
- 查询条件里必须包含
{ _id, __v: expectedVersion } - 更新操作里必须用
$inc: { __v: 1 },不能只靠$set - 务必检查返回结果的
matchedCount,而不是modifiedCount(后者在__v不变时也可能为 0)
示例:
const res = await User.findOneAndUpdate(
{ _id: userId, __v: 3 },
{ $set: { email: 'a@b.com' }, $inc: { __v: 1 } },
{ new: true }
);
if (!res) {
// 说明没匹配到 __v === 3 的文档,发生并发修改
throw new Error('Concurrent update rejected');
}
版本号字段命名与 Schema 配置要注意什么
默认 __v 是隐藏字段,不影响序列化输出,但如果你改成 versionKey: 'version',就要注意:
- 该字段名会出现在所有
toJSON()/toObject()结果中,除非显式配置versionKey: { version: false } - 使用
select('-version')会同时排除查询和更新中的该字段,导致乐观锁失效 - 如果用 TypeScript,需在 interface 中声明该字段,否则类型检查会报错
- MongoDB 索引不会自动为
__v建索引,高并发场景下建议手动加:schema.index({ _id: 1, __v: 1 })
另外,__v 仅对数组子文档的原子操作(如 $push)有额外行为,普通字段更新无需担心嵌套影响。
真正麻烦的是跨服务或离线编辑场景:版本号只在单次读-改-写链路里有效,无法替代分布式事务。别指望靠它解决长事务或客户端缓存导致的“延迟提交”问题。











