updateMany在高并发下会丢更新,因其无版本校验、纯覆盖写:两请求同时读库存10并各减1后均写回9,实际应为8;需用findOneAndUpdate配合version字段实现乐观锁,且version必须为数字、条件精确匹配、更新含$inc、returnDocument设为'after'。
不是 updateMany 本身有问题,而是默认行为不防冲突:它只管“找到就改”,不检查当前值是否已被其他请求覆盖。比如两个请求同时读到库存为 10,各自减 1 后都写回 9,实际应为 8——这就是典型的“丢失更新”。现象是业务数据对不上、状态错乱,但 MongoDB 不报错,静默失败。
根本原因在于 updateMany 没有版本控制或条件约束,纯靠“覆盖写”。它适合后台批量同步这类无状态场景,但不适合订单、库存、计数器等需强一致性的并发更新。
这是单文档并发更新最轻量、最可靠的方案。核心是把 version 当作“数据快照指纹”,每次更新都带上旧 version 值,MongoDB 只有在匹配时才执行,否则 matchedCount === 0。
关键点必须全部满足,缺一不可:
version 字段插入时必须初始化为 0 或 1(不能是 null、undefined 或字符串 "1"){ _id: id, version: oldVersion },不能用 $ne: null 这类宽松匹配$inc: { version: 1 },不能手动 $set: { version: 2 }——否则破坏原子性returnDocument: 'before',重试时拿不到新 version,务必显式设为 returnDocument: 'after'
示例:
const result = await collection.findOneAndUpdate({ _id: docId, version: currentVersion },{ $set: { status: 'paid' }, $inc: { version: 1 } },{ returnDocument: 'after', upsert: false })
如果你真要一次改几百条(比如发优惠券、批量改状态),又要求每条都防冲突,updateMany 就不合适了。得退回到逐条 + 有限并发:
for...of 串行执行 findOneAndUpdate,最稳,适合中小批量(Promise.allSettled() 控制最多 3–5 个并发批次,别用 Promise.all()——一个失败全崩await new Promise(r => setTimeout(r, 5)),缓解服务端压力,降低 WiredTiger 锁竞争cursor.toArray() 分页拉取,防 OOM注意:bulkWrite 的 updateOne 也支持 version 条件,但返回结果是批量统计,你无法知道哪一条因 version 不匹配而跳过——除非你后续再查一遍,成本高。
当你的更新逻辑涉及外部依赖(比如调用支付网关、发短信),或者 version 字段已存在但类型不规范(如字符串型 version),乐观锁就容易失效。这时更稳妥的做法是:
findAndModify(即 findOneAndUpdate 的底层命令),它保证读写原子,且支持 new: true 直接返回新值真正容易被忽略的是:version 字段的 BSON 类型。MongoDB 对 "1" 和 1 的比较规则完全不同,线上出过太多因字符串 version 导致的“看似成功、实则没更新”的坑。每次插入前用 Number() 显式转一下,比事后 debug 强十倍。