简介:这是一套基于Spring Boot+Java+Maven+MySQL+MyBatis实现的企业仓库存储管理系统,采用B/S结构,适合Java Web课程设计或毕设参考。系统覆盖客户信息、仓库信息、产品信息、用户权限、入库/出库记录、库存管理和系统日志等核心模块,可帮助初学者理解业务逻辑与前后端交互流程。资源共221个文件,包含42个Java源码、28个HTML页面、24个JS脚本、9个XML配置及SQL脚本、CSS样式文件等,压缩包大小约6.95MB,另附75个GIF操作演示图,便于直观了解界面效果与功能操作。目前已有568人学习下载,适合需要完整项目方案、数据库设计及界面参考的开发者,可直接导入IDE运行,并在此基础上扩展功能。
1. 基于Java+MySQL的仓库管理系统到底解决什么问题?
仓库管理员还在用Excel记出入库,月底一核对才发现库存是负数,这种情况在中小企业里太常见了。基于Java+MySQL实现(Web)企业仓库存储管理系统,说白了就是用一个浏览器能访问的Java Web应用,把仓库、商品、库存、出入库单这四类核心数据管起来。它解决的是三个具体问题:库存余额不准、出入库流程不受控、不同仓库之间的数据权限混乱。适合正在做进销存、ERP或者毕设项目的Java开发者和企业内部的IT运维人员。这篇笔记按一个能真正交付的Web工程来拆,从MySQL表结构讲到Java事务,再到前端权限控制,最后把最容易翻车的几个并发坑逐个排掉。
2. 数据库模型设计:仓库与库存表怎么建才能避免月底对账翻车
先别急着写Controller和页面,数据库表结构直接决定这个系统能跑多久。很多新手上来就建一张“流水表”,每次查询都靠SUM算库存,数据量小看着没问题,一旦仓库里有几万种商品、每天上千张单据,这条路必堵。我一般会把表拆成仓库、商品、库存、出入库单主表、出入库单明细表五张,库存表单独存余额,所有变更在事务里同时写流水和余额,这样查询快、对账也简单。
2.1 仓库、商品、库存、出入库单四张核心表怎么建
先看核心建表SQL,这里去掉了一些不必要的冗余字段,保留最关键的部分。
CREATE TABLE `wh_warehouse` ( `id` int NOT NULL AUTO_INCREMENT, `code` varchar(32) NOT NULL COMMENT '仓库编码', `name` varchar(64) NOT NULL COMMENT '仓库名称', `manager_id` int DEFAULT NULL COMMENT '负责人ID', `status` tinyint DEFAULT '1' COMMENT '1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库表'; CREATE TABLE `wb_product` ( `id` bigint NOT NULL AUTO_INCREMENT, `sku_code` varchar(64) NOT NULL COMMENT 'SKU编码', `name` varchar(128) NOT NULL COMMENT '商品名称', `spec` varchar(64) DEFAULT NULL COMMENT '规格', `unit` varchar(16) DEFAULT NULL COMMENT '单位', `status` tinyint DEFAULT '1', PRIMARY KEY (`id`), UNIQUE KEY `uk_sku_code` (`sku_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE `wh_stock` ( `id` bigint NOT NULL AUTO_INCREMENT, `warehouse_id` int NOT NULL, `product_id` bigint NOT NULL, `quantity` int NOT NULL DEFAULT '0' COMMENT '可用库存', `locked_quantity` int NOT NULL DEFAULT '0' COMMENT '锁定库存', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', `last_updated` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_wh_product` (`warehouse_id`, `product_id`), KEY `idx_product` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库-商品库存表'; CREATE TABLE `wh_stock_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '单据号', `order_type` tinyint NOT NULL COMMENT '1入库 2出库 3盘盈 4盘亏', `warehouse_id` int NOT NULL, `status` tinyint DEFAULT '0' COMMENT '0草稿 1已确认 2已审核', `created_by` int DEFAULT NULL, `created_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出入库单主表'; CREATE TABLE `wh_stock_order_item` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL, `product_id` bigint NOT NULL, `quantity` int NOT NULL, `remark` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出入库单明细表';这里最关键的约束是wh_stock表的uk_wh_product唯一键,它保证同一个仓库里同一件商品只有一行库存记录,后面做并发扣减时才能用这一行的行锁。仓库表、商品表都设计成独立的码表,方便以后扩展仓库维度和商品属性,不要图省事把仓库名称直接塞到库存表里。出入库单单独拆主表和明细表,是为了让一张单支持多种商品,也方便以后按单号追溯历史。所有表都用InnoDB引擎,因为InnoDB支持事务和行级锁,这也是MySQL在Web企业系统里比MyISAM更适合的原因。
2.2 为什么库存表要单独建,不能只在流水里算余额
有一种设计是只保留出入库明细,需要当前库存时用SUM(quantity)聚合。这在数据量小的时候没毛病,但有两个隐患:一是明细表只要被更新或删改,历史对账就乱了;二是每次查库存都要全表扫描聚合,秒级响应根本做不到。所以正确做法是冗余一个wh_stock表,每次出入库事务里同时插入流水明细并更新库存余额,MySQL事务保证这两步要么都成功要么都失败。locked_quantity字段用来处理先锁定后出库的场景,比如订单审核占用库存、最终发货时再扣减可用量,这样可以避免超卖。
2.3 MySQL排序和分页:用复合索引避免Using filesort
库存列表最常见的查询是按仓库过滤后按“最后更新时间”倒序分页。如果不建索引,MySQL会先把匹配到的所有行放进排序缓冲区,数据量大时就会走磁盘临时表,翻页越来越慢。看一下这个常见的分页SQL:
SELECT s.id, w.name AS warehouse_name, p.name AS product_name, s.quantity, s.last_updated FROM wh_stock s JOIN wh_warehouse w ON s.warehouse_id = w.id JOIN wb_product p ON s.product_id = p.id WHERE s.warehouse_id = 1 ORDER BY s.last_updated DESC LIMIT 0, 20;要让它走索引,需要给wh_stock建一个复合索引。从MySQL的索引选择性来看,WHERE先过滤仓库,ORDER BY再排时间,所以索引顺序应该把仓库ID放前面:
ALTER TABLE `wh_stock` ADD KEY `idx_wh_lastupdated` (`warehouse_id`, `last_updated`);这时用EXPLAIN看到的Extra列就不会再有Using filesort。注意INNER JOIN查出来的字段也必须从索引覆盖或回表,如果查询结果中需要商品名称,索引里没有这些列,MySQL会回表取数据,但仍能保证排序不走临时表。这个细节经常被忽略,很多人看到排序慢就加一个单列索引,发现没效果,其实就是索引顺序写反了。
2.4 用MySQL存储过程生成测试数据,造一万个商品再压测
开发环境验证分页和并发,手工插数据效率太低。我习惯写一个存储过程快速生成测试商品,这样后续调试LIMIT、索引和并发才能真正压出问题。
DELIMITER $$ CREATE PROCEDURE `sp_gen_test_products`(IN p_count INT) BEGIN DECLARE i INT DEFAULT 1; WHILE i <= p_count DO INSERT INTO wb_product(`sku_code`, `name`, `spec`, `unit`) VALUES(CONCAT('SKU', LPAD(i, 8, '0')), CONCAT('测试商品', i), '标准规格', '件'); SET i = i + 1; END WHILE; END $$ DELIMITER ;调用时传入数量即可:CALL sp_gen_test_products(10000);。这个存储过程用了WHILE循环和LPAD保证SKU定长,目的是让数据看起来更像真实编号。要注意的是,单线程循环插入1万条可能要几十秒,如果想更快,可以把自动提交关闭,或者改用INSERT ... SELECT批量造数。存储过程适合一次性初始化,不适合频繁调用,真正的业务数据写入还是应该走Java事务接口。
3. Java后端实现入库出库:事务、行级锁与并发扣减
这一章是Java开发者最容易产生误解的地方。很多人把Web系统当成增删改查,但在仓库系统里,“扣减库存”这一步必须和“写流水”放进同一个事务,并且要在并发场景下锁住库存行。如果只是先查库存再写数据库,两个浏览器同时出库同一件商品,最后的库存很可能是负数。这里用一个Spring Boot + MyBatis的最小工程来说明,从依赖配置到Service代码,把事务和锁的关系讲清楚。
3.1 用Spring Boot + MyBatis搭建Web工程的最小配置
先看pom.xml里的核心依赖。仓库管理系统属于典型的企业级Web开发,Spring Boot + MyBatis 是最常见的组合,尤其适合中小团队。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies>Spring Boot 2.7.18是2.x比较稳定的版本,MyBatis starter 2.3.2能和它正常搭配。MySQL连接器8.0.x支持MySQL 5.7和8.0,如果公司的库还是5.7,这个版本也能兼容。再配一个application.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/wms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: root hikari: maximum-pool-size: 20 minimum-idle: 5 max-lifetime: 1800000 connection-timeout: 3000 thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.wms.entity连接串里useSSL=false是开发环境方便调试,生产环境如果数据库有SSL证书,应该开启并配套把证书配置到位。allowPublicKeyRetrieval=true是MySQL 8.0连接时偶尔需要用的参数,否则可能报Public Key Retrieval is not allowed。HikariCP的最大连接数20对绝大多数中小仓库系统足够,max-lifetime设成30分钟,是要让它比MySQL服务端的wait_timeout短,防止空闲连接被服务端切断后客户端还不知道。
3.2 入库出库核心逻辑:用SELECT FOR UPDATE锁住库存行
先写服务层的核心方法。这里以出库为例,入库方法逻辑类似,把decrease换成increase即可。
@Service public class StockService { @Autowired private StockMapper stockMapper; @Autowired private StockOrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public void outbound(Long warehouseId, Long productId, int quantity) { if (quantity <= 0) { throw new BusinessException("数量必须为正整数"); } // 1. 锁定库存行,阻止并发事务同时读到同一份库存 Stock stock = stockMapper.selectForUpdate(warehouseId, productId); if (stock == null) { throw new BusinessException("库存不存在"); } int available = stock.getQuantity() - stock.getLockedQuantity(); if (available < quantity) { throw new BusinessException("可用库存不足"); } // 2. 扣减库存余额 int updated = stockMapper.decrease(stock.getId(), quantity); if (updated != 1) { throw new BusinessException("库存扣减失败,请重试"); } // 3. 生成出库单据 StockOrder order = StockOrder.create(warehouseId, productId, quantity); orderMapper.insert(order); } }对应的Mapper XML是这段SQL,注意FOR UPDATE是InnoDB行锁的关键:
<select id="selectForUpdate" resultType="Stock"> SELECT id, warehouse_id, product_id, quantity, locked_quantity, version FROM wh_stock WHERE warehouse_id = #{warehouseId} AND product_id = #{productId} FOR UPDATE </select> <update id="decrease"> UPDATE wh_stock SET quantity = quantity - #{quantity} WHERE id = #{id} AND quantity - locked_quantity >= #{quantity} </update>这样的设计里,selectForUpdate会把命中uk_wh_product唯一索引的记录锁住,MySQL事务处理机制会阻塞其他事务对该行同样使用FOR UPDATE的请求。等到当前事务提交,锁才释放,后面的请求拿到的就是已经更新的库存值。UPDATE语句里再次加条件quantity - locked_quantity >= #{quantity}是第二道防线,即使锁意外没生效,数据库层面也不会把库存改成负数。
这里要特别说明,FOR UPDATE必须放在@Transactional事务方法里才有效,因为锁只存在于事务周期内。如果去掉@Transactional,SELECT FOR UPDATE执行完锁就释放,后面两个线程依然会并发改写库存。rollbackFor = Exception.class也很关键,Spring默认只回滚RuntimeException,如果业务里抛的是受检异常,不加这个参数事务不会回滚。
3.3 MySQL事务处理与隔离级别:REPEATABLE READ够用,锁等待超时要设短
MySQL的默认隔离级别是REPEATABLE READ,对仓库系统这种“先查询余额再更新”的场景,配合FOR UPDATE这种当前读,可以避免幻读和不可重复读,不用大动干戈改成SERIALIZABLE。很多Java面试题里会问事务隔离级别,实际落地时真正要调的是锁等待时长。两个事务同时更新同一行库存时,后到的线程会等待,如果等待时间太长,前端就一直转圈,而且连接池会被占满。可以通过数据库参数控制:
mysql -uroot -p -e "SET GLOBAL innodb_lock_wait_timeout=10;"如果想让配置永久生效,需要写入my.cnf或my.ini的[mysqld]段落。这里的10秒是指一个事务等待行锁的最长秒数,超时会报Lock wait timeout exceeded。实际项目中,我会把HikariCP的connection-timeout设成3秒,让应用层更快失败,而不是堆积在高耗时的事务上。
4. Web端功能实现:分页查询、条件筛选与基于角色的行级权限
后端Service做完了,还需要一个能让仓库管理员在浏览器里操作的Web界面。这里用Thymeleaf做服务端渲染,不走前后端分离,原因是企业内部系统页面数量不多,服务端渲染维护成本最低。这一章讲三件事:库存列表怎么分页,行级权限怎么做到不同仓库的人只看自己的数据,以及表单提交为什么后端还要再做一次校验。
4.1 用Thymeleaf + Controller实现库存分页列表
Controller的核心是接收分页参数并调用Service查询,返回逻辑视图:
@Controller @RequestMapping("/stock") public class StockController { @Autowired private StockService stockService; @GetMapping("/list") public String list(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) Long warehouseId, @RequestParam(required = false) String productName, Model model) { PageResult<StockVO> page = stockService.queryPage( warehouseId, productName, pageNum, pageSize); model.addAttribute("page", page); return "stock/list"; } }Mapper里的分页SQL使用LIMIT #{offset}, #{limit},同时支持动态条件。注意offset的计算在Java代码里完成,即(pageNum - 1) * pageSize。
<select id="queryPage" resultType="StockVO"> SELECT s.id, w.name AS warehouse_name, p.name AS product_name, s.quantity, s.last_updated FROM wh_stock s JOIN wh_warehouse w ON s.warehouse_id = w.id JOIN wb_product p ON s.product_id = p.id <where> <if test="warehouseId != null"> AND s.warehouse_id = #{warehouseId} </if> <if test="productName != null and productName != ''"> AND p.name LIKE CONCAT('%', #{productName}, '%') </if> </where> ORDER BY s.last_updated DESC LIMIT #{offset}, #{limit} </select><where>标签是MyBatis里最常用的手段,它会自动去掉开头的AND,避免SQL拼接出错。这里有个MySQL排序相关的坑:如果warehouseId传入,前面建的idx_wh_lastupdated能同时完成过滤和排序;如果什么都不传,SQL只能全表扫描再排序。所以Web页面里建议强制选择一个仓库,或者在SQL层做一个分支,否则数据量大时列表页会非常卡。LIKE CONCAT('%', ...)这种模糊匹配扫的是全表,如果商品名称也参与性能瓶颈,可以考虑加全文索引,但一般几千种商品不需要过度优化。
4.2 用Java实现基于角色的行级权限:仓库管理员只能看自己的仓库
仓库系统天然有数据归属问题:华东仓的管理员不应该看到华南仓的库存,只有超级管理员才应该看到全部。这就是行级权限。常见的做法是给仓库表加manager_id字段,用户登录后把当前用户信息放到ThreadLocal,查询时自动拼条件。先看自定义注解和切面的写法:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { String tableAlias() default "s"; }然后定义一个切面,在进入Controller方法前把当前用户的仓库ID写入ThreadLocal,供后面的SQL访问。这个方案很轻量,不引入额外框架:
@Aspect @Component public class DataScopeAspect { @Around("@annotation(dataScope)") public Object around(ProceedingJoinPoint pjp, DataScope dataScope) throws Throwable { User current = SecurityUtils.getCurrentUser(); if (!current.isAdmin()) { DataScopeContext.setWarehouseId(current.getWarehouseId()); } try { return pjp.proceed(); } finally { DataScopeContext.clear(); } } }切面里拿到当前用户后,如果非管理员就把他的仓库ID放到一个上下文中。接下来有两种落地方式:一种是在Service方法里读取DataScopeContext,手动拼到查询条件;另一种是写一个MyBatis拦截器,在Executor执行前拦截SQL,自动拼上AND warehouse_id = ?。手动拼的方式更直观,也不容易被拦截器误伤其他SQL,适合权限规则简单的系统。
更简单的做法是直接在Controller方法里判断:
@GetMapping("/list") public String list(...) { User current = SecurityUtils.getCurrentUser(); if (!current.isAdmin()) { warehouseId = current.getWarehouseId(); } // 后续查询都用这个warehouseId }这种“强制覆盖参数”的方案,能保证即使前端恶意传了其他仓库ID也不会生效。注意行级权限Java实现时,不能只在页面隐藏按钮或者前端判断,因为HTTP请求可以被绕过,后端必须带上权限判断逻辑。
4.3 Web表单提交:前端校验只是体验,后端必须重复拦截
出库表单长这样:
<form id="outboundForm" action="/stock/outbound" method="post"> <input type="hidden" name="warehouseId" value="1"> <input type="hidden" name="productId" value="1"> <input type="number" name="quantity" id="quantity" min="1" required> <button type="submit">出库</button> </form> <script> document.getElementById('outboundForm').addEventListener('submit', function (e) { var qty = document.getElementById('quantity').value; if (!qty || parseInt(qty, 10) <= 0) { alert('数量必须大于0'); e.preventDefault(); } }); </script>这段JavaScript只能拦掉最粗心的操作。用户完全可以用浏览器开发者工具改掉表单限制,甚至直接发一个POST请求,模拟器测试时也会绕过前端。所以Controller里要重复校验:
@PostMapping("/outbound") public String outbound(@RequestParam Long warehouseId, @RequestParam Long productId, @RequestParam Integer quantity, Model model) { if (quantity == null || quantity <= 0) { throw new BusinessException("数量必须为正整数"); } stockService.outbound(warehouseId, productId, quantity); return "redirect:/stock/list"; }这里如果后端抛出异常,我习惯用一个全局异常处理器统一捕获BusinessException,返回错误页面或者JSON。Web项目里前端校验负责用户友好提示,后端校验负责数据安全,两者缺一不可。
5. 台账对不上、负数库存、连接超时:5个高频坑与排查记录
这一章把仓库管理系统上线后最常遇到的几个真实问题拿出来复盘。每一条都按“现象 → 原因 → 解决”的顺序写,方便你在现场照着排查。
5.1 并发出库出现负库存:锁粒度或锁方式不对
现象:用JMeter压测,100个线程同时出库同一件库存为100的商品,跑完发现库存变成-20,明细里却有120条出库成功记录。
原因:出库Service里没有使用SELECT ... FOR UPDATE,而是先SELECT查余额,再UPDATE扣减。两个线程同时读到余额为50,各自判断够用,然后都扣掉20,最终余额就是10而不是应该的10?实际上多次重复导致负数。另一种情况是锁加错了对象,比如先锁商品表的product_id,结果不同仓库之间也互相阻塞,甚至死锁。
解决:按3.2的写法,在事务内使用selectForUpdate锁定wh_stock中的那唯一一行,并且UPDATE语句里再带上quantity - locked_quantity >= #{quantity}条件。这样即使锁出了意外,数据库也不会把库存更新为负数。这里要检查@Transactional是否真的生效,别让自调用问题把事务弄失效。
5.2 @Transactional自调用导致事务失效
现象:在同一个类里,方法A调用方法B,方法B上有@Transactional。B抛了异常,但数据库里流水和库存还是被写进去了,没有回滚。
原因:Spring事务基于AOP代理,只有外部调用才会走进代理逻辑。同一个类内部方法之间的调用走的是当前对象引用,不经过Spring的代理对象,所以@Transactional完全没生效。这个坑在Java面试题里也经常出现,实际线上最容易误伤。
解决:把事务方法拆到另一个@Service里,或者注入自身代理。我习惯用拆Service的方式,更透明。比如把outbound放到StockService,然后在StockFacade里调用它:
@Service public class StockFacade { @Autowired private StockService stockService; public void outboundWithAudit(Long warehouseId, Long productId, int quantity) { // 先写审计,再调用事务方法 stockService.outbound(warehouseId, productId, quantity); } }这样StockService.outbound是被外部对象调用的,事务能正常开启。
5.3 MySQL连接超时:wait_timeout与HikariCP参数冲突
现象:系统隔了一晚没人访问,第二天早上第一个打开页面的人要等好几秒,后台日志报Communications link failure或者Connection is not available, request timed out。
原因:MySQL服务端默认wait_timeout是28800秒(8小时),如果连接池里的连接空闲超过这个时间,服务端会主动断开。但HikariCP不知道连接已经被断了,下一次请求从连接池拿到的是失效连接,直到连接池发现并重建连接。
解决:把HikariCP的max-lifetime调成比MySQL的wait_timeout小,比如1800000毫秒(30分钟),并且在配置里加上连接测试策略。我之前用的配置是:
spring: datasource: hikari: max-lifetime: 1800000 validation-timeout: 3000 connection-test-query: SELECT 1如果生产环境的MySQL浪好像改不了wait_timeout,就把max-lifetime再调短到15分钟。这里的经验是:连接池参数必须和数据库端的超时时间配套,不能只改一边。
5.4 分页查询越翻越慢:LIMIT深翻页的经典毛病
现象:库存列表第一页响应50毫秒,翻到第100页时涨到3秒,而且越往后越慢。
原因:LIMIT 2000, 20这种写法,MySQL要扫描前2000行,然后丢掉前1980行,只返回最后的20行。表里数据越多,扫描行数越多,即使有索引也要顺着索引一直走。
解决:深翻页优化用延迟关联,先查出主键范围,再回原表取完整行:
SELECT s.* FROM wh_stock s INNER JOIN ( SELECT id FROM wh_stock ORDER BY id LIMIT 10000, 20 ) tmp ON s.id = tmp.id这样内层查询走覆盖索引只需要返回主键,外层再按主键回表,避免了大范围的随机I/O。也可以改成“游标分页”:前端记住上一页最后一条记录的ID,下一页查询WHERE id > lastId ORDER BY id LIMIT 20。这种方案尤其适合仓库单据列表,翻页深度可控。
5.5 重复提交生成两张入库单:唯一索引 + 捕获DuplicateKeyException
现象:用户出库时双击提交,或者前端网络超时后又点了一次,数据库里生成了两条相同单号的出库单,库存被扣了两次。
原因:前端按钮防抖只能防正常人,不能防请求重试。用户可能用浏览器开发者工具看到真正的请求URL,再手动提交一次,或者因为网络闪断客户端自动重发。
解决:在单据主表wh_stock_order的order_no字段上加唯一索引,也就是2.1里已经建好的uk_order_no。后端写入时正常INSERT,如果遇到唯一索引冲突,Spring会把DuplicateKeyException抛出来:
try { orderMapper.insert(order); } catch (DuplicateKeyException e) { throw new BusinessException("该单据号已存在,请勿重复提交"); }这样即使两个请求同时进来,数据库也只会允许一个插入成功,另一个被唯一索引挡住。注意异常捕获要精确,不要捕成Exception,否则会掩盖真正的SQL错误。
6. 从单机到多实例:扣库存的进阶策略与压测验证
当这套系统要从单体应用扩展成多台服务器部署时,原本的SELECT ... FOR UPDATE方案会遇到新问题。行锁在MySQL端仍然有效,但应用层的连接池会变成瓶颈,大量并发请求排队等待锁释放,吞吐量上不去;更极端的情况下,多个应用实例各自持有本地锁,会出现跨实例的库存超卖。针对这种情况,我会在热销商品和高并发扣减接口上引入Redis原子操作作为前置拦截。
底层的Lua脚本是保证原子性的关键。把扣减逻辑写成一段脚本,Redis执行脚本时不会被其他客户端命令插入:
if redis.call('get', 'stock:' .. KEYS[1]) >= tonumber(ARGV[1]) then redis.call('decrby', 'stock:' .. KEYS[1], ARGV[1]) return 1 else return 0 endJava端调用这段脚本时,把仓库ID和商品ID拼成Redis key,例如stock:1:10001,然后传入扣减数量。返回1表示扣减成功,返回0表示库存不足。实际单据流水还是写MySQL,Redis只负责准确的库存预占,这种“Redis拦截+MySQL落账”的模式能扛住比单库大得多的并发。但要注意,Redis宕机会导致库存数据丢失,所以不能把Redis当成唯一数据源,正确思路是让Redis挡第一波请求,MySQL做最终对账,当天业务结束后以MySQL流水重算Redis的初始值。
验证这套方案是否可靠,可以用JMeter开200个线程循环打到出库接口,然后在压测后执行一个核对脚本:统计MySQL里的出库成功次数,检查库存余额是否等于初始库存减去成功出库总量。如果发现负数,就说明锁或脚本有问题,需要回去看事务和Lua判断条件。我自己在这个环节翻过车——单机版用FOR UPDATE压测500并发没问题,部署到四台应用服务后忘了把扣减入口改成Redis,结果超卖数据直接发到客户那里。后来才明白,单机锁和数据库行锁从来都不是分布式环境的后悔药。希望这个从表结构到并发验证的完整思路,能帮你在做仓库系统时少走这一段弯路。
本文还有配套的精品资源,点击获取