基于 Node.js 与 MongoDB 的物资管理系统重构实践

发布时间:2026/10/14 8:03:52
基于 Node.js 与 MongoDB 的物资管理系统重构实践 去年下半年我接了个活给本地一家做医疗器械经销的公司做物资管理系统需求说简单也简单就是把出入库从 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 调 serviceservice 操作 modelmodel 返回结果。以入库模块为例入库存货时 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 模块的 serviceB 模块又反过来引用了 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?authSourcematerial。这样业务应用只拥有它需要的最小权限管理员账号只留给人管理用。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_NAMEmaterial 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就是全表扫描的意思一旦在高频查询里看到这个优先级最高的任务就是加索引。整套系统从重建到上线花了大约三周。如果让我重来一遍唯一会调整的地方是把审计日志和库存流水的表结构想得更深一点——第一天就把它们做成独立模块而不是后面补上去。技术栈本身不复杂真正难的是模块边界划分得足够清楚让每个新需求都能在已有的架构里找到自己的位置而不是推翻旧代码。希望这篇记录能帮你少走我走过的那段弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询