简介:这是一套面向高校计算机专业学生与Java初学者的库存管理系统完整项目源码,基于SpringBoot与Vue前后端分离架构开发,适用于毕业设计、课程设计及期末大作业等场景,代码含注释,新手也能读懂。压缩包共431个文件,约21.12MB,其中117个Java文件承载后端业务逻辑,60个Vue组件构建前端页面,另有大量svg图标、png与jpg图片资源,以及xml、yml、js、css等配置与样式文件,并附带sql数据库脚本、bat启动脚本和软件工具,前后端代码一应俱全。项目功能完善、界面美观、操作便捷,经过严格调试可稳定运行,部署环境为IDEA、MySQL、Tomcat与Maven。目前已有62人学习下载,读者可据此快速搭建可运行系统,掌握SpringBoot与Vue整合开发思路,并参考目录结构与注释完成二次开发或答辩准备。
1. 库存管理系统为什么总在“入库”这一步翻车
很多做 Java 项目的人,第一次接触springboot+Vue 库存管理系统,都是冲着“增删改查”四个字去的。真动手才发现,库存管理最难的从来不是把数据写进数据库,而是入库、出库、盘点这三条链路在并发下怎么保证数字对得上。一个典型的翻车现场:采购单审核通过,库存表加了 100,结果同一时刻销售出库扣了 80,两边都读到了旧值,最后账面库存比实际多了 80。这不是代码写得丑,是没想清楚库存这个业务的数据一致性边界在哪。
这套springboot+Vue 库存管理系统,后端用 SpringBoot 提供 REST 接口,前端用 Vue 做单页交互,数据库落 MySQL,核心要解决的就是商品、仓库、出入库单据、库存流水这几张表之间的联动。它适合两类人:一类是 Java 基础刚过、想拿一个完整项目练手的在校生或转行者;另一类是小团队里需要快速搭一套内部进销存工具的后端开发。前者关心“怎么跑起来”,后者关心“并发扣减会不会超卖、单据和流水怎么对账”。这篇笔记按后者的标准写,但每一步都给出能直接抄的命令和配置,新手照着也能落地。
需要先明确一点:库存系统的“库存”不是一个字段,而是一组状态。可用库存、锁定库存、在途库存、实际库存,这四个概念如果一开始不区分,后面做调拨和盘点时一定会返工。我一般会在建表阶段就把这几个字段拆开,哪怕第一版业务只用得到其中一个。下面从选型和建表开始,一步步把这条链路搭出来。
2. 技术选型与数据库表结构:先把库存字段拆对
2.1 为什么是 SpringBoot + Vue + MySQL 这套组合
选 SpringBoot 不是因为它是“最新框架”,而是因为库存系统天然需要事务、连接池、定时任务这三样东西,SpringBoot 的 starter 生态把它们都封装好了。spring-boot-starter-jdbc或mybatis-spring-boot-starter直接给你事务管理和DataSource,不用自己写DataSourceConfig。Vue 这边选 2 还是 3 都行,库存系统的前端交互复杂度不高,主要是表格、表单、弹窗,Vue 2 配 Element UI 的资料最多,Vue 3 配 Element Plus 更现代,团队里谁熟用谁。
数据库选 MySQL 而不是 SQLite,是因为库存系统迟早要面对多端并发写入,SQLite 的写锁粒度太粗,一个人盘点另一个人入库就会互相阻塞。MySQL 的 InnoDB 行锁配合事务隔离级别,能撑住中小规模的并发扣减。连接池用 HikariCP,SpringBoot 2.x 之后默认就是它,不用额外配,但要把maximum-pool-size根据实际并发调一下,默认 10 在压测时容易成为瓶颈。
提示:如果团队已经在用 MyBatis,就别为了“技术统一”硬上 JPA。库存系统的查询条件经常是动态拼接的,MyBatis 的 XML 写动态 SQL 比 JPA 的 Criteria 更直观,尤其是按仓库、按时间段、按单据类型筛选流水的时候。
2.2 库存相关表的最小字段集
建表这一步决定了后面代码好不好写。下面给出四张核心表的最小字段集,字段名和类型可以直接用。注意stock表里我把可用库存和锁定库存分开了,stock_record表记录每一次变动,方便对账。
-- 商品表 CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL COMMENT '商品名称', `sku` varchar(64) NOT NULL COMMENT '商品编码', `unit` varchar(16) DEFAULT '件' COMMENT '单位', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku` (`sku`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 仓库表 CREATE TABLE `warehouse` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, `address` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存表:可用和锁定分开 CREATE TABLE `stock` ( `id` bigint NOT NULL AUTO_INCREMENT, `product_id` bigint NOT NULL, `warehouse_id` bigint NOT NULL, `available_qty` int NOT NULL DEFAULT 0 COMMENT '可用库存', `locked_qty` int NOT NULL DEFAULT 0 COMMENT '锁定库存', `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_product_warehouse` (`product_id`,`warehouse_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存流水表:每次变动都记一条 CREATE TABLE `stock_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `product_id` bigint NOT NULL, `warehouse_id` bigint NOT NULL, `change_qty` int NOT NULL COMMENT '正数入库,负数出库', `type` varchar(32) NOT NULL COMMENT 'INBOUND/OUTBOUND/LOCK/UNLOCK', `biz_no` varchar(64) NOT NULL COMMENT '关联单据号', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_biz_no` (`biz_no`), KEY `idx_product_warehouse` (`product_id`,`warehouse_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;stock表的uk_product_warehouse唯一索引是关键,它保证一个商品在一个仓库只有一条库存记录,避免并发插入时出现两条记录。version字段是给乐观锁用的,后面扣减库存时会用到。stock_record表的biz_no关联单据号,盘点时拿单据号一查就知道这批货动了哪些库存。
2.3 SpringBoot 项目里数据库连接和 MyBatis 的配置
建完表之后,在application.yml里把数据源和 MyBatis 配好。下面这段配置可以直接抄,注意mapper-locations的路径要和实际目录一致。
spring: datasource: url: jdbc:mysql://localhost:3306/inventory?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.inventory.entity configuration: map-underscore-to-camel-case: truemaximum-pool-size设成 20 是给中小规模并发留的余量,如果压测时发现连接等待时间超过 100ms,可以往上加,但不要超过数据库max_connections的 80%。map-underscore-to-camel-case打开后,数据库的product_id会自动映射到 Java 的productId,省掉一堆resultMap配置。MyBatis 的分页插件用 PageHelper,在pom.xml里加依赖后在配置类里注册拦截器即可,这里不展开,网上资料很多。
3. 入库、出库、锁库存三条链路的代码实现
3.1 入库:先写流水再更新库存,顺序不能反
入库的逻辑看起来简单,但顺序错了会对账对不上。正确顺序是:先插入stock_record,再更新stock。为什么?因为如果先更新库存再写流水,中间服务挂了,库存加了但流水没记,盘点时这笔货就成了“黑匣子”。先写流水,即使更新库存失败,流水还在,可以人工补偿。
@Service public class StockService { @Autowired private StockMapper stockMapper; @Autowired private StockRecordMapper stockRecordMapper; @Transactional(rollbackFor = Exception.class) public void inbound(Long productId, Long warehouseId, int qty, String bizNo) { // 1. 先写流水 StockRecord record = new StockRecord(); record.setProductId(productId); record.setWarehouseId(warehouseId); record.setChangeQty(qty); record.setType("INBOUND"); record.setBizNo(bizNo); stockRecordMapper.insert(record); // 2. 再更新库存,如果记录不存在则插入 int updated = stockMapper.increaseAvailable(productId, warehouseId, qty); if (updated == 0) { stockMapper.insertStock(productId, warehouseId, qty); } } }@Transactional保证两步在同一个事务里,任何一步失败都回滚。increaseAvailable对应的 SQL 是UPDATE stock SET available_qty = available_qty + #{qty} WHERE product_id = #{productId} AND warehouse_id = #{warehouseId},用数据库的原子加法而不是先查再改,避免并发覆盖。如果updated == 0,说明这个商品在这个仓库还没有库存记录,走插入逻辑。
3.2 出库:乐观锁扣减与超卖拦截
出库比入库多一层风险:库存不够时不能扣成负数。这里用乐观锁,在UPDATE语句里带上available_qty >= #{qty}条件,同时用version字段防止并发更新丢失。
@Transactional(rollbackFor = Exception.class) public void outbound(Long productId, Long warehouseId, int qty, String bizNo) { // 1. 查询当前库存和版本号 Stock stock = stockMapper.selectByProductAndWarehouse(productId, warehouseId); if (stock == null || stock.getAvailableQty() < qty) { throw new BizException("库存不足"); } // 2. 乐观锁更新:带 version 和数量条件 int updated = stockMapper.decreaseAvailable( productId, warehouseId, qty, stock.getVersion()); if (updated == 0) { throw new BizException("并发冲突,请重试"); } // 3. 写流水 StockRecord record = new StockRecord(); record.setProductId(productId); record.setWarehouseId(warehouseId); record.setChangeQty(-qty); record.setType("OUTBOUND"); record.setBizNo(bizNo); stockRecordMapper.insert(record); }decreaseAvailable的 SQL 是UPDATE stock SET available_qty = available_qty - #{qty}, version = version + 1 WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND available_qty >= #{qty} AND version = #{version}。available_qty >= #{qty}这条件在数据库层面拦截超卖,version = #{version}拦截并发覆盖。如果updated == 0,要么库存不够,要么版本号变了,统一抛异常让上层重试或提示。
注意:乐观锁在并发极高时重试率会上升,如果业务允许,可以改成
UPDATE ... SET available_qty = available_qty - #{qty} WHERE ... AND available_qty >= #{qty},去掉 version 条件,靠数据库行锁保证原子性。两种方案都能防超卖,区别是乐观锁把冲突暴露给应用层,行锁让数据库排队。
3.3 锁库存:下单锁定与超时释放
电商场景里下单要先锁库存,支付成功再扣减,超时未支付则释放。锁库存就是把available_qty减掉、locked_qty加上,释放则反过来。
@Transactional(rollbackFor = Exception.class) public void lockStock(Long productId, Long warehouseId, int qty, String bizNo) { int updated = stockMapper.lockStock(productId, warehouseId, qty); if (updated == 0) { throw new BizException("锁定失败,库存不足"); } // 写一条 LOCK 类型的流水 stockRecordMapper.insert(buildRecord(productId, warehouseId, -qty, "LOCK", bizNo)); } @Transactional(rollbackFor = Exception.class) public void unlockStock(Long productId, Long warehouseId, int qty, String bizNo) { stockMapper.unlockStock(productId, warehouseId, qty); stockRecordMapper.insert(buildRecord(productId, warehouseId, qty, "UNLOCK", bizNo)); }lockStock的 SQL 是UPDATE stock SET available_qty = available_qty - #{qty}, locked_qty = locked_qty + #{qty} WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND available_qty >= #{qty}。释放时反向操作。超时释放用 Spring 的@Scheduled定时任务扫stock_record里超过 30 分钟还是 LOCK 状态的单据,调unlockStock。这里要注意幂等,同一笔单据不能释放两次,可以在流水表里加唯一索引uk_biz_no_type来兜底。
4. 避坑与排查:库存系统上线后最容易踩的五个坑
4.1 现象:库存扣成负数,但代码里明明判断了
原因:判断和扣减不在同一个原子操作里。先SELECT查库存,Java 里判断qty > 0,再UPDATE扣减,两个并发请求都查到库存为 1,都判断通过,都扣了 1,结果变成 -1。解决:把判断条件写进UPDATE的WHERE子句,用available_qty >= #{qty}让数据库来保证原子性,Java 层只根据affected rows判断成功与否。
4.2 现象:单据审核通过,库存没变,流水也没记
原因:@Transactional失效。常见触发场景是同类内部方法调用,比如inbound调了本类的private方法,事务代理没生效。解决:把事务方法抽到另一个 Service 里,或者用AopContext.currentProxy()拿代理对象调用。更稳妥的做法是入库、出库、锁库存各写一个 Service,互相调用走注入。
4.3 现象:盘点时流水和库存对不上,差了几十件
原因:流水表只记了业务单据,没记系统自动调整。比如定时任务释放超时锁库存时,只更新了stock表,忘了写stock_record。解决:所有改变库存数量的操作,无论人工还是定时任务,都必须写一条流水。可以在StockService里把“更新库存 + 写流水”封装成一个私有方法,所有入口都调它。
4.4 现象:并发压测时大量请求报“并发冲突,请重试”
原因:乐观锁版本号冲突率太高。当同一商品同一仓库的并发扣减超过每秒 50 次时,version字段的冲突会明显上升。解决:要么在应用层加重试(重试 3 次,每次间隔随机毫秒),要么改用UPDATE ... WHERE available_qty >= #{qty}不带 version 的方案,靠 InnoDB 行锁排队。后者吞吐量更高,但锁等待时间会变长,需要监控数据库的innodb_row_lock_time。
4.5 现象:Vue 前端提交入库表单,后端收到数量为 0
原因:前端把数量字段绑成了字符串,v-model没加.number修饰符,传到后端 JSON 里是"0"而不是0,SpringBoot 反序列化时可能转成 0 或者报错。解决:v-model.number="form.qty",并在后端 DTO 里用@Min(1)校验,双保险。另外前端提交前用Number(form.qty)转一下,避免用户输入空格或全角数字。
5. 用流水对账和库存快照验证数据一致性
库存系统上线后,怎么知道数据是对的?我一般用两个手段:流水对账和库存快照。流水对账是拿stock_record表按product_id + warehouse_id分组求和,和stock表的available_qty + locked_qty对比,差值不为 0 的就是异常。下面这条 SQL 直接跑,能查出所有对不上的商品。
SELECT s.product_id, s.warehouse_id, s.available_qty + s.locked_qty AS stock_total, COALESCE(r.record_total, 0) AS record_total, (s.available_qty + s.locked_qty) - COALESCE(r.record_total, 0) AS diff FROM stock s LEFT JOIN ( SELECT product_id, warehouse_id, SUM(change_qty) AS record_total FROM stock_record GROUP BY product_id, warehouse_id ) r ON s.product_id = r.product_id AND s.warehouse_id = r.warehouse_id HAVING diff <> 0;这条 SQL 的逻辑是:库存表里的总数应该等于流水表里所有变动之和。diff不为 0 说明有操作没写流水,或者流水写了但库存没更新。跑出来之后,拿product_id和warehouse_id去查stock_record的明细,按时间排序,看哪一笔对不上。常见的是定时任务释放锁库存时漏写流水,补一条UNLOCK记录再重跑对账即可。
库存快照是另一个手段:每天凌晨定时任务把stock表当前数据复制一份到stock_snapshot表,记录快照时间。当流水对账发现异常时,可以拿快照和当前库存对比,缩小排查范围。快照表不用建索引,只做归档用,保留 30 天即可。
@Scheduled(cron = "0 0 2 * * ?") public void snapshotStock() { List<Stock> list = stockMapper.selectAll(); for (Stock s : list) { StockSnapshot snap = new StockSnapshot(); snap.setProductId(s.getProductId()); snap.setWarehouseId(s.getWarehouseId()); snap.setAvailableQty(s.getAvailableQty()); snap.setLockedQty(s.getLockedQty()); snap.setSnapshotTime(new Date()); stockSnapshotMapper.insert(snap); } }@Scheduled的 cron 表达式0 0 2 * * ?表示每天凌晨 2 点执行。快照插入用批量插入,不要循环单条插,数据量大时循环插会拖慢数据库。可以在StockSnapshotMapper里写一个insertBatch方法,用foreach拼批量 SQL。
最后说一个我自己的习惯:每次改完库存相关的代码,上线前一定在测试环境跑一遍对账 SQL,确认diff为 0 再发。这个习惯帮我拦过至少三次因为事务边界写错导致的库存偏差。库存系统的数据一致性没有“后悔药”,只能靠对账提前发现。希望帮到你。
本文还有配套的精品资源,点击获取