ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Spring Boot的库存管理系统:事务、并发扣减与防超卖实战

2026/9/11 21:47:41 拓冰建站 浏览量
基于Spring Boot的库存管理系统:事务、并发扣减与防超卖实战 简介一套基于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减掉取消再加回来。这种做法在单机玩具项目里没问题但一旦并发高一点两个请求同时读到quantity10各扣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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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 groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependencyService层使用IServiceStock接口自带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同步不设TTLstock: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创建一个线程组线程数设成100Ramp-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这个系统的数据可靠性不会比大多数生产环境里的库存模块差。本文还有配套的精品资源点击获取