根本原因是未正确使用乐观锁或滥用事务——单文档更新本原子,但裸写会覆盖,加错版本条件或用事务反增冲突;事务因wiredtiger写锁机制导致同文档操作必冲突,而正确version校验需精确匹配、数值类型及$inc升版。

updateOne 或 findOneAndUpdate 在高并发更新同一文档时频繁失败,根本原因不是代码写错了,而是你用了事务或没加版本条件——单文档更新本就原子,但裸写会覆盖,加错条件会失效,用事务反而激化冲突。
为什么事务会让 write conflict 更严重
MongoDB 事务对文档加的是 WiredTiger 写锁,不是“逻辑锁”。只要两个事务碰了同一个文档(哪怕只读一次、只改一个字段),后提交的那个就会被中止,报错 112 或 129。这不是配置问题,是 MVCC 快照机制的必然行为。
常见误操作:
- 把「查文档 → 改字段 → 更新」包进
session.startTransaction()—— 完全没必要,还主动制造热点 - 事务里只做单文档
updateOne,却期望靠它防覆盖 —— 实际上它比裸updateOne更慢、更易冲突 - 重试逻辑只 catch
WriteConflict,不检查result.matchedCount === 0—— 后者才是乐观锁失败的真实信号
findOneAndUpdate 的 version 条件怎么写才生效
关键不在有没有 version 字段,而在匹配逻辑是否精确、类型是否正确。MongoDB 的 $eq 对数字和字符串的比较规则完全不同。
必须满足以下三点:
-
filter中必须用{ _id: id, version: oldVersion },不能是{ version: { $ne: null } }或{ version: "1" } -
version字段插入时就得设为NumberInt(0)或NumberLong(1),不能是null、""、"0" -
update操作必须用$inc: { version: 1 }升版,不能在$set里手动赋值 —— 否则原子性断裂
错误示例:{ _id: id, version: "1" } → 字符串比较下 "10" 会匹配成功,导致跳过校验。
重试逻辑里最容易漏掉的三件事
写个 while (matchedCount === 0) 循环不等于解决冲突。线上丢更新,往往卡在这三个细节上:
- 每次重试前没重新
findOne拿最新文档 —— 复用第一次读出的version和业务字段,等于拿过期快照反复撞墙 - 更新前调了发短信、扣第三方账户等外部服务,但没做幂等 —— 重试就重复扣款、重复发通知
- 只判断
result.matchedCount === 0,漏了result.lastErrorObject?.code === 11000(唯一键冲突)—— 这种失败也得退避重试,不能当业务异常吞掉
Node.js 驱动还要额外注意:returnDocument 默认是 'before',你拿到的是旧值,无法用于下一轮重试;必须显式设为 'after'。
热点文档扛不住时,别硬刚,要打散
如果业务确实绕不开高频更新同一逻辑实体(比如实时 PV 计数器、秒杀库存),靠单文档乐观锁吞吐会迅速见顶。这时该放弃「一个文档代表一个实体」的思维。
实操做法:
- 加一个
shard字段,取值为0到N-1的随机整数 - 写入时用
Math.floor(Math.random() * N)选分片,更新对应子文档 - 读取时用
aggregate+$sum聚合所有分片,记得给{ counterId: 1, shard: 1 }加复合索引
冲突概率直接降到原来的 1/N,且完全不改业务语义。真正难的不是写代码,是说服团队接受「一个逻辑计数器 = N 个物理文档」这个设计拐点。











