ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java+MySQL仓库管理系统实战:库存扣减与流水追溯设计

Java+MySQL仓库管理系统实战:库存扣减与流水追溯设计 简介本资源为基于 Java 与 MySQL 实现的仓库管理系统完整源码工程面向希望以真实项目练手的初学者与进阶开发者可直接用作毕业设计、课程设计、大作业或工程实训选题。项目采用 SpringBoot、Shiro、MybatisPlus 搭建后台前端使用 LayUI 与 DTree开发环境为 IDEA、Navicat、Maven 3.5.2、Tomcat 8.5 与 MySQL系统划分为系统模块和业务模块业务侧包含客户管理与供应商管理支持列表分页、模糊查询及增删改与批量删除等操作。压缩包共 367 个文件约 5.34MB以 106 个 Java 源码、49 个 HTML 页面、42 个 JS 脚本、75 个 GIF 与 23 个 PNG 图片资源为主另含 XML、JSON、CSS 及 SQL 建表脚本结构完整便于二次开发。目前已有 422 人学习适合对照源码理解分层设计与权限控制思路。1. 仓库管理系统到底在管什么从一张入库单说起很多团队做仓库管理系统第一反应是打开 IDE 建表写 CRUD结果上线三个月后库存对不上、盘点靠 Excel 补、采购和仓储互相甩锅。问题不在代码写得烂而在于一开始没想清楚「仓库管理」到底在管什么。它管的不是商品列表而是每一次库存变动的来龙去脉谁在什么时间、因为哪张单据、把哪个批次的多少件货、从哪个库位挪到了哪个库位。Java MySQL 这套组合之所以成为仓库管理系统的常见落地方式是因为 Java 的强类型和成熟生态能把业务规则写死MySQL 的事务能力能保证库存扣减不出负数。这套方案适合中小型仓储、电商后台、制造业原料库这类场景日单量在几千到几万之间团队规模三到八人。如果你正被库存不准、单据追溯难、并发扣减超卖这些问题困扰下面这套从建表到扣减的实现路径可以直接参考。2. 表结构怎么设计库存、单据、流水三张核心表的关系2.1 为什么不能只建一张商品表加个库存字段新手最容易踩的坑就是建一张product表里面放一个stock字段入库加、出库减。这种设计在单仓库、单批次、无并发时能跑一旦出现以下任一情况就会翻车同一个 SKU 分布在多个库位、同一批货有不同生产日期、需要追溯某次盘亏是哪张单据造成的。库存的本质是「流水累加的结果」而不是一个可以随意覆盖的数字。所以核心表至少拆成三层商品基础信息、库存快照、库存流水。商品表管「是什么」库存表管「现在有多少」流水表管「怎么变成这么多的」。常见做法是再加一张单据主表和单据明细表形成「单据 → 明细 → 流水 → 库存」的链路。这样任何一次库存变动都能反查到源头单据盘点差异也能定位到具体操作。2.2 建表 SQL 与字段说明下面这套表结构是我在多个中小型仓库项目里反复用过的精简版去掉了花哨的扩展字段保留最核心的追溯能力。-- 商品表管是什么 CREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT, sku_code VARCHAR(64) NOT NULL COMMENT SKU编码业务唯一, product_name VARCHAR(128) NOT NULL, unit VARCHAR(16) DEFAULT 件, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sku (sku_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品基础表; -- 库存表管现在有多少按商品库位批次维度 CREATE TABLE inventory ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_code VARCHAR(32) NOT NULL COMMENT 仓库编码, location_code VARCHAR(32) NOT NULL COMMENT 库位编码, batch_no VARCHAR(64) DEFAULT COMMENT 批次号无批次填空串, quantity INT NOT NULL DEFAULT 0 COMMENT 当前库存数量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_inv (product_id,warehouse_code,location_code,batch_no), KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存快照表; -- 库存流水表管怎么变成这么多的只增不改 CREATE TABLE inventory_flow ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_code VARCHAR(32) NOT NULL, location_code VARCHAR(32) NOT NULL, batch_no VARCHAR(64) DEFAULT , change_qty INT NOT NULL COMMENT 变动数量入正出负, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型INBOUND/OUTBOUND/ADJUST, biz_no VARCHAR(64) NOT NULL COMMENT 来源单据号, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_biz (biz_no), KEY idx_product_time (product_id,created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;inventory表上的唯一键uk_inv是关键它保证了同一商品在同一库位同一批次只有一行记录避免并发插入产生重复库存行。version字段用于乐观锁后面扣减时会用到。inventory_flow表只增不改每次变动插一条change_qty用正负号区分出入库这样对账时直接SUM(change_qty)就能和inventory.quantity比对发现不一致立刻能定位。注意batch_no默认值用空串而不是 NULL是因为 MySQL 唯一索引中多个 NULL 不冲突会导致同一商品同一库位出现多行「无批次」库存这是血泪教训。2.3 单据表的最小字段集单据表不需要一开始就设计得很复杂但biz_no、status、created_by这三个字段必须有。biz_no是业务单号和流水表关联status控制单据状态流转防止已完成的单据被重复提交created_by用于责任追溯。明细表则记录每个商品的应入/应出数量实际执行时再和流水表比对形成「计划 vs 实际」的闭环。3. 入库出库怎么落地Java 服务层的事务与扣减逻辑3.1 入库先写流水还是先改库存入库逻辑相对简单但顺序有讲究。正确顺序是校验单据状态 → 写流水 → 更新库存 → 更新单据状态全部放在一个Transactional方法里。先写流水的好处是即使后续更新库存失败回滚流水也会一起回滚不会出现「有流水没库存」的脏数据。如果反过来先改库存再写流水一旦写流水失败库存已经变了回滚虽然能救回来但逻辑上不够清晰。Service public class InboundService { Autowired private InventoryMapper inventoryMapper; Autowired private InventoryFlowMapper flowMapper; Autowired private InboundOrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void inbound(Long orderId) { // 1. 校验单据状态防止重复入库 InboundOrder order orderMapper.selectByIdForUpdate(orderId); if (order null || !CREATED.equals(order.getStatus())) { throw new BizException(单据状态不允许入库); } // 2. 逐条明细处理 for (InboundDetail detail : order.getDetails()) { // 2.1 写流水change_qty 为正 InventoryFlow flow new InventoryFlow(); flow.setProductId(detail.getProductId()); flow.setWarehouseCode(order.getWarehouseCode()); flow.setLocationCode(detail.getLocationCode()); flow.setBatchNo(detail.getBatchNo()); flow.setChangeQty(detail.getQty()); flow.setBizType(INBOUND); flow.setBizNo(order.getBizNo()); flowMapper.insert(flow); // 2.2 更新库存存在则累加不存在则插入 int updated inventoryMapper.increaseStock( detail.getProductId(), order.getWarehouseCode(), detail.getLocationCode(), detail.getBatchNo(), detail.getQty()); if (updated 0) { inventoryMapper.insertInventory( detail.getProductId(), order.getWarehouseCode(), detail.getLocationCode(), detail.getBatchNo(), detail.getQty()); } } // 3. 更新单据状态 orderMapper.updateStatus(orderId, FINISHED); } }selectByIdForUpdate用了行锁防止同一单据被并发处理。increaseStock是一条UPDATE inventory SET quantity quantity #{qty}, version version 1 WHERE ...的语句利用数据库原子性保证累加正确。如果返回 0 说明该库位该批次还没有库存行此时插入新行。这里有个细节插入时如果并发冲突唯一键会报错外层事务回滚调用方重试即可。3.2 出库乐观锁扣减与超卖防护出库比入库复杂因为要防止扣成负数。常见做法有两种悲观锁SELECT ... FOR UPDATE和乐观锁UPDATE ... WHERE quantity #{qty}。中小型系统我更倾向乐观锁因为锁粒度小、吞吐高配合重试机制足够用。Transactional(rollbackFor Exception.class) public void outbound(Long orderId) { OutboundOrder order orderMapper.selectById(orderId); if (order null || !CREATED.equals(order.getStatus())) { throw new BizException(单据状态不允许出库); } for (OutboundDetail detail : order.getDetails()) { // 乐观锁扣减quantity qty 才更新 int updated inventoryMapper.decreaseStock( detail.getProductId(), order.getWarehouseCode(), detail.getLocationCode(), detail.getBatchNo(), detail.getQty()); if (updated 0) { throw new BizException(库存不足或并发冲突商品 detail.getProductId()); } // 扣减成功后再写流水change_qty 为负 InventoryFlow flow new InventoryFlow(); flow.setProductId(detail.getProductId()); flow.setWarehouseCode(order.getWarehouseCode()); flow.setLocationCode(detail.getLocationCode()); flow.setBatchNo(detail.getBatchNo()); flow.setChangeQty(-detail.getQty()); flow.setBizType(OUTBOUND); flow.setBizNo(order.getBizNo()); flowMapper.insert(flow); } orderMapper.updateStatus(orderId, FINISHED); }decreaseStock对应的 SQL 是UPDATE inventory SET quantity quantity - #{qty}, version version 1 WHERE product_id ... AND quantity #{qty}。quantity #{qty}这个条件就是防超卖的关键数据库层面保证不会扣成负数。如果返回 0要么库存真的不够要么并发冲突导致版本变了两种情况都抛异常让上层处理。这里没有用version字段做 CAS因为quantity qty本身已经足够version更多是留给后续扩展用的。提示如果出库单明细很多逐条扣减可能产生死锁。常见做法是按product_id排序后再处理保证加锁顺序一致。3.3 盘点调整让库存和流水对得上盘点调整本质上是「以实际盘点数为准反向生成一条调整流水」。假设系统库存 100实际盘点 95那就生成一条change_qty -5、biz_type ADJUST的流水同时把inventory.quantity直接设为 95。这里不要用累加因为盘点就是强制校准。调整完成后用SELECT SUM(change_qty) FROM inventory_flow WHERE product_id ? AND ...和inventory.quantity比对两者必须相等否则说明有流水漏写或库存被绕过修改。4. 并发与一致性那些让库存对不上的坑4.1 避坑一事务里调用远程接口导致锁持有过久现象出库接口偶尔超时数据库连接池被打满库存扣减变慢。原因在Transactional方法里调用了外部 HTTP 接口比如通知 WMS 或推送消息远程调用耗时几百毫秒甚至超时导致数据库行锁一直不释放。解决把远程调用移到事务提交之后用TransactionSynchronizationManager.registerSynchronization的afterCommit回调或者用本地消息表 异步任务。事务里只做数据库操作这是铁律。4.2 避坑二批量入库时逐条提交导致部分成功现象一次入库 100 个 SKU前 50 个成功后 50 个失败库存只加了一半单据状态却是「处理中」。原因循环里每条明细单独开事务没有整体回滚。解决整个单据的处理放在一个事务方法里任何一条明细失败就抛异常回滚全部。如果数据量确实大拆成「预占 确认」两阶段但第一阶段也要保证幂等。4.3 避坑三MySQL 隔离级别选错导致幻读现象两个线程同时入库同一商品同一库位都查不到库存行都执行插入结果唯一键冲突报错。原因RR 隔离级别下普通SELECT是快照读看不到其他事务未提交的插入。解决插入库存行时用INSERT ... ON DUPLICATE KEY UPDATE quantity quantity #{qty}把「查-插-改」合并成一条原子语句。或者用SELECT ... FOR UPDATE加间隙锁但性能差一些。4.4 避坑四流水表 change_qty 正负号写反现象对账时发现流水累加和库存对不上差值是库存的两倍。原因出库时change_qty写成了正数导致流水越加越多。解决在InventoryFlow的 setter 里做约束或者用枚举BizType统一控制符号。更稳妥的做法是在数据库层加CHECK约束MySQL 8.0.16 支持但很多团队用 5.7那就靠代码规范和单元测试覆盖。4.5 避坑五忘记处理 batch_no 为 NULL 的情况现象同一商品同一库位有的库存行batch_no是 NULL有的是空串唯一键没拦住出现两行库存。原因Java 对象里String batchNo默认是 null插入时没转成空串。解决在 MyBatis 的insert语句里用IFNULL(#{batchNo}, )或者在 Service 层统一batchNo batchNo null ? : batchNo。这个坑很隐蔽往往上线后盘点才发现。5. 从能跑到好用库存对账与性能验证的两个技巧5.1 用一条 SQL 做每日库存对账系统跑起来之后最怕的是「账实不符」。除了定期盘点我习惯每天凌晨跑一条对账 SQL把流水累加和库存快照比对差异超过阈值的记录直接告警。SELECT i.product_id, i.warehouse_code, i.location_code, i.batch_no, i.quantity AS snapshot_qty, IFNULL(f.flow_sum, 0) AS flow_qty, i.quantity - IFNULL(f.flow_sum, 0) AS diff FROM inventory i LEFT JOIN ( SELECT product_id, warehouse_code, location_code, batch_no, SUM(change_qty) AS flow_sum FROM inventory_flow GROUP BY product_id, warehouse_code, location_code, batch_no ) f ON i.product_id f.product_id AND i.warehouse_code f.warehouse_code AND i.location_code f.location_code AND i.batch_no f.batch_no HAVING diff 0;这条 SQL 的HAVING diff 0会直接列出所有不一致的记录。正常情况下结果为空一旦有数据就说明某次操作绕过了流水直接改了库存或者流水写漏了。我一般会把这个查询配成定时任务结果推送到内部告警群比人工盘点发现得早得多。5.2 压测时重点看三个指标库存扣减的性能瓶颈往往不在 SQL 本身而在锁竞争。用 JMeter 或 wrk 压测时我重点看三个指标一是innodb_row_lock_waits如果持续增长说明行锁竞争激烈二是Threads_running超过 CPU 核数两倍就要警惕三是接口 P99 延迟如果 P99 是 P50 的十倍以上通常是锁等待导致的。优化方向一般是把单条扣减改成批量扣减、按商品 ID 分片、或者引入 Redis 做预扣减再异步落库。但后者会引入一致性复杂度中小系统不到万不得已不建议上。5.3 一个我坚持了多年的习惯每次改完库存相关的代码不管多小的改动我都会在测试环境跑一遍「入库 100 → 出库 30 → 盘点调整为 60 → 再出库 10」的完整链路然后执行上面那条对账 SQL确认 diff 为 0 才提交。这个习惯帮我拦住了至少三次符号写反、两次事务漏加的问题。库存系统的 bug 不会立刻暴露往往在几个月后盘点时才炸出来那时候追溯成本极高。宁可多花十分钟跑一遍链路也别给自己留后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表