findOneAndUpdate默认不防并发覆盖,因其仅保证单次操作原子性,不控制多请求间顺序;需用乐观锁(如version字段+重试)避免丢失更新。

为什么 findOneAndUpdate 默认不防并发覆盖
MongoDB 的 findOneAndUpdate 本身不是原子锁,它只保证单次操作的原子性,不保证多次并发请求之间的顺序。当多个线程同时读取同一条文档、各自计算新值、再写回时,后到的更新会直接覆盖先到的——典型“丢失更新”问题。这不是 MongoDB 的 bug,而是设计使然:它把并发控制权交给你。
实操建议:
- 别依赖
version字段自动递增(MongoDB 不内置该行为) - 必须显式引入版本号或时间戳字段,比如
version或updatedAt - 用
$eq在filter中校验当前版本,确保“读到的旧值还没被别人改过”
怎么用乐观锁 + 重试实现安全更新
核心思路是:每次更新都带上“我读到的版本号”,让数据库判断“这个版本还存不存在”。如果已被更新,就失败,由应用层决定是否重试。
示例(Node.js + MongoDB Driver):
const result = await collection.findOneAndUpdate(
{ _id: docId, version: expectedVersion },
{
$set: { status: 'processed', updatedAt: new Date() },
$inc: { version: 1 }
},
{ returnDocument: 'after' }
);
关键点:
-
filter中必须包含version: expectedVersion,否则等于没锁 -
$inc: { version: 1 }要和业务更新一起提交,避免版本号滞后 - 如果
result.value === null,说明更新失败(版本不匹配),需重新读取 + 重试 - 重试次数建议设上限(如 3–5 次),避免死循环或雪崩
重试逻辑里最容易漏掉的三个细节
很多人写了重试,但依然出错,问题常出在“读-算-写”链条的中间环节。
- 没在重试前重新
findOne获取最新文档——沿用旧数据重试,等于重复失败 - 业务逻辑含副作用(比如调外部 API、发消息),重试时没做幂等防护,导致重复执行
- 没处理
result.lastErrorObject?.updatedExisting === false这类底层返回,误判为成功
建议把“读取 → 计算 → 尝试更新”包成一个函数,每次重试都走完整流程,不要拆开缓存中间状态。
比乐观锁更重的场景要不要换方案
如果并发极高、冲突频繁(比如秒杀库存扣减),乐观锁重试可能大量失败,吞吐骤降。这时得考虑:
- 用
$inc直接原子增减(适合纯数值更新,无需读取原值计算) - 把热点数据拆分(如库存分 10 个桶,随机选一个扣,再汇总)
- 引入 Redis + Lua 做分布式锁预检(但要小心锁粒度和超时)
注意:MongoDB 的 findAndModify 系列操作本身不阻塞其他读写,所以“锁”只是逻辑层面的,真正卡住你的永远是应用层重试策略和业务复杂度。










