“基于Spring Boot的外卖系统”这个题目在计算机毕业设计里已经算老面孔了。但说句实话我这两年帮人评审、自己也动手重构过好几个类似项目真正能做到逻辑自洽、能扛住答辩追问的其实不多。大多数人的问题不是没写功能而是把CRUD堆完就以为完工了结果订单状态流转说得不清不楚、库存扣减被问“超卖怎么办”直接卡壳、骑手定位是写死的假数据。这篇文章就把一个完整外卖系统的设计思路、核心模块实现、并发处理经验和踩坑记录从头捋一遍。不管你是拿它应付毕业设计还是想通过实战项目把Spring Boot这套技术栈串起来里面提到的方案都能直接参考。1. 项目整体设计与技术选型1.1 需求拆解一套外卖系统到底要管哪些事做这个项目之前先把角色和边界想清楚。外卖系统表面上是一套“用户下单、商家出餐、骑手配送”的流程实际上平台侧还藏着一个管理后台。简单拆可以分成四个端用户端注册登录、浏览商家和菜品、加入购物车、下单支付、查看订单状态、收货地址管理、订单评价。商家端菜品管理上下架、价格库存维护、订单处理接单、拒单、标记出餐、营业状态切换。骑手端接单、更新实时位置、标记取餐和送达。管理端用户管理、商家审核、基础数据统计。我见过不少项目把商家和骑手功能全塞进管理后台用一套接口搞定。这样确实省事但答辩时很容易被问“系统角色怎么划分的”“权限怎么控制的”。我自己更推荐的做法是在用户表上增加角色字段用Spring Security或者自定义拦截器做统一鉴权商家端和骑手端各自独立一套接口路径这样逻辑清晰代码也更好维护。关键点是订单数据的流转它是整个系统的核心纽带。订单表里要同时关联用户、商家、配送员后续的支付回调、超时处理、平台统计全都要以订单状态为基准。1.2 技术选型为什么是 Spring Boot Redis WebSocket技术栈的选择不需要花里胡哨但要能讲清楚每个组件在项目里到底承担什么职责。我最终推荐这套组合Spring Boot 2.7.x JDK 8这个版本组合最稳定网上资料也最全。如果你非要上 Spring Boot 3.x记得 JDK 17 起并且有些第三方组件的兼容性要提前查。MyBatis-Plus实体映射和单表 CRUD 直接省掉大量重复代码分页插件也好用。比纯 MyBatis 少写一大堆 XML比 JPA 更容易让答辩老师接受“我知道 SQL 怎么写的”。MySQL数据持久层不用多解释订单、菜品、用户这些结构化数据都得进关系型数据库。Redis这玩意儿在外卖系统里能干的活太多了用户登录态、商家菜品缓存、分布式锁、订单超时处理。后面我会单独讲它的几个典型用法。WebSocket骑手位置实时推送、订单状态变更通知靠 HTTP 轮询太浪费资源WebSocket 是标准解法。JWT Redis登录鉴权用 JWT 保证无状态配合 Redis 解决“强制下线”和 token 续期的痛点。有些人喜欢在这个项目里塞 RabbitMQ 或者 ElasticSearch如果是为了展示技术面倒不是不行但毕业设计最忌讳“大而空”。与其堆一堆中间件然后每个都只写个 Demo不如把 Redis、WebSocket 这些核心组件吃透写出真实的业务场景。2. 数据库设计与订单状态机2.1 核心表结构与关键字段设计数据表的设计直接决定后面写代码的体验。我按“用户、商家、商品、订单、配送”这条主线拆开核心表大概是下面这些表名核心字段作用说明userid、username、password、phone、role、status用户表role 区分用户/商家/骑手/管理员merchantid、user_id、name、address、phone、business_status、delivery_fee、min_order_amount商家信息表营业状态单独用字段控制dishid、merchant_id、category_id、name、price、stock、status菜品表库存字段是后续防超卖的关键shopping_cartid、user_id、dish_id、merchant_id、number购物车以 user_id merchant_id 区分不同商家的购物车ordersid、order_no、user_id、merchant_id、address_snapshot、total_amount、status、pay_time、finish_time订单主表收货地址必须做快照order_detailid、order_id、dish_id、dish_name_snapshot、price_snapshot、number订单明细菜品名称和价格也要快照deliveryid、order_id、rider_id、status、pickup_time、deliver_time、current_lat、current_lng配送表记录骑手实时位置commentid、order_id、user_id、merchant_id、rating、content评价表关联订单和商家这里有两个我特别想强调的设计细节。第一个是“快照”。订单详情里存的是 dish_name_snapshot、price_snapshot而不是直接关联菜品表。为什么因为商家随时可能改价或者下架菜品如果订单明细直接关联菜品表那半年前下的订单里看到的价格和菜名全变了这在任何电商系统里都属于严重数据事故。快照的本质是记录“下单那一刻”的事实后面改价不影响历史订单。第二个是订单号不要用数据库自增ID。自增ID暴露订单量不说而且会被人遍历。我习惯用时间戳 随机数生成一个订单号比如订单号 format 成 yyyyMMddHHmmss 6位随机数再建唯一索引这样既能保证业务可读性又不会重复。金额字段一律用 decimal(10, 2)不用 float 或 double这是老生常谈了但每届都有同学踩坑。浮点数在金额计算上会有精度丢失问题答辩时被老师问一句“为什么金额不用double”就会尴尬。2.2 订单状态流转先画状态机再写代码订单状态是整个系统最容易写乱的地方。我建议拿到需求后第一件事不是建表而是先把状态机画出来。一个外卖订单从产生到结束通常会经历这些状态状态码状态含义可执行操作目标状态-1已取消无终态-0待支付用户支付系统超时关闭1-11待接单已支付商家接单商家拒单用户申请取消2-1退款2备餐中已接单骑手取餐骑手开始配送33配送中骑手确认送达44已完成用户发起评价5评价完成注意几个细节付款前用户取消订单、系统超时关闭和目标状态都是“已取消”但原因不一样商家拒单后要触发退款流程单靠状态字段是不够的最好加一个 cancel_reason取消原因字段记录这在实际排查问题时非常有用。代码层面我强烈建议不要在各个 Service 层到处写“if (status 1) { status 2; }”而是抽一个 OrderStatusService 或者用枚举加状态流转校验。例如写一个OrderStatusTransition枚举里面定义合法的迁移关系每次修改状态前做一次校验非法流转直接抛异常。这样到了答辩环节老师问“用户能不能对已完成的订单发起退款”你直接引用状态机说“已完成订单不在合法迁移路径上”比临时翻代码找判断逻辑有说服力得多。3. 核心业务模块实现3.1 登录鉴权JWT Redis 的组合打法外卖系统的登录要区分用户、商家、骑手三种身份用同一个登录接口、靠 role 区分是常规做法。我采用的是 JWT Redis 双保险方案。用户输账号密码登录成功后后端生成一个 JWTJWT 里面只放 userId 和 role。然后把 token 存到 Rediskey 是login:token:{userId}value 是 token过期时间设为 24 小时。之后再提供一个拦截器每次都从请求头里取 token先解析 JWT 拿到 userId再去 Redis 查这个 token 还在不在、是否一致。为什么要多查一次 Redis因为纯 JWT 是无状态的后端没法主动让一个 token “失效”。比如用户修改密码、管理员封禁账号老 token 在纯 JWT 方案下依然有效这就麻烦了。加了 Redis 之后强制下线只需要把 Redis 里的 key 删掉拦截器查询失败就直接拒绝访问。这个点讲出来懂行的人会认为你确实理解 JWT 的边界。拦截器里统一做权限校验配置一个白名单登录接口、商品浏览接口放行其余接口都要带 token。代码大概是public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录); } String userId JwtUtil.parseToken(token.replace(Bearer , )); String cacheToken stringRedisTemplate.opsForValue().get(login:token: userId); if (!token.equals(cacheToken)) { throw new BusinessException(401, 登录已过期请重新登录); } UserContext.setUserId(userId); return true; } }这里的Bearer前缀是一个约定实际业务里可以根据项目情况调整但统一性要有。3.2 下单与库存扣减事务和锁的顺序很关键下单接口是整个系统里最要求严谨的地方。我踩过最大的坑就是把“减库存”和“创建订单”顺序搞反了。正确顺序应该是校验商家是否营业。根据购物车数据生成订单明细计算出总价。逐一校验菜品库存看每个菜品剩余库存是否满足购买数量。扣减库存用乐观锁的方式执行 SQL。创建订单主表和订单明细表状态为待支付。清空当次结算的购物车数据。整个过程要用Transactional包起来。注意第4步的扣库存 SQL 长这样UPDATE dish SET stock stock - #{num} WHERE id #{dishId} AND stock #{num}这行 SQL 的关键在于AND stock #{num}。如果影响行数为 0说明库存不够直接抛异常整个事务回滚。这就是乐观锁的典型思路不靠数据库行锁而是靠 WHERE 条件来保证并发安全。下单后用户可能不会马上支付所以要考虑超时关单。我用的方案是下单后往 Redis 里写入一条带过期时间的键比如order:timeout:{orderId}过期时间设为 15 分钟。再用一个延迟任务或者监听 Redis 过期事件来判断如果订单已经支付就不处理如果还是待支付就关单并回补库存。后面在“常见问题”里我会单独讲它容易踩的坑。先创建订单再扣库存的写法会有什么问题如果一个用户下单时看到了库存但真正支付前库存被别人买光了就会出现“下单成功但支付时无货可发”的情况。所以库存扣减必须发生在创建订单时而不是支付时。如果走到了支付环节说明库存已经被这一单锁定或扣减了这样设计更贴近真实业务。3.3 配送模块WebSocket 推送骑手实时位置外卖系统的配送模块很多同学会做成“骑手点一下按钮填入当前位置”。但更真实的场景是骑手端APP每隔几秒上报一次经纬度用户端地图实时刷新。这套东西最合适的技术是 WebSocket。后端用一个 WebSocketHandler 维护所有在线连接核心数据结构是一张 ConcurrentHashMappublic class LocationWebSocketHandler extends TextWebSocketHandler { // key 为 userIdvalue 为 WebSocketSession private static final MapLong, WebSocketSession SESSIONS new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { Long userId (Long) session.getAttributes().get(userId); SESSIONS.put(userId, session); } }骑手端调用位置上报接口后后端拿到riderId、orderId、lat、lng先更新 delivery 表里的位置字段再通过订单查询找到这个订单关联的用户 userId最后从 Map 里取出用户的 session推送一条 JSON 消息{ type: location, orderId: 123, lat: 30.123456, lng: 120.123456 }关键点有两个。第一连接建立时怎么知道这个 WebSocket 是谁一般做法是在握手阶段从 URL 参数里带 token或者用拦截器把用户信息塞进 session attributes这是 WebSocket 鉴权的常见姿势。第二消息体里的经纬度精度和推送频率不能全部写死在实际部署中要注意经纬度更新的频次一般 3 到 5 秒一次比较合理否则地图轨迹会乱飞。如果赶进度不想自己实现 WebSocket 鉴权可以直接用现成的消息推送框架但如果能在答辩时讲清楚“握手拦截器 session 管理 消息推送”三个环节这块反而是加分项。4. 缓存、并发与数据一致性处理4.1 Redis 在项目里的几个落地场景Redis 用得多了会被质疑为“为了用而用”。为了讲清楚我总结一下它在我的项目里到底承载了哪些职责商家信息缓存商家详情页会被用户频繁访问这部分数据读多写少很适合缓存。key 设计成merchant:info:{id}商家修改资料后主动删除缓存下次请求再重建。菜品列表缓存用户打开商家主页要查询分类和菜品列表。这一条接口在高峰期会被高频调用可以用merchant:dishes:{merchantId}作为 key把菜品列表序列化成 JSON 存进 Redis。登录态缓存前面讲的login:token:{userId}。分布式锁用于定时任务防重复执行、支付回调防并发修改订单。超时关单标记前面讲过的order:timeout:{orderId}。缓存的更新策略我用的是 Cache Aside Pattern读数据时先查缓存缓存没有查数据库再把数据回填到缓存写数据时先更新数据库再删缓存。这里要注意“先更新数据库再删缓存”的顺序。如果反过来先删缓存、再更新数据库中途一个请求进来发现缓存为空就会拿旧数据重建缓存等于删了个寂寞。4.2 并发扣库存为什么简单的几条 SQL 也会超卖不少同学在项目里写的扣库存逻辑是“先查一下库存判断够不够再 update 减 1”。这个逻辑在并发情况下必出问题。假设库存只剩 1 件两个请求同时查到了 stock1都判断“够”然后都执行减 1库存就变成 -1 了。这就叫超卖。用前面那行带条件的 SQL 就能从根上避免UPDATE dish SET stock stock - #{num} WHERE id #{dishId} AND stock #{num}数据库执行这条语句时会对满足条件的行加锁两个并发请求只有一个能更新成功另一个影响行数为 0业务层抛异常。这也是“乐观锁”最简单的一种实现不依赖版本号字段而是靠更新条件本身。如果项目里用了 Redis 做库存预扣这里要额外注意 Redis 与数据库的一致性。我个人的建议是毕业设计阶段就直接用数据库扣库存简单可靠、能讲清楚原理。Redis 预扣是为高并发压测准备的方案普通系统反而容易出现数据不一致。4.3 支付回调的幂等性处理支付模块在毕业设计里通常不会真的对接第三方支付但你至少要把回调逻辑模拟出来。很多同学写支付回调时只做一件事接收到请求就把订单状态改成已支付。如果回调发送了两次呢状态会被更新两次虽然结果一样但如果在状态改写前加了别的操作那就会出问题。正确处理分两步先查订单当前状态如果已经是已支付或已完成直接返回“成功”不再走后续流程。加一把 Redis 分布式锁key 为pay:callback:{orderId}获取锁成功后再查一次订单状态确认仍是待支付再更新。这么做逻辑上就是“幂等校验 双重检查”。答辩时被问到“支付回调重复请求怎么办”你直接把这个流程说出来老师一般都会点头。5. 常见问题与排查实录5.1 N1 查询问题这个问题的症状非常典型接口响应慢数据库 CPU 飙升打开 SQL 日志发现执行了几十条甚至上百条 SQL。场景是这样的查询订单列表时先查了 10 条订单然后循环遍历这 10 个订单分别去查“每个订单的商家名称”这就产生了 1查订单 10查商家 11 条 SQL。如果还要查每个订单的明细SQL 数量还要翻倍。解决办法有几种最简单粗暴的是用 MyBatis-Plus 的listByIds做批量查询把循环查库改为一次 IN 查询。或者干脆在订单表增加冗余字段比如 merchant_name下单时直接快照进去查询时压根不用关联商家表——这其实就是“空间换时间”的典型思路。这个问题在开发环境很难发现因为数据量小、感觉不出来慢。但答辩时一旦被问到“你的订单接口数据量大了怎么办”这就是一个很好的切入点。5.2 缓存穿透与缓存击穿缓存穿透用户请求一个不存在的菜品 IDRedis 没有缓存数据库也没有于是每个请求都穿透到数据库。解决办法是“缓存空值”把不存在的 ID 也缓存一份TTL 设置短一点比如 5 分钟或者在查询前做一层参数校验比如 ID 明显非法直接拒绝。缓存击穿某个热点菜品 key 过期瞬间大量请求同时发现缓存没有一起打向数据库。解决办法是在重建缓存时加互斥锁用 Redis 的setnx命令实现第一个请求获取锁去查数据库并重建缓存其他请求等一会儿再查缓存。我的做法是写一个工具类专门封装“缓存空值 互斥锁重建”的逻辑避免在业务代码里到处写 try-catch 和加锁的样板代码。5.3 定时任务重复关闭超时订单订单超时关单我前面提到用 Redis 过期事件但很多人会直接用Scheduled定时扫表。这里有一个很隐蔽的坑如果你用了集群部署或者本地开多个服务实例定时任务会在每个实例上都执行一遍导致重复关单、重复回补库存。解决办法是给定时任务加分布式锁。执行任务之前先执行SETNX lock:order:timeout 1 EX 60如果设置成功说明当前实例拿到锁执行完毕后删除锁拿不到锁的实例直接跳过本次执行。还有一个细节是扫描区间要控制好SELECT id FROM orders WHERE status 0 AND create_time DATE_SUB(NOW(), INTERVAL 15 MINUTE) LIMIT 100一次只处理 100 条避免一次性加载大量数据导致内存溢出。5.4 事务失效的几个隐蔽场景Transactional注解是 Spring 里最容易用错的东西之一。最常见的失效场景同类内部的调用。比如 OrderService 的 A 方法调用 B 方法B 方法上标了Transactional但因为是同类内部调用事务不生效。原因在于 Spring 事务是基于代理实现的内部调用不会经过代理对象。解决方法是把 B 方法放到另一个 Service或者自己注入自身代理。方法被 try-catch 吞掉了异常。事务提交或回滚是靠异常触发的你 catch 住了异常又不往外抛Spring 就认为方法正常执行完不会回滚。记住事务方法里不要随意 try-catch要么 catch 后重新抛出运行时异常要么用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。数据库引擎不是 InnoDB。MyISAM 引擎不支持事务这会让你所有的事务配置全部白费建表时要注意默认引擎。这几个坑我在实际帮别人看项目代码时反复遇到。建议写完之后专门审查一下事务方法确认没有内部调用、没有吞异常。6. 项目部署与后续扩展方向6.1 Docker Compose 一键部署毕业设计提交的时候很多老师会要求能跑起来。与其让人手工装 MySQL、Redis不如直接提供一个 Docker Compose 文件把 MySQL、Redis、应用容器、Nginx 一次性拉起来。一个最小化的编排文件大致包含四个服务MySQL 8.0、Redis 7、Spring Boot 应用镜像、Nginx 反向代理。应用镜像的 Dockerfile 里做两件事先用 JDK 17 或 JDK 8 构建 jar 包再用一个精简运行时镜像跑 jar。这个“多阶段构建”能显著缩小最终镜像体积。Nginx 这里不光是做反向代理还要处理前端静态文件配置一个/api路径转发到应用容器其余路径指向前端构建产物。部署完以后访问 80 端口就能看到系统页面整个编排文件加注释控制在 80 行左右谁拿到都能快速跑起来。6.2 从毕业设计到完整系统的扩展思考如果做完基础版本还有余力或者想在简历上多写一笔可以考虑下面几个方向优惠券系统涉及模板创建、发放、核销、过期处理正好能用到 Redis 的简单限流和过期键。商家维度月度统计报表按商家聚合订单数、营业额配套图表展示能体现一点数据分析的思路。热点菜品榜单基于订单明细统计销量做缓存预热保持榜单数据实时性。推荐逻辑用户常买品类偏好可以用简单的标签匹配做一套推荐规则不必上复杂算法。我个人不推荐在毕业设计阶段引入微服务、消息队列这类重型中间件。一个完整的 Spring Boot 单体项目能把事务、缓存、并发、部署这些点讲透已经能甩开大部分同题目的作品了。毕竟毕业设计的重点是考察你是否理解了工程化的完整链路而不是技术栈的数量。最后说点个人体会带过几次这类项目之后我的心得是外卖系统的难点不在某一个接口写得有多漂亮而在于能否把“订单状态怎么流转、库存怎么保证不超卖、支付回调怎么保证幂等、位置推送怎么保持实时”这一整条链路想明白。我见过不少同学把数据库表建了二十多张结果主业务订单表的状态设计得乱七八糟最后写代码越写越难受。反过来只要先把状态机和核心流程理清楚后面的接口实现基本都是体力活。如果你正在做这个题目我强烈建议先把订单状态机和数据库表结构定下来再动代码。用半天时间画流程图、列字段、定状态后面能省下至少一周返工的功夫。项目做完之后再把 Docker Compose 部署跑通整个项目的完成度会上一个台阶。答辩的时候能把这些设计决策讲清楚比堆砌功能更能体现真实水平。