去年下半年我接了个活:给本地一家做医疗器械经销的公司做物资管理系统,需求说简单也简单,就是把出入库从 Excel 搬到网页上。我第一版偷懒,几乎全塞进了一张 MySQL 表,结果后面被“批次效期怎么算”“同一批货先出哪个”“领导要按业务员看库存周转”这类需求折磨得天天改表。后来推倒重来,改成 Node.js + MongoDB,按模块化设计重新梳理业务,最后部署到云端交付。这篇文章就把这次重构里的选型理由、建模思路、代码划分和踩坑经验一次说清楚,给准备做同类管理系统的朋友做个参考。
1. 项目起点:一张 Excel 表引发的库存管理事故
1.1 我遇到的真实业务场景
这家公司主营三类商品:一类是常规耗材,一类是带灭菌批号的植入器械,还有一类是含锂电池的温度敏感型设备。麻烦就出在“三类”上——耗材只需要数量准确,植入器械必须精确到每一个批号,设备则要记录序列号和质保期。同一个“物资”概念,在不同品类里字段差别非常大。
如果只做库存数量登记,那真的是一张表的事。但他们还需要仓库按库位管理,采购按批次追踪,财务按供应商核对账期,销售要知道哪些货积压超过九十天。每个角色看待同一批物资的方式都不一样。这就是我第一版失败的根源:我一开始以为系统要做的是“登记”,实际上要做的是“追踪”。
1.2 第一版为什么注定要打补丁
第一版我用了经典的“物资主表 + 分类扩展表”写法。主表放品名、规格、单位、库存量,扩展表放各品类各自的属性。听起来灵活,但真实业务一跑就露馅了。
比如效期管理。耗材有失效期,器械有灭菌日期,设备要看安装日期。这明明是三种不同的时间语义,我在数据库里却只能用“到期时间”一个字段硬塞。再比如出库规则:器械系列要求先进先出,耗材系列要求先到期先出。业务规则一旦和数据结构长得不一样,代码里就全是 if else,每加一个品类就往 controller 里加一段分支。到第三个月,光是出库逻辑就有四百多行,没人敢动。
真正让我下定决心的是一次统计需求——按供应商维度看每个 SKU 的到货合格率。我算了算关联查询要跨五张表,前端列表接口要改成动态拼接 SQL,凌晨两点我坐在屏幕前问自己到底图什么。
1.3 重构前的四项硬要求
第二版动手之前,我给自己定了四条规矩,后面所有设计都是围绕它们展开:
- 任何新的物资品类加入,不允许改数据库表结构,只能通过新增配置或文档字段解决。
- 出入库操作必须保证原子性,库存账目不能出现“用户看到扣了,实际没扣”的情况。
- 代码按业务模块划分,新增一个业务流程不需要碰其他模块的代码。
- 系统部署在云端,但数据库不能裸奔在公网上,必须做完整认证和访问控制。
现在回头看,这四条其实分别对应了 MongoDB 的文档模型、原子操作、模块化工程和云端安全。它们不是从技术书里抄来的,是被第一版打出来的。
2. 技术选型:在物料管理里,MongoDB 比 MySQL 到底强在哪里
2.1 物资数据的字段天然不一样
很多人把 MongoDB 理解成“可以不用写 SQL 的数据库”,这其实是误解。它真正的价值是文档模型能把“一个物资的完整状态”存成一份聚合数据,不需要为了查询把数据拆散到多张表里。
以植入器械为例,一条库存记录包含产品信息、灭菌批号、失效日期、所在仓库、库位、入库单号、供应商 ID。在 MongoDB 里,这就是一条文档,取出即用。换成关系型数据库,这至少拆成物资表、批次表、库存表三张,每次查询都要 JOIN。物资管理这种业务,本质上就是反复地“按批号/按库位/按供应商聚合数据”,文档模型正好命中这个模式。
还有一个很实际的点:字段演进。第一版后期我只要加一列,就要写迁移脚本;在 MongoDB 里新增字段只是往文档里加一个 key,旧文档缺这个字段时用默认值兜底就行。对于中小型系统,这个灵活度能省下大量“改表”的时间。
2.2 业务是 I/O 密集而非计算密集
仓库管理系统的典型操作是:请求进来、查库存、校验状态、扣减数量、写流水、返回结果。链路里百分之八十时间花在数据库 I/O 上,CPU 反而不忙。Node.js 的事件循环模型处理这类高并发短任务很合适,配合异步驱动,单台服务器扛几百个并发用户完全没问题。
再加上后端用 JavaScript,和前端、管理后台天然同语言。我们这个项目后期还要给仓库扫码枪写一个简化版 H5 操作页,前后端共享一套校验逻辑和工具函数,这一点直接降低了维护成本。
2.3 边界:什么时候别选这套组合
技术选型不能只看优点,也得说清楚什么场景下别用。锁定的实话是:如果系统核心是“钱”和“强一致性报表”,比如财务总账、银行流水、多级审批结算,MongoDB 的跨文档事务会比关系型数据库重,关系型数据库依然更合适。
我在这个项目里坚持用 MongoDB,是因为物资管理虽然涉及数量金额,但真正的核心是“批次追踪 + 高并发库存扣减 + 灵活属性”。这些恰好不是关系型数据库的强项。选型的原则就一句:让数据的形状长成业务的形状,而不是反过来。
3. 数据库建模:五个集合怎么撑起一套物资系统
3.1 一份可以直接落地的 schema 设计
我给这套系统设计的核心数据结构由五个集合组成,不建议再少,也不建议一上来就拆十几个集合。
products:物资基础档案,包含品名、规格、单位、默认仓库、预警阈值、自定义属性字典。batches:库存批次,核心字段是 productId、库存量、可用量、入库时间、到期时间、仓库 ID、库位。warehouses:仓库与库位,一个仓库可以配置多个库位。stock_logs:库存流水,每一次入库、出库、盘点、调整都记一条原始凭证。audit_logs:操作审计,记录“谁在什么时间通过哪个接口做了什么操作”。
batches集合是我整个设计的核心。它把“物资基本信息”和“某批货在某仓库里的即时状态”拆开了。查询当前库存就是按 productId 对 batches 的剩余量做聚合,查看效期预警就是扫到期时间字段,先进先出就是按入库时间排序取前几条。字段不多,但每一种业务视角都能在这个集合上找到答案。
// models/batch.js 简化示例 const mongoose = require("mongoose"); const batchSchema = new mongoose.Schema({ productId: { type: mongoose.Schema.Types.ObjectId, ref: "Product", required: true }, warehouseId: { type: mongoose.Schema.Types.ObjectId, ref: "Warehouse", required: true }, batchNo: { type: String, required: true, unique: true }, quantity: { type: Number, required: true, min: 0, default: 0 }, lockedQuantity: { type: Number, default: 0 }, expiryDate: { type: Date }, createdAt: { type: Date, default: Date.now }, }); batchSchema.index({ productId: 1, expiryDate: 1 }); batchSchema.index({ warehouseId: 1, batchNo: 1 }); module.exports = mongoose.model("Batch", batchSchema);3.2 绕不开的 ObjectId:_id 到底怎么用
MongoDB 要求每一条文档必须有_id字段,默认是一个 ObjectId 类型的值。很多新手在联调接口时遇到“明明数据存在却查不到”的问题,十有八九是把字符串和 ObjectId 搞混了。
举个我实际工作中的例子:前端传一个批次 ID “63f1a2b3c4d5e6f7a8b9c0d1”作为请求参数,后端直接Batch.findById(req.params.id)没问题;但如果有人图方便写Batch.find({ _id: req.body.batchId }),而batchId是字符串类型,Mongoose 查不到结果也不会报错,只返回空数组。这种 bug 特别隐蔽。
我的习惯是:所有从路由参数或请求体接收的 ID,在进入查询前统一做一次类型转换。
const mongoose = require("mongoose"); function toObjectId(id) { return mongoose.isValidObjectId(id) ? new mongoose.Types.ObjectId(id) : null; }另外,不要用_id去承载业务含义。比如有人想用“批次号”当主键,看着方便,但一旦货品退货、批次合并、数据修正,主键就失去灵活性了。_id只是一个内部标识,业务编号单独用batchNo字段存,这才是正确姿势。
3.3 索引:灵活不等于无约束
文档模型的优点是灵活,但“灵活”并不等于可以没有约束。最典型的约束就是索引。
第一批数据量不大的时候,全表扫描没问题。等库存流水攒到几十万条,每次查询库存都做 COLLSCAN,接口延迟瞬间从几十毫秒涨到几秒。我在batches和stock_logs上建的索引可以算是“改命”级别的操作:
batches:{ productId: 1, expiryDate: 1 }支持按产品查批次并按效期排序。batches:{ warehouseId: 1, batchNo: 1 }支持仓库维度的唯一批次查找。stock_logs:{ batchId: 1, createdAt: 1 }支持单批次的流水追溯。stock_logs:{ productId: 1, type: 1, createdAt: 1 }支持按产品和操作类型统计。
建索引的时候要注意,复合索引的字段顺序会直接影响查询效果。原则是“等值查询的字段放前面,排序字段放后面”。productId是等值匹配,expiryDate是做范围排序,所以{ productId: 1, expiryDate: 1 }的顺序是成立的;反过来写,这个索引就只能服务一部分查询场景。
4. 模块化重构:从 2000 行 controller 到七层协作
4.1 按业务拆模块,而不是按文件类型堆代码
“模块化”喊得最响的项目,往往只是把代码按目录堆了一遍:controllers文件夹、models文件夹、routes文件夹。这种分层在项目小的时候看着整齐,业务一复杂,问题全暴露出来——出库逻辑既要改 controller 又要动 service 还要碰 model,任何一个改动都牵一发动全身。
我做模块化拆分时,核心思想是“按业务流程划分边界”。每个模块内部包含属于自己的路由、控制器、服务、模型定义。模块之间只通过明确的接口调用,不允许直接访问对方的 model。
最终拆成了七个模块,每个模块都只对一类业务负责:
auth:登录、令牌刷新、密码修改。product:物资档案维护。inbound:采购入库、退货入库、入库流水。outbound:销售出库、领用出库、出库登记。inventory:盘点、库位调整、库存上下限预警。report:按品类/供应商/仓库维度的统计。system:用户、角色、菜单、操作日志。
4.2 模块内部的分工:router -> controller -> service -> model
每个模块内部,我坚持一条单向数据流:路由层只做参数校验和分发,控制器层负责组装业务结果,服务层实现具体的业务规则,模型层只做数据存取。数据流的走向永远是 controller 调 service,service 操作 model,model 返回结果。
以入库模块为例,入库存货时 controller 拿到的参数是“产品 ID、数量、批次号、仓库 ID、供应商 ID”,真正的业务规则在 service 层实现:先查产品是否存在,再查仓库是否存在,随后生成批次记录并插入库存流水。这一套逻辑如果全写在 controller 里,controller 会膨胀到没人敢改;如果散在 model 层,又有可能被其他模块误调用。
// inbound.service.js 简化的业务规则 async function createInbound({ productId, quantity, batchNo, warehouseId, supplierId, operatorId }) { const product = await Product.findById(productId); if (!product) throw new BizError("PRODUCT_NOT_FOUND", 404); const warehouse = await Warehouse.findById(warehouseId); if (!warehouse) throw new BizError("WAREHOUSE_NOT_FOUND", 404); const batch = await Batch.create({ productId, warehouseId, batchNo, quantity, expiryDate }); await StockLog.create({ batchId: batch._id, productId, warehouseId, type: "INBOUND", quantity, operatorId, refId: batch._id }); return batch; }4.3 接口隔离与循环依赖预防
模块化做得不好,最常见的技术债就是循环依赖。A 模块的 service 引用了 B 模块的 service,B 模块又反过来引用了 A 模块的 service,在两处文件增删字段时,很容易出现初始化 undefined。
我的做法是:模块之间尽量不互相引用内部 service,而是通过一个轻量的领域事件层解耦。比如出库模块完成后,需要通知库存预警模块重新计算上下限。我不会让outbound.service直接 importinventory.service,而是触发一个事件,由inventory模块自己去监听处理。
const { EventEmitter } = require("events"); const domainEvents = new EventEmitter(); // outbound 模块发布事件 domainEvents.emit("stock.outbound.completed", { batchId, quantity, operatorId, at: new Date() }); // inventory 模块监听事件 domainEvents.on("stock.outbound.completed", async ({ batchId, quantity }) => { await recalcThreshold(batchId, quantity); });这样两个模块之间没有任何直接依赖,后续给库存预警加策略,出库模块完全不用动。模块化最重要的不是“文件拆得多细”,而是“依赖关系足够少”。
4.4 为什么“文件夹=模块”会失效
有一种常见的伪模块化,是把所有关于“订单”的文件放到 orders 文件夹里就算完事,但订单的 service 里偷偷引用了用户的 model。真正的问题在于没有一个明确的分界:哪些表是订单模块的私产,哪些表是共享的?
我在项目里给每个模块定义了“私有数据”概念:batches虽然是被出库模块操作最频繁的集合,但它属于inventory模块的私产;outbound需要改库存时,必须调用inventory模块暴露出来的接口,而不是自己直接Batch.updateOne。
一开始这么写觉得绕,后来才发现这是防止代码腐烂最有效的手段。它逼着每一个跨模块操作都变成显式接口调用,代码审查的时候一眼就能看出——哦,这里出库模块通过 inventory 接口扣了库存,逻辑合理;这里出库模块直接操作了产品表,有问题。
5. 核心流程实现:入库出库如何做到不重不漏
5.1 入库:一次要保证原子性的写入
入库操作不能只是“批量插入数据”,它必须同时完成两类写操作:更新批次库存、写入一条库存流水。这两件事只要有一个失败,数据就是对不齐的。
在 MongoDB 里,我强烈建议把“更新批次 + 插入流水”放到同一个事务里。MongoDB 4.0 之后支持副本集事务,4.2 之后支持分片集群事务。用 mongoose 操作事务时,注意必须用session,并且所有写操作都要把session传进去。
const session = await mongoose.startSession(); session.startTransaction(); try { const batch = await Batch.findById(batchId).session(session); batch.quantity += quantity; await batch.save({ session }); await StockLog.create([{ batchId, productId, warehouseId, type: "INBOUND", quantity, operatorId }], { session }); await session.commitTransaction(); } catch (error) { await session.abortTransaction(); throw error; } finally { session.endSession(); }很多人会问:“单文档操作不是天然原子吗,为什么还要事务?”因为入库本质上涉及批次文档和流水文档两个文档的修改。如果先更新批次、再插入流水,中途断电就会留下“库存增加了但流水没记”的坏账。事务不一定天天用到,但必须把骨架搭好,后面出库、盘点全都会用到。
5.2 出库:防止超卖才是刚需
出库比入库麻烦,因为要考虑库存核对。最简单的写法是“查出库存,判断够不够,够就扣”,但并发一高就会出现超卖。场景是这样的:两个操作员同时给同一个批次做出库,A 查到剩余 10 件,B 也查到剩余 10 件,A 扣了 8 件、B 扣了 8 件,结果库存变成负数。
解决方式有两种,我建议优先用“条件更新”的原子操作:
const result = await Batch.findOneAndUpdate( { _id: batchId, quantity: { $gte: requireQuantity } }, { $inc: { quantity: -requireQuantity } }, { new: true } ); if (!result) throw new BizError("INSUFFICIENT_STOCK", 409);关键点是查询条件里带上quantity: { $gte: requireQuantity }。MongoDB 的单文档更新本身是原子的,两个并发请求同时执行这条 update 时,数据库内部会串行处理,第二个请求会因为不满足条件而匹配不到文档,直接返回 null。这样就天然挡住了超卖。
如果业务要求“同一商品分批出库”,比如先扣批次 A 不够再扣批次 B,那就必须结合事务,按“先效期最早、再到期最近”的规则逐批扣减。这一点实现起来不复杂,复杂的是规则要和业务确认清楚——是先到期先出,还是先进先出?不同品类不一样,必须做成可配置项,不要写死在代码里。
5.3 操作日志:审计比演示功能更重要
管理系统的价值不光在于“能操作”,更在于“能追溯”。哪个库位多出来了货物,谁改的价格,哪个批次被清零了,这些都必须有据可查。我在每个写操作接口里都会同步记录一条audit_logs数据。
字段不复杂:操作人 ID、操作类型、目标资源 ID、请求摘要、结果状态、发生时间。前端调用后端时,我会在中间件里统一提取操作人身份,不需要每个 controller 单独去塞 operatorId。
async function auditMiddleware(req, res, next) { const originalJson = res.json; res.json = function (body) { AuditLog.create({ operatorId: req.user?._id, method: req.method, path: req.originalUrl, params: req.body, statusCode: res.statusCode, result: body?.code || "ok", }).catch(() => {}); originalJson.call(this, body); }; next(); }审计日志在云端部署后尤其重要。因为服务器不在眼皮底下,出问题排查时,第一反应就是查审计日志。没有这一步,排查线上问题相当于盲人摸象。
5.4 登录与权限:JWT 在管理后台的正确用法
权限设计不用做得花哨,JWT + 角色中间件就够了。登录成功后,后端签发一个包含用户 ID 和角色信息的 JWT,前端后续请求带上这个 token。我专门做了一个requireAuth中间件挂在所有业务路由之前,确保未登录用户拿不到任何数据。
const jwt = require("jsonwebtoken"); async function requireAuth(req, res, next) { const token = req.headers.authorization?.replace("Bearer ", ""); if (!token) return res.status(401).json({ code: 401, message: "未登录" }); try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = await User.findById(decoded.userId); if (!req.user) throw new Error(); next(); } catch (e) { return res.status(401).json({ code: 401, message: "登录已过期" }); } }一些讲究的点:JWT 密钥一定要通过环境变量注入,不能写死在代码里;登录接口要做失败次数限制;用户密码用 bcrypt 加盐哈希保存。这些细节看着基础,但在云端部署时,很多安全事故恰恰是从“能猜到密码”“密钥泄露在代码仓库”这种低级问题开始的。
6. 云上部署:MongoDB 安全配置与上线前避坑清单
6.1 开认证的先后顺序
很多人第一次把 MongoDB 部署到云服务器,直接默认配置就上线了,结果几天后数据库被 scan 工具发现,27017 端口暴露在公网,数据被删掉并留勒索信息。这不是耸人听闻,是真实发生过的。
正确的顺序是先建用户,再开启--auth。在没开认证的情况下启动 MongoDB,本地用 mongosh 连上,创建一个管理员用户和业务用户:
mongosh --port 27017 use admin db.createUser({ user: "admin", pwd: "这里用强密码", roles: [{ role: "root", db: "admin" }] }) use material db.createUser({ user: "materialApp", pwd: "另一个独立强密码", roles: [{ role: "readWrite", db: "material" }] })完成之后,修改 MongoDB 配置文件/etc/mongod.conf,把security.authorization置为enabled,再重启服务。应用连接串用mongodb://materialApp:密码@127.0.0.1:27017/material?authSource=material。这样业务应用只拥有它需要的最小权限,管理员账号只留给人管理用。
6.2 网络暴露:最常见的漏洞不在代码里
我见过不少系统,代码写得不错,权限校验也够,结果栽在了网络层面。MongoDB 默认监听 0.0.0.0,也就是公网所有网卡都能访问。即使开了认证,暴力破解仍然是一个真实风险。
我的部署方式是分两步。第一步,把 MongoDB 的bindIp改成服务器内网 IP 或 127.0.0.1,让它不要出现在公网网卡上。第二步,用防火墙把端口收窄,只对应用服务器开放。如果 MongoDB 跑在和 Node.js 应用同一台服务器上,绑定 127.0.0.1 就足够了。
sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable不要把 27017 加进 ufw 放行列表。外部有需要访问的话,走 SSH 隧道或用云平台的内网地址访问,这是底线。
6.3 备份:mongodump 不是备份完就结束了
备份这件事很容易被拖到最后才想起来。我在上线前就做了两套方案:一套是用mongodump做全量备份,一套是启用 MongoDB 的定期任务做增量备份。
全量备份的脚本很简单,但要注意备份后文件必须异地保存。我一般会在备份完成后,把 dump 目录压缩上传到对象存储,不要在服务器本地留太久。定时任务我写在下面,供参考:
#!/bin/bash BACKUP_DIR="/data/backup/mongo" DATE=$(date +%Y%m%d_%H%M) DB_NAME="material" mongodump --db "$DB_NAME" --out "$BACKUP_DIR/$DATE" tar -czf "$BACKUP_DIR/$DATE.tar.gz" "$BACKUP_DIR/$DATE" # 上传到对象存储的命令省略,可用 rclone 或 s3cmd恢复用的命令也一并记住:mongorestore --db material /path/to/dump/material。建议在测试环境做一次完整的“备份 -> 恢复 -> 数据核对”演练,别等到出问题时才第一次执行恢复流程。
6.4 上线后的三个观察指标
系统跑起来之后,我会先看三样东西:CPU 和内存曲线、MongoDB 连接数、慢查询日志。
Node.js 应用如果内存持续上涨,多半有内存泄漏,常见原因是全局变量没清理、长生命周期对象持有大面积引用。MongoDB 连接数过高,很多时候不是业务压力大,而是应用代码里没复用连接池,每次请求都新建连接。慢查询日志则是索引生效情况的最直接反馈:如果某个高频查询出现大量几百毫秒甚至几秒的耗时,先去explain()看有没有走索引。
db.batches.find({ productId: ObjectId("..."), expiryDate: { $lte: new Date() } }).explain("executionStats")explain输出里的COLLSCAN就是全表扫描的意思,一旦在高频查询里看到这个,优先级最高的任务就是加索引。
整套系统从重建到上线花了大约三周。如果让我重来一遍,唯一会调整的地方,是把审计日志和库存流水的表结构想得更深一点——第一天就把它们做成独立模块,而不是后面补上去。技术栈本身不复杂,真正难的是模块边界划分得足够清楚,让每个新需求都能在已有的架构里找到自己的位置,而不是推翻旧代码。希望这篇记录能帮你少走我走过的那段弯路。