单文档更新场景下,用findOneAndUpdate配合version字段是最轻量、最可靠的防冲突方案;事务不解决单文档并发问题,反而引入写锁和write conflict错误,因其在WiredTiger层对文档加X锁,高并发易排队并触发错误码112/129,而单文档原子性本就完备,事务仅适用于跨文档一致性;version字段必须显式初始化为0或1(非null/undefined/空字符串),且严格为NumberInt或NumberLong类型,不可为字符串。

单文档更新场景下,用 findOneAndUpdate 配合 version 字段是最轻量、最可靠的防冲突方案;事务不解决单文档并发问题,反而引入写锁和 write conflict 错误。
为什么不能靠事务解决单文档并发更新?
事务在 MongoDB 中会对目标文档加 WiredTiger 层写锁(X 锁),高并发时容易排队,触发内核级 write conflict 错误(错误码 112 或 129)并被自动中止。而单文档的 findOneAndUpdate 本身就是原子操作,事务纯属冗余——它解决的是跨文档一致性(比如 A 扣款 + B 入账),不是“读-改-写同一个文档”。
version 字段必须怎么初始化和校验才有效?
这个字段不是装饰,是乐观锁的命门,MongoDB 不会帮你生成、递增或类型校验:
- 插入首条文档时,必须显式设
version: 0或version: 1,不能是null、undefined或空字符串 -
version必须是数字类型(NumberInt或NumberLong),不能是字符串"1"—— BSON 字典序比较会导致"10" 这类逻辑错乱 - 匹配条件必须用
$eq精确比对,例如{ _id: id, version: oldVersion };写成{ _id: id, version: { $ne: null } }就失去锁的意义 - 更新操作必须原子包含
$inc: { version: 1 },不能在$set里手动赋值version: 2,否则破坏原子性
重试逻辑里最容易漏掉的三件事
很多人只写 while (result.matchedCount === 0) 就上线,结果线上丢更新:
- 每次重试前必须重新调用
findOne拿最新文档,不能复用第一次读出的version和业务字段——否则你是在拿过期快照反复撞墙 - 如果更新前调用了外部服务(如发短信、扣第三方账户),这些操作必须幂等;否则重试会重复扣款、重复发通知
- 别只检查
result.matchedCount === 0,还要捕获result.lastErrorObject?.code === 11000(唯一键冲突),它也可能导致更新失败
returnDocument: 'after' 是 Node.js Driver 4.x+ 的默认陷阱
Node.js 官方驱动 4.x+ 默认 returnDocument: 'before',意味着你拿到的是更新前的快照,无法直接用于下一轮重试:
- 错误配置:
findOneAndUpdate(filter, update, { returnDocument: 'before' }) - 正确配置:
findOneAndUpdate(filter, update, { returnDocument: 'after' })
不显式指定,重试时你拿不到升版后的 version 值,retry 循环永远卡死。

















