findoneandupdate必须显式带version条件校验,否则无法防止丢失更新;正确写法为{_id: docid, version: 5},错误写法如{version: {$ne: null}}或字符串类型均失效。

findOneAndUpdate 必须显式带 version 条件,否则等于没锁
默认的 findOneAndUpdate 只保证单次操作原子性,不校验文档是否被他人改过。并发时多个线程读到同一版数据、各自计算后写回,后写的直接覆盖前写的——这就是“丢失更新”。要防住它,filter 里必须同时匹配 _id 和当前已知的 version 值,例如 { _id: docId, version: 5 }。
常见错误写法:
-
{ _id: docId, version: { $ne: null } }—— 不比对具体值,失去校验意义 -
{ _id: docId, version: "5" }—— 字符串类型,BSON 字典序比较会导致"10" 这类逻辑错乱 - 漏掉
version字段直接更新 —— 完全绕过乐观锁
version 字段必须是数字类型,且首次插入就要初始化
version 不是装饰字段,MongoDB 不会自动创建、不自动递增、也不做类型转换。你得自己管到底。
初始化和类型要点:
- 首条文档插入时,必须显式设
version: 0或version: 1,不能为null、undefined或空字符串 - 务必用数字类型:
NumberInt(1)或原生 JSnumber(驱动会自动转为NumberLong),别存成字符串或 ObjectId - 更新时必须用
$inc: { version: 1 },且和业务字段更新放在同一个$set+$inc操作里,避免版本号滞后
重试逻辑里最容易漏掉的三件事
只写个 while (result.matchedCount === 0) 循环远远不够。线上丢更新往往就栽在这几个点上。
- 每次重试前必须重新
findOne拿最新文档,不能复用第一次读出的version和业务字段 —— 否则你在拿过期快照反复撞墙 - 如果更新前调用了外部服务(比如发短信、扣第三方账户),这些操作必须幂等;否则重试会重复扣款、重复发通知
- 别只检查
result.matchedCount === 0,还要捕获result.lastErrorObject?.code === 11000(唯一键冲突)或写入超时等非乐观锁失败原因
Mongoose 中 __v 默认不生效,save() 是唯一自动管理场景
很多人开了 versionKey: '__v',却发现 updateOne 或 findOneAndUpdate 完全不拦并发 —— 因为 Mongoose 只在 save() 里自动读取、校验、递增 __v,其他方法全跳过。
安全用法分两种:
- 走
save():先findById()拿完整文档 → 修改字段 → 调用doc.save()。中间不能手动改doc.__v,也不能用Object.assign()覆盖时漏传__v - 走
findOneAndUpdate():必须手动写条件{ _id, __v: expectedVersion }+ 更新$inc: { __v: 1 },并检查matchedCount(不是modifiedCount)
另外注意 Node.js Driver 4.x+ 默认 returnDocument: 'before',拿到的是旧快照,重试时要用 returnDocument: 'after' 才能拿到升版后的值。











