用$inc是mongodb中对数值字段原子自增最可靠、最轻量的方式,它天然规避“读-改-写”竞态问题,但须配合精确查询条件和索引,否则可能误更新或多条漏更新。

用 $inc 原子操作直接扣减库存,别先查再改
查完再更新(read-then-write)在高并发下必然超卖。MongoDB 的 $inc 是原子的,只要库存字段是整数,就能保证扣减过程不被干扰。比如扣 1 件:
{ $inc: { stock: -1 } },这条操作要么成功扣减,要么失败——不会出现“查到还有 1 件,但两个请求同时扣,变成 -1”的情况。常见错误是写成两步:findOne() 拿到 stock,判断 >0 后再 updateOne({$set: {stock: stock - 1}})。这中间没有锁,竞态直接发生。
实操建议:
- 更新时必须带库存余量检查,例如:
{ stock: { $gt: 0 } },否则$inc可能让库存变负 - 用
updateOne()而非findOneAndUpdate(),后者默认返回更新前的文档,容易误导业务逻辑误判结果 - 务必检查
result.matchedCount和result.modifiedCount:前者为 1 表示找到符合条件的文档,后者为 1 才代表真正扣减成功
库存字段必须单独建索引,且避免嵌套路径
查询条件 { stock: { $gt: 0 } } 如果没索引,每次更新都要全表扫描,QPS 上不去还拖慢整个集合。MongoDB 对单字段数值查询的索引效率极高,但对嵌套字段(如 goods.stock)支持有限,尤其当嵌套层级深或存在数组时,索引可能失效或无法用于范围查询。
实操建议:
- 库存字段放在根层级,命名就叫
stock,不要套在inventory或spec对象里 - 建立普通升序索引:
db.items.createIndex({ stock: 1 }),不需要稀疏或唯一,但必须存在 - 避免用 TTL 索引或文本索引覆盖该字段,它们不加速
$gt查询 - 如果业务需要按商品 ID 查+扣,复合索引
{ itemId: 1, stock: 1 }更优,能支撑{ itemId: "xxx", stock: { $gt: 0 } }的高效定位
用 findAndModify(或 findOneAndUpdate)返回扣减结果,别依赖二次查询
秒杀成功与否,得立刻知道“这次扣没扣上”,不能扣完再 findOne 查一遍——那又引入一次读延迟和潜在不一致。MongoDB 的原子更新命令支持返回更新后的文档,这才是唯一可信的结果源。
实操建议:
- 用
findOneAndUpdate并设置returnDocument: 'after'(Node.js 驱动),确保拿到的是扣减后的快照 - 检查返回文档的
stock值是否 ≥0:如果扣完是 0,说明这是最后一单;如果已是负数,说明前置条件漏了(应靠$gt: 0过滤拦截) - 不要把“扣减成功”等同于“返回文档非空”:如果条件不匹配(如库存已为 0),
findOneAndUpdate返回null,这是正常失败,不是异常 - 避免在应用层做“重试 N 次直到成功”:这会放大集群压力,应配合限流和前端友好提示(如“手慢了”)
预扣减 + 异步核销模型更适合大流量场景
纯文档扣减在百万级 QPS 下仍可能因 WiredTiger 引擎的文档锁粒度(按文档,非字段)导致热点争用。真实生产中,更稳的做法是把“预占库存”和“最终确认”拆开:先用 Redis 做分布式计数器快速预减,再异步落库并校验一致性。
实操建议:
- MongoDB 文档只存“总库存”和“已售出”两个字段,不参与实时扣减逻辑,仅作最终对账和展示
- Redis 用
DECR做预减,key 为seckill:itemId,初始值 = 总库存;扣完 ≤0 即拒绝 - 下单成功后发 MQ 消息,由消费者调用 MongoDB 更新
sales字段:{ $inc: { sales: 1 } },并比对sales是否超过总库存,超则告警人工介入 - 这种混合模型下,MongoDB 不再是性能瓶颈,而是作为可靠、可审计的底座存在
真正难的不是语法怎么写,而是想清楚哪一层该承担什么职责:Redis 快、MongoDB 稳,混用时边界一旦模糊,超卖和数据不一致就会悄悄发生。











