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,这条更新就匹配不到文档。应用看到 matchedCount 或 modifiedCount 为 0,就可以提示重试或返回状态冲突。
这个思路比单纯依赖事务更轻,适合订单状态、配置版本、审批流等场景。
六、什么时候需要事务
单文档原子性不能解决所有问题。比如同时修改订单、账户流水、优惠券状态,且这些数据分散在多个文档或多个集合里,就可能需要多文档事务。
但面试中要避免把事务当万能答案。事务会带来更高开销、锁和冲突处理成本,在分片集群里还会更复杂。
判断顺序可以是:
能否建模到一个文档?
-> 能:优先单文档原子更新
-> 不能:能否接受最终一致?
-> 能:用事件、补偿、对账
-> 不能:考虑事务
这套判断能体现工程取舍,而不是只背功能列表。
七、常见误区与追问
- 误区:MongoDB 不支持原子操作。 MongoDB 单文档写操作具备原子性,多文档一致性才需要额外考虑事务。
- 误区:先查询再更新和条件更新一样安全。 两步操作存在竞态,库存扣减、状态流转应把条件写进更新过滤器。
- 误区:
replaceOne和$set没区别。 覆盖替换更容易丢字段,局部更新更能表达并发下的修改意图。 - 追问:怎么防止库存扣成负数? 用
{ stock: { $gte: n } }作为更新条件,再用$inc扣减,并检查修改行数。 - 追问:版本号乐观锁怎么做? 过滤条件带
version,更新时$inc版本,匹配不到说明并发冲突。 - 追问:什么时候用事务? 多文档、多集合且必须强一致时再用,单文档内优先使用原子更新。
八、加强记忆
MongoDB 更新题可以记成“边界、操作符、条件”三件事:原子性边界在单文档;$set/$inc/$push/$addToSet/$pull 这些操作符让数据库端直接做局部原子修改;并发场景要把库存、状态、版本号等条件写进更新过滤器。多文档事务不是不能用,而是要在建模、最终一致和性能成本之间做判断。