☰
Java仓库管理系统实战:从Excel台账到Spring Boot+MyBatis库存系统
2026/10/1 10:50:48 网站建设 项目流程

简介:这是一套面向Java初学者与毕业设计学生的仓库管理系统完整项目源码,围绕商品管理、价格管理、用户认证与授权等核心业务展开,帮助读者理解Java Web应用从需求到落地的整体流程。压缩包共104个文件,约5.51MB,以73个class编译文件与14个java源码为主,另含3个jar依赖、1个sql建库脚本及少量jpg、gif、png界面素材,结构清晰便于对照学习。项目涉及Java SE基础、MVC设计模式、Spring依赖注入与事务管理、MyBatis持久层操作、Servlet与JSP交互以及MySQL数据库设计等知识点,并配有IDEA工程配置与Git版本管理痕迹。目前已有1067人学习下载,适合作为毕业设计参考或Java后端练手项目,读者可借此梳理分层架构、数据库表设计与权限控制思路,积累实际业务系统的开发与调试经验。

1. 从一张 Excel 台账到一套 Java 仓库管理系统:这件事到底值不值得做

很多中小团队管库存的起点,是一张在群里传来传去的 Excel:采购改一版、仓管改一版、财务再改一版,最后没人说得清哪个是准的。等到盘点发现账实不符,追溯成本已经高得离谱。基于 Java 的仓库管理系统,本质上就是把这套靠人肉维护的台账,换成一套有数据库、有权限、有事务的在线系统,让入库、出库、盘点、调拨这些动作都留下可查的记录。

它适合谁?一是中小制造、贸易、电商仓储的 IT 负责人,想用一套自己能维护的系统替掉 Excel;二是正在做课程设计或毕业设计的同学,需要一个结构完整、能讲清楚技术选型的项目;三是想从 CRUD 练手进阶到真实业务的 Java 开发者。这篇文章不讲空泛概念,而是把技术选型、表结构、核心代码、并发与数据一致性这些真正会翻车的地方讲透,让你看完能自己搭出一套跑得起来的版本。

2. 技术选型与整体架构:为什么是 Spring Boot 加 MyBatis 这套组合

2.1 分层架构怎么切,别一上来就堆微服务

仓库管理系统的业务复杂度其实不高,核心就是几张主表加一堆流水记录。我一般会把它做成单体分层架构,而不是一上来就拆微服务。原因很直接:中小团队没有运维微服务的人力,拆开之后分布式事务、链路追踪、服务注册这些成本会瞬间压垮项目进度。单体分层足够撑住日均几千到几万次操作的量级。

典型分层是这样的:Controller 层负责接收请求和参数校验,Service 层承载业务规则和事务边界,Mapper 层做数据访问,Entity 和 DTO 分离,避免把数据库字段直接暴露给前端。这个切法的好处是事务边界清晰——出入库这种要同时改库存和写流水的操作,事务注解加在 Service 方法上,不会因为分层混乱导致事务失效。

技术栈上,Spring Boot 负责快速起服务和自动装配,MyBatis 负责 SQL 可控的数据访问,MySQL 做持久化,Redis 可选地用来缓存热点商品信息或做分布式锁。前端用 Vue 或 Thymeleaf 都行,看团队熟悉度。选 MyBatis 而不是 JPA,是因为仓库系统里经常要写复杂的库存汇总、按时间段统计出入库的 SQL,手写 SQL 比 ORM 自动生成更可控,也更好优化。

2.2 依赖与项目骨架的落地配置

先把工程骨架搭起来。用 Spring Initializr 或者手写 pom.xml 都行,关键是依赖版本要对齐,避免出现「源发行版 17 需要目标发行版 17」这类编译报错。下面是一份可以直接用的依赖片段。

<properties> <java.version>17</java.version> <mybatis.version>3.0.3</mybatis.version> </properties> <dependencies> <!-- Web 层,提供 REST 接口和内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 整合,注意版本要和 Spring Boot 3.x 匹配 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>${mybatis.version}</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 参数校验,出入库数量、商品编码都靠它兜底 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

这段配置里,java.version设成 17 是当前主流 LTS 版本,如果你本地装的是 8 或 11,要么改这里,要么升级 JDK,否则编译期就会报发行版不匹配。mybatis-spring-boot-starter的版本必须和 Spring Boot 大版本对应,Spring Boot 3.x 要用 3.0.x 的 starter,用 2.x 的 starter 会出现自动装配失败。validation依赖别省,出入库数量为负、商品编码为空这类问题,在 Controller 层用注解拦掉,比在 Service 里写一堆 if 干净得多。

配置文件里把数据源和 MyBatis 的映射路径写清楚:

spring: datasource: url: jdbc:mysql://localhost:3306/wms?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.wms.entity configuration: map-underscore-to-camel-case: true

serverTimezone一定要显式指定,否则 MySQL 8 驱动在写入时间字段时可能报时区错误。map-underscore-to-camel-case打开后,数据库的product_code能自动映射到实体的productCode,省掉大量 resultMap 配置。连接池的maximum-pool-size不要盲目调大,20 对中小系统足够,调太大反而会拖垮数据库连接数。

3. 核心表结构与出入库逻辑:库存字段到底该怎么设计

3.1 商品、库存、流水三张主表怎么定

仓库系统的数据模型,核心就三块:商品定义、当前库存、变动流水。很多人图省事把库存数量直接放在商品表里,短期能跑,长期一定出问题——因为商品属性和库存状态是两种变化频率完全不同的数据,混在一起会让表又宽又难维护。

我一般拆成三张表。商品表存编码、名称、规格、单位这些相对静态的信息;库存表存商品在某仓库某库位的当前数量,用商品 ID 加仓库 ID 做联合唯一键;流水表记录每一次出入库的变动,包含变动前后的数量、操作人、时间、单据号。流水表是排查账实不符的黑匣子,绝对不能省。

CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_code VARCHAR(64) NOT NULL UNIQUE COMMENT '商品编码,业务唯一', product_name VARCHAR(128) NOT NULL, spec VARCHAR(128) COMMENT '规格', unit VARCHAR(16) COMMENT '单位', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_product_warehouse (product_id, warehouse_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_type TINYINT NOT NULL COMMENT '1入库 2出库 3盘点调整', change_qty INT NOT NULL COMMENT '变动数量,出库为负', before_qty INT NOT NULL, after_qty INT NOT NULL, order_no VARCHAR(64) COMMENT '关联单据号', operator VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_product_time (product_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

inventory表上的联合唯一键uk_product_warehouse是防止同一商品在同一仓库出现多条库存记录的关键,没有它,并发插入会直接产生脏数据。version字段是为乐观锁准备的,后面讲并发时会用到。stock_record上的idx_product_time索引,是为了支撑「查某商品最近一段时间的出入库明细」这类高频查询,没有索引时数据量一上来就会全表扫描。

3.2 入库出库的 Service 实现与事务边界

出入库的核心逻辑是:校验参数、锁定库存行、更新数量、写流水,这几步必须在同一个事务里。下面是一段出库的 Service 实现,用乐观锁处理并发扣减。

@Service public class StockService { @Autowired private InventoryMapper inventoryMapper; @Autowired private StockRecordMapper stockRecordMapper; /** * 出库扣减库存 * @param productId 商品ID * @param warehouseId 仓库ID * @param qty 出库数量,必须为正数 * @param orderNo 关联单据号 */ @Transactional(rollbackFor = Exception.class) public void outbound(Long productId, Long warehouseId, int qty, String orderNo) { if (qty <= 0) { throw new BizException("出库数量必须大于0"); } // 先查当前库存,拿到版本号 Inventory inv = inventoryMapper.selectByProductAndWarehouse(productId, warehouseId); if (inv == null || inv.getQuantity() < qty) { throw new BizException("库存不足"); } int before = inv.getQuantity(); int after = before - qty; // 带版本号更新,返回影响行数为0说明被别人抢先改了 int rows = inventoryMapper.updateWithVersion( inv.getId(), after, inv.getVersion()); if (rows == 0) { throw new BizException("库存已被其他操作修改,请重试"); } // 写流水,出库记为负数 StockRecord record = new StockRecord(); record.setProductId(productId); record.setWarehouseId(warehouseId); record.setChangeType(2); record.setChangeQty(-qty); record.setBeforeQty(before); record.setAfterQty(after); record.setOrderNo(orderNo); stockRecordMapper.insert(record); } }

对应的 Mapper SQL 里,更新语句必须带上版本号条件:

<update id="updateWithVersion"> UPDATE inventory SET quantity = #{quantity}, version = version + 1 WHERE id = #{id} AND version = #{version} </update>

这段逻辑的关键点有三个。第一,@Transactional的rollbackFor显式写成Exception.class,因为默认只对运行时异常回滚,业务里抛的受检异常如果不写这个,事务不会回滚,库存改了流水没写,账就乱了。第二,乐观锁的更新语句里version = #{version}是并发安全的命门,两个线程同时读到 version=3,只有一个能更新成功,另一个影响行数为 0,直接抛异常让用户重试,比悲观锁select for update的吞吐更好。第三,流水记录在更新成功之后才写,保证流水和库存变动一一对应。

注意:如果出库并发量特别高,乐观锁重试率会上升,这时可以退化成UPDATE inventory SET quantity = quantity - #{qty} WHERE product_id = ? AND warehouse_id = ? AND quantity >= #{qty}这种原子扣减,用数据库行锁保证安全,代价是锁持有时间略长。

4. 并发扣减与数据一致性:库存超卖是怎么发生的

4.1 超卖的三种典型成因

库存超卖是仓库系统最经典的事故。第一种是「先查后改」没有加锁,两个请求同时查到库存 10,各自扣 8,最后库存变成 2 甚至负数。第二种是事务边界没包住查询和更新,查询在事务外,更新在事务内,中间的时间窗足够另一个请求插进来。第三种是缓存和数据库不一致,Redis 里显示有货,数据库实际已经扣完,用户下单成功但发不出货。

第一种和第二种靠前面讲的乐观锁或原子扣减能解决。第三种要单独处理:如果用了 Redis 缓存库存,扣减必须以数据库为准,缓存只做展示加速,或者用「先扣数据库、再删缓存」的顺序,绝不能反过来先改缓存再异步写库。

4.2 用压测验证并发安全

写完乐观锁,别急着上线,先压一把。用 JMeter 或者简单的多线程脚本,模拟 100 个并发对同一商品出库,看最终库存是否等于初始值减去成功出库总量。

// 简易并发测试,验证乐观锁是否真的防住了超卖 public class ConcurrencyTest { public static void main(String[] args) throws InterruptedException { int threads = 100; ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch latch = new CountDownLatch(threads); AtomicInteger success = new AtomicInteger(); AtomicInteger fail = new AtomicInteger(); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { // 每个线程尝试出库1件 stockService.outbound(1L, 1L, 1, "TEST-" + Thread.currentThread().getId()); success.incrementAndGet(); } catch (Exception e) { fail.incrementAndGet(); } finally { latch.countDown(); } }); } latch.await(); // 初始库存100,成功数应该正好等于100,失败数等于0 System.out.println("成功:" + success.get() + " 失败:" + fail.get()); } }

跑之前把初始库存设成 100,如果成功数超过 100,说明乐观锁没生效,大概率是 Mapper 的更新语句漏了 version 条件,或者事务没包住。如果成功数远小于 100,说明重试机制缺失,用户体感很差,这时要在 Service 外层加有限次重试。这个测试是上线前的后悔药,花十分钟跑一遍,能省掉线上对账几小时的痛苦。

5. 避坑与排查:那些上线后才暴露的问题

5.1 事务注解失效导致库存和流水对不上

现象:出库接口返回成功,但流水表里没有对应记录,或者库存扣了流水没扣。原因通常是@Transactional加在了 private 方法上,或者同类内部方法直接调用绕过了代理。Spring 的事务是基于 AOP 代理的,自调用不走代理,注解等于没写。解决办法是把事务方法抽到独立的 Service 里,或者通过注入自身代理来调用。排查时可以在事务方法里打日志,看进入和退出是否成对出现。

5.2 时间字段时区错乱

现象:流水表里的created_at比实际时间差 8 小时。原因是 JDBC 连接串没指定serverTimezone,或者 JVM 时区和数据库时区不一致。解决是在连接串里显式写serverTimezone=Asia/Shanghai,同时确认服务器time_zone参数。这个坑很隐蔽,因为功能测试时不一定看得出来,等到按时间段统计出入库报表时才发现数据全错位。

5.3 库存表出现重复记录

现象:同一商品同一仓库在inventory表里有两条记录,数量各算各的。原因是并发插入时没有唯一键约束,两个请求同时判断「记录不存在」然后各自插入。解决是给(product_id, warehouse_id)加联合唯一键,插入时用INSERT ... ON DUPLICATE KEY UPDATE或者捕获唯一键冲突异常后转为更新。加唯一键这一步,在建表时就要做,事后补会面临历史脏数据清理。

5.4 大批量导入时连接池被打满

现象:用 Excel 批量导入商品或初始库存时,接口超时,日志里出现Connection is not available。原因是导入逻辑在循环里逐条查询和插入,每条都占用一次连接,量大时连接池耗尽。解决是改成批量插入,用 MyBatis 的foreach拼批量 SQL,或者用 JDBC 的addBatch,同时把导入操作放到独立线程池里异步执行,避免阻塞主请求线程。

5.5 盘点调整没有留痕

现象:盘点后库存数量变了,但查不到是谁在什么时候调的。原因是盘点逻辑直接更新了inventory表,没有写stock_record。解决是把所有库存变动,包括盘点调整,都统一走同一个 Service 方法,强制写流水。流水表就是仓库系统的审计日志,任何绕过它直接改库存的代码都是隐患。

6. 进阶技巧:用乐观锁重试加对账任务把一致性兜住

前面讲的乐观锁在低并发下够用,但高并发时失败率会上升,用户体验变差。我一般会在 Service 外层加一层有限次重试,配合退避策略,把瞬时冲突消化掉。

public void outboundWithRetry(Long productId, Long warehouseId, int qty, String orderNo) { int maxRetry = 3; for (int i = 0; i < maxRetry; i++) { try { stockService.outbound(productId, warehouseId, qty, orderNo); return; } catch (BizException e) { // 只有版本冲突才重试,库存不足直接抛出 if (!e.getMessage().contains("已被其他操作修改")) { throw e; } try { // 退避一下,避免所有线程同时重试再次撞车 Thread.sleep(50L * (i + 1)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new BizException("出库被中断"); } } } throw new BizException("系统繁忙,请稍后重试"); }

这段重试逻辑的边界要卡死:只对版本冲突重试,库存不足这种业务失败直接抛,否则会变成无效循环。退避时间随重试次数递增,避免多个线程同步重试再次冲突。重试次数不要超过 3 次,再多用户等待时间就不可接受了。

除了重试,我还会加一个定时对账任务,每天凌晨跑一次,核对inventory表的当前数量和stock_record流水累加出来的理论数量是否一致。不一致的记录打出来人工核查。这个任务用 Spring 的@Scheduled就能实现,SQL 大致是拿每个商品每个仓库的流水SUM(change_qty)和库存表quantity对比。

-- 对账查询:找出库存与流水累计不一致的记录 SELECT i.product_id, i.warehouse_id, i.quantity AS current_qty, IFNULL(SUM(r.change_qty), 0) AS record_qty FROM inventory i LEFT JOIN stock_record r ON i.product_id = r.product_id AND i.warehouse_id = r.warehouse_id GROUP BY i.product_id, i.warehouse_id, i.quantity HAVING i.quantity <> IFNULL(SUM(r.change_qty), 0);

这个查询返回空结果,说明账实一致;返回记录,就是需要人工介入的异常。我踩过的最大一次坑,就是早期版本没写流水,盘点时发现库存对不上却完全无法追溯,最后只能全仓重盘。从那以后,我给团队定了一条规矩:任何改动库存的代码路径,必须先写流水再改数量,顺序都不能反。希望这些经验能帮到你,少走我当年走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询