企业仓库存储管理系统Java Web实战:Spring Boot+MySQL事务与并发控制
发布时间:2026/9/28 23:46:51来源:尧图网络
简介这是一套面向高校计算机相关专业学生与Java Web初学者、课程设计实践者的企业仓库存储管理系统完整源码采用Spring BootJavaMavenMySQLMyBatis技术栈基于B/S结构开发开发工具为IntelliJ IDEA可作为课程设计、毕业设计或Java Web综合练习的参考方案。系统功能覆盖客户、仓库、产品三类基本信息管理以及用户权限管理、入库与出库记录管理、库存管理和系统日志查看业务闭环较为完整。资源包共221个文件约6.95MB包含42个Java源文件、28个HTML页面、24个JavaScript脚本、9个XML配置、8个CSS样式及1个SQL建表脚本另有gif、jpg、png等图片与字体资源前端采用layui等组件库结构清晰便于二次开发。目前已有568人学习下载适合需要快速理解SSM/Spring Boot项目分层、数据库设计与增删改查实现思路的读者参考借鉴。1. 企业仓库存储管理系统从一张入库单到一套能跑起来的 Java Web 工程很多做 Java 课程设计或企业内训的同行第一次接到“企业仓库存储管理系统”这个题目时脑子里浮现的往往是几张 CRUD 表格物料、入库、出库、库存。真动手写起来才发现难点根本不在增删改查而在于库存数量在并发下的数据一致性、入库单与库存流水的对账关系、以及 MySQL 连接池在长时间空闲后的断连重连。这套系统本质是一个典型的 Java Web 单体应用Spring Boot 做后端、MySQL 做持久化、前端用 JSP 或 Thymeleaf 渲染核心业务围绕“物料主数据—入库—出库—库存结余”这条链路展开。它适合两类人一是需要一份能讲清楚业务闭环的 Java Web 项目完整案例 MySQL 实现的在校生二是想用一个小系统练手 Spring Boot 集成、事务控制和连接池调优的初中级工程师。下面我按自己实际搭过的一套方案把选型、建表、核心代码和踩过的坑一次讲透。2. 技术选型与数据库设计为什么这套组合最稳2.1 后端框架选 Spring Boot 而不是裸 Servlet标题里写的是 JavaMySQL没有限定框架。常见做法有两种一种是纯 JSPServletJDBC另一种是 Spring Boot MyBatis/JPA。我一般会选后者原因很实际。纯 Servlet 方案里每个请求都要手动DriverManager.getConnection连接无法复用压测到 50 并发就开始报Too many connections而 Spring Boot 自带 HikariCP 连接池默认最大池 10、最小空闲 10配合spring.datasource.hikari.connection-timeout就能扛住课程设计级别的并发。更重要的是声明式事务入库要同时写stock_in主表、stock_in_item明细表和inventory库存表三张表必须在一个事务里用Transactional一行注解解决裸 JDBC 得手写setAutoCommit(false)加一堆 try-catch-rollback稍不留神就漏掉回滚分支。依赖上只需要四个核心 starterspring-boot-starter-web、spring-boot-starter-jdbc或mybatis-spring-boot-starter、mysql-connector-java、spring-boot-starter-validation。版本上 Spring Boot 2.7.x 配 MySQL 8.0 驱动是经过大量项目验证的组合不建议追最新版驱动和连接池的兼容问题在课程设计阶段纯属自找麻烦。2.2 四张核心表怎么切分企业仓库存储管理系统的表设计关键是区分“单据”和“状态”。单据是历史记录只增不改状态是当前库存频繁更新。把两者混在一张表里后期查历史入库和查当前库存会互相拖累。表名作用关键字段更新频率material物料主数据id, code, name, unit, safety_stock低stock_in入库单主表id, order_no, supplier, operator, create_time只增stock_in_item入库单明细id, order_id, material_id, quantity, price只增inventory当前库存material_id, quantity, update_time高频更新建表时有两个细节必须定死。第一material.code加唯一索引防止同一物料重复录入第二inventory.material_id做主键而不是自增 id因为库存和物料是一对一关系用物料 id 直接做主键能让后续的UPDATE ... WHERE material_id ?走聚簇索引比二级索引快一个量级。下面是我实际用的建表语句CREATE TABLE material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL, name VARCHAR(64) NOT NULL, unit VARCHAR(16) NOT NULL, safety_stock INT NOT NULL DEFAULT 0, UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE inventory ( material_id BIGINT PRIMARY KEY, quantity INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;inventory表用material_id做主键意味着一个物料只有一行库存记录更新时直接命中主键。quantity设NOT NULL DEFAULT 0避免出现 NULL 导致后续quantity ?计算结果为 NULL 的玄学问题——这个坑我在早期项目里踩过出库时库存直接变成 NULL查了半天才发现是初始值没设默认。update_time用ON UPDATE CURRENT_TIMESTAMP省去在 Java 代码里手动 set 时间减少一处出错点。2.3 连接池与字符集配置application.yml里最容易配错的是连接池参数和字符集。MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.DriverURL 必须带serverTimezone否则启动就报时区错误。字符集用utf8mb4而不是utf8因为后者在 MySQL 里其实是三字节的存不了 emoji 和部分生僻字物料名称里带特殊符号就会插入失败。spring: datasource: url: jdbc:mysql://localhost:3306/warehouse?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000max-lifetime设 1800000 毫秒即 30 分钟必须小于 MySQL 的wait_timeout默认 28800 秒但很多云数据库会调到更短。如果max-lifetime大于数据库端的超时时间连接会被服务端先断开而连接池还以为它活着取出来用就报Communications link failure。allowPublicKeyRetrievaltrue是 MySQL 8.0 默认加密方式caching_sha2_password在非 SSL 连接下的必要参数不加会报Public Key Retrieval is not allowed。这两个参数是新手配 MySQL 连接池时翻车率最高的地方。3. 入库与出库的核心实现把事务和并发锁写对3.1 入库流程的三表写入与事务边界入库的业务动作是前端提交一张入库单包含供应商、操作员和若干条物料明细。后端要做三件事——写主表、写明细、更新库存。这三步必须原子化任何一步失败都要整体回滚。用 Spring 的Transactional时最容易犯的错是把事务注解加在 Controller 上或者方法内部自己 catch 了异常没往外抛导致事务不回滚。Service public class StockInService { Autowired private StockInMapper stockInMapper; Autowired private InventoryMapper inventoryMapper; Transactional(rollbackFor Exception.class) public void createStockIn(StockInDTO dto) { // 1. 写主表回填自增 id StockIn main new StockIn(); main.setOrderNo(generateOrderNo()); main.setSupplier(dto.getSupplier()); main.setOperator(dto.getOperator()); stockInMapper.insert(main); // 2. 逐条写明细并累加库存 for (StockInItemDTO item : dto.getItems()) { StockInItem detail new StockInItem(); detail.setOrderId(main.getId()); detail.setMaterialId(item.getMaterialId()); detail.setQuantity(item.getQuantity()); detail.setPrice(item.getPrice()); stockInMapper.insertItem(detail); // 库存累加用 INSERT ... ON DUPLICATE KEY UPDATE 保证幂等 inventoryMapper.upsertQuantity(item.getMaterialId(), item.getQuantity()); } } }rollbackFor Exception.class是必须写的因为 Spring 默认只对RuntimeException回滚如果 Mapper 抛的是受检异常事务不会回滚。库存更新用INSERT ... ON DUPLICATE KEY UPDATE而不是先查再判断是因为后者在并发下会有竞态两个线程同时查到库存不存在都去 insert第二个就报主键冲突。用 upsert 一条 SQL 解决INSERT INTO inventory (material_id, quantity) VALUES (#{materialId}, #{quantity}) ON DUPLICATE KEY UPDATE quantity quantity #{quantity}这条 SQL 在material_id主键冲突时自动转为更新quantity quantity ?是在数据库端做加法天然带行锁不会丢更新。注意ON DUPLICATE KEY UPDATE后面的quantity指的是表中已有值不是VALUES(quantity)写错了会变成覆盖而不是累加。3.2 出库时的库存扣减与超卖防护出库比入库多一个约束不能扣成负数。常见做法是先SELECT quantity FROM inventory WHERE material_id ?查出来在 Java 里判断够不够再UPDATE。这个写法在并发下必然超卖——两个线程都查到库存 10都判断够扣 8都去更新最后库存变成 -6。正确做法是把判断和扣减合并到一条 SQL 里用WHERE quantity ?做条件更新UPDATE inventory SET quantity quantity - #{qty} WHERE material_id #{materialId} AND quantity #{qty}Mapper 方法返回int即受影响行数。如果返回 0说明库存不足此时在 Service 里抛异常触发回滚int affected inventoryMapper.deductQuantity(materialId, qty); if (affected 0) { throw new BusinessException(物料 materialId 库存不足); }这里有个细节UPDATE ... WHERE quantity ?在 InnoDB 下会对命中行加排他锁第二个并发事务会阻塞等待等第一个提交后再判断条件所以不会超卖。但前提是material_id上有索引主键天然满足否则会锁全表并发直接崩。另外出库单的明细写入和库存扣减也要放在同一个Transactional方法里保证扣了库存就一定有待出库记录反之亦然。3.3 单据号生成别用时间戳裸拼入库单号order_no要求唯一且可读。我见过不少项目直接用System.currentTimeMillis()拼物料 id结果同一毫秒内两笔入库就撞号。可靠做法是“日期 当日序列”序列用一张sequence表配合UPDATE ... SET val val 1来取或者用 Redis 的INCR。课程设计级别用数据库序列表就够了CREATE TABLE sequence ( seq_name VARCHAR(32) PRIMARY KEY, seq_val BIGINT NOT NULL DEFAULT 0 ); UPDATE sequence SET seq_val seq_val 1 WHERE seq_name stock_in; SELECT seq_val FROM sequence WHERE seq_name stock_in;这两条要在同一个事务里执行UPDATE会加行锁保证并发下取到的序列不重复。单号格式拼成RK20250101-0001前缀区分入库出库日期段便于按天查询序列段补零保证排序正确。别用 UUID 做单号虽然唯一但没法读、没法排序仓库管理员看到一串十六进制会直接骂人。4. 查询、对账与前端联调让数据能看能核4.1 库存流水与结余的对账查询系统跑起来后业务方最常问的一句话是“这个物料现在有多少怎么来的”。光有inventory表的当前值不够得能追溯到每一笔入库出库。做法是加一张stock_flow流水表每次库存变动都插一条记录变动类型、数量、关联单号、变动后结余。查询时按物料 id 和时间范围拉流水前端做累加展示。SELECT f.create_time, f.flow_type, f.quantity, f.balance_after, f.ref_order_no FROM stock_flow f WHERE f.material_id #{materialId} AND f.create_time BETWEEN #{start} AND #{end} ORDER BY f.create_time DESC, f.id DESC LIMIT #{offset}, #{size}ORDER BY里带上id DESC是为了处理同一秒内多笔变动的情况create_time精度只到秒时会乱序加自增 id 做次级排序才稳定。balance_after字段在写入流水时就算好存进去查询时不用再累加避免前端算错。这个字段是典型的空间换时间仓库系统数据量不大多存一列换来查询简单值得。4.2 分页查询的 count 优化物料列表和单据列表都要分页。MyBatis 分页常见写法是查两次一次SELECT COUNT(*)一次SELECT ... LIMIT。当WHERE条件复杂时COUNT(*)可能比数据查询还慢。优化手段是如果查询条件里没有过滤、只是全表分页可以用SELECT COUNT(*) FROM material走覆盖索引如果有过滤确保过滤字段有索引。另外深分页LIMIT 100000, 20会扫描前 100000 行再丢弃性能极差改用游标分页WHERE id #{lastId} ORDER BY id LIMIT 20每次带上上一页最后一条的 id。SELECT id, code, name, unit, safety_stock FROM material WHERE id #{lastId} ORDER BY id LIMIT #{size}游标分页的代价是不能跳页只能上一页下一页。仓库管理场景里用户基本是顺序翻这个限制可以接受。如果产品经理非要跳页那就老老实实LIMIT offset, size但要在文档里写清楚数据量超过十万行后需要换方案。4.3 安全库存预警的定时任务safety_stock字段存的是安全库存阈值低于它要预警。实现方式有两种查询时实时判断或者定时任务扫描。实时判断简单在库存列表 SQL 里加CASE WHEN quantity safety_stock THEN 1 ELSE 0 END AS warning前端标红。定时任务则用Scheduled(cron 0 0 8 * * ?)每天早上八点扫一遍把预警物料写进消息表或发邮件。课程设计里用实时判断就够了定时任务作为加分项。注意Scheduled需要在启动类上加EnableScheduling且默认单线程执行多个任务会互相阻塞生产环境要配ThreadPoolTaskScheduler。5. 避坑与排查那些让我加班到凌晨的问题5.1 现象启动报Public Key Retrieval is not allowed原因MySQL 8.0 默认认证插件是caching_sha2_password客户端在非 SSL 连接下首次认证需要获取服务端公钥驱动默认禁止这个行为。解决在 JDBC URL 后加allowPublicKeyRetrievaltrueuseSSLfalse。如果生产环境要求 SSL则改为useSSLtrue并配置信任证书不要图省事一直关 SSL。5.2 现象库存更新后查出来没变或者变成 NULL原因inventory表quantity字段没设NOT NULL DEFAULT 0初始插入时是 NULL后续quantity ?结果还是 NULL。或者事务没提交另一个连接读的是旧快照。解决建表时强制NOT NULL DEFAULT 0检查Transactional方法是不是被同类内部调用Spring AOP 代理失效事务不生效内部调用要走注入的代理对象或拆到另一个 Bean。5.3 现象并发出库时库存扣成负数原因用了“先查再判断再更新”的三步写法中间没有锁。解决改成UPDATE ... WHERE quantity ?的条件更新用返回的受影响行数判断是否成功。同时确认material_id是主键或有唯一索引否则行锁升级为表锁并发性能骤降。5.4 现象连接池报Communications link failure重启才好原因max-lifetime大于 MySQL 服务端的wait_timeout连接被服务端单方面关闭池里还留着死连接。解决把max-lifetime设为比wait_timeout小几分钟比如数据库 30 分钟超时池设 25 分钟。同时开启connection-test-query: SELECT 1或依赖 JDBC4 的isValid做取连接前检测。5.5 现象中文物料名插入后显示问号原因数据库、表、连接三处字符集不一致。解决建库建表统一utf8mb4JDBC URL 带characterEncodingutf8mb4MySQL 配置文件my.cnf里character-set-serverutf8mb4。三处缺一处都可能乱码排查时用SHOW VARIABLES LIKE character%逐项确认。6. 进阶技巧用乐观锁和审计字段把系统做扎实前面讲的库存扣减用的是数据库行锁简单可靠但锁等待在高并发下会拖慢响应。如果想把系统再往上提一档可以引入乐观锁给inventory表加version字段更新时带上版本号。UPDATE inventory SET quantity quantity - #{qty}, version version 1 WHERE material_id #{materialId} AND version #{version} AND quantity #{qty}Java 侧先查出当前version更新时传入返回 0 就说明版本被别的线程改了重试或报错。乐观锁适合冲突少的场景冲突多时重试成本反而高于行锁。仓库系统里同一物料被并发扣减的概率通常不高乐观锁是个合理选择但要在代码里加重试上限比如重试 3 次还失败就抛业务异常别无限循环。另一个值得加的是一套审计字段create_by、create_time、update_by、update_time。用 MyBatis 的拦截器或 JPA 的PrePersist自动填充别在每个 Service 里手动 set。我一般会写一个MetaObjectHandler在 insert 时填创建人和创建时间update 时填更新人和更新时间。这样后期查“谁改了这个库存”时有据可依出问题能定位到人。验证这套系统是否做对我习惯跑三个用例一是并发 20 线程对同一物料出库看最终库存是否等于初始值减去成功出库总量且不为负二是入库后立刻查流水确认balance_after和inventory.quantity一致三是把 MySQL 停掉再启动看连接池能否自动恢复不用重启应用。这三个过了系统基本就站得住。最后说个我自己的习惯每次改完库存相关的 SQL我都会在测试库先跑一遍EXPLAIN确认走的是主键或唯一索引而不是全表扫描。这个动作花不了两分钟但能挡掉大部分上线后才发现的性能问题。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网