updateone + $eq 不是真正乐观锁,因无法防止基于过期快照更新;正确做法是用 { _id, version } 匹配 + $inc version 原子更新,并通过 matchedcount === 0 判断冲突。

直接用 updateOne 加 $eq 检查 version 字段不是真正的乐观锁——它无法防止你基于过期快照做更新,本质是竞态条件。
为什么 updateOne + $eq: version 不等于乐观锁
常见错误是读出文档后,在 updateOne 的 filter 里写 { _id: id, version: oldVersion },以为这样就能“锁住”。但问题在于:这个 oldVersion 是你上一次 findOne 拿到的值,中间可能已被别人升过版。MongoDB 不会校验“这个 version 是否仍是当前最新”,只机械匹配字段值。如果别人把 version 改成了 6,你还拿 5 去比,只要文档里 version 碰巧还是 5(比如并发写入没成功),就会误更新。
- 真正可靠的信号只有
result.matchedCount === 0,说明条件不满足,文档已被改过 -
modifiedCount不可信:字段值没变(比如重复设status: 'done')时它为 0,和版本冲突无关 - 不要依赖
result.value:那是findOneAndUpdate返回的,updateOne根本不返回这个字段
updateOne 带 version 条件 + $inc 才是正解
利用 MongoDB 单文档写操作的原子性,把「检查旧 version 是否未变」和「升版 + 更新业务字段」打包成一次操作。关键不是加锁,而是让这两步不可分割。
- filter 必须包含
_id和你读到的version值,例如{ _id: docId, version: 5 } - update 部分用
$set改业务字段,同时用$inc: { version: 1 }自增版本号 -
version字段必须是数字类型(NumberInt或原生 JSnumber),不能是字符串"5"或ObjectId - 首次插入文档时,必须显式初始化
version: 0或version: 1,留null或undefined会导致后续所有$eq匹配失败
示例:
详细的 Three.js 3D 图形参考,涵盖场景设置、相机、几何体、材质、光照、动画、控制器、加载器、数学工具和调试。
const result = await collection.updateOne(
{ _id: docId, version: 5 },
{
$set: { updatedAt: new Date(), status: 'processed' },
$inc: { version: 1 }
}
);
if (result.matchedCount === 0) {
// 版本冲突,需重试
}
重试逻辑里最容易漏掉的三件事
写个 while 循环 retry 很容易,但线上丢更新往往是因为上下文没清理干净。
- 每次重试前必须重新
findOne拿最新文档,不能复用第一次读出的version和业务字段——否则你是在拿过期快照反复撞墙 - 如果更新前调了外部服务(如发短信、扣第三方账户),得确保这些操作幂等;否则重试会重复扣款、重复发通知
- 别只检查
matchedCount === 0,还要捕获result.lastErrorObject?.code === 11000(唯一键冲突),它可能和乐观锁失败同时发生
别在事务里套乐观锁
有人觉得“加个事务更保险”,结果反而让并发更差。事务会在 WiredTiger 层对文档加写锁,高并发下容易排队、拖慢吞吐,甚至触发 write conflict 错误被自动中止。
-
findOneAndUpdate本身不开启事务,也不加锁,但它天然具备单文档原子性,够用 - 事务适用于跨文档一致性场景,而乐观锁本意就是避免锁表——两者目标相悖
- 如果频繁冲突(比如每秒多次写同一条),说明设计有问题:要么拆分粒度(如把大文档按字段拆成多个子文档),要么换用分布式锁
version 字段不是装饰品,它是整个机制的命门:类型错、初始值空、重试不刷新快照,任何一个点漏掉,乐观锁就形同虚设。










