资讯动态

从数据建模到并发扣减:Java进销存系统拆解

发布时间:2026/9/12 19:45:36 来源:尧图企业网站定制
简介面向Java开发者的商店库存管理系统项目资源聚焦商品、仓库与进销存一体化管理适合正在做课程设计、毕业设计或希望参考企业级进销存业务实现的读者。系统完整覆盖商品入库出库、分类上下架、库存预警、仓库入出库审批、采购与销售订单联动等典型模块并融入Spring Boot、MyBatis/JPA、MySQL、Redis、Spring Security等主流技术栈可帮助读者理解微服务架构下的模块拆分、消息解耦与安全认证优化思路。压缩包整体5.38MB便于快速获取。目前已有130人学习此资源能够为多模块管理系统开发提供整体性参考也适合在此基础上进行二次扩展落地为更完整的进销存解决方案。1. 拆这个 Java 进销存项目前先想清楚它解决什么问题很多小型门店和电商仓库还在用 Excel 管库存盘点靠人肉数数入库出库全凭记忆最怕促销做一半发现爆款断货。这套基于 Java 的商店库存管理系统把商品管理、仓库库存、进销存流程收拢到一个工程里解决的就是这件小事。它不像 ERP 那么重但覆盖了商品上下架、分类维护、入库验收、出库审批、库存预警和采购销售订单的闭环。适合两类人一是正在做 Java 课程设计或毕业设计的学生二是想用 SSM 或 Spring Boot 练手、把数据建模和事务并发真正跑通的从业者。下文不吹架构直接按我拆这类项目的顺序把表结构、业务代码、部署方式和最容易翻车的并发扣减讲明白。2. 商品与库存的数据建模是这套系统的地基2.1 为什么商品和库存必须拆成两张表先看常见错误很多初版代码喜欢把“商品名称、数量、仓库位置”全放一张product表里。看起来方便但只要商品有多个仓库或者需要查看历史库存快照这张表就被迫重复记商品信息改价格要带出一堆冗余行。规范做法是把“商品主数据”和“库存事实”分开。商品主数据存不变或低频变化的信息商品编号、名称、分类、条码、零售价、上下架状态。库存表只关心“某个 SKU 在某个仓库有多少可用和冻结数量”。这样拆的好处有三个多仓库存天然支持库存盘点时不需要锁商品表商品资料的维护和库存流水无关后续扩展批次、效期管理也方便。这套项目里用的就是典型的主数据 事实表模型表名一般叫product和warehouse_stock。2.2 核心 DDL商品表、库存表、出入库流水表我按 Spring Boot MyBatis MySQL 的常见写法给出一版核心建表语句字段以实用为主删掉了过度设计部分CREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, sku_code VARCHAR(32) NOT NULL COMMENT SKU编码, name VARCHAR(128) NOT NULL COMMENT 商品名称, category_id BIGINT DEFAULT NULL COMMENT 分类ID树形结构, price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 零售价, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sku (sku_code), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品主数据表; CREATE TABLE warehouse_stock ( id BIGINT NOT NULL AUTO_INCREMENT, sku_code VARCHAR(32) NOT NULL, warehouse_id BIGINT NOT NULL, available_qty INT NOT NULL DEFAULT 0 COMMENT 可用库存, frozen_qty INT NOT NULL DEFAULT 0 COMMENT 冻结库存, warning_line INT NOT NULL DEFAULT 10 COMMENT 预警阈值, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_sku_warehouse (sku_code, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT仓库库存表; CREATE TABLE stock_change_record ( id BIGINT NOT NULL AUTO_INCREMENT, sku_code VARCHAR(32) 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, ref_order_no VARCHAR(64) DEFAULT NULL COMMENT 来源单号, create_by VARCHAR(32) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_sku (sku_code), KEY idx_ref (ref_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;先解释商品表sku_code设唯一索引是为了让订单、库存、流水都通过这段字符串关联避免id被业务到处引用后失去可读性。price用DECIMAL(10,2)不要用FLOAT否则金额对账会出经典误差。状态字段用TINYINT而不是VARCHAR省空间、查询快也为后续接枚举类留余量。再看库存表available_qty和frozen_qty分开是这套系统的关键。库存被下单后先冻结出库完成再扣减能避免“下单同时出库”导致超卖。version字段是给乐观锁用的后面章节会单独讲。最后一张流水表记录了每次变动的方向和前后快照盘点、审核、追责全都依赖它。字段注释里已经写了正负号表达方向比如入库传正值出库传负值这样统计时直接SUM(change_qty)就能还原当前库存。2.3 出入库单据状态机设计商品表、库存表解决了“现在有多少货”的问题但业务上还需要回答“这批货是申请了还没到还是已经验收上架”。所以这套项目里还有一类单据表比如采购入库单和销售出库单。它们通常带一个状态字段我用状态机表达更严谨状态含义可流转到0 待提交草稿允许修改1 待审核0 撤回1 待审核已提交等待审批2 已通过3 已驳回2 已通过审核通过等待执行4 已完成3 已驳回退回修改0 待提交4 已完成库存更新完毕无把状态流转做成表而不是放任业务代码到处改status好处是后续接工作流引擎时可以直接映射节点。哪怕不用 Activiti、Flowable 这类重型组件只用一个状态枚举类也能把非法跳转拦截在入口处。常见做法是写一个StateMachine工具类传入当前状态和目标状态返回是否允许流转也可以在数据库层面加检查约束但维护成本高不如在 Service 层统一校验。3. 用 Spring Boot MyBatis 把库存和商品管理跑起来3.1 工程分层与核心依赖这套系统的后端我建议按最简单的分层来拆Controller 负责参数接收和响应包装Service 写业务规则Mapper 只做数据库操作。不需要一上来就上 DDD那是给复杂中台用的会拖慢课程设计和毕设的进度。依赖方面Spring Boot 项目在pom.xml里引入这些基本上就够dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency为什么用 MyBatis 而不是 JPA因为这项目涉及库存流水、多表联查MyBatis 的 SQL 写起来直观你能清楚看到每条 SQL 的索引命中情况。MyBatis 的 Mapper 接口是 JDK 动态代理实现的这也是 Java 面试里常考的点框架在运行时给接口生成代理对象调用方法时解析Select、Insert注解或 XML 里的 SQL。如果项目里准备用 JPA也可以用但库存扣减这类高频更新最好走自定义Modifying查询避免自动更新整行字段。3.2 商品分类树形结构与递归商品分类是典型的树形结构父子关系通过parent_id字段表达。前端如果是 Vue.js 项目后端一般返回一棵树前端如果是 Thymeleaf 服务端渲染也可以在后端直接拼好层级。拆这种结构的常见写法是先用一条 SQL 查出全部分类再在内存里组装成树避免在循环里反复查库public ListCategoryVO buildCategoryTree(ListCategory allCategories) { MapLong, CategoryVO map new HashMap(); for (Category c : allCategories) { CategoryVO vo new CategoryVO(); vo.setId(c.getId()); vo.setParentId(c.getParentId()); vo.setName(c.getName()); vo.setChildren(new ArrayList()); map.put(c.getId(), vo); } ListCategoryVO roots new ArrayList(); for (CategoryVO vo : map.values()) { if (vo.getParentId() null || vo.getParentId() 0L) { roots.add(vo); } else { CategoryVO parent map.get(vo.getParentId()); if (parent ! null) { parent.getChildren().add(vo); } } } return roots; }这段代码用Map做了一次哈希索引把时间复杂度压到 O(n)。第一层循环先把所有节点放入 Map第二层循环根据parentId找父节点找不到就当作根节点。要注意的是如果数据量超过几千条这种全量查库再组装的方式依然可以因为分类表的体量通常不大真要支持无限深度的场景就必须引入path字段或嵌套集合模型否则递归深度会撑爆调用栈。3.3 入库出库与库存预警的 Service 实现库存核心动作无非入库、出库、冻结、解冻。我一般把改动库存的方法收敛到一个StockService里避免散落在各个订单 Service 中。下面是入库和出库的简化版实现Service public class StockService { Autowired private WarehouseStockMapper stockMapper; Autowired private StockChangeRecordMapper recordMapper; Transactional(rollbackFor Exception.class) public void inbound(String skuCode, Long warehouseId, int qty, String refOrderNo) { WarehouseStock stock stockMapper.findBySkuAndWarehouse(skuCode, warehouseId); if (stock null) { stock createStock(skuCode, warehouseId); } int before stock.getAvailableQty(); stock.setAvailableQty(before qty); stockMapper.updateById(stock); recordMapper.insert(skuCode, 1, qty, before, stock.getAvailableQty(), refOrderNo); checkWarning(stock); } Transactional(rollbackFor Exception.class) public void outbound(String skuCode, Long warehouseId, int qty, String refOrderNo) { WarehouseStock stock stockMapper.findBySkuAndWarehouse(skuCode, warehouseId); int before stock.getAvailableQty(); if (before qty) { throw new BusinessException(库存不足剩余 before); } stock.setAvailableQty(before - qty); stockMapper.updateById(stock); recordMapper.insert(skuCode, 2, -qty, before, stock.getAvailableQty(), refOrderNo); checkWarning(stock); } private void checkWarning(WarehouseStock stock) { if (stock.getAvailableQty() stock.getWarningLine()) { // 实际项目中在这里发送通知常见做法是写入预警表或推送MQ消息 System.out.println(警告SKU stock.getSkuCode() 库存低于预警线); } } }这段代码有两个点值得细看。第一Transactional(rollbackFor Exception.class)必须显式声明Spring 默认只在运行时异常时回滚如果你的业务异常继承的是Exception不加这个参数会导致库存已扣但流水未写对不上账。第二checkWarning放在事务内只是打日志或写预警记录如果将来要推送消息给外部系统千万不要把 HTTP 调用放在这里否则外部接口变慢会让库存事务一直占用数据库连接。正确做法是把预警事件发到消息队列比如 RabbitMQ 或 Kafka由消费端异步处理这也是进销存系统解耦采购、销售和仓库模块的常规手段。业务代码里查库存、改库存、写流水要在一个事务里完成但发送通知、生成报表、更新搜索索引这些下游动作都不应该出现在事务中。项目规模小的时候直接用 Spring 的Async也能凑合但要注意线程池隔离避免Async方法被同一个事务线程吞掉异常。4. 进销存闭环与并发扣减不能只做减法4.1 从采购订单到入库的完整链路进销存管理的核心不是“卖货”而是采购、销售、库存三者的步调一致。这套系统里链路通常是这样的运营创建采购订单对应供应商发货仓库收货后在系统中根据采购单生成入库单并标记“已通过”审核通过后调用inbound方法更新库存之后销售订单从库存冻结数量开始扣减扣减完成后再把销售单推到出库单流程。整个过程里的商品、供应商、订单、库存流水全部通过sku_code或订单号串起来。4.2 乐观锁与悲观锁的选择前一小节outbound方法的写法在单线程场景没问题但真实门店或电商促销时两个线程可能同时读到available_qty 10各自减 8最后库存变成 2 而不是 6 以下于是超卖。解决超卖有两条常见路线方案原理优点缺点悲观锁SELECT ... FOR UPDATE查询时锁住库存行其他事务等待实现简单强一致并发低容易死锁乐观锁版本号更新时比较 version读多写少时性能好失败后需要重试写冲突多时效率差条件更新WHERE直接限制库存足量才更新原子无锁等待返回值影响业务判断我一般优先选择“条件更新”作为兜底只有确实需要读后写多步运算时才用悲观锁。下面这段 SQL 就是条件更新的典型写法UPDATE warehouse_stock SET available_qty available_qty - #{qty}, version version 1 WHERE sku_code #{skuCode} AND warehouse_id #{warehouseId} AND available_qty #{qty};这个语句把“检查库存 qty”和“扣减库存”合并成一次数据库原子操作InnoDB 的行锁保证同一行不会同时被两个事务更新。Mapper 方法返回int类型等于 1 说明扣减成功等于 0 说明库存不足或行不存在。使用这个方法时outbound里的先select再update可以合并成一条 SQL事务时间更短超卖概率几乎为零。4.3 事务边界与消息队列解耦前面采购、销售、库存之间用同步调用在中小项目里没问题但一旦订单量上来每次下单都要扣库存、记账、通知仓库响应时间会越来越长。进销存系统的常规优化方向是拆分同步和异步链路同步链路只负责校验、预处理和生成订单库存扣减放到消息队列消费端。比如订单服务把“订单创建完成”事件发到 RabbitMQ库存服务监听该事件后执行扣减扣减失败再走补偿流程。这个方案看起来很优雅但注意别掉进另一个坑不要把事务消息和本地事务混在一起。如果本地事务先提交再发 MQ消息发送失败会导致订单说创建了但库存没扣如果先发 MQ 再提交事务消费端可能读到未提交的脏数据。可靠做法是使用本地消息表在同一个数据库事务里写入业务数据和消息记录然后由定时任务扫表发送消息确认发送成功后再删除消息记录。虽然在代码上多一张表但比引入分布式事务框架简单得多也更容易在项目答辩时讲清楚。5. 部署、排错与把库存扣减做成面试技能点5.1 JDK 环境变量配置与应用启动这套系统拿到手后第一步不是跑代码而是确认 JDK 环境。很多同学卡在“java 安装好了但找不到命令”多半是环境变量没配。Windows 下需要在系统变量里新建JAVA_HOME指向 JDK 安装目录比如C:\Program Files\Java\jdk1.8.0_291然后把%JAVA_HOME%\bin追加到Path环境变量中。配置完成后打开新终端执行java -version javac -version mvn -v三个命令都能输出版本号说明 Java 环境没有问题。IDE 方面IDEA 打开项目后要确认两块Maven 配置里的 JDK 版本是否和项目pom.xml一致项目的application.yml里的 MySQL 密码是否是本地库的密码。很多人把项目导入后直接点运行看到红色报错就傻眼其实 80% 都是数据库连接失败。5.2 常见启动失败与乱码处理报错现象原因解决方式Access denied for user rootlocalhost数据库账号或密码错误修改application.yml中的username和passwordPort 8080 was already in use端口被占用换端口或在application.yml里改server.portUnknown database shop数据库没有创建执行CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4;返回 JSON 中文变成问号数据库连接字符集未指定JDBC URL 加characterEncodingutf8除了这些最容易迷惑人的是控制台日志里 SQL 能查出中文但页面显示乱码。这个问题十有八九出在 MySQL 建库时没指定utf8mb4或者前端页面本身的contentType没有声明 UTF-8。检查路径先看数据库表字符集再看 JDBC 连接参数最后看后端响应头。字符集问题在课程设计答辩时几乎是必问项能主动说出排查顺序反而是加分项。5.3 面试高频追问库存扣减如何防止超卖库存超卖是这类系统面试时绕不开的题目也是你在项目描述里最能体现深度的位置。面试官通常会从你写的outbound方法切入追问“两个请求同时进来怎么办”。你可以按下面这条线回答。先把第一版的逻辑讲清楚读取库存、判断是否充足、更新库存这在并发下会产生丢失更新问题。然后给出优化后的 SQL也就是上面那条UPDATE ... WHERE available_qty #{qty}。说明这条 SQL 如何通过行锁保证原子性。必要时补充一条对立方案如果交易链路中要先冻结库存再扣减那么可以改成UPDATE warehouse_stock SET frozen_qty frozen_qty #{qty} WHERE sku_code #{skuCode} AND available_qty #{qty};这个方案把“冻结”和“扣减”拆成两个动作冻结时校验可用库存出库完成后再把冻结数转成已出库数。好处是下单到发货之间的时间窗口其他订单不会看到这部分库存坏处是多一个事务状态流转更复杂。最后再补一句“如果真的遇到极端并发可以引入 Redis 的DECR作为前置计数但最终库存一致性还是要靠数据库兜底”这就把项目经验从增删改查拉到了架构取舍层面。这套 Java 进销存系统能拎出来讲的东西很多但最值钱的部分不是页面写得有多花哨而是商品、库存、流水、订单背后的数据建模以及扣减库存时对并发和事务的敏感度。把这两个点讲透哪怕项目本身只是课程设计也能让对方觉得你理解的不只是代码而是这套业务真正的风险点在哪里。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价