☰
Mongoose 中 $inc 的实战用法:从字段自增到并发安全更新
2026/9/25 6:58:46 网站建设 项目流程

1. 从一次库存超卖说起:为什么我最终选了 $inc

做电商类项目时,最容易踩的坑之一就是库存扣减。我最早的做法是先把商品文档查出来,在 Node.js 里判断stock > 0,然后stock = stock - 1,最后save()回去。单机压测看着没问题,一上并发就出事:两个请求同时读到stock = 1,各自判断通过,各自写回stock = 0,结果卖出去两件,库存只扣了一件。

这个问题的本质是「读-改-写」不是原子操作。Mongoose 里解决它最直接的工具就是$inc。$inc是 MongoDB 的原子更新操作符,作用是把指定字段的值增加一个指定的数量,正数就是自增,负数就是自减。它由数据库在单文档层面保证原子性,不需要你在应用层加锁,也不会出现两个请求互相覆盖的情况。

这篇文章聚焦$inc的典型场景:计数器、库存扣减、积分累加。我会给出可以直接复制的 Schema 定义、updateOne和findOneAndUpdate的配置片段,并演示怎么验证原子自增的结果,以及在并发下怎么确认数据一致性。适合已经会用 Mongoose 做基础 CRUD、但还没系统用过原子更新操作符的同学。读完之后,你应该能把项目里那些「先查再改再存」的计数逻辑,安全地换成$inc。

2. 前置准备:装好 Mongoose 并连上数据库

在写$inc之前,先把环境跑通。这里假设你已经有一个可用的 MongoDB 实例,本地或者云端都行。如果你还没有稳定的模型调用环境,可以先用 TaoToken 把对话和编码相关的 Key 配好,方便边写边验证。

先初始化项目并安装依赖:

mkdir mongoose-inc-demo && cd mongoose-inc-demo npm init -y npm install mongoose

然后写一个连接文件。Mongoose 8.x 之后连接 API 有一些变化,建议用下面的写法:

// db.js const mongoose = require('mongoose'); async function connect() { await mongoose.connect('mongodb://127.0.0.1:27017/inc_demo', { serverSelectionTimeoutMS: 5000, }); console.log('MongoDB connected'); } module.exports = { connect };

如果你用的是云端连接串,把mongodb://127.0.0.1:27017/inc_demo换成你的实际地址即可。连接成功后,Mongoose 默认会开启缓冲,模型操作会等连接就绪,但显式await connect()更稳妥。

关于 Key 的获取,你可以到 TaoToken 的 API Keys 页面创建一个,然后在接入文档里对照环境变量写法。把 Key 放进.env,不要硬编码进代码:

# .env TAOTOKEN_API_KEY=your_key_here
// 读取环境变量 require('dotenv').config(); const apiKey = process.env.TAOTOKEN_API_KEY;

这一步不是$inc必需的,但如果你后面想用模型对话来辅助排查报错,或者用 Coding Plan 做长期编码,提前配好会省事很多。

3. 可复制的 Schema 与 $inc 配置片段

3.1 定义带计数字段的 Schema

先定义一个商品模型,包含库存stock、销量sold,以及一个嵌套的统计对象metrics:

// models/Product.js const mongoose = require('mongoose'); const productSchema = new mongoose.Schema({ sku: { type: String, required: true, unique: true }, name: { type: String, required: true }, stock: { type: Number, default: 0, min: 0 }, sold: { type: Number, default: 0 }, metrics: { views: { type: Number, default: 0 }, orders: { type: Number, default: 0 }, }, }, { timestamps: true }); module.exports = mongoose.model('Product', productSchema);

注意stock上加了min: 0。这个校验只在save()和validate()时生效,$inc是绕过 Schema 校验直接打到数据库的,所以min: 0不能防止库存被扣成负数。防负数要靠查询条件,后面会讲。

3.2 updateOne 做原子自增

最简单的用法是updateOne,只更新不返回文档:

const Product = require('./models/Product'); // 销量 +1 await Product.updateOne( { sku: 'abc123' }, { $inc: { sold: 1 } } ); // 库存 -2,同时订单数 +1 await Product.updateOne( { sku: 'abc123' }, { $inc: { stock: -2, 'metrics.orders': 1 } } );

$inc的语法是{ $inc: { <field>: <amount> } },amount可以是正数也可以是负数,还可以一次更新多个字段,包括用点号表示的嵌套字段。如果字段不存在,$inc会自动创建它,并把值设为amount。

3.3 findOneAndUpdate 返回更新后的值

如果你需要拿到自增之后的结果,用findOneAndUpdate,并设置new: true:

const updated = await Product.findOneAndUpdate( { sku: 'abc123' }, { $inc: { sold: 1 } }, { new: true } ); console.log(updated.sold); // 自增后的值

这里有个容易忽略的点:findOneAndUpdate默认返回更新前的文档,必须显式传new: true(Mongoose 里等价于returnDocument: 'after')才能拿到更新后的值。很多人第一次用会以为返回的是新值,结果拿到旧数据,排查半天。

3.4 库存扣减要带条件防负数

前面说过$inc不校验min,所以扣库存时要把「库存足够」写进查询条件:

const result = await Product.updateOne( { sku: 'abc123', stock: { $gte: 2 } }, { $inc: { stock: -2, sold: 2 } } ); if (result.modifiedCount === 0) { console.log('库存不足,扣减未执行'); }

这样当stock < 2时,查询匹配不到文档,$inc不会执行,modifiedCount为 0。整个「判断 + 扣减」在数据库层面是一次原子操作,不会出现超卖。

4. 验证原子自增与并发一致性

4.1 单次自增的结果验证

先插入一条测试数据,然后连续自增,观察结果:

await Product.create({ sku: 'test001', name: '测试商品', stock: 10 }); for (let i = 0; i < 5; i++) { await Product.updateOne({ sku: 'test001' }, { $inc: { sold: 1 } }); } const doc = await Product.findOne({ sku: 'test001' }); console.log(doc.sold); // 5

预期输出是 5。如果字段原本不存在,第一次$inc会把它创建为 1。

4.2 并发下的数据一致性验证

真正要验证的是并发。用Promise.all同时发起 100 次自增:

await Product.updateOne({ sku: 'test001' }, { $set: { sold: 0 } }); const tasks = Array.from({ length: 100 }, () => Product.updateOne({ sku: 'test001' }, { $inc: { sold: 1 } }) ); await Promise.all(tasks); const finalDoc = await Product.findOne({ sku: 'test001' }); console.log(finalDoc.sold); // 100

如果换成「先查再改再存」的写法,这个测试大概率会得到小于 100 的结果,因为存在丢失更新。而$inc的最终值一定是 100,因为每次自增都是数据库层面的原子操作,100 次自增不会互相覆盖。

4.3 用模型对话辅助排查

如果你在验证过程中遇到报错,比如Cannot apply $inc to a value of non-numeric type,可以直接把报错贴到模型对话里,让它帮你定位。这个报错通常是因为目标字段当前是字符串或 null,$inc只能作用于数字类型。解决办法是先用$set把字段初始化为数字,或者检查数据写入时有没有把数字存成了字符串。

5. 本篇常见错误排查

5.1 $inc 用在空字段或非数字字段上报错

这是最常见的报错。$inc要求目标字段的值是数字。如果字段是null、字符串、数组,都会报错。比如:

// 假设 sold 当前是 null await Product.updateOne({ sku: 'test001' }, { $inc: { sold: 1 } }); // 报错:Cannot apply $inc to a value of non-numeric type

解决方式是先修正数据类型,或者用$set初始化:

await Product.updateOne( { sku: 'test001', sold: null }, { $set: { sold: 0 } } );

注意:字段「不存在」和字段「值为 null」是两回事。字段不存在时$inc会创建它,字段存在但值为 null 时会报错。

5.2 findOneAndUpdate 拿不到新值

前面提过,默认返回旧文档。检查你有没有传new: true:

// 错误:返回旧值 await Product.findOneAndUpdate({ sku: 'abc123' }, { $inc: { sold: 1 } }); // 正确:返回新值 await Product.findOneAndUpdate( { sku: 'abc123' }, { $inc: { sold: 1 } }, { new: true } );

5.3 嵌套字段路径写错

更新嵌套字段要用点号字符串,不能用对象嵌套:

// 错误:会被当成替换整个 metrics 对象 await Product.updateOne({ sku: 'abc123' }, { $inc: { metrics: { orders: 1 } } }); // 正确 await Product.updateOne({ sku: 'abc123' }, { $inc: { 'metrics.orders': 1 } });

5.4 并发扣库存仍然超卖

如果你用了$inc但还是超卖,检查查询条件里有没有带库存判断。只写{ sku: 'abc123' }是不够的,必须加上stock: { $gte: 扣减数量 }。另外,如果你在应用层先查了一次库存再扣,那个查询结果在并发下是不可靠的,判断必须放进updateOne的查询条件里。

5.5 自增字段被 Schema 默认值覆盖

如果你在 Schema 里给字段设了default: 0,新建文档时没问题,但更新时$inc不会触发默认值。真正要注意的是:不要用save()去覆盖$inc的结果。比如你先$inc了sold,然后又拿到一个旧文档save(),旧文档的sold会把自增结果覆盖掉。原子更新和文档保存不要混用在同一个字段上。

6. 把 $inc 用对,比加锁更省心

$inc的价值在于把「读-改-写」压缩成一次数据库原子操作,省掉了应用层的锁和重试逻辑。库存扣减、积分累加、浏览量统计这类场景,只要把判断条件写进查询里,就能在并发下保持正确。

如果你想把这类原子更新逻辑沉淀成长期可维护的代码,可以用 Coding Plan 把常用的更新模式整理成工具函数;遇到报错时,直接到模型对话里贴日志排查会更快。Key 在 API Keys 页面创建,接入细节对照接入文档即可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询