ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Java的仓库管理系统实战:表结构、并发扣减与盘点校正

基于Java的仓库管理系统实战:表结构、并发扣减与盘点校正 简介面向Java初学者与毕业设计选题学生这是一套基于Java的仓库管理系统完整项目资料包覆盖商品、价格、供应商、用户认证等核心业务模块并运用MVC设计模式、Spring框架、MyBatis持久层、Servlet/JSP技术清晰展示了从数据库设计到Web交互的整套开发流程。压缩包共104个文件核心内容包括14个Java源码、73个编译后的class文件、SQL数据库脚本、jar依赖包以及项目配置与说明文档整体仅5.51MB下载和部署都很快捷。目前已有1067人学习下载适合作为毕业设计参考、课程设计素材或Java Web进阶练手项目。资料内包含可运行的业务代码与数据库设计脚本既可以帮助开发者理解仓库管理的分层架构和增删改查实现也可以用于答辩演示或求职项目展示是实战性较强的学习材料。1. 基于java的仓库管理系统别从零闭门造车先搞清这五件事做后端这行几乎每个人都接过大大小小的“仓库管理系统”需求用 Java 落地的居多。表面看是一套商品和出入库单的 CRUD真正上线后才发现账实不符、出库超卖、盘点差异这些坑一个比一个难缠。我不打算按教科书顺序给你堆 Spring Boot 和 MyBatis 的配置而是站在怎么把库存数量算准、怎么抗住多人同时出库、怎么让月底盘点不吵架的角度把基于 Java 的仓库管理系统拆成选型、表结构、并发扣减、定时任务、排错五项。适合正在做毕设、准备面试项目或者要给小企业搭内部系统的同学照着落到代码层面。2. 技术选型与表结构用Spring Boot MyBatis把仓库模型先立住2.1 为什么选Spring Boot MyBatisJava环境变量先说清做仓库管理系统我见过用纯 Servlet 写的也见过用 JPA 写的但真实项目里出现频率最高的组合还是 Spring Boot MyBatis。Spring Boot 负责把数据源、事务、定时任务这些基础能力自动装配好MyBatis 让你能直接写 SQL库存这类业务涉及多表 join、行级锁、条件更新SQL 比对象关系映射更可控。另外 MyBatis 是半自动 ORMJava 后端团队接手成本低面试题里常问的“#{} 和 ${} 区别”“Mapper 接口怎么绑定 SQL”都来自这套东西项目经验也更好讲。动手前先把 Java 环境确认好。如果你还停留在 JDK 8建议至少升到 JDK 11 或 17Spring Boot 2.7 还能兼容 JDK 8但 Spring Boot 3 已经强制要求 JDK 17。仓库管理系统常用到的时间类型LocalDateTime、容器List、Stream 排序在新 JDK 上都要自然得多。这里提一个很多新手翻车的地方java环境变量配置详细教程里总说配JAVA_HOME和PATH但忘了把%JAVA_HOME%\bin放在 PATH 靠前的位置。如果你机器上装了多个 JDK命令行里java -version显示的版本和 IDE 里不一致多半就是 PATH 顺序问题后面 Maven 编译、Spring Boot 启动都会跟着报错。2.2 核心表设计商品、库存、出入库单、盘点单仓库管理系统最怕“数量对不上”根因往往不是代码是表结构里没有库存流水。只有一张库存表改数的时候你根本不知道这个数是怎么来的。我一般会至少建这六张表仓库表、商品表、库存表、库存流水表、出入库单表、盘点单表。其中库存表只存当前结余流水表负责记录每一次变动这样才能追溯“账实差异”发生在哪一单。-- 商品表 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_code VARCHAR(64) NOT NULL UNIQUE COMMENT 商品编码, name VARCHAR(128) NOT NULL, spec VARCHAR(255) COMMENT 规格型号, unit VARCHAR(16) COMMENT 计量单位, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品; -- 仓库表 CREATE TABLE warehouse ( id BIGINT PRIMARY KEY AUTO_INCREMENT, warehouse_code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT仓库; -- 库存表唯一键是仓库商品不能出现重复记录 CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, warehouse_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT 当前库存数量, locked_quantity DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT 冻结/锁定数量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_wh_product (warehouse_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表; -- 库存流水表只增不改用于追溯 CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, warehouse_id BIGINT NOT NULL, product_id BIGINT NOT NULL, change_type VARCHAR(32) NOT NULL COMMENT IN/OUT/CHECK/CORRECT, quantity_change DECIMAL(18,3) NOT NULL COMMENT 正数入库负数出库, before_quantity DECIMAL(18,3) NOT NULL, after_quantity DECIMAL(18,3) NOT NULL, biz_no VARCHAR(128) COMMENT 业务单号, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_product_time (product_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水;这段 SQL 里有两个容易被忽略的设计库存表用(warehouse_id, product_id)做唯一键从数据库层面杜绝同一个商品在同一个仓库出现多行库存否则你的 SUM 永远算不准。库存流水表只做 insert不做 update 和 delete任何库存变动都留痕这是后面做盘点差异分析的基础。数量字段我用DECIMAL(18,3)因为很多仓库的出入库是带小数单位的比如钢材按吨、布匹按米Java 端的BigDecimal正好对应。2.3 代码分层与Java数据类型映射实体类怎么设计分层没有悬念Controller 接请求Service 写业务Mapper 做数据库访问。仓库管理系统里我建议在 Service 层多放一个“库存事务服务”专门处理入库、出库、盘点的库存变动避免每个业务 Service 自己写扣减 SQL导致同一套逻辑散落多处。这个类名我通常叫StockTxnService所有库存写操作都从这里过。实体类的数据类型映射是个高频面试题也是写仓库代码时的基本功。MySQL 的DATETIME对应 Java 的LocalDateTimeDECIMAL对应BigDecimalTINYINT对应Boolean或Integer千万不是拿Date和Double硬接。下面是库存实体和查询条件对象的简化版public class Stock { private Long id; private Long warehouseId; private Long productId; private BigDecimal quantity; private BigDecimal lockedQuantity; private Integer version; private LocalDateTime updatedAt; // getter/setter 省略 } public class StockQuery { private Long warehouseId; private ListLong productIds; // Java 容器类型做批量查询 private BigDecimal minQuantity; // 库存预警下限 public boolean isValid() { return warehouseId ! null || (productIds ! null !productIds.isEmpty()); } }这段代码里StockQuery这类查询对象我一般不会写成Map传参。用 Java 容器和实体类而不是 Map能让 Mapper 的可读性高很多也方便在 Service 层做参数校验。另外实体类里凡是参与计算的字段都用BigDecimal不要用double不然 0.1 加 0.2 的浮点误差会让库存差出一分钱月底对账的时候你就是背锅的那个。3. 库存核心业务实现入库、出库、盘点与并发扣减3.1 出库扣库存用乐观锁把“超卖”挡在SQL层面仓库管理系统的核心不是 CRUD是并发。两个人同时出库最后一件商品如果还是先select再update两次都读到库存 1两个单子都成功库存就变成 -1。这里常见做法是乐观锁更新的时候带version条件影响行数为 0 就说明被别人改过了需要重试。我在 Mapper 里会这样写update iddeductStockByVersion UPDATE stock SET quantity quantity - #{changeQuantity}, version version 1 WHERE warehouse_id #{warehouseId} AND product_id #{productId} AND quantity - #{changeQuantity} 0 AND version #{version} /updateMapper 接口方法int deductStockByVersion(Param(warehouseId) Long warehouseId, Param(productId) Long productId, Param(changeQuantity) BigDecimal changeQuantity, Param(version) Integer version);这段 SQL 的关键在于把校验放进了数据库的原子操作里AND quantity - #{changeQuantity} 0保证扣减后不为负AND version #{version}保证只有当读取后没人改过时才扣减。如果返回 0说明库存不足或版本冲突Service 层可以重新读取库存重试两次不行就提示“库存不足或操作过于频繁”。出库这种短操作不需要悲观锁行级锁SELECT ... FOR UPDATE会拉长锁释放时间吞吐反而上不去。为什么不用synchronized它只能锁单机进程。仓库系统部署多实例时每台机器各有一把锁照样超卖。如果你用的是单机部署且并发量极低synchronized能用但不能作为通用方案。我一般会把乐观锁 SQL 和重试逻辑封装在StockTxnService其他业务模块调它保证只有一种扣减方式。3.2 事务边界与数据一致性Transactional 为什么会失效扣库存不只更新 stock 表还要写流水表、更新出入库单状态。这三步必须是一个事务要么全成功要么全回滚。Spring Boot 里的做法是在 Service 方法上加Transactional但很多人不知道它默认只在RuntimeException时回滚而且同类自调用会失效。Service public class OutboundService { private final StockTxnService stockTxnService; public OutboundService(StockTxnService stockTxnService) { this.stockTxnService stockTxnService; } Transactional(rollbackFor Exception.class) public void outbound(OutboundCommand cmd) { // 步骤1校验并扣减库存 stockTxnService.deduct(cmd.getWarehouseId(), cmd.getProductId(), cmd.getQuantity(), cmd.getOrderNo()); // 步骤2记录库存流水 stockTxnService.recordFlow(cmd); // 步骤3更新出库单状态 outboundMapper.updateStatus(cmd.getOrderNo(), DONE); } }这里我把deduct和recordFlow放到StockTxnService再由OutboundService调用而不是在同一个类里写三个方法互相调。原因是Transactional基于 Spring AOP 代理同类内部this.deduct(...)不会走代理事务注解就失效了。这是很多线上只扣了库存没写流水的原因。另外要记住rollbackFor Exception.class否则抛出受检异常时事务不回滚数据就会出现一半成功一半失败。还有一个常见误用在事务方法里调用Thread.sleep()长时间占着数据库连接或者在里面做远程接口调用。仓库系统的出库如果对接了物流接口我一般把远程调用挪到事务提交后发事件避免数据库连接池被耗尽。事务边界越大锁时间越长并发吞吐越差。你可以用一个事务事件监听器TransactionalEventListener(phase AFTER_COMMIT)来发送 MQ 或调用接口这样数据库锁很快释放。3.3 盘点差异处理冻结库存和强制校正的单据逻辑盘点时最怕边盘边发货。逻辑是盘点开始先把库存“冻结”成locked_quantity出库只能扣非冻结部分盘完后按“盘点数 - 账面数”做差异调整。这里常见的做法是建一张盘点单明细里存账面数、实盘数、差异数审核后生成一条校正流水。Transactional(rollbackFor Exception.class) public void approveCheck(CheckOrder order, BigDecimal actualQuantity) { // 读取账面库存加行锁防止审核期间并发修改 Stock stock stockMapper.selectForUpdate(order.getWarehouseId(), order.getProductId()); BigDecimal bookQuantity stock.getQuantity(); BigDecimal diff actualQuantity.subtract(bookQuantity); if (!Objects.equals(diff, BigDecimal.ZERO)) { // 校正库存并把差异写入流水 stockMapper.adjustQuantity( order.getWarehouseId(), order.getProductId(), diff, CHECK_ order.getOrderNo()); stockFlowMapper.insertFlow( order.getWarehouseId(), order.getProductId(), CHECK, diff, bookQuantity, bookQuantity.add(diff), order.getOrderNo()); } }这段代码用了selectForUpdate因为盘点审核那一刻必须让所有针对这个商品的写操作排队等待否则校正数和实际变动会交错。注意先冻结后调整的时序盘点录入期间商品还在出库所以校正时要用“冻结时的账面数”而不是“审核时的实时数”来计算差异否则每次对不上。我一般会把冻结数量和解冻数量单独记流水月底好解释为什么某天库存出现过负数。盘点单状态至少要有“盘点中、待审核、已完成、已作废”状态机不复杂但能防止业务方误操作。4. 定时任务与报表库存预警、库龄统计的Java实现4.1 用Spring的定时任务框架做库存上下限预警库存不是入库后就不用管了。小仓库常见的需求是库存低于安全下限要提醒补货高于上限要提醒停采。这个用 Java 里的定时任务框架做起来很快Spring Boot 自带Scheduled一个方法就能跑。Component public class StockAlertTask { private final StockMapper stockMapper; private final AlertNotifyService notifyService; Scheduled(cron 0 0 8 * * ?) // 每天早上8点执行 public void checkStockAlert() { ListStockWarning warnings stockMapper.selectBelowSafety(); for (StockWarning warning : warnings) { notifyService.sendMessage( 库存预警, 商品[ warning.getProductName() ]剩余 warning.getQuantity() 低于下限 warning.getMinQuantity()); } } }cron 0 0 8 * * ?的含义是秒、分、时分别为 0、0、8*表示每天?表示不指定星期几。很多人在这个字段上栽跟头因为 Spring 的 cron 是六段不是 Linux 的五段。如果只想工作日跑就写成0 0 8 ? * MON-FRI。执行频率不建议低于 1 分钟因为定时任务跑太频繁会反复扫描库存表量大了以后数据库压力会很大。还有一个细节Scheduled默认是单线程执行的。如果你在系统里挂了两个定时任务一个跑慢 SQL另一个就会被阻塞延迟。我一般会在配置里调大定时任务线程池spring.task.scheduling.pool.size5。多实例部署时要小心重复执行的问题这个放到避坑章说。4.2 报表统计按仓库、商品维度的聚合SQL和Java对象仓库管理系统的报表一般逃不开这几个库存汇总、出入库流水统计、库龄分析。最忌讳的做法是 Java 代码查出所有明细再 for 循环累加数据量一大就慢。正确姿势是把聚合压到 SQL 里Java 这边只接收结果集。SELECT p.id AS product_id, p.name AS product_name, SUM(CASE WHEN f.change_type IN THEN f.quantity_change ELSE 0 END) AS in_quantity, SUM(CASE WHEN f.change_type OUT THEN -f.quantity_change ELSE 0 END) AS out_quantity FROM stock_flow f JOIN product p ON p.id f.product_id WHERE f.created_at #{beginTime} AND f.created_at #{endTime} GROUP BY p.id, p.name ORDER BY out_quantity DESC LIMIT 100;这段 SQL 用CASE WHEN把同一张流水表里的入库和出库分别聚合再用ORDER BY out_quantity DESC找出库最多的前 100 个商品。注意出库的quantity_change在流水表里是负数所以这里用-f.quantity_change转成正数否则排序会反掉。如果报表要带仓库维度GROUP BY里再加上warehouse_id就行。报表查询的数据量往往很大我会给created_at和change_type建联合索引否则定时聚合会拖垮主库。对应 MyBatis 的查询结果我一般定义一个专门的报表 DTO而不是复用实体类。比如StockReportVO包含productId、productName、inQuantity、outQuantity四个字段用BigDecimal承接聚合结果。报表查询不建议放进实体 Mapper 里单独开一个ReportMapperSQL 好维护也避免字段冲突。4.3 Java排序在库存排行榜里的用武之地报表接口除了 SQL 排序有些场景需要在内存里排序。比如从多个仓库系统同步过来的库存数据合并后按“可用库存 总库存 - 冻结库存”排一个补货优先级。这种时候 Java 的Comparator就派上用场了注意别用Collections.sort那种老写法直接用List.sort配合 lambda。ListStockPriority list stockService.loadMergedStocks(); list.sort(Comparator .comparing(StockPriority::getAvailableQuantity) .reversed() .thenComparing(StockPriority::getProductCode));这段代码先按可用库存降序可用库存相同再按商品编码升序。comparing的reversed()很容易踩坑如果你在comparing后面直接调.reversed()它是把整个排序规则反转而不是只反转前一个字段。要按字段混合升降序正确写法是像上面这样把reversed()放在方法引用后。另外Comparator里不要用double字段做比较用BigDecimal时注意Comparator.comparing要求字段实现ComparableBigDecimal本身支持。排序这块看起来简单但并发场景下如果列表是从多个Map合并来的要小心null。可用库存为空时Comparator会抛NullPointerException所以我一般会在排序前先过滤掉空对象或者用Comparator.nullsLast(...)包一层。这个习惯在报表接口里能省很多线上告警。5. 避坑/常见问题让仓库管理系统翻车的5个真实场景下面这几条没有一条是我从书上看来的全是仓库管理系统上线后被业务方骂出来的。每一个都对应真实的现象、原因和解决路径建议你写代码之前先看一遍。5.1 现象控制台报 “Error: Could not find or load main class”java -version 也不对原因安装多个 JDK 后java环境变量配置时没有把新版本的JAVA_HOME更新或者 PATH 里旧版本路径排在前面Maven 用的 JDK 和 IDE 不一致。我见过最离谱的是同事把CLASSPATH也配错了导致SpringApplication.run直接起不来。解决命令行执行where javaWindows或which javaLinux看第一行路径是不是你期望的 JDK 安装目录然后统一JAVA_HOME和 PATH删除旧的java.exe残留。如果 IDE 里能跑但命令行不能跑重点检查pom.xml里java.version和实际 JDK 是否一致。这里有个笨办法把%JAVA_HOME%\bin放到 PATH 最前面然后用一个新终端验证很多玄学问题其实是终端缓存了旧环境变量。5.2 现象MyBatis 查询商品返回的实体类字段全是 null但数据库里明明有值原因数据库列名是sku_code这种下划线风格Java 属性是skuCode。MyBatis 默认不会把sku_code自动映射成skuCode除非开启驼峰映射。解决在application.yml里加上mybatis.configuration.map-underscore-to-camel-case: true。如果单独建了resultMap要检查column和property是否一一对应数量字段DECIMAL对应BigDecimal而不是Double类型对不上时 MyBatis 会静默置 null 或者抛异常。这是仓库系统里最常见的“查出来是空”问题先别怀疑 SQL先看映射配置。另外 Java 数据类型和 MySQL 类型不对应时错误提示往往不明显比如TINYINT映射成Boolean后你拿它判断状态时容易出现所有值都是 true。5.3 现象库存扣成负数月底对账发现出库总量大于入库总量原因没有做并发控制或者用了先查再改的代码。最常见的写法是if (stock.getQuantity() 0) { doUpdate(...) }这段代码在 Tomcat 多线程下必然有间隙两个请求都通过了 if 判断然后各自扣减。解决按第 3.1 节的乐观锁 SQL把判断和更新放在一条 update 语句里靠quantity - #{changeQuantity} 0保证不超卖。如果业务允许超卖再退单那就不叫超卖需要在流程上设计“锁定库存”。还有一种情况是扣减库存时没有同步更新version导致并发下 version 一直不变乐观锁形同虚设。写代码后一定检查SET version version 1是否在 SQL 里。5.4 现象每天早上 8 点的定时预警重复发了三遍原因服务部署了多个实例每个实例的Scheduled都会触发。仓库系统哪怕只部署两台机器这个坑也一定会遇到。解决一种做法是引入分布式锁比如用数据库里的一张锁表执行任务前INSERT一条唯一 key插入成功就执行完成后删除另一种做法是只把定时任务调度到单独实例上但这样那台机器挂了预警就停。我更倾向用 Redis 的SETNX做过期锁锁的过期时间要大于任务最大执行时间防止任务还没跑完锁就过期另一个实例抢着又跑一遍。如果你不想引 Redis也可以基于数据库唯一键实现任务开始前INSERT INTO task_lock(task_name, lock_until)使用INSERT ... ON DUPLICATE KEY UPDATE判断是否拿到锁。这个方案的最大坑是时间要统一用数据库时间或者加Scheduled(zone Asia/Shanghai)否则不同服务器时区差一点就白做了。5.5 现象用BeanUtils.copyProperties复制库存对象后改副本把正本的库存也改了原因org.springframework.beans.BeanUtils是浅拷贝Stock里的BigDecimal是不可变对象所以安全但如果实体里嵌套了ListStockDetail或者引用类型的商品对象复制后两个对象共享同一个子对象改副本就影响正本。解决仓库系统的核心实体需要拷贝时我会在类里手动实现一个copyFrom方法显式创建新的BigDecimal和容器对象做 Java 对象深度拷贝。不要偷懒用浅拷贝也别上来就引一个重量级工具库存对象字段通常就十个左右手写最可靠。做法很简单新 new 一个对象把每个字段逐个 set遇到List就new ArrayList(source.getList())如果列表元素也是可变对象还要对元素深层复制。这个坑对库存数据尤其危险因为一个库存对象常常同时被多个线程持有浅拷贝会让你在不知情的情况下把别人的库存改了。6. 进阶验证把库存数量和报表跑出可信度的几个习惯仓库管理系统能不能交付不看你用了多新的框架而是你拿什么证明库存数是准的。我现在的习惯是每张库存变动单都留流水然后定期跑一个对账任务把stock_flow按商品和仓库分组求和再和stock表当前值比对不一致就报警。这个任务可以接在定时任务后面也可以手动在页面触发是验证系统可信度的底线。我在第一个仓库项目里吃过亏没有写流水表只靠 session 里的库存快照做页面展示结果月底盘点和手工账差了几百件最后只能连数据库手工改改完下个月又对不上。后来我给自己定了几条规矩第一库存金额和数量一律用BigDecimal前端传数字进来也先转字符串再转换防止浮点精度损失第二所有库存变更都过同一个StockTxnService禁止在别的地方直接写UPDATE stock第三每次发布前用脚本并发跑一百次出库确认库存不为负再看流水和库存是否一致第四页面权限至少做到仓库维度员工只能看他所在仓库的库存数据这个用DataScope注解或 MyBatis 拦截器都能实现别把全部仓库的库存暴露给临时工账号。最后说一个改错的教训。有一次上线后我发现某商品的初始库存录错了那已经是下班时间我图省事直接UPDATE stock SET quantity 500 WHERE id 123没有补流水也没更新 version。第二天这个商品出库乐观锁版本号还是旧的第一次扣减成功第二次并发请求因为 version 不匹配直接失败。排查半天才想起来是手工改库破坏了版本号。从那之后我再也不直接动库存表了就算要校正也会走盘点审核流程保证流水和版本号都完整。仓库系统这种业务慢一点、繁琐一点都比账实不符好修。希望这份基于 Java 的仓库管理系统方案能帮你少踩几个我踩过的坑。本文还有配套的精品资源点击获取
返回列表