mongodb 的 updateone + version 条件不是事务,而是依赖单文档原子性实现乐观锁:它一次性完成版本检查、业务更新和 version 自增,无需事务隔离;放入事务反而引发写锁和冲突。

MongoDB 本身不支持在事务中“嵌套”乐观锁逻辑,findOneAndUpdate 或 updateOne 带 version 条件的操作,本身就不是事务——它靠的是单文档原子写,而不是事务隔离。硬把它们塞进 session.startTransaction() 里,反而会引入写锁、触发 write conflict 错误、降低吞吐。
为什么 updateOne + version 条件不是事务,但能当乐观锁用
MongoDB 单文档写操作天然原子:当你执行 updateOne({ _id: docId, version: 5 }, { $set: { status: 'done' }, $inc: { version: 1 } }),引擎会一次性完成「检查当前 version 是否仍为 5」+「更新业务字段」+「version 自增」三件事。中间没有间隙被其他写入插队。
这和事务无关,也不依赖锁。事务在这里是累赘:
- 事务会在 WiredTiger 层对文档加写锁,高并发下排队严重
- 事务内多个操作失败时回滚,但乐观锁本意就是“失败即退出,由业务重试”,没必要兜底
-
matchedCount === 0这个信号只在原子写里可靠;放进事务后,你还得额外判断事务是否提交成功,逻辑变重
如何正确用 updateOne 实现乐观锁(Node.js 驱动示例)
核心是两步闭环:读出当前文档 → 业务处理 → 带旧 version 值发起原子更新 → 检查 matchedCount。
示例代码关键点:
const result = await collection.updateOne(
{ _id: docId, version: currentVersion }, // 必须是上一步读到的值
{
$set: { updatedAt: new Date(), status: 'processed' },
$inc: { version: 1 } // 不要 $set version: currentVersion + 1
}
);
判断逻辑只看这一行:
-
result.matchedCount === 1→ 成功,版本未被改过 -
result.matchedCount === 0→ 冲突,别人已抢先更新,必须重试 - 别信
modifiedCount:字段值没变时它为 0,和版本冲突无关 - 别用
result.value:那是findOneAndUpdate的返回字段,updateOne根本不提供
version 字段初始化和类型踩坑清单
version 不是可选装饰字段,它是乐观锁生效的前提:
- 插入首条文档时,必须显式设
version: 0或version: 1,不能是null、undefined、空字符串或"1"(字符串) - 必须是数字类型:
NumberInt(1)、NumberLong(1)或原生 JSnumber,否则$eq匹配失败 - 不要在
$set里再赋值version,否则破坏原子性:MongoDB 不保证$set和$inc执行顺序,可能被并发覆盖 - 驱动行为差异:某些旧版驱动对
NumberLong处理不稳定,生产环境统一用 JSnumber更稳妥
重试逻辑里最容易漏掉的三件事
写了 while (retries 不等于真正防住了并发丢更:
- 每次重试前必须调一次
findOne拿最新文档 —— 不能复用第一次读出的version和业务字段,否则是在拿过期快照撞墙 - 如果更新前调了外部服务(比如发短信、扣第三方账户),这些调用必须幂等;否则重试会重复发通知、重复扣款
- 除了
matchedCount === 0,还要捕获result.lastErrorObject?.code === 11000(唯一键冲突),它可能和乐观锁失败同时发生,需合并处理
真正难的不是写对那一行 updateOne,而是让重试上下文干净、外部依赖可控、初始化严格——这些地方一松懈,乐观锁就退化成“看起来像锁”的幻觉。











