updateone返回matchedcount === 0是并发冲突的唯一可靠信号,表明version已过期需重试;modifiedcount或result.value不可靠,且事务与乐观锁不可混用。

updateOne 返回 matchedCount === 0 是并发冲突的唯一可靠信号,不是事务没生效,也不是锁没加对——根本原因是把两套并发控制机制混用了。
别在事务里套乐观锁。MongoDB 的事务(session.startTransaction())和单文档乐观锁(findOneAndUpdate + version 字段匹配)解决的是不同层级的问题:事务保证多文档操作的原子性,乐观锁保证单文档多次读-改-写不被覆盖。硬把它们叠在一起,反而会因写锁排队、write conflict 自动中止、重试逻辑错乱,导致更新失败率更高。
为什么 updateOne.matchedCount === 0 才是真失败
很多人盯着 modifiedCount 或 result.value 判断是否成功,这完全不可靠:
-
modifiedCount只表示“字段值实际变了”,比如你$set: { status: 'done' },但原来就是'done',它就为 0 —— 和版本冲突无关 -
result.value是findOneAndUpdate特有的返回字段,updateOne根本不提供它 - 真正能确认“我读到的文档状态是否还有效”的,只有
result.matchedCount === 0:说明 filter 中的version已被别人改过,你手里的快照已过期
重试逻辑里最容易漏掉的三件事
写了 while 循环 retry,线上还是丢更新?问题不在重试本身,而在上下文没重置干净:
- 每次重试前必须重新
findOne拿最新文档,不能复用第一次读出的version和业务字段——否则你是在拿过期快照反复撞墙 - 如果更新前调了外部服务(如发短信、扣第三方账户),得确保这些操作幂等;否则重试会重复扣款、重复发通知
- 别只检查
matchedCount === 0,还要捕获result.lastErrorObject?.code === 11000(唯一键冲突),它可能和乐观锁失败同时发生
version 字段初始化和类型必须严格
version 不是装饰字段,它是乐观锁的命门:
- 插入首条文档时,必须显式设
version: 0或version: 1,不能留null、undefined或空字符串——否则后续所有$eq匹配都失效 -
version必须是数字(NumberInt或NumberLong),不能是字符串"1"或ObjectId;字符串比较在 BSON 里是字典序,"10"会小于"2",直接崩逻辑
version 去撞墙,再多的重试次数也只是在确认自己已经掉队了。











