☰
库存管理系统 JAVA+SQL 实战:从建表到并发扣减的完整设计
2026/10/11 10:14:22 网站建设 项目流程

简介:这是一套面向Java初学者与课程设计者的库存管理系统完整源码,采用Java作为开发语言、SQL数据库负责数据存储,帮助理解业务逻辑与数据持久化的结合方式。资源包共173个文件,约1005KB,以java源文件为主,辅以jpg界面截图、jar依赖包、config配置项以及mdf、ldf数据库文件,覆盖从代码到数据库的完整结构。已有1166人学习下载,适合作为毕业设计、实训项目或自学练手参考。内容围绕库存查询、入库出库、供应商与商品信息管理等核心模块展开,涉及Swing或JavaFX界面构建、多线程并发处理、CRUD操作、JOIN关联查询以及ORM映射思路,并包含数据模型设计与索引优化等实践要点。读者可据此梳理分层结构、理解Java与SQL的协作方式,并对照配置与数据库文件快速还原运行环境,为后续功能扩展与性能调优提供可复用的基础。

1. 库存管理系统 JAVA+SQL:为什么“能跑”和“敢用”之间隔着一整套设计

很多开发者第一次做库存管理系统,都是被“进销存”三个字骗进来的。以为无非是商品表、入库表、出库表三张表,加几个增删改查接口,一周就能收工。真到上线那天才发现:两个人同时出同一批货,库存扣成了负数;盘点时账面数量和实物对不上,翻遍日志找不到是哪一笔写错的;老板要一张“近30天周转率”报表,SQL 写了三小时还没跑出结果。库存管理系统 JAVA+SQL 这个组合之所以长期挂在搜索热榜上,不是因为技术新,而是因为它是一个“业务正确性”远重于“功能数量”的典型场景。它适合两类人:一类是刚学完 SSM 或 Spring Boot 想找个真实项目练手的开发者,另一类是被 Excel 库存表折磨到崩溃、想自己搭一套轻量系统的小团队技术负责人。这篇文章不讲空泛的架构图,只讲怎么用 JAVA 做服务层、用 SQL 做数据层,把库存这件事从“能跑”做到“敢用”。

2. 表结构定生死:库存管理系统的 SQL 建模与字段取舍

库存系统的绝大多数 bug,根因不在 Java 代码,而在建表时少想了一步。这一章先把数据模型立住,后面所有的并发控制、报表查询、对账逻辑才有落脚点。

2.1 商品表、库存表、流水表:三张核心表的职责边界

常见做法是把库存数量直接放在商品表里,一个stock字段搞定。小规模能用,但只要出现“同一商品多仓库”或“需要追溯每一次变动”,这个设计就会崩。我一般会拆成三张表:

  • product:商品基础信息,只放名称、规格、单位、条码,不放数量。
  • inventory:商品与仓库的库存快照,记录当前可用量、锁定量。
  • stock_record:每一次入库、出库、盘点的流水,只增不改。

这样拆的好处是:库存数量永远可以从流水重算,流水表就是库存系统的“黑匣子”,对不上账时能倒查。

-- 商品表:只存静态属性 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_code VARCHAR(64) NOT NULL UNIQUE COMMENT '商品编码,业务唯一键', product_name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL DEFAULT '件', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存快照表:商品+仓库维度唯一 CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, available_qty INT NOT NULL DEFAULT 0 COMMENT '可用库存', locked_qty INT NOT NULL DEFAULT 0 COMMENT '已锁定未出库', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_product_warehouse (product_id, warehouse_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存流水表:只增不改,所有变动留痕 CREATE TABLE stock_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_type TINYINT NOT NULL COMMENT '1入库 2出库 3锁定 4解锁 5盘点', change_qty INT NOT NULL COMMENT '正数增加,负数减少', before_qty INT NOT NULL, after_qty INT NOT NULL, biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,用于幂等', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_product_time (product_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:inventory表上的UNIQUE KEY是防止同一商品同一仓库出现两条记录,这是很多新手建表时漏掉的,一旦漏掉,后续UPDATE会随机更新一条,库存直接错乱。stock_record里的before_qty和after_qty是排查问题时最值钱的两个字段,没有它们,你只能看到“变了”,看不到“从多少变到多少”。biz_no配合唯一索引可以做幂等,防止网络重试导致重复扣减。

参数说明:available_qty和locked_qty分开是必须的。下单未付款时锁库存,付款后才真正扣减。如果只有一个stock字段,你无法区分“被订单占用”和“实际可卖”。version字段留给乐观锁,下一章会用到。

2.2 用 SQL 约束兜住业务规则,而不是全靠 Java 判断

很多团队把所有校验写在 Service 层,SQL 层毫无约束。结果是:定时任务、数据修复脚本、其他服务直连数据库时,全部绕过校验,脏数据就是这么来的。我的习惯是能在数据库层表达的规则,绝不只写在 Java 里。

-- 库存不能为负:用 CHECK 约束(MySQL 8.0.16+ 支持) ALTER TABLE inventory ADD CONSTRAINT chk_available_non_negative CHECK (available_qty >= 0), ADD CONSTRAINT chk_locked_non_negative CHECK (locked_qty >= 0); -- 流水表业务单号+类型唯一,保证同一笔业务只记一次 ALTER TABLE stock_record ADD UNIQUE KEY uk_biz_type (biz_no, change_type, product_id);

逻辑说明:CHECK约束是最后一道防线。Java 层判断available_qty >= qty之后,到真正UPDATE之间有时间窗口,并发下仍可能扣成负数。加上数据库约束后,即使代码有漏洞,最坏结果是抛异常回滚,而不是写出负库存。uk_biz_type保证同一张出库单对同一商品只扣一次,重试接口不会重复扣减。

参数说明:MySQL 8.0.16 之前CHECK是被忽略的,如果用的是 5.7,需要用触发器或应用层加锁替代。这一点在选版本时就要确认,别写完才发现约束没生效。

2.3 索引怎么建:让库存查询和流水对账都不全表扫描

库存系统有两类高频查询:一是按商品查当前库存,二是按时间段查某商品的流水。索引建错,数据量上到十万级就开始卡。

-- 按商品+仓库查库存,走唯一索引 uk_product_warehouse,天然覆盖 -- 按商品查流水并按时间倒序,建联合索引 ALTER TABLE stock_record ADD KEY idx_product_created (product_id, created_at DESC); -- 按业务单号反查流水(对账常用) ALTER TABLE stock_record ADD KEY idx_biz_no (biz_no);

逻辑说明:idx_product_created让“查某商品最近30天流水”直接走索引范围扫描,避免全表排序。idx_biz_no用于对账时根据单号拉出所有变动。注意不要给change_type单独建索引,区分度太低,优化器通常不会选。

参数说明:created_at DESC在 MySQL 8.0 支持降序索引,5.7 会忽略 DESC 但仍可用。如果流水表预计超过千万行,考虑按月分表,但那是另一个话题,初期不必过度设计。

3. JAVA 服务层怎么写:扣减库存的并发控制与事务边界

表建好了,接下来是 Java 层。库存系统最核心的一段代码就是“扣减库存”,写错这一处,前面所有设计都白费。这一章把并发场景拆开讲。

3.1 乐观锁扣减:UPDATE 带 version 条件的最小实现

高并发下扣库存,常见做法有两种:悲观锁SELECT ... FOR UPDATE和乐观锁UPDATE ... WHERE version = ?。前者锁行时间长,后者重试成本高但吞吐更好。库存场景我一般优先乐观锁,因为冲突概率通常可控。

@Service public class InventoryService { @Autowired private InventoryMapper inventoryMapper; /** * 扣减可用库存,乐观锁实现 * @param productId 商品ID * @param warehouseId 仓库ID * @param qty 扣减数量,正数 * @return 是否成功 */ public boolean deductStock(Long productId, Long warehouseId, int qty) { // 最多重试3次,避免无限循环 for (int i = 0; i < 3; i++) { Inventory inv = inventoryMapper.selectByProductAndWarehouse(productId, warehouseId); if (inv == null || inv.getAvailableQty() < qty) { return false; // 库存不足,直接失败 } int affected = inventoryMapper.deductWithVersion( productId, warehouseId, qty, inv.getVersion()); if (affected > 0) { return true; // 扣减成功 } // affected == 0 说明版本被其他线程改了,重试 } return false; } }

对应的 Mapper SQL:

<update id="deductWithVersion"> UPDATE inventory SET available_qty = available_qty - #{qty}, version = version + 1 WHERE product_id = #{productId} AND warehouse_id = #{warehouseId} AND version = #{version} AND available_qty >= #{qty} </update>

逻辑说明:WHERE里同时带version和available_qty >= qty是关键。前者保证没有其他线程改过这条记录,后者是数据库层的兜底,防止在读取和更新之间库存被扣光。affected == 0有两种可能:版本变了,或者库存不够了。这里统一按重试处理,重试时重新读取会自然发现库存不足并返回 false。

参数说明:重试次数设 3 次是经验值。冲突率低于 10% 时,3 次重试成功率已经很高;如果冲突率超过 30%,说明热点商品太集中,应该考虑把库存拆成分段(如按仓库再分片)或改用队列串行化。qty必须是正数,扣减方向由 SQL 里的减号决定,不要传负数,否则逻辑会乱。

3.2 事务边界:扣库存、写流水、更新订单必须在一个事务里

扣库存成功但流水没写、或者订单状态没更新,是库存系统最典型的“半成功”故障。解决办法是把这三步放进同一个@Transactional方法。

@Transactional(rollbackFor = Exception.class) public void createOutboundOrder(OutboundRequest req) { // 1. 扣减库存 boolean deducted = deductStock(req.getProductId(), req.getWarehouseId(), req.getQty()); if (!deducted) { throw new BizException("库存不足"); } // 2. 写流水 stockRecordMapper.insert(buildRecord(req)); // 3. 更新订单状态 orderMapper.updateStatus(req.getOrderId(), OrderStatus.OUTBOUND); }

逻辑说明:rollbackFor = Exception.class确保任何异常都回滚,包括受检异常。默认 Spring 只回滚RuntimeException,如果中间抛了IOException之类,事务不会回滚,库存扣了但流水没写。这个坑我踩过,排查了半天。

参数说明:事务方法里不要做远程调用或发消息,否则事务时间被拉长,锁持有时间增加,并发能力下降。正确做法是用本地消息表或事务消息,把“发消息”变成事务内的一次 insert,再由后台任务投递。

3.3 幂等设计:同一笔出库请求重复提交怎么办

前端重复点击、网关重试、消息重复消费,都会导致同一笔出库请求到达两次。没有幂等,库存就被扣两次。

public void handleOutbound(String bizNo, Long productId, int qty) { // 先查流水表,该业务单号是否已处理 int count = stockRecordMapper.countByBizNo(bizNo, productId); if (count > 0) { return; // 已处理,直接返回 } // 未处理,走正常扣减流程 createOutboundOrder(...); }

逻辑说明:这里依赖第 2 章建的uk_biz_type唯一索引。即使两个线程同时通过count == 0检查,最终只有一个能插入成功,另一个会因唯一键冲突抛异常,捕获后当作“已处理”返回即可。这是“先查后插”加“唯一约束兜底”的标准组合。

参数说明:bizNo必须由上游生成且全局唯一,常见是订单号或“订单号+商品ID”。不要用时间戳或随机数,那样每次重试都是新单号,幂等失效。

4. 避坑与排查:库存系统上线后最容易翻车的 5 个场景

这一章全是血泪经验,每条都按“现象 → 原因 → 解决”写,遇到对应问题时可以直接对照。

4.1 库存扣成负数,日志里却找不到哪一笔扣的

现象:盘点时发现某商品库存为 -5,但流水表里所有记录加起来不等于 -5。

原因:早期版本没有before_qty和after_qty,只有change_qty。一旦有并发写入,流水顺序和实际执行顺序不一致,无法还原。更糟的是,如果某次扣减没写流水(事务边界错误),流水总和和库存快照永远对不上。

解决:流水表必须记录变动前后值,且扣减和写流水在同一事务。已经上线的系统,可以写一个对账脚本,用流水重算库存,和快照比对,差异记录人工核查。

4.2 乐观锁重试次数用完了,用户看到“系统繁忙”

现象:大促时部分用户下单失败,提示系统繁忙,但库存明明还有。

原因:热点商品冲突率过高,3 次重试不够。乐观锁在冲突激烈时退化成“不断重试”,吞吐反而下降。

解决:对热点商品改用悲观锁SELECT ... FOR UPDATE,或者把库存拆成多段(如 100 件拆成 10 段每段 10 件),扣减时随机选一段,降低单行冲突。另一种做法是引入 Redis 预扣减,数据库只做最终落账,但复杂度更高,初期不建议。

4.3 报表 SQL 跑几分钟,拖垮整个数据库

现象:运营点一下“库存周转率”,数据库 CPU 飙到 100%,其他接口全部超时。

原因:报表 SQL 直接查流水表做聚合,数据量大时全表扫描加排序,和业务查询抢资源。

解决:报表走单独的只读从库,或者预计算。常见做法是每天凌晨跑定时任务,把周转率算好写入report_daily表,前端查报表只读这张小表。如果必须实时,至少给报表查询加超时和限流。

4.4 盘点时锁全表,业务全部卡死

现象:盘点功能一执行,所有出入库接口都超时。

原因:盘点逻辑用了LOCK TABLES或者长事务,把整张inventory表锁住。

解决:盘点不要锁表,而是按商品分批处理,每批一个短事务。盘点期间允许出入库,用“盘点基准时间 + 期间流水”来修正结果。具体做法是:记录盘点开始时间点,盘点结束后,把该时间点之后的流水重新应用到盘点结果上。

4.5 数据库连接池被打满,报 “too many connections”

现象:高峰期接口大量报错,日志显示获取连接超时。

原因:慢 SQL 持有连接不释放,或者事务方法里做了远程调用,连接被长时间占用。

解决:先开慢查询日志找出耗时最长的 SQL,通常是缺索引或报表查询。然后检查事务方法,把远程调用、文件 IO 移出事务。连接池大小不是越大越好,一般设为CPU核数 * 2 + 磁盘数,配合合理的超时时间。

5. 从能用到好用:库存对账脚本与压测验证的具体做法

前面把系统搭起来了,但怎么证明它“敢用”?我的习惯是两件事:写一个对账脚本,能随时验证库存快照和流水是否一致;做一次并发压测,看扣减在冲突下是否还正确。

对账脚本的核心逻辑是用流水重算库存,和快照比对。下面是一个可以直接跑的 SQL 版本,适合数据量不大的场景:

-- 按商品+仓库汇总流水,得到理论库存 SELECT product_id, warehouse_id, SUM(change_qty) AS calc_qty FROM stock_record GROUP BY product_id, warehouse_id HAVING calc_qty <> 0; -- 与快照表比对,找出不一致的记录 SELECT i.product_id, i.warehouse_id, i.available_qty + i.locked_qty AS snapshot_qty, COALESCE(r.calc_qty, 0) AS record_qty FROM inventory i LEFT JOIN ( SELECT product_id, warehouse_id, SUM(change_qty) AS calc_qty FROM stock_record GROUP BY product_id, warehouse_id ) r ON i.product_id = r.product_id AND i.warehouse_id = r.warehouse_id WHERE i.available_qty + i.locked_qty <> COALESCE(r.calc_qty, 0);

逻辑说明:第一条 SQL 先看流水本身是否平衡(理论上所有变动加起来应该等于当前库存,但如果流水从中间开始记录,calc_qty不等于库存是正常的,所以这条更多是辅助)。第二条是真正的对账:快照的available_qty + locked_qty应该等于流水汇总。不一致的记录就是需要人工核查的。注意locked_qty也要算进去,因为锁定也是一次流水变动。

参数说明:如果流水表数据量很大,这个查询会很慢。生产环境建议按商品分批跑,或者用定时任务增量对账。对账频率不用太高,每天一次足够,重点是发现差异后能定位到具体单号。

压测验证更直接。用 JMeter 或 wrk 对扣减接口发并发请求,比如 100 个线程同时扣同一商品,初始库存 50,预期结果是 50 次成功、50 次失败,最终库存为 0,且流水正好 50 条。如果最终库存是负数,或者成功次数超过 50,说明并发控制有漏洞。这个测试我每次改完扣减逻辑都会跑一遍,比看代码可靠得多。

最后一个习惯:所有库存变动接口,入参里必须带业务单号,且日志里打印单号、商品、数量、变动前后值。出问题时,一条grep就能拉出完整链路。库存系统不怕出问题,怕的是出了问题查不到原因。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询