upsert本身不提供跨请求的原子防重,必须配合唯一索引才能确保并发写入安全;唯一索引由存储引擎底层强制校验,是防重的真正底线,upsert仅负责语义,索引负责兜底。

upsert 本身不是原子防重的银弹
upsert 只是“查不到就插入、查到了就更新”的语义,但它不保证“查”和“插/更”之间没有其他写入——这两个动作在服务端不是单次原子操作。当多个请求几乎同时执行 updateOne(带 upsert: true),且查询条件都未命中时,MongoDB 会为每个请求各自插入一条新文档。
这不是 bug,而是设计使然:upsert 的原子性仅限于单个操作内,不跨请求。它无法感知其他并发请求的存在,更不会排队或加锁。
典型表现是数据库里突然冒出多条 { order_id: "ORD-123" } 文档,而业务上要求这个字段必须唯一。
唯一索引才是真正的并发防重底线
没有 unique: true 索引,仅靠 upsert 就像用纸糊门挡洪水——看着有动作,实则无效。唯一索引由 WiredTiger 存储引擎在写入路径最底层强制校验,是真正原子、不可绕过的兜底。
常见失效场景包括:
- 建索引前集合已有重复数据,
createIndex直接失败但你没检查返回值 - 字段值含隐藏字符(如末尾空格、
\u200b),导致"abc "和"abc"被视为不同值 - 大小写敏感需求下没配
collation: { locale: "en", strength: 2 } - 字段允许
null,而默认行为允许多个null共存——此时应改用sparse: true
建完务必运行 db.collection.getIndexes(),确认状态是 "ready" 且 "unique": true 明确存在。
upsert + 唯一索引组合才是安全写入姿势
正确做法是两者缺一不可:应用层统一走 updateOne({ order_id: "ORD-123" }, { $set: { ... } }, { upsert: true }),同时在 order_id 字段上建唯一索引。
这样即使并发写入,第二个请求也会因违反唯一约束而抛出错误码 11000(duplicate key),而不是静默创建重复文档。你只需捕获该错误并按需忽略或重试。
容易踩的坑:
- 漏传
upsert: true,结果变成纯更新,查不到就什么都不做 - 查询条件太宽,比如只用
{"name": "Alice"},导致不同用户的同名记录互相覆盖 - 更新时没用
$set,而是传普通对象,意外清空其他字段(见 MongoDB “坑1”)
事务里用 upsert 也得加唯一索引
很多人误以为把 upsert 包进 session.withTransaction() 就万事大吉。其实事务只保证原子性和回滚,不解决重复提交问题——网络超时后客户端重发,服务端看到的是新事务,照样会再插一次。
所以事务内的写入,同样要依赖唯一索引 + upsert 组合。额外建议:
- 事务开始前,用同一
session先轻量查一次:findOne({ order_id: "ORD-123" }),存在则跳过整个事务体 - 事务体内仍用
updateOne(..., { upsert: true }),双重保险 - 别用 Redis 锁替代唯一索引——Redis 和 MongoDB 不在同一个原子域,锁成功但事务失败会导致锁残留,后续请求被误拒
真正可靠的锚点只有一个:MongoDB 自身的唯一索引。其他都是辅助,且可能引入新故障面。

















