简介:一套基于Spring Boot的库存管理系统毕业设计资源包,面向计算机相关专业学生与开发者,旨在解决毕业设计或课程设计中从需求分析到前后端落地的完整实现问题。系统分为管理员与员工双角色,管理员端包含个人中心、管理员管理、基础数据管理、供应商管理、商品管理、采购入库管理、客户管理、公告信息管理、员工管理等模块,员工端则支持商品、采购入库、客户、公告信息管理及注册登录,业务链条清晰。资源共431个文件,以Java源码、Vue组件、SVG图标、XML配置为主,同时配套SQL数据库脚本、yml配置、构建/运行批处理及演示视频等辅助内容,压缩包整体约20.19MB,目录结构规整,便于按模块检索。已有1235人学习下载,适合需要快速搭建库存管理系统的Spring Boot学习者参考实践。通过该资源可获取完整前后端源码、数据库初始化脚本、可直接运行的批处理命令与操作演示,还能借鉴其权限划分和接口设计思路,为毕业论文撰写、答辩及二次开发提供扎实支撑。
1. 基于Spring Boot的库存管理系统,难点不在CRUD
很多人在Spring Boot库存管理系统里一上手就写Controller、Service、Mapper,把商品、供应商、入库单都做成普通增删改查,等做到“订单扣库存”才发现对不上账。真正让库存管理区别于学生管理、博客系统的,是数量准确性和并发一致性:一张库存主表在多线程下的可用库存判断、多表之间的流水对账、订单支付与库存扣减的事务边界,任何一个环节松了都会出现超卖或者账面与实物不符。这套系统适合作为毕业设计,也适合想补一遍企业级事务设计基础的开发者,核心技术是Spring Boot、MyBatis或JPA、MySQL事务,以及可以延伸到Redis的预扣方案。这篇就按“领域模型、事务实现、并发扣减、验证手段”的顺序把整个方案讲透。
2. 库存领域模型:从数据库字段设计开始区分账面与实物
2.1 为什么库存要用“可用、冻结、在途”三段式表达
常见的股票系统写一个stock表,字段是sku_id和quantity,用户下单就把quantity减掉,取消再加回来。这种做法在单机玩具项目里没问题,但一旦并发高一点,两个请求同时读到quantity=10,各扣8,最终只剩2,而两次都下单成功,库存变成了负数。业务上库存数据要同时服务采购入库、销售出库、订单取消、仓库调拨,单一数量字段根本无法表达“这个商品被订单占用但还没出库”的状态。
我更习惯把一份库存拆成可用库存、冻结库存、在途库存三个概念。可用库存就是可以销售的数量,冻结库存是已经下订单但还没正式出库的数量,在途库存是已经采购但还没到达仓库的数量。对外销售判断只看可用库存,出库时把冻结转出去,取消时把冻结释放回可用。这样库存变动的每一步都有迹可循,也能在后端订单量较大时用锁定单做明细溯源。
2.2 三张核心表:库存主表、库存流水表、库存锁定单表
库存主表负责保存当前实时数量,字段设计要尽量冗余但不冗余到业务上。sku_id和warehouse_id是天然的唯一键,available_qty是可售数,frozen_qty是冻结数,total_qty是账面总库存,另外加version字段给乐观锁用。库存流水表负责保存每一次数量变更的原始记录,相当于审计日志,只追加不修改。锁定单表负责保存某个订单对库存的占用记录,包含订单号、SKU、数量、状态,是订单与库存之间解耦的桥梁。
以MySQL为例,三张表的核心DDL如下:
CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL COMMENT '商品SKU ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', available_qty INT NOT NULL DEFAULT 0 COMMENT '可用库存', frozen_qty INT NOT NULL DEFAULT 0 COMMENT '冻结库存', total_qty INT NOT NULL DEFAULT 0 COMMENT '账面总库存', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id) ) ENGINE=InnoDB COMMENT '库存主表'; CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_no VARCHAR(64) NOT NULL COMMENT '流水号', sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_type VARCHAR(32) NOT NULL COMMENT 'IN/OUT/FREEZE/UNFREEZE', change_qty INT NOT NULL COMMENT '变动数量,正数增加负数减少', before_qty INT NOT NULL, after_qty INT NOT NULL, order_no VARCHAR(64) DEFAULT NULL COMMENT '关联订单号', operator_id BIGINT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_flow_no (flow_no) ) ENGINE=InnoDB COMMENT '库存流水表'; CREATE TABLE stock_lock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, lock_no VARCHAR(64) NOT NULL COMMENT '锁定单号', order_no VARCHAR(64) NOT NULL, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, locked_qty INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0锁定 1已出库 2已释放', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_sku (order_no, sku_id) ) ENGINE=InnoDB COMMENT '库存锁定单表';这里有两个需要特别说明的参数选择。第一个是数量和流水号都不使用无符号整数,因为业务上可能出现反向调整,比如冲销、退货入库,使用正负号表达更直观,SQL里也方便做SUM。第二个是流水号采用唯一键约束而不是简单的主键自增,因为流水表会承接多个服务的写入请求,一旦出现重复流水,唯一键可以直接拦住。业务上通常将流水号生成为日期加机器标识加自增序列,比如20250607120300-001-000123。
2.3 持久层选型:MyBatis-Plus还是Spring Data JPA
在这个库存场景里,我更推荐MyBatis-Plus。原因是库存更新需要精细控制SQL,用UPDATE ... SET available_qty = available_qty - #{qty} WHERE available_qty >= #{qty}这种条件更新语句来保证原子性,MyBatis的XML或注解方式写起来更直观。JPA在复杂更新上虽然能用@Modifying和@Query,但批量更新的语义和返回值处理不如MyBatis直观。
相关热搜里的「java mybatis 和 spring boot框架」其实就是当前国内中小型系统的主流组合。依赖引入如下:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency>Service层使用IService<Stock>接口,自带getById、updateById等封装,而库存扣减方法则自己写Mapper接口,通过@Update注解执行自定义SQL,普通CRUD交给框架,关键写操作保持手动控制,这样既符合毕业设计的代码量要求,又不失工程落地能力。
3. 库存出入库与事务边界:Service层怎么组织才算干净
3.1 入库、冻结、出库、释放的方法骨架
库存操作从业务语义上可以分为四类:入库增加可用库存,下单冻结可用库存,出库把冻结库存转出,取消订单释放冻结库存。业务上不能直接用updateById先查出对象再改再更新,因为这样多了一次查询,也更容易丢失更新。我一般会把写操作收敛到一组以“库存动作”命名的方法里,由统一入口分发。
核心Service方法骨架如下:
@Service public class StockService { @Resource private StockMapper stockMapper; @Resource private StockFlowMapper stockFlowMapper; @Resource private StockLockMapper stockLockMapper; @Transactional(rollbackFor = Exception.class) public boolean inbound(StockInboundCommand cmd) { int rows = stockMapper.increaseAvailable(cmd.getSkuId(), cmd.getWarehouseId(), cmd.getQty()); if (rows == 0) { throw new BizException("库存主表不存在,请先初始化"); } StockFlow flow = StockFlow.buildInFlow(cmd); stockFlowMapper.insert(flow); return true; } @Transactional(rollbackFor = Exception.class) public boolean freezeStock(String orderNo, Long skuId, Long warehouseId, Integer qty, String lockNo) { int rows = stockMapper.freezeAvailable(skuId, warehouseId, qty); if (rows == 0) { throw new BizException("可用库存不足,无法锁定"); } stockLockMapper.insert(StockLock.buildLock(lockNo, orderNo, skuId, warehouseId, qty)); stockFlowMapper.insert(StockFlow.buildFreezeFlow(lockNo, skuId, warehouseId, qty)); return true; } }increaseAvailable对应的Mapper SQL是:
@Update("UPDATE stock SET available_qty = available_qty + #{qty}, total_qty = total_qty + #{qty}, version = version + 1 " + "WHERE sku_id = #{skuId} AND warehouse_id = #{warehouseId}") int increaseAvailable(@Param("skuId") Long skuId, @Param("warehouseId") Long warehouseId, @Param("qty") Integer qty);freezeAvailable对应的SQL是:
@Update("UPDATE stock SET available_qty = available_qty - #{qty}, frozen_qty = frozen_qty + #{qty}, version = version + 1 " + "WHERE sku_id = #{skuId} AND warehouse_id = #{warehouseId} AND available_qty >= #{qty}") int freezeAvailable(@Param("skuId") Long skuId, @Param("warehouseId") Long warehouseId, @Param("qty") Integer qty);这里的逻辑说明:把查询和更新合并成一条条件更新SQL,数据库在InnoDB行锁的粒度上做唯一性和数量判断,available_qty >= #{qty}这个条件由数据库完成校验,应用层不提前查一次库存,避免读到脏值。参数上rollbackFor = Exception.class很关键,Spring默认只对RuntimeException回滚,如果自定义业务异常继承Exception而不是RuntimeException,不加这个参数事务不会回滚。
3.2 事务边界划分:一张订单涉及的所有库存操作必须在同一个事务里
库存模型里有一个很容易踩的坑:同一个订单如果含多个SKU,有人会在一个事务里循环调用freezeStock方法,这看起来没问题,但如果在循环第二遍时抛了异常,整个事务回滚,第一遍的冻结也会回滚,这确实是期望的行为。问题往往出现在把“生成订单”和“冻结库存”放在两个Service里,订单是订单事务,库存是库存事务,中间一旦出现网络短暂超时,订单库里多了订单,库存里没有冻结记录。
正确做法是把订单创建和库存冻结放在同一个事务内。常见的做法是:
@Transactional(rollbackFor = Exception.class) public Long createOrderAndFreezeStock(OrderCreateCommand cmd) { Order order = orderMapper.insert(cmd.toOrder()); for (OrderItem item : cmd.getItems()) { stockService.freezeStock(order.getOrderNo(), item.getSkuId(), item.getWarehouseId(), item.getQty(), generateLockNo()); } return order.getId(); }这里要让废弃的freezeStock方法不要被同类内部调用,否则@Transactional会失效,这是Spring AOP的经典问题。解决方法是把库存操作抽到独立的StockService,订单Service通过注入的实例调用,或者把freezeStock拆到另一个Bean里,让代理对象生效。搜索词「spring boot四层架构」在这里真正起到了作用:Controller、Service、Mapper、领域对象各司其职,事务边界放在Service层方法上,而不是Controller里甚至Mapper里。
3.3 库存流水写入为何要与业务在同一事务内
流水表的insert操作必须和库存数量的update放在同一个事务方法里。如果先更新库存,再单独调流水接口写入,一旦流水写入失败,库存已经变了,对账时少了记录,就再也说不清那个数量变化是什么时候发生的。反过来先写流水再更新库存,如果更新失败,流水会留下一条没有实际发生的记录,同样麻烦。
放入同一个@Transactional方法后,数据库本身保证了两条写操作的一致性。流水号唯一键在这种方案下承担最后一道防线,即使代码里出现重复调用,流水表也会因为唯一键冲突抛异常,把整个事务回滚掉,不会出现同一笔库存变更被记录两次的情况。
4. 并发扣减与防超卖:乐观锁、唯一约束与Redis预扣的取舍
4.1 两种最常见的并发扣减方案
第一种是悲观锁,扣减时在SQL后面加FOR UPDATE,直接锁住库存主表这一行,主要优点是简单,缺点是锁开销大,而且如果事务时间很长,后面所有请求都要排队等。第二种是乐观锁,在更新条件里带上version或available_qty >= #{qty},更新失败说明版本已经变化,应用层决定重试或返回错误。库存这种高频读、低频写且数据量不大但一致性要求极高的场景,乐观锁是更合适的默认选择。
Redis预扣是第三种方案,适合瞬时流量非常大的场景,比如秒杀。这个方案把可用库存放到Redis里,用DECR做预扣,异步再落库。它的问题在于如果订单最终没有支付,Redis和数据库两个数据源之间必须做补偿标记,否则会出现Redis扣了但数据库没扣,或者数据库扣了Redis又恢复回去的状态偏差。毕业设计如果并发要求不高,不建议直接上Redis,这里我分成两条路径来讲。
4.2 乐观锁防超卖的完整实现
乐观锁在这个系统里的核心是一条带条件的更新SQL。刚才看到的freezeAvailable已经包含了available_qty >= #{qty}条件,这就是“数据库层的乐观判断”,不需要显式传version进去。还有一种写法是把version也加入更新条件:
UPDATE stock SET available_qty = available_qty - #{qty}, frozen_qty = frozen_qty + #{qty}, version = version + 1 WHERE sku_id = #{skuId} AND warehouse_id = #{warehouseId} AND available_qty >= #{qty} AND version = #{version}返回影响行数int rows大于0表示扣减成功,等于0时,需要区分是库存不足还是版本冲突。可以在捕获后重新查询库存,给出不同的提示语:“库存不足”或者“操作过于频繁,请重试”。这里的version字段就是库存主表中的乐观锁版本号,每次更新加1,初始为0。
Service层做有限重试:
public boolean freezeWithRetry(String orderNo, Long skuId, Long warehouseId, Integer qty, int retryTimes) { for (int i = 0; i < retryTimes; i++) { Stock stock = stockMapper.selectBySkuAndWarehouse(skuId, warehouseId); if (stock == null) { throw new BizException("库存主数据不存在"); } int rows = stockMapper.freezeWithVersion(skuId, warehouseId, qty, stock.getVersion()); if (rows > 0) { return true; } } throw new BizException("系统繁忙,请稍后重试"); }注意这个重试过程必须放在无大数据量事务的内部,而且不要把这个方法直接标记为@Transactional,否则事务还没提交就会读到上一次的version快照。正确做法是先执行重试更新,事务提交完成后,再写流水表,流水表写失败则通过定时任务补偿。更简单稳妥的方法是把重试和无事务的校验都放到Service外层,让freezeStock本身保持单事务无循环。
4.3 引入Redis预扣时的三个关键参数
如果坚持要在高并发节点使用Redis预扣,我通常会保持下面的Key设计和参数约定:
| Key | 类型 | 说明 | 失效时间 |
|---|---|---|---|
stock:available:{skuId} | String | 可售库存,启动时从DB同步 | 不设TTL |
stock:lock:{orderNo} | String | 锁定订单号,防重复处理 | 30分钟 |
stock:recover:{skuId}:{orderNo} | String | 补偿落库失败标记 | 24小时 |
预扣逻辑用Lua脚本保证原子性:
local available = tonumber(redis.call('GET', KEYS[1])) local qty = tonumber(ARGV[1]) if available == nil or available < qty then return 0 end redis.call('DECRBY', KEYS[1], qty) redis.call('SET', KEYS[2], 1, 'EX', ARGV[2]) return 1脚本里KEYS[1]是stock:available:{skuId},KEYS[2]是stock:lock:{orderNo},ARGV[1]是扣减数量,ARGV[2]是TTL秒数。相比先GET再DECR两步操作,Lua脚本避免了检查与减少之间的并发窗口。TTL设置不建议太长,30分钟足够覆盖一次订单创建到支付的时间,超过后订单已过期,锁定标记作废,但Redis里的数量需要由延迟任务做回补。
这里有一个非常容易踩的坑:Redis预扣成功但数据库冻结失败,Redis数量必须回补。回补操作不能用简单的INCR,应该先写入一条补偿流水,再由定时任务在业务低谷期以对账方式同步数据库的真实可用库存,否则Redis和数据库永远在漂移。相关热词里「spring boot actuator未授权访问」在生产环境也是个隐患,暴露Redis预扣指标的同时注意给actuator设置访问权限。
5. 上线前必做的验证:并发压测、库存核对与幂等兜底
5.1 用JMeter模拟高并发下单,实测扣减是否超卖
写完Service之后,不要只拿Postman点两次请求就算验证通过。我一般会启动一个Spring Boot应用,用JMeter创建一个线程组,线程数设成100,Ramp-Up Period设为1秒,循环次数设为10,也就是总共1000个请求同时进入下单接口。下单接口内部调用freezeStock扣减库存,把库存初始值设为50,压测完成后查看数据库里的库存剩余数量。
如果系统没有并发问题,最终结果应该是:可用库存减为0,冻结库存加50,另外950个请求在available_qty >= #{qty}条件上被数据库拦截并抛异常。如果压测后出现了负库存,那就说明条件更新SQL没有生效,很可能写成了先查询再更新的普通逻辑。
压测时的响应时间也很关键。乐观锁方案在高并发下会出现大量重试,重试次数不能设太大,2到3次即可。如果JMeter里看到大量5xx异常且错误信息是“系统繁忙”,说明版本冲突率很高,这时候可以加大version判断的粒度,或者改成分段库存,按库存桶维度拆细行锁,但这已经超出普通库存管理系统的必要范围。
5.2 不依赖测试报告的库存一致性核对SQL
压测通过不等于数据正确,还要有一组能随时手动执行的核对SQL。第一组核对“库存主表自身一致性”:
SELECT sku_id, warehouse_id, available_qty, frozen_qty, total_qty, available_qty + frozen_qty AS calc_total FROM stock WHERE available_qty + frozen_qty <> total_qty;正常情况下,available_qty + frozen_qty应该等于total_qty,如果出现不等,说明某个变更逻辑在更新数量时漏了其中一个字段。第二组核对“流水累计与主表当前值”的关系,以入库为例:
SELECT f.sku_id FROM stock_flow f GROUP BY f.sku_id HAVING SUM(CASE WHEN f.change_type = 'IN' THEN f.change_qty ELSE 0 END) <> (SELECT s.total_qty - 5000 FROM stock s WHERE s.sku_id = f.sku_id);这里5000是初始库存值,具体值要根据测试环境的初始化数据替换。如果流水总和和主表变化量不一致,基本可以定位到某个操作只更新了主表没有写流水。第三组核对锁定单状态:
SELECT l.order_no, l.sku_id, l.locked_qty, l.status, f.change_qty FROM stock_lock l LEFT JOIN stock_flow f ON f.lock_no = l.lock_no WHERE l.status = 0 AND f.change_type = 'OUT';状态为0的锁定单如果关联了出库流水,说明冻结状态的订单已经出库但没有更新锁定单状态,属于漏更新的典型问题。
这些核对SQL可以在压测前、压测后、运行一周后各跑一遍,比看任何单元测试覆盖率都更能说明这个库存管理系统的数据可靠性。
5.3 幂等兜底:库存接口防重,比防超卖更隐蔽
库存接口除了并发扣减之外,还有一个很容易被忽略的问题:重复请求。用户在前端连续点击提交订单,或者支付回调因网络超时自动重试,同一笔订单可能被调用两次。就算两次调用最终结果都是扣一次库存,但在第一次事务还没提交前,第二次调用可能查不到锁定记录,于是又扣了一次。
我常用的做法是在库存流水表上增加lock_no唯一键,并在Service层先插入锁定单,再扣减库存。如果重复请求发起时,锁定单已经存在,直接返回“该订单已锁定”。如果第二次请求和第一次几乎同时到达,唯一键冲突会让其中一个事务失败,失败的那一侧在Spring事务回滚后捕获到DuplicateKeyException,不返回错误,而是返回已处理。
这个技巧其实只需要几行代码:
try { stockLockMapper.insert(lock); } catch (DuplicateKeyException e) { return true; }注意这行代码必须写在freezeStock方法内部,并且不要只在方法开头判断一次selectCount就结束,因为并发时count和insert之间仍有时间差,唯一键才是最终依据。把幂等逻辑收在数据库约束层,比在应用层加分布式锁更简单,也更容易在毕业设计答辩时讲清楚。
到此,整个基于Spring Boot的库存管理系统从表结构到事务实现,从乐观锁防超卖到压测核对,已经形成了一条可以完整复现的路径。把这三张表建好,把freezeStock和outbound两类方法写好,再配上一组核对SQL,这个系统的数据可靠性不会比大多数生产环境里的库存模块差。
本文还有配套的精品资源,点击获取