findOneAndUpdate 配合 version 字段是解决单文档并发更新冲突最轻量、最可靠的方式,需严格控制字段初始化、类型及重试逻辑;事务不适用单文档更新,因其引入冗余写锁与 write conflict 错误。

findOneAndUpdate 配合 version 字段是解决单文档并发更新冲突最轻量、最可靠的方式,不需要事务、不加锁、不阻塞,但必须严格控制字段初始化、类型和重试逻辑。
为什么不用事务处理单文档更新
事务在 MongoDB 中会对涉及文档加写锁(WiredTiger 层),高并发下容易排队、触发 write conflict 错误并被自动中止;而单文档更新本就具备原子性,事务纯属冗余。真正需要事务的场景是跨文档一致性(如 A 扣款、B 入账),不是“读-改-写同一个文档”。
-
write conflict错误码通常是112或129,不是业务逻辑错误,而是内核级重试失败信号 - 事务默认超时 60 秒,但实际写锁持有时间不可控,尤其在慢查询或网络延迟时更易拖垮吞吐
-
findOneAndUpdate是原子操作,天然支持“条件匹配 + 更新 + 返回结果”一步到位,matchedCount就是乐观锁成败的唯一判据
findOneAndUpdate 的 version 条件怎么写才有效
关键不是“有没有 version”,而是匹配逻辑是否严格:必须用 $eq 精确比对旧值,且 version 必须是数字类型(NumberInt 或 NumberLong),不能是字符串或 null。
- 错误写法:
{ _id: id, version: "1" }—— 字符串比较会出错("10" 成立) - 错误写法:
{ _id: id, version: { $ne: null } }—— 不校验具体值,失去乐观锁意义 - 正确写法:
{ _id: id, version: oldVersion }+{ $set: { field: newVal }, $inc: { version: 1 } } - 插入首条文档时,必须显式设
version: 0或version: 1,不能留空
重试逻辑里最容易漏掉的三件事
很多人写了 while (matchedCount === 0) 就以为安全了,结果线上仍丢更新——问题出在重试上下文没重置。
- 每次重试前必须重新
findOne拿最新文档,不能复用第一次读出的version和业务字段 - 如果更新前调用了外部服务(如发短信、扣第三方账户),这些操作必须幂等;否则重试会重复触发
- 别只检查
result.matchedCount === 0,还要捕获result.lastErrorObject?.code === 11000(唯一键冲突),它也可能导致更新失败
Node.js Driver 的 returnDocument 默认值陷阱
4.x+ 的官方驱动默认 returnDocument: 'before',意味着你拿到的是更新前的快照,无法直接用于下一轮重试——必须显式设为 'after' 才能拿到升版后的最新值。
- 错误配置:
findOneAndUpdate(filter, update, { returnDocument: 'before' }) - 正确配置:
findOneAndUpdate(filter, update, { returnDocument: 'after' }) - 如果用的是
updateOne,它不返回文档内容,必须额外findOne,多一次 round-trip,不推荐
version 字段不是装饰,是乐观锁的命门:它不自动创建、不自动递增、不校验类型,全靠你写对。一旦初始化错、类型错、重试逻辑漏重读,乐观锁就形同虚设。


















