真正意义上的乐观锁需原子性校验并自增version字段,且version必须为显式初始化的数值类型;更新失败唯一可靠判断依据是matchedcount===0,重试时须重读最新文档并确保外部操作幂等。

直接用 updateOne 加 { version: { $eq: oldVersion } } 不算真正意义上的乐观锁——它漏掉了「读出的 version 是否仍是最新」这个关键校验点,容易导致误更新。
version 字段必须是数字且显式初始化
第一次插入文档时,version 不能为 null、undefined 或空字符串,否则后续所有 $eq 匹配都会失败。必须显式设为 0 或 1:
db.users.insertOne({ name: "alice", status: "active", version: 0 })
-
version类型只能是 Number(NumberInt或原生 JSnumber),不能是ObjectId、Date或字符串 - 若用
Date做版本字段,高并发下时间精度不够(毫秒级)会导致冲突漏判 - Spring Data MongoDB 的
@Version注解只支持Long、Integer等数值类型,Date仅作理论可行,不推荐线上使用
更新操作必须原子性地校验 + 自增
靠 MongoDB 单文档写原子性兜底,不能拆成两步(先查再改),也不能在 $set 里硬写新 version 值:
const result = await collection.updateOne(
{ _id: docId, version: 5 },
{
$set: { updatedAt: new Date(), status: "processed" },
$inc: { version: 1 }
}
);
- 查询条件里的
version: 5是你上一步findOne读到的值,不是固定常量 - 必须用
$inc: { version: 1 },而不是$set: { version: 6 }—— 后者绕过原子性,可能被并发写覆盖 - 判断是否冲突的唯一可靠依据是
result.matchedCount === 0,不是modifiedCount,也不是result.value(updateOne根本不返回该字段)
重试逻辑里最容易踩的三个坑
写了 while 循环 retry,线上还是丢更新?问题往往出在上下文没重置干净:
- 每次重试前必须重新
findOne拿最新文档,不能复用第一次读出的version和业务字段 - 如果更新前调了外部服务(如发短信、扣款),这些操作必须幂等;否则重试会重复触发
- 除了检查
matchedCount === 0,还要捕获result.lastErrorObject?.code === 11000(唯一键冲突),它可能和乐观锁失败同时发生
真正难的不是写对那一行 updateOne,而是把读、业务处理、更新、重试这整条链路里的状态管理做干净——尤其是 version 值的生命周期和外部副作用的隔离。











