最轻量可靠的方式是updateone配合version精确匹配+$inc自增;事务会加x锁、引发排队或write conflict错误,仅适用于跨文档一致性,不解决单文档并发更新。

直接用 updateOne 配合 version 字段精确匹配 + $inc 自增,是最轻量、最可靠的方式;事务不仅没用,反而会引入写锁和 write conflict 错误。
为什么不能靠事务解决单文档并发更新
事务在 WiredTiger 层会对目标文档加 X 锁,高并发下容易排队、触发错误码 112 或 129 并被自动中止。而单文档的 updateOne 本身已是原子操作——事务只适用于跨文档一致性(比如 A 扣款 + B 入账),不是为“读-改-写同一个文档”设计的。
updateOne 中 version 条件必须怎么写才真正起效
关键不是“有没有 version 字段”,而是它是否参与原子校验:
- 查询条件必须是
{ _id: docId, version: 5 }这种精确匹配,不能写成{ _id: docId, version: { $ne: null } }或{ _id: docId, version: { $gte: 0 } } - 更新操作必须包含
$inc: { version: 1 },不能在$set里手动赋值version: 6——否则校验和升版就脱钩了 -
version字段必须是数字类型(NumberInt或NumberLong),不能是字符串"5",否则 BSON 字典序比较会让"10""2"
matchedCount === 0 是唯一可靠的冲突信号
很多人误判失败原因,导致重试逻辑失效:
-
modifiedCount === 0不代表冲突——它只表示字段值没变(比如你$set: { status: 'done' },但原来就是'done') -
result.value是findOneAndUpdate特有字段,updateOne根本不返回它 - 只有
result.matchedCount === 0能确认:你读出的version已过期,文档已被别人更新 - 还要额外捕获
result.lastErrorObject?.code === 11000(唯一键冲突),它可能和乐观锁失败同时发生
重试逻辑里最容易漏掉的三件事
写了 while 循环但线上还是丢更新,问题往往出在上下文没重置干净:
- 每次重试前必须重新
findOne拿最新文档,不能复用第一次读出的version和业务字段——否则你是在拿过期快照反复撞墙 - 如果更新前调用了外部服务(如发短信、扣第三方账户),这些操作必须幂等;否则重试会重复扣款、重复发通知
- 插入首条文档时,
version必须显式设为0或1,不能留null、undefined或空字符串——否则后续所有$eq匹配都失效
version 字段不是装饰,它是乐观锁的命门;它不自动递增、不校验类型、不帮你初始化——每一步都得你亲手写死、写对、写稳。











