findOneAndUpdate 通过 version 字段实现乐观锁,无需事务即可避免单文档并发覆盖;其原子性保证“检查 version + 更新+升版”一步完成,失败时 matchedCount 为 0 需重试。

findOneAndUpdate 带 version 条件不是事务,但能避免并发覆盖
MongoDB 的 findOneAndUpdate 本身不开启事务,也不加锁,但它天然具备单文档原子性——这正是乐观锁能落地的基础。你不需要用事务包裹它来防并发覆盖,反而该避免滥用事务:事务会在写入时对涉及文档加写锁(WiredTiger 层),在高冲突场景下容易排队、拖慢吞吐,甚至触发“write conflict”错误被自动中止。
真正起作用的是你在 filter 中显式带上 version 字段匹配,再配合 $inc: { version: 1 }。MongoDB 会把「检查旧 version 是否仍存在」和「升版+更新业务字段」打包成一次原子写操作。只要中间没人抢先改过,就成功;否则 matchedCount === 0,你知道该重试了。
- 事务适合跨文档一致性(比如转账扣 A 加 B),单文档更新用它纯属杀鸡用牛刀
-
findOneAndUpdate返回的result.matchedCount比modifiedCount更可靠:前者表示“条件命中且执行了更新”,后者只表示“字段值真变了”,而你关心的是“我读到的状态是否还有效” - 驱动版本影响行为:4.x+ 的 Node.js Driver 默认
returnDocument: 'before',你要显式设为'after'才能拿到升版后的最新快照
重试逻辑里最容易漏掉的三件事
很多人写了 while (retry 就以为万事大吉,结果线上还是丢更新。问题不在 retry 本身,而在 retry 的上下文没重置干净。
- 每次重试前必须重新
findOne拿最新文档,不能复用第一次读出的version和业务字段——否则你是在拿过期快照反复撞墙 - 如果更新前调了外部服务(如发短信、扣第三方账户),得确保这些操作幂等;否则重试会重复扣款、重复发通知
- 别只看
result.value === null,还要检查result.lastErrorObject?.code === 11000(唯一键冲突)或result.matchedCount === 0;后者才是乐观锁失败的正牌信号
version 字段初始化和类型必须严格
version 不是装饰字段,它是乐观锁的命门。MongoDB 不会自动创建、不会自动递增、也不会帮你校验类型。
- 插入首条文档时,必须显式设
version: 0或version: 1,不能留null、undefined或空字符串——否则后续所有$eq匹配都失效 -
version必须是数字(NumberInt或NumberLong),不能是字符串"1"或ObjectId;字符串比较在 BSON 里是字典序,"10" 会导致逻辑错乱 - 不要在
$set里手动赋值version: 2——这绕过了原子性,且并发写可能把别人刚升的 2 覆盖回 2,等于没升
冲突太频繁时,别硬扛乐观锁
如果压测发现 matchedCount === 0 的比例超过 10%,说明乐观锁已退化成“重试风暴”。这不是代码写得不够好,而是设计粒度不合理。
- 优先考虑能否用
$inc、$push等纯原子操作替代“读-算-写”流程(比如库存扣减直接$inc: { stock: -1 }) - 热点文档可拆分:把一个
user.balance拆成balance_shard_0到balance_shard_9,每次随机选一个 shard 更新,最后汇总——冲突概率下降近 10 倍 - 真要强一致性且冲突极高(如秒杀),就得上分布式锁(Redis + Lua),但要注意锁粒度和过期时间,别让失败请求卡死整个链路
version 字段的生命周期从插入第一行就开始,到删除最后一行才结束;中间任何一步松懈,都会让乐观锁形同虚设。


















