直接用find_and_modify不等于乐观锁,因其仅保证单文档原子性,未强制要求应用层显式校验版本号并递增;真正乐观锁必须由应用维护version字段,在update_one中以{"_id":id,"version":old}为条件、$inc version与业务更新同原子操作,matchedCount为0才标识冲突。

为什么直接用 find_and_modify 不等于乐观锁?
很多人以为调用 find_and_modify 或 update_one 带 upsert=False 就是乐观锁,其实不是。MongoDB 本身不提供“带版本号的原子校验更新”原语,所谓乐观锁必须靠应用层自己维护版本字段,并在更新时显式校验——否则并发写入仍会覆盖彼此。
典型错误是只查不校验:doc = collection.find_one({"_id": id}) → 修改 doc → collection.replace_one(...)。这中间没有任何并发保护,纯属“先读后写”(Read-Then-Write),不是锁。
- 必须把版本号(如
version字段)作为更新条件的一部分 - 每次更新都要检查当前文档的
version是否等于你读到的值 - 更新成功后要主动递增
version,且该递增必须和业务修改在同一原子操作中
用 update_one + $eq 实现可靠版本校验
最常用、最稳妥的方式是:读取文档时带上 version,更新时用 {"_id": id, "version": old_version} 作查询条件,再用 {"$set": {...}, "$inc": {"version": 1}} 更新。MongoDB 的 update_one 是原子的,只要匹配不到文档,就说明已被别人改过。
result = collection.update_one(
{"_id": doc_id, "version": doc["version"]},
{
"$set": {"name": "new_name", "updated_at": datetime.utcnow()},
"$inc": {"version": 1}
}
)
if result.matched_count == 0:
raise Exception("Optimistic lock failed: document version mismatch")
-
matched_count == 0表示没找到符合旧version的文档,即发生并发冲突 - 不要用
find_one_and_update替代——它虽然原子,但无法在更新前拿到旧值做业务逻辑判断(比如校验状态是否允许修改) - 初始化文档时务必设
"version": 0,避免空值导致$eq匹配失败
遇到 ObjectId 或嵌套字段时怎么写校验条件?
如果版本字段在子文档里(比如 metadata.version),查询条件得写成 {"_id": id, "metadata.version": old_version};如果用 ObjectId 当主键,没问题,version 仍是普通整数字段,不影响校验逻辑。
立即学习“Python免费学习笔记(深入)”;
注意:MongoDB 的 $eq 对 None、NaN、缺失字段行为敏感。如果某次更新漏写了 version 字段,后续所有基于 "version": x 的查询都会失败——所以务必确保每次写入都包含 version 字段(可用 $exists: true 在索引中强制约束)。
- 推荐给
version字段建索引:collection.create_index("version"),尤其当集合很大且频繁按版本查时 - 避免用字符串当
version,整数或ObjectId更安全、排序更可靠 - 如果业务需要“多字段联合校验”(比如状态 + 版本),就把它们全塞进查询条件里:
{"_id": id, "status": "draft", "version": 5}
重试逻辑要不要加?加在哪一层?
乐观锁天然伴随失败重试,但不能在驱动层或框架层无脑重试——因为业务逻辑可能已改变(比如用户改了两次输入,第二次提交不该覆盖第一次的合法修改)。重试必须由业务代码控制,且要明确“重试前重新读取最新状态”。
- 不要在 DAO 层封装“自动重试 N 次”,那会让上层误以为操作一定成功
- 典型模式:捕获版本冲突异常 →
find_one拿最新文档 → 重新计算变更 → 再次尝试update_one - 最多重试 3–5 次,超过就抛出明确错误(比如
ConcurrentUpdateError),交由前端提示用户“他人已修改,请刷新后重试”
真正容易被忽略的是:版本号递增必须和业务字段更新严格绑定在同一 update_one 调用里。分开两步(先 $inc 再 $set)或用两个独立命令,会彻底破坏原子性。


















