
简介这是一套基于Spring BootJavaMavenMySQLMyBatis实现的企业仓库存储管理系统采用B/S结构适合Java Web课程设计或毕设参考。系统覆盖客户信息、仓库信息、产品信息、用户权限、入库/出库记录、库存管理和系统日志等核心模块可帮助初学者理解业务逻辑与前后端交互流程。资源共221个文件包含42个Java源码、28个HTML页面、24个JS脚本、9个XML配置及SQL脚本、CSS样式文件等压缩包大小约6.95MB另附75个GIF操作演示图便于直观了解界面效果与功能操作。目前已有568人学习下载适合需要完整项目方案、数据库设计及界面参考的开发者可直接导入IDE运行并在此基础上扩展功能。1. 基于JavaMySQL的仓库管理系统到底解决什么问题仓库管理员还在用Excel记出入库月底一核对才发现库存是负数这种情况在中小企业里太常见了。基于JavaMySQL实现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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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会先把匹配到的所有行放进排序缓冲区数据量大时就会走磁盘临时表翻页越来越慢。看一下这个常见的分页SQLSELECT 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency /dependenciesSpring Boot 2.7.18是2.x比较稳定的版本MyBatis starter 2.3.2能和它正常搭配。MySQL连接器8.0.x支持MySQL 5.7和8.0如果公司的库还是5.7这个版本也能兼容。再配一个application.ymlspring: datasource: url: jdbc:mysql://localhost:3306/wms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue 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连接串里useSSLfalse是开发环境方便调试生产环境如果数据库有SSL证书应该开启并配套把证书配置到位。allowPublicKeyRetrievaltrue是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 idselectForUpdate resultTypeStock 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 iddecrease 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事务方法里才有效因为锁只存在于事务周期内。如果去掉TransactionalSELECT 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_timeout10;如果想让配置永久生效需要写入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) { PageResultStockVO 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 idqueryPage resultTypeStockVO 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 testwarehouseId ! null AND s.warehouse_id #{warehouseId} /if if testproductName ! null and productName ! AND p.name LIKE CONCAT(%, #{productName}, %) /if /where ORDER BY s.last_updated DESC LIMIT #{offset}, #{limit} /selectwhere标签是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 idoutboundForm action/stock/outbound methodpost input typehidden namewarehouseId value1 input typehidden nameproductId value1 input typenumber namequantity idquantity min1 required button typesubmit出库/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表示库存不足。实际单据流水还是写MySQLRedis只负责准确的库存预占这种“Redis拦截MySQL落账”的模式能扛住比单库大得多的并发。但要注意Redis宕机会导致库存数据丢失所以不能把Redis当成唯一数据源正确思路是让Redis挡第一波请求MySQL做最终对账当天业务结束后以MySQL流水重算Redis的初始值。验证这套方案是否可靠可以用JMeter开200个线程循环打到出库接口然后在压测后执行一个核对脚本统计MySQL里的出库成功次数检查库存余额是否等于初始库存减去成功出库总量。如果发现负数就说明锁或脚本有问题需要回去看事务和Lua判断条件。我自己在这个环节翻过车——单机版用FOR UPDATE压测500并发没问题部署到四台应用服务后忘了把扣减入口改成Redis结果超卖数据直接发到客户那里。后来才明白单机锁和数据库行锁从来都不是分布式环境的后悔药。希望这个从表结构到并发验证的完整思路能帮你在做仓库系统时少走这一段弯路。本文还有配套的精品资源点击获取