Spring Boot超市外卖系统毕业设计:从数据库建模到并发扣库存实战

发布时间:2026/9/12 22:31:29
Spring Boot超市外卖系统毕业设计:从数据库建模到并发扣库存实战 简介这是一套基于Spring Boot的超市外卖系统毕业设计完整资料涵盖系统设计与实现全过程适合Java方向本科生、课程设计及毕设选题者参考。资源围绕用户管理、商品管理、购物车、订单管理等核心模块展开同时包含前台Vue页面与后台Java代码可帮助读者快速理解前后端分离开发流程并支持将项目直接用于论文撰写与答辩准备。包内共776个文件包含210个Java源码文件、144个Vue前端组件、159个SVG图标、63个JavaScript脚本以及SQL、XML、YML等配置与数据库脚本压缩包约23.95MB目录结构清晰便于按模块检索学习。系统支持多角色登录、商品筛选、购物车数量调整、多种支付方式等典型超市外卖场景具备较高的工程参考价值。目前已有51人学习下载适合需要完整项目源码、毕业设计论文及答辩PPT或希望借助真实案例提升Spring Boot实战能力的读者使用。1. 基于 Spring Boot 的超市外卖系统为什么这份毕业设计能扛住一整条后端工程链路先抛一个反直觉的结论标题里同时带“超市”和“外卖”的单体系统写起来比很多企业级后台管理项目更费脑子。超市在于商品 SKU 多而杂同一个商品有规格、批次和门店库存外卖则要求订单从“已提交”到“已签收”的每一次流转都有迹可循。Spring Boot 的价值在于把这些散点收拢成一条可测试、可部署的线索。随便打开一个招聘 App 搜 Java 后端岗位你会发现这些能力每一条都在面试要求里出现过。这个标题拿来做毕业论文或者课设目的不是让你堆业务代码它真正要训练的是四件事数据库建模时能不能分清主表和明细表业务层能不能用状态机收住订单流转并发场景下 Redis 和数据库能不能正确处理库存扣减以及答辩现场能不能把设计画成让老师点头的图。下面按做这套题的标准路径走把领域建模、Spring Boot 业务实现、并发一致性、论文 PPT 呈现四段拆开讲。没有源码包可以抄但每一步的关键决策和参数细节都在这里。2. 超市外卖的数据库设计ER 图、表结构拆分与订单状态约定数据库设计是先手棋。前期建模多花两成时间后面业务代码能顺一大截。做超市外卖系统最常见的建模错误叫“一张订单表装天下”把买家信息、商品快照、地址、优惠全塞进同一张表。表面上看简单实际上一旦碰到售后拆分、订单改价、配送时段重排这张表就变成烂尾楼。2.1 从 ER 图到主干表用户、商品、订单、库存、配送五条线ER 图的核心是“实体—关系”拆分画图的粒度决定答辩时你能讲多细。常见做法是先画五个主干实体user用户/会员、product商品、store门店、order_master 与 order_item订单主表与明细表、delivery配送单。字段名统一用 snake_case和 Spring Boot 后续配置里的驼峰映射保持一致避免代码和数据库各说各话。这几个实体间的关系并不复杂但要能一句话讲清楚user 对 order_master 是 1:n一个用户能下多个订单order_master 对 order_item 是 1:n主表只保存总额、状态、地址和用户 ID明细表保存每一件商品的快照store 与 product 之间是 n:m通过 store_product 关联表维护“哪些门店在卖哪些商品”。库存表 stock 按照 store_id 与 product_id 的唯一组合来组织这也是为什么后面扣库存的 SQL 要同时带这两个条件。光有这些主干还不够完整。做外卖系统最好再加两个辅助实体coupon优惠券和 order_status_log订单状态流转日志。前者用于促销计算后者用于记录订单从待支付到已签收的每一步时间点。答辩时如果老师问“订单状态怎么追踪的”把 order_status_log 这张表的结构讲出来比空口说“我们有状态管理”可信得多。一张 ER 图能写进一页 A4说明你对边界有掌控力画三页还不收住说明建模没想清楚。2.2 订单主表与订单明细表MySQL DDL 的关键字段设计ER 图定边界DDL 定细节。这里给出订单主表的核心建表语句覆盖下单、结算与配送查询的最小字段集CREATE TABLE order_master ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 自增主键, order_sn VARCHAR(32) NOT NULL COMMENT 业务单号对外展示, user_id BIGINT UNSIGNED NOT NULL COMMENT 下单用户, store_id BIGINT UNSIGNED NOT NULL COMMENT 门店ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待支付1已支付2配送中3已签收4已取消5售后, total_amount DECIMAL(10,2) NOT NULL COMMENT 商品总金额, pay_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 实付金额, receiver_name VARCHAR(32) NOT NULL COMMENT 收货人姓名, receiver_phone VARCHAR(20) NOT NULL COMMENT 收货人电话, receiver_address VARCHAR(255) NOT NULL COMMENT 收货地址快照, remark VARCHAR(255) DEFAULT NULL COMMENT 用户备注, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除标志, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, KEY idx_user_id (user_id), KEY idx_store_id (store_id), KEY idx_status (status), UNIQUE KEY uk_order_sn (order_sn) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;这段 DDL 里最重要的不是类型而是三个字快照。receiver_name、receiver_phone、receiver_address 存的是下单那一刻的用户档案不随用户后续改地址而变化。同样order_item 里要把商品名称、单价、图片 URL 也做一次快照商品后面改价或改名不会污染历史订单。对账时如果地址或价格跟着变了责任就说不清。订单明细表的字段要配合主表来设计CREATE TABLE order_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单主表ID, product_id BIGINT UNSIGNED NOT NULL COMMENT 商品ID, product_name VARCHAR(128) NOT NULL COMMENT 商品名称快照, product_image VARCHAR(255) DEFAULT NULL COMMENT 商品图片快照, unit_price DECIMAL(10,2) NOT NULL COMMENT 成交单价, quantity INT NOT NULL COMMENT 购买数量, total_price DECIMAL(10,2) NOT NULL COMMENT 行总价可含优惠分摊, KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;字段设计上的细节约定金额一律用 DECIMAL(10,2)不要用 DOUBLE逻辑删除用 deleted TINYINT 而不是物理 DELETE对论文系统而言好处是保留完整数据轨迹order_sn 建唯一索引这一步是后面接口幂等的地基。库存表 stock 单独建字段是 store_id、product_id、quantity加上UNIQUE KEY uk_store_product (store_id, product_id)防止一个门店同一个商品出现多行库存。2.3 状态字段用 TINYINT 还是 VARCHAR状态机的存储层约定订单状态在数据库里一般用 TINYINT 存数字字典不用字符串。好处有三个存储更省配合 Java 枚举的 code 映射干净查询条件里写status 2比status DELIVERING更省索引空间。如果你不喜欢数字的可读性差也可以在枚举类和数据库注释里把映射写清楚排错时看一眼表结构就知道 2 代表什么。关键的不是状态叫什么名字而是提前规定好迁移路径这里先给出最简版本status 值含义允许流转到的值0待支付1、41已支付2、52配送中3、53已签收54已取消终态5售后终态这张表在答辩时就是状态机的核心素材。但只定义表格还不够一个已取消的订单如果支付回调迟到了服务端必须拒绝入账一个配送中的订单想立刻退款除了状态允许之外还要校验操作者身份。存储层的状态只负责“能不能到下一步”业务层的校验在后面层层叠加。建表时把 create_time 和 update_time 都带上这两列几乎必被问到“你这个记录谁改的、什么时候改的”时间字段不齐问两轮就露馅。3. Spring Boot 业务实现分层边界、订单状态机与事务落点很多参考代码把业务写进 Controller下单接口里查库存、算金额、插订单、减库存一气呵成。演示能跑但工程上经不起推敲答辩现场也会被追问“如果支付回调同时进来怎么办”。Spring Boot 的优势在于标准化分层之后每一次改动都可以按固定套路追查。3.1 分层与配置Controller、Service、Mapper 各管一段常见做法是拆三层每层只做一件事。Controller 负责接收 HTTP 参数、做基础校验、调用 Service、返回统一结果Service 写业务规则、管理事务边界一个方法对应一个业务用例Mapper 只做数据访问不掺业务逻辑。这样拆之后Controller 里不该出现订单状态判断的 if/else所有状态迁移逻辑下沉到 Service。这么设计不只是为了分层好看更是因为状态迁移的触发路径不止一条用户点按钮会触发下单支付回调会触发付款成功定时任务会触发超时关单运营后台手动操作会触发退款。四个入口如果各自写一套状态判断逻辑稍微一偏移就出事故。统一在一个 Service 方法里做迁移校验每个入口只调同一个方法逻辑才能收拢。application.yml 里有三个配置项要在写代码之前就定好否则跑起来容易在奇怪的地方卡住spring: datasource: url: jdbc:mysql://localhost:3306/supermarket_delivery?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:root} redis: host: localhost port: 6379 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这行解决的是 order_sn 查出来能不能自动映射到 orderSn 的问题没开的话字段全是 null新手经常在这里耗很久。数据库密码写在明文 yml 里本地没问题但论文附录不要整份贴配置环境变量DB_PASSWORD的写法更安全也更好讲。Jackson 的 date-format 统一返回的时间格式答辩演示时如果接口返回带T的 ISO 时间戳观感会差很多。3.2 用 Java 枚举定义订单状态机状态迁移的代码不要散成到处铺开的 if/else用 Java 枚举做是最小但最完整的实现思路。枚举把“当前状态 可迁移目标 语义描述”放在同一处任何地方要判断迁移合法性都调同一个方法public enum OrderStatus { WAIT_PAY(0, 待支付), PAID(1, 已支付), DELIVERING(2, 配送中), FINISHED(3, 已签收), CANCELED(4, 已取消), AFTER_SALE(5, 售后中); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } private static final MapInteger, SetInteger TRANSITIONS Map.of( WAIT_PAY.code, Set.of(PAID.code, CANCELED.code), PAID.code, Set.of(DELIVERING.code, AFTER_SALE.code), DELIVERING.code, Set.of(FINISHED.code, AFTER_SALE.code), FINISHED.code, Set.of(AFTER_SALE.code) ); public boolean canTransferTo(int targetCode) { return TRANSITIONS.getOrDefault(code, Set.of()).contains(targetCode); } }这段代码注意两点。第一Map.of 是 JDK 9 引入的项目 JDK 版本如果不是 9 以上要换成 HashMap 手动 put第二TRANSITIONS 只定义“允许迁移到哪些状态”不定义任何业务动作。真正需要做的拦截比如“已取消订单不能支付”“售后中的订单不能重复申请售后”都写在使用这个枚举的 Service 方法里。3.3 事务边界与 Transactional 的放置规则Transactional默认只在抛出 RuntimeException 时回滚受检异常不会触发回滚。这是 Java 后端面试里出现频率极高的问题落到项目里也容易踩如果 Service 方法签名写了 throws Exception 又希望回滚必须显式声明Transactional(rollbackFor Exception.class)。这句话没有任何例外直接用就好。另一个高频问题是注解放错层。Transactional放在 Controller 层等于一个请求占一个事务数据库连接被长期持有并发一上来连接池就会被打满。应该只放在 Service 层的业务方法上并且同一个类内部方法自调用时不经过 Spring AOP 代理事务不会生效。比如一个 Service 里 methodA 调 methodB两个方法都有事务注解methodB 的事务实际不介入因为调用发生在本类内部。Service public class OrderServiceImpl { Transactional(rollbackFor Exception.class) public PlaceOrderResult placeOrder(PlaceOrderCommand cmd) { // 1. 幂等校验防止重复下单 if (!orderIdempotentService.checkAndMark(cmd.getIdempotentKey())) { throw new BizException(重复下单); } // 2. 创建订单主表 OrderMaster master buildMaster(cmd); save(master); // 3. 插入订单明细保存商品快照 orderItemService.saveBatch(buildItems(cmd, master.getId())); // 4. 扣减库存核心逻辑在第 4 章 stockService.deduct(cmd.getStoreId(), cmd.getItems()); return PlaceOrderResult.of(master.getOrderSn()); } }这里的执行顺序是“先建主表、后扣库存”。有人会问为什么不先扣库存因为订单主表要先拿到 order_id 才能写明细把扣库存放在最后一步一旦库存不足整个事务回滚主表和明细一起撤销。“事务里先做哪个操作”会影响锁的持有时间把锁库存这种相对昂贵的写操作尽量放在事务靠后的位置能缩短数据库行锁的持有时间。事务里的幂等校验如果依赖 Redis注意 Redis 操作不参与数据库事务要回滚时 Redis 里已写入的标记不会被自动清除后面章节会展开讲。4. 并发扣库存与订单幂等Redis、数据库行锁的一次完整布置外卖系统的并发压力集中在两个动作上点单时扣库存支付时回写状态。两个动作如果并发了最容易出现的结果就是超卖。超卖的直接原因只有一个先查询再判断再更新的三步操作中间被人插队了。解决办法要么把三步合成一步要么用中间件把并发串行化。4.1 Redis 预扣库存把“查库存再减”改成“原子 decrement”在 Redis 里以门店加商品为维度设置 key例如stock:1001:2001下单时直接使用 StringRedisTemplate 的原子自减命令Long remain stringRedisTemplate.opsForValue().decrement( stock: cmd.getStoreId() : item.getProductId() ); if (remain null || remain 0) { stringRedisTemplate.opsForValue().increment( stock: cmd.getStoreId() : item.getProductId() ); throw new BizException(商品已售罄); }decrement()这一步走的是 Redis 单线程命令队列两个并发请求到达时Redis 内部会让它们排队执行这就是“原子性”的底层来源。需要特别注意decrement 本身不限制下限它可以把值减成负数。所以扣完必须立刻判断结果发现小于 0 要马上 increment 把数加回来。不过这中间毕竟有一个恢复窗口更严谨的写法是把“扣减 判断 回补”合并成一个 Lua 脚本在 Redis 端原子执行local remain redis.call(DECR, KEYS[1]) if remain 0 then redis.call(INCR, KEYS[1]) return -1 end return remainLua 脚本在 Redis 里整体执行其他命令在脚本跑完之前插不进来。这段脚本通过 DefaultRedisScript 注入到 Spring 容器后Java 侧只调一次 execute 就能拿到库存结果连上面的 if 判断都不用了。这里有个 Field 上很容易踩的坑StringRedisTemplate 默认使用 String 序列化器Redis 里的库存值必须是纯数字如果换成了 RedisTemplate 且序列化器配置不当value 可能被写成带引号的字符串执行 decrement 时会报类似“value is not an integer or out of range”的错。全部清一色用 StringRedisTemplate 处理计数器别混用。4.2 数据库乐观锁回防一条 UPDATE 完成校验与扣减Redis 是第一道闸数据库是最后一道闸。Redis 重启丢数据、key 过期、缓存和数据库不一致任何一种情况都可能导致预扣失效所以业务事务里必须用数据库行锁再守一次UPDATE stock SET quantity quantity - #{count}, update_time NOW() WHERE store_id #{storeId} AND product_id #{productId} AND quantity #{count};这条 SQL 把“判断库存够不够”和“扣减库存”合并成同一个原子操作。数据库的 UPDATE 会锁定命中的行第二个事务执行到同一行时会被阻塞等第一个事务提交后才继续执行。受影响行数为 0 说明库存不足或记录不存在为 1 说明扣减成功。千万别写成“先 SELECT quantity在 Java 里判断再 UPDATE”。两个请求同时读到 quantity 10各自判断“够”各自执行 UPDATE最终把这行改成一个错误结果。依然会超卖。UPDATE 语句里把quantity #{count}写进 WHERE 条件把判断交给数据库锁去仲裁这是标准做法也是答辩时最值得强调的一个设计点。4.3 订单幂等与防重Redis setIfAbsent 加数据库唯一键高并发最容易造成重复提交的是点击下单用户双击按钮、前端重试、网络超时后再次请求任何一种情况都会让同一个下单请求发两次。幂等方案的常见实现是前端提交前先向后端请求一个幂等 token后端把 token 用 setIfAbsent 写入 Redis 并设置过期时间Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(idem: token, 1, Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(first)) { throw new BizException(重复提交请勿多次点击); }setIfAbsent 对应 Redis 的 SETNX 语义只有这个 key 不存在时才写入成功。两个并发请求同时到达只有第一个能拿到 true。需要强调千万不要先 get 判断再 set这两步之间存在时间窗口并发下照样重复放行。过期时间 5 分钟是保护机制用户中途关掉页面导致 token 没有被消费这个 key 也不会一直占着 Redis 内存。Redis 令牌不是唯一防线order_master 表里的 uk_order_sn 唯一索引作为兜底。即使 Redis 重启导致全部 token 丢失数据库的唯一索引也会拦截重复的 order_sn 插入。两层叠起来这套防重设计基本没有盲区对应的正是后端面试里常被追问的“接口幂等你在项目里怎么实现的”。Redis 标记不参与数据库事务所以下单失败回滚后Redis 里的 token 还在用户需要重新获取 token 或等待过期这是可以接受的行为。4.4 缓存一致性先更新数据库再删缓存商品列表页一般会缓存库存和商品信息但扣库存落在数据库。两边数据不一致怎么办比较直接的做法是 Cache Aside 模式先更新数据库再删除 Redis 缓存里对应的 key下一次读请求查库后把新值重新写回缓存。stockService.deduct(storeId, productId, count); stringRedisTemplate.delete(stock: storeId : productId);顺序不能反过来。先删缓存再更新数据库时数据库还没改完新的读请求会查库拿到旧值并回填到 Redis这个旧值可能在缓存里停留很久脏读窗口被拉长。先更库再删缓存理论上有极小概率删缓存失败但可以通过给缓存设置短的 TTL 兜底。对论文和演示系统这个精度足够引入 binlog 监听异步删缓存属于加分项但复杂度不建议写进论文正文答辩时容易被连环追问。5. 毕业论文与 PPT 的高分表达ER 图、架构图和接口验证数据怎么给答辩时老师不看你敲代码只看三样东西你能不能画清楚图能不能把自己的系统跑通能不能解释关键数据。代码写在 IDE 里但分数大多在图和实证上。5.1 三张图的分工ER 图、系统架构图、时序图论文里一般放三种图各管一段ER 图对应数据库设计系统架构图对应 Spring Boot 分层与中间件选型时序图对应订单流。画架构图建议用“客户端—Spring Boot—MySQL/Redis”纵向三层把 Nginx 也画上去表示静态资源与反向代理但不要画没用到的消息队列。画了 MQ 却没有实际落地的逻辑现场一问就露馅这是最亏的失分点。画图的硬标准是“名称一致”ER 图里的字段名、架构图里的模块名、代码里的包名、数据库表名四个地方不能互相打架。最常见的翻车现场就是 ER 图里商品表还在用 product_name 而代码里已经改成 goods_name老师一眼就能看出设计与实现脱节。5.2 答辩前必跑的验证清单准备演示前把这张清单完整过一遍验证项操作方式预期结果库存扣减正确性同一商品两个浏览器同时下单只有一个成功stock 表数量正确状态机迁移对已取消订单调用支付回调返回“订单状态不允许该操作”接口幂等同一 token 连续请求两次第二次返回重复提交错误缓存一致性下单后查看 Redis key商品缓存键已被删除每一行都可以用 curl 或 Postman 快速验证。例如幂等接口连续执行两次同一条命令curl -X POST http://localhost:8080/api/order/place \ -H Content-Type: application/json \ -d {idempotentKey:demo-20250101-001,storeId:1,items:[{productId:1001,count:2}]}第一次返回订单号第二次返回“重复提交请勿多次点击”。这个验证过程本身就可以录屏或截图放进 PPT 的“系统验证”章节。演示的时候不要只点正常流程一定要把异常分支也点一遍评委想看的是你如何处理失败场景。5.3 并发数据怎么讲才可信很多论文写“本系统支持高并发”却没有任何数据支撑。一个实用的做法是在本地用 JMeter 或压测工具开 200 个线程并发提交下单请求记录成功数、失败数和平均响应时间。压测图放进 PPT不写“性能优秀”这种空话直接把数字摆出来。数据的可信度取决于参数完整性硬件配置、数据库版本、压测线程数、单次事务内容都要写清楚。哪怕压出来 QPS 只有几百只要条件写全可信度远高于“单机几万”的浮夸数字。把两个浏览器并发下单、Redis 缓存键变化、数据库库存变化做成三张截图对着架构图把调用链路讲一遍这道题的答辩基本就稳了。本文还有配套的精品资源点击获取