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_KEYyour_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: trueconst updated await Product.findOneAndUpdate( { sku: abc123 }, { $inc: { sold: 1 } }, { new: true } ); console.log(updated.sold); // 自增后的值这里有个容易忽略的点findOneAndUpdate默认返回更新前的文档必须显式传new: trueMongoose 里等价于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 页面创建接入细节对照接入文档即可。
企业数字化 ERP 产品动态
相关推荐
【Bluetooth】Bluetooth MAC 地址组成 请勿与 Company Identifier 混淆格式
根据蓝牙核心规范,蓝牙会被分配一个 48-bit 蓝牙设备地址(BD_ADDR),格式如图:LAP: 24-bit lower address part
UAP: 8-bit upper address part
NAP: 16-bit non-significant addre… · 2026/9/25 6:58:50
ethers.js 浏览器分发包(dist)完全指南:ESM 产物、词表优化与构建管线 区块链Web3 【免费下载链接】ethers.js Complete Ethereum library and wallet implementation in JavaScript. 项目地址: https://gitcode.com/gh_mirrors/et/ethers.js 点击查看 免费下载 本篇技术指南以仓库内 misc/basedirs/dist/README.md 为骨架,… · 2026/9/25 6:58:50
AI Agent技能库工程化实践:从Prompt乱象到可控工具调用 如果你最近在研究AI Agent,一定遇到过类似的困局:模型什么都能聊,但一落到具体业务就抓瞎。我去年接手了一个智能客服项目,最初的方案是“一个大模型 一套大而全的Prompt 一份工具列表”,结果模型频繁选错工具、传错… · 2026/9/25 7:31:42
终端环境兼容性与云原生IDE实战指南:从手机写代码到生产级开发工作流 1. 这不是“手机能装个VS Code”——而是重构开发工作流的临界点 2026年,我拆开三台主力设备:一台折叠屏安卓旗舰、一台iPad Pro配妙控键盘、一台搭载ARM架构的Windows平板,把它们全换成主力开发机。不是为了炫技,而是因为本地ID… · 2026/9/25 7:31:42
Neo4j社区版5.26.0 Windows安装配置与避坑指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 7:31:23
小米平板4 Plus刷Droidian:从解锁分区到蓝牙修复的完整指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 7:31:23
龙芯智能车从零搭建:LoongArch工具链、Linux内核编译与逐飞库集成 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 7:31:17
创维E900V22D刷机全攻略:S905L3SB芯片兼容性解析与救砖实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:31
MQTT协议原理与Broker服务器搭建实战:从Mosquitto到EMQX /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:37