资讯动态

Spring Boot小型超市管理系统:库存扣减与事务处理实战解析

发布时间:2026/10/14 3:44:02 来源:尧图企业网站定制
简介针对区域性小型商超在供应链信息孤岛、动态决策支持不足等痛点该资源提供了一套基于SpringBoot与MySQL的完整管理系统设计与实现方案面向计算机相关专业毕业设计者、Java学习者及中小型超市信息化建设人员。压缩包内402个文件涵盖158个Java后端源码、58个Vue前端组件、30个JavaScript及HTML/CSS样式文件并配齐SQL数据库脚本与项目配置文档可满足从环境搭建、编码实现到系统部署的全流程参考。包体整体大小约13.18MB目录结构清晰便于按功能模块检索。当前已有147人学习下载资源结合商品分类、采购流程与销售数据分析等核心业务模块同时提供数据库脚本、技术文档及运行截图能有效帮助读者理解B/S架构下前后端交互逻辑降低自主开发与论文撰写难度。1. Spring Boot小型超市管理系统不用从零写但也不是拿来就能交花一晚上跑通一个 Spring Boot 小型超市管理系统听起来足够应付毕业设计了。但真正动手的人会发现问题商品、库存、销售、会员四件事看着各管各的连起来全是细节——库存到底在哪个环节扣、卖一笔送多少积分、收银中途失败要不要整体回滚。这套资源恰好是把这些环节跑通的完整后端代码、MySQL 数据库脚本和配套说明文档LW。它不是那种只把页面堆出来就收工的 Demo而是把业务表怎么建、事务放在哪一层、库存扣减怎么防超卖都交代清楚的落地项目适合正在做同类型选题、又不想在基础设施上耗时间的同学。2. 模块拆解与 MySQL 建表七张表守住商品、库存、销售、会员2.1 功能边界先划清哪些模块是你真正要写的打开这套代码先别急着跑先把功能边界认一遍。小型超市管理系统最核心的模块就四个商品管理、库存管理、收银销售、会员积分。外围再配一个后台登录和用户管理。这四个模块之间是有依赖顺序的。商品管理管的是“超市里有什么”库存管的是“每样商品还剩多少”销售管的是“今天卖了什么”会员管的是“卖给谁了、攒了多少分”。我拿到这套资源后的第一件事就是把这些功能从 Controller 层到 Service 层逐个对应到数据库表上确认没有“代码里明明写了库存却找不到对应字段”的情况。这套资源的后端技术栈很常规Spring Boot 2.x MyBatis MySQL 8.0前端是普通的 HTML/JS 静态页面没有引入复杂的前端框架。好处是你不用为环境折腾太久本机装了 JDK 1.8 和 MySQL 就能跑起来。2.2 七张核心表的字段设计与建表理由数据库设计决定业务代码好写还是难写。这套资源的表结构我只挑核心的表来说第一张是商品表CREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 商品ID, name VARCHAR(64) NOT NULL COMMENT 商品名称, category_id BIGINT NOT NULL COMMENT 商品分类ID, price DECIMAL(10,2) NOT NULL COMMENT 售价, stock INT NOT NULL DEFAULT 0 COMMENT 当前库存, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1上架 0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表;这里有几个字段值得细说。price用DECIMAL(10,2)而不是DOUBLE是为了避免金额精度丢失这一点在 Java 侧也要配合使用BigDecimal后面避坑章会专门讲。stock用INT如果超市规模不大完全够用。version这个字段是给乐观锁用的库存扣减时判断版本号防超卖的关键就在这里。再看销售相关表。销售分为单头和明细两张表这是典型的主从表设计CREATE TABLE sale_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 销售单ID, order_no VARCHAR(32) NOT NULL COMMENT 销售单号, member_id BIGINT DEFAULT NULL COMMENT 会员ID空为散客, total_amount DECIMAL(10,2) NOT NULL COMMENT 实收金额, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售单主表; CREATE TABLE sale_detail ( id BIGINT NOT NULL AUTO_INCREMENT, sale_id BIGINT NOT NULL COMMENT 关联销售单ID, product_id BIGINT NOT NULL COMMENT 商品ID, product_name VARCHAR(64) NOT NULL COMMENT 冗余商品名称, price DECIMAL(10,2) NOT NULL COMMENT 成交单价, quantity INT NOT NULL COMMENT 购买数量, subtotal DECIMAL(10,2) NOT NULL COMMENT 小计金额, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售明细表;product_name在明细表里冗余了一份这个设计初看违反范式但在实际超市系统里很实用。商品改了名字历史销售单里的商品名不会被连带修改查账的时候更稳。sale_order里的order_no加了唯一索引防止并发下单时生成重复订单号。会员表这块常见设计是会员基础信息 积分流水表。基础信息表存手机号、姓名、累计积分积分流水表记录每一笔积分的来源消费赠送、手动调整等。这套包里积分计算规则很简单消费 1 元积 1 分凑整计算。规则本身写在 Service 层想改成满 10 元积 1 分改一个除法参数就行。2.3 包结构与应用配置启动前要检查的三个参数拿到代码包先看包结构。常见分层是 controller / service / dao / entity / config 五层。这套资源里的代码结构也基本沿用这个套路好处是每一层职责清楚答辩时也好讲。# application.yml 核心配置片段 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/supermarket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai server: port: 8080 servlet: context-path: /supermarket启动前有三个地方容易翻车。第一driver-class-name必须是com.mysql.cj.jdbc.Driver这是 MySQL 8.0 的驱动类写成旧版的com.mysql.jdbc.Driver会直接报类找不到。第二serverTimezoneAsia/Shanghai不配数据库连接时会报时区错误这是 MySQL 8.0 之后的强制要求。第三context-path配了/supermarket那么所有接口的前缀都带/supermarket前端页面的请求路径也得跟着改不然后端能通、前端全 404。3. 商品管理与库存扣减超卖问题就靠一条 UPDATE 解决3.1 商品查询接口分页和条件筛选是答辩必问点商品管理模块里最常被问到的是分页查询怎么写的。这套资源用的是 MyBatis 手写 SQL分页参数通过PageHelper插件处理。Controller 层接收pageNum和pageSizeService 层调用查询方法返回分页结果集。GetMapping(/product/list) public ResultPageInfoProductVO list( RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId) { ProductQuery query new ProductQuery(); query.setPageNum(pageNum); query.setPageSize(pageSize); query.setKeyword(keyword); query.setCategoryId(categoryId); PageInfoProductVO page productService.pageQuery(query); return Result.success(page); }defaultValue 1和defaultValue 10这两个参数是给前端的兜底防止未传参时接口报错。keyword是模糊搜索商品名用的categoryId用于按分类过滤。这里我一般会让ProductQuery继承一个通用的分页基类把pageNum、pageSize放公共字段里代码更整洁。对应的 Mapper XML 里模糊查询的写法要注意防止 SQL 注入风险select idpageQuery resultTypecom.supermarket.entity.Product SELECT * FROM product where if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if AND status 1 /where ORDER BY id DESC /select用where标签代替手工拼接WHERE 11用CONCAT(%, #{keyword}, %)接受参数而不是直接拼进 SQL这两点都是面试和答辩中容易提到的加分项。3.2 库存扣减的正确姿势先查再改是万恶之源超市收银的场景是前端把购物车商品列表提交到后端后端逐件扣库存。很多初学者写库存扣减是三步走——先 SELECT 查库存再在 Java 里判断库存是否充足最后 UPDATE。这套代码最大的价值在于它用了更可靠的原子扣减。Transactional(rollbackFor Exception.class) public boolean deductStock(Long productId, int quantity) { if (quantity 0) { throw new IllegalArgumentException(购买数量必须大于0); } int rows productMapper.deductStock(productId, quantity); if (rows 0) { throw new RuntimeException(库存不足商品ID productId); } return true; }update iddeductStock UPDATE product SET stock stock - #{quantity}, version version 1 WHERE id #{id} AND stock #{quantity} /update逻辑说明这一步的关键在WHERE stock #{quantity}。MySQL 执行 UPDATE 时会先定位满足条件的行再把stock减去数量。如果库存不够影响行数是 0Java 侧拿到 0 就知道扣减失败直接抛异常让事务回滚。参数说明quantity是本次购买数量rows是数据库返回的影响行数这是判断成功与否的唯一依据。代码里不需要先 SELECT 再判断一次 UPDATE 既完成了“校验库存”又完成了“扣减库存”。这套逻辑即使两个请求同时来买同一件只剩 1 件的商品数据库的行锁也会让其中一个 UPDATE 等待等锁释放后另一个发现stock quantity影响行数为 0直接失败。这就是防超卖的底层机制。3.3 乐观锁字段 version 的用途和取舍商品表里的version字段在这套资源里承担的是乐观锁职责。有人会问上面已经用stock #{quantity}防超卖了还要 version 干什么答案是双保险。stock #{quantity}解决的是“库存数量不够”的问题version解决的是“基于旧版本修改导致的数据覆盖”问题。比如后台管理员编辑商品价格时读取到了旧的 version3另一个管理员同时改了价格使 version 变成 4前一个提交时 WHERE version3 匹配不到行更新就失败了。update idupdatePrice UPDATE product SET price #{price}, version version 1 WHERE id #{id} AND version #{version} /update使用乐观锁时Java 侧要处理更新失败的重试逻辑。常见做法是捕获影响行数为 0 的情况重新查询最新数据再让用户确认。这套代码里库存扣减用原子 UPDATE编辑场景用乐观锁两者搭配是成熟项目的常见设计答辩时能说清楚这一点比背十页 PPT 都管用。4. 收银结算与会员积分一个事务里藏着三个坑4.1 收银结算的完整链路单头、明细、库存、积分怎么串起来收银是超市系统的核心场景前端扫码或手动选中商品点击结账后后端要做四件事生成销售单主表记录、写入销售明细表、扣减库存、如果是会员则累计积分。这四件事必须在一个事务里完成否则可能出现“钱收了但库存没扣”或“库存扣了但积分没记”的脏数据。Transactional(rollbackFor Exception.class) public SaleResponse createSale(SaleRequest request) { // 1. 生成销售单主表 SaleOrder order new SaleOrder(); order.setOrderNo(generateOrderNo()); order.setMemberId(request.getMemberId()); order.setTotalAmount(request.getTotalAmount()); saleOrderMapper.insert(order); // 2. 循环写入销售明细同时扣减库存 for (SaleItemDTO item : request.getItems()) { SaleDetail detail new SaleDetail(); detail.setSaleId(order.getId()); detail.setProductId(item.getProductId()); detail.setProductName(item.getProductName()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); detail.setSubtotal(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); saleDetailMapper.insert(detail); // 库存扣减 int rows productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足 item.getProductName()); } } // 3. 处理会员积分 if (request.getMemberId() ! null) { memberService.addPoints(request.getMemberId(), request.getTotalAmount().intValue()); } SaleResponse response new SaleResponse(); response.setOrderNo(order.getOrderNo()); response.setTotalAmount(request.getTotalAmount()); return response; }逻辑说明方法从上到下依次执行任何一个环节抛异常Transactional会让前面已经执行的数据库操作全部回滚包括已插入的销售单主表和明细。注意rollbackFor Exception.class这个参数Spring 默认只对RuntimeException回滚如果业务方法里抛的是受检异常不加这个参数事务就不会回滚。参数说明SaleOrder是主表单对象SaleItemDTO是前端传入的购买项包含商品 ID、名称、单价、数量。totalAmount我用BigDecimal接收避免 double 转字符串出现多余小数位。generateOrderNo()是自定义的单号生成方法常见做法是yyyyMMddHHmmss 随机数。4.2 会员积分累计的幂等问题积分累计这里有个容易被忽略的坑。如果支付成功后网络抖动前端重试提交了同一个订单后端如果没有做幂等处理同一笔交易可能被插入两次销售单、扣两次库存、积两次分。这套资源里的做法是前端生成一个唯一的请求流水号后端在创建销售单前先查这个流水号是否已经存在存在就直接返回已有结果不再重复执行业务。// 幂等校验根据请求唯一流水号查询是否已处理 String requestNo request.getRequestNo(); if (requestNo ! null saleOrderMapper.countByRequestNo(requestNo) 0) { throw new DuplicateOrderException(重复提交的订单); }sale_order表里建议加一个request_no字段并设置唯一索引。数据库层面的唯一约束是最后一道防线比 Java 代码里的判断更可靠因为并发场景下两个请求可能同时通过 Java 层的校验但数据库索引会让其中一个插入直接失败。4.3 事务边界在哪Service 层还是 Controller 层很多初学 Spring Boot 的人会把Transactional加到 Controller 方法上这套资源的代码里事务都放在 Service 层这个选择是有道理的。Controller 层的职责是参数接收、参数校验、结果封装它不该关心数据库事务。事务放在 Service 层保证的是“业务逻辑完整性”。假设一个场景Controller 里先调用了订单 Service 的创建方法又调用了短信 Service 的发送通知方法事务放在 Controller 层会把短信发送也卷进同一个事务短信服务器响应慢的时候数据库连接会被长时间占用这是很典型的性能隐患。这套代码里 Service 层方法的粒度控制得比较清楚一个方法对应一个完整的业务操作内部调用多个 Mapper 方法。事务加在最外层 Service 实现类的方法上内部各 Mapper 操作共享同一个连接这一点在答辩时讲出来是明显的加分项。5. 常见问题与排查五次翻车记录照着改就能跑5.1 启动报错找不到驱动类或时区异常现象项目启动后控制台报ClassNotFoundException: com.mysql.jdbc.Driver或者报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。原因驱动类写成了 MySQL 5.x 时代的com.mysql.jdbc.Driver且连接串里没带serverTimezone参数。MySQL 8.0 之后驱动改为com.mysql.cj.jdbc.Driver时区参数必须显式指定。解决对照 2.3 节的application.yml改驱动类名和连接串加上serverTimezoneAsia/Shanghai。注意如果是用 Docker 装的 MySQLurl里的localhost要改成容器映射出来的 IP否则会报连接拒绝。5.2 金额计算出现 19.899999 这种奇怪结果现象前端页面显示总价 19.899999或者数据库里存的金额长了尾巴。原因Java 侧用了double类型做金额计算。0.1 0.2在二进制浮点里等于0.30000000000000004这是 IEEE 754 的先天缺陷不是超市系统特有的问题。解决Java 侧接收金额参数全部改用BigDecimal构造函数用字符串入参的版本比如new BigDecimal(19.90)不要用new BigDecimal(19.9)。数据库字段类型用DECIMAL(10,2)。这套资源里实体类的金额字段已经是BigDecimal如果自己另写了转换逻辑注意别把BigDecimal转回double再计算。5.3 并发测试时库存被扣成负数现象用 Jmeter 同时发 10 个请求买同一件只剩 5 件库存的商品最终库存变成负数。原因库存扣减代码写成了“先 SELECT 查库存再在 Java 里 if 判断够不够最后 UPDATE 减库存”。两个请求同时查到库存为 5都通过了 Java 层的 if 判断然后先后执行 UPDATE库存就变成了 3 和 -1。解决改成 3.2 节的原子扣减一条 UPDATE 语句完成校验和扣减通过影响行数判断是否成功。如果包里的代码已经是这个写法还出现了负数检查是不是 Mapper 里 SQL 写成了SET stock #{stock} - #{quantity}而不是SET stock stock - #{quantity}——前者用的是旧值覆盖丢掉了并发下的更新。5.4 配套文档LW和代码实际逻辑对不上现象答辩前照着文档讲表结构讲到某一处导师翻开代码一核对发现字段名或者功能描述不一致。原因文档是早期版本代码后续改过需求两边没有同步更新。这类资源和文档分离的项目包里很常见文档停留在初版设计代码已经迭代了好几轮。解决拿到资源后第一天就做文档与代码的对照核查。建一张核对表左边列文档里的功能描述右边列代码里的实际实现对不上的地方用批注标出来。答辩时主动说“文档里第 X 处描述和代码有差异以代码为准”比被导师发现要好得多。这是血泪经验我见过不止一个同学栽在这上面。5.5 前端页面请求后端接口全部 404现象系统能启动登录页能打开但一点登录按钮就报 404Network 面板里看到请求地址是http://localhost:8080/user/login。原因application.yml里配了context-path: /supermarket所有接口的真实路径是http://localhost:8080/supermarket/user/login前端没带这个前缀。解决两个思路选一个。一是前端 JS 里的请求基础路径统一加上/supermarket前缀二是不用context-path直接去掉这个配置保持后端接口根路径。这套资源默认是有前缀的如果是第一次跑优先改前端请求地址保留后端统一前缀后续部署到 Nginx 反向代理时更灵活。6. 答辩前一天的验证十分钟跑通演示的说辞与清单毕业设计答辩当天最怕的不是功能缺而是演示环节卡壳。我自己就栽过一次提前两天没碰代码答辩当天启动项目数据库连不上页面白屏硬着头皮讲了五分钟 PPT导师问了一句“你确定系统跑起来过吗”当场被噎住。从那以后我每次拿到的项目包都会先在本地强制走一遍完整的验证清单再做其他事这里把清单分享给你。步骤操作预期结果预计耗时1启动 MySQL 服务导入supermarket.sql控制台显示导入成功SHOW TABLES能看到 7 张表2 分钟2启动 Spring Boot 项目控制台出现Started日志无异常堆栈1 分钟3浏览器访问登录页页面正常渲染登录后跳转首页1 分钟4新增一件商品库存设为 100商品列表出现新数据库存显示 1001 分钟5模拟一笔收银购买 3 件销售单生成库存变为 97积分按规则累计2 分钟6用错误的密码连续登录 5 次页面出现账号锁定提示1 分钟7清空浏览器缓存重新访问需要重新登录证明登录态已失效1 分钟验证完功能后用下面两条 SQL 做数据层面的自检确认下单后的数据没有脏数据-- 查询商品库存与扣减记录 SELECT id, name, stock, version FROM product WHERE id 1; -- 核对销售单与明细的数量一致性 SELECT s.id, s.total_amount, COUNT(d.id) AS detail_count FROM sale_order s LEFT JOIN sale_detail d ON d.sale_id s.id GROUP BY s.id;搭配这个演示流程答辩时建议按三条主线来讲。第一条从数据库出发说明表结构为什么这样设计重点讲product表的version字段和sale_order与sale_detail的主从关系。第二条从一次完整收银出发现场打开数据库操作日志展示下单后库存和积分的联动变化这里的亮点是事务把多步操作粘成整体。第三条从代码质量出发指出幂等校验和原子扣减这两处是经过并发场景验证的不是堆功能。如果时间充裕还可以演示一个深度操作把某件商品库存改成 1同时开两个浏览器窗口一起下单只有一单能成功另一单会看到库存不足的提示。这个演示能直观展示乐观锁和原子 UPDATE 的价值给评委留下的印象远好过单纯的“页面能增删改查”。希望这份拆解和清单帮到你答辩那天少点意外多点从容。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑