← 返回题目列表

MongoDB 的单文档原子性和更新操作怎么理解?

高频 中等 第 7 / 31 题 更新于 2026/07/29
MongoDB原子性更新操作并发控制

简化版

MongoDB 的写操作在单个文档级别具备原子性,即一次更新要么完整生效,要么不生效。常用 $set$inc$push$addToSet 等更新操作符做局部修改,避免读出整个文档再覆盖写回导致并发丢失。

详细版

MongoDB 鼓励把需要一起原子修改的数据放在同一个文档内,因为单文档更新天然原子。例如库存扣减可以用条件更新和 $inc

db.products.updateOne(
  { _id: 1, stock: { $gte: 2 } },
  { $inc: { stock: -2 } }
)

这个写法把“检查库存”和“扣库存”放在同一条更新里,避免先查再改的竞态。多文档一致性可以使用事务,但事务不是默认答案,建模时应优先判断是否能通过文档聚合和原子更新解决。

完整版教学

一、MongoDB 的原子性边界是单文档

MongoDB 最常见的面试结论是:单个文档内的写操作是原子的。即使一次更新修改多个字段,只要目标是同一个文档,也会作为一个整体生效。

例如订单状态和版本号一起更新:

db.orders.updateOne(
  { _id: 1001, status: "NEW" },
  {
    $set: { status: "PAID", paidAt: new Date() },
    $inc: { version: 1 }
  }
)

这条更新不会出现 status 改了但 paidAt 没改的半成品状态。面试回答时要把“单文档原子”和“多文档事务”分清楚。

记忆钩子:MongoDB 默认强项是“一个文档内一起改”,不是“任意多个文档自动一起改”。

二、为什么更新操作符比整文档覆盖更安全

如果应用先查出文档,在内存里改字段,再 replaceOne 覆盖,容易把别人刚写入的字段覆盖掉。

// 风险较高:读出旧文档后整体替换
db.users.replaceOne(
  { _id: 1 },
  { _id: 1, name: "Alice", score: 20 }
)

更稳妥的方式是用更新操作符表达局部意图:

db.users.updateOne(
  { _id: 1 },
  { $inc: { score: 1 }, $set: { lastActiveAt: new Date() } }
)

这样只修改指定字段,减少并发覆盖面。尤其是计数、余额、库存、数组追加这类场景,更新操作符是高频考点。

三、条件更新能把校验和修改合成一步

很多并发问题来自“两步走”:先读库存,再判断,再扣减。两个请求都看到库存足够,就可能超卖。

错误思路:

1. find stock = 1
2. app 判断 stock > 0
3. update stock = stock - 1

正确思路是把条件放进更新过滤器:

const ret = db.products.updateOne(
  { _id: 1, stock: { $gte: 1 } },
  { $inc: { stock: -1 } }
)

如果 modifiedCount 是 1,说明扣减成功;如果是 0,说明库存不足或状态已变化。这个模式也适合状态机流转。

四、常用更新操作符要按语义记

MongoDB 更新操作符不是语法糖,它们表达了数据库端的原子修改意图。

操作符含义高频场景
$set设置字段修改状态、更新时间
$unset删除字段清理废弃字段
$inc数值增减计数器、库存
$push数组追加追加日志、消息
$addToSet数组去重追加标签、关注列表
$pull从数组删除匹配项删除成员、移除标签

面试里如果只说“增删改查”,会显得停在 API 层。更好的回答是结合原子性、并发和建模说明为什么使用这些操作符。

五、乐观并发控制可以放进过滤条件

MongoDB 没有强制你使用版本号,但版本号是常见工程方案。更新时带上当前版本,成功后版本加 1。

db.orders.updateOne(
  { _id: 1001, version: 7 },
  {
    $set: { status: "CANCELLED" },
    $inc: { version: 1 }
  }
)

如果另一个请求已经把版本改成 8,这条更新就匹配不到文档。应用看到 matchedCountmodifiedCount 为 0,就可以提示重试或返回状态冲突。

这个思路比单纯依赖事务更轻,适合订单状态、配置版本、审批流等场景。

六、什么时候需要事务

单文档原子性不能解决所有问题。比如同时修改订单、账户流水、优惠券状态,且这些数据分散在多个文档或多个集合里,就可能需要多文档事务。

但面试中要避免把事务当万能答案。事务会带来更高开销、锁和冲突处理成本,在分片集群里还会更复杂。

判断顺序可以是:

能否建模到一个文档?
  -> 能:优先单文档原子更新
  -> 不能:能否接受最终一致?
      -> 能:用事件、补偿、对账
      -> 不能:考虑事务

这套判断能体现工程取舍,而不是只背功能列表。

七、常见误区与追问

  • 误区:MongoDB 不支持原子操作。 MongoDB 单文档写操作具备原子性,多文档一致性才需要额外考虑事务。
  • 误区:先查询再更新和条件更新一样安全。 两步操作存在竞态,库存扣减、状态流转应把条件写进更新过滤器。
  • 误区:replaceOne$set 没区别。 覆盖替换更容易丢字段,局部更新更能表达并发下的修改意图。
  • 追问:怎么防止库存扣成负数?{ stock: { $gte: n } } 作为更新条件,再用 $inc 扣减,并检查修改行数。
  • 追问:版本号乐观锁怎么做? 过滤条件带 version,更新时 $inc 版本,匹配不到说明并发冲突。
  • 追问:什么时候用事务? 多文档、多集合且必须强一致时再用,单文档内优先使用原子更新。

八、加强记忆

MongoDB 更新题可以记成“边界、操作符、条件”三件事:原子性边界在单文档;$set/$inc/$push/$addToSet/$pull 这些操作符让数据库端直接做局部原子修改;并发场景要把库存、状态、版本号等条件写进更新过滤器。多文档事务不是不能用,而是要在建模、最终一致和性能成本之间做判断。