Spring Boot外卖系统实战:从架构设计到高并发订单处理 1. 项目概述与核心价值最近几年无论是自己点外卖还是看别人做项目“外卖点餐系统”几乎成了Java和Spring Boot学习者绕不开的一个实战课题。这玩意儿听起来简单不就是用户选菜、下单、商家接单嘛但真上手做你会发现从数据库表设计到订单状态流转从高并发秒杀到支付回调处处是细节步步有“坑”。我前后带过不少团队也自己动手重构过好几版今天就来聊聊如何基于Spring Boot从零开始搭建一个不仅“能跑”而且“健壮”、“可扩展”的商用级外卖点餐系统核心骨架。这不仅仅是完成一个课程设计更是理解现代Web应用开发中业务、技术与工程化思维如何结合的一次绝佳演练。这个系统主要服务于三类用户前端消费者C端用户、后台管理商家B端商户以及我们看不见的系统管理员。它要解决的核心问题是在线上将“想吃”的需求与“可做”的供给高效、可靠地连接起来并处理好中间的交易、履约和信息流。对于学习者而言通过这个项目你能深入掌握Spring Boot在Web开发中的全套组合拳MVC分层、MyBatis/Spring Data JPA数据操作、Redis缓存、RabbitMQ消息队列、JWT鉴权、支付集成等。更重要的是你能学会如何将一个复杂的业务需求拆解成清晰的技术模块和稳定的代码结构。2. 系统整体架构与核心模块设计做一个系统最忌讳的就是一上来就敲代码。我们先得把蓝图画清楚知道各个部分怎么拼装数据怎么流动。一个典型的外卖系统我们通常会采用经典的分层架构并引入一些中间件来提升性能和解耦业务。2.1 技术栈选型与考量为什么是Spring Boot因为它提供了“约定大于配置”的极速开发体验内嵌Tomcat一键启动能让我们把精力聚焦在业务逻辑本身而不是繁琐的XML配置上。围绕它我们搭建起一套成熟的技术矩阵后端框架Spring Boot 2.7.x (选择LTS长期支持版本稳定压倒一切)。为什么不是最新的3.x对于学习型项目2.7.x生态更成熟社区资料和第三方库兼容性极佳避免在环境问题上耗费时间。数据持久层MyBatis-Plus。相比原生MyBatis它提供了强大的CRUD封装和条件构造器能极大减少简单SQL的编写。对于复杂查询我们依然可以使用自定义XML mapper兼顾灵活与效率。缓存Redis。外卖系统的热点数据太多了店铺信息、菜品分类、购物车、秒杀库存。用Redis做缓存能瞬间提升查询性能减轻数据库压力。我们主要会用它做String存储简单KV、Hash存储对象如用户信息、Sorted Set实现排行榜等数据结构。消息队列RabbitMQ。订单创建后需要通知商家、更新库存、可能触发营销活动。这些操作如果都在主线程同步执行用户下单响应会变慢且一个环节失败可能导致整个订单失败。用消息队列异步解耦提高系统响应速度和可靠性。权限认证JWT (JSON Web Token)。对于C端和B端我们采用无状态的Token认证。用户登录后服务器生成一个包含用户ID、角色等信息的Token返回给前端后续请求只需在Header中携带此Token即可。这比传统的Session更适用于分布式环境。数据库MySQL 8.0。关系型数据库依然是业务数据存储的主力负责用户、订单、商品等核心强一致性数据的存储。表结构设计是重中之重。项目管理与构建Maven。管理项目依赖规范项目结构。注意技术选型没有银弹。这里的选择是基于“学习成本”、“社区生态”、“项目普适性”的综合考量。在实际公司项目中可能会看到Spring Data JPA更面向对象、Kafka更高吞吐、Spring Security更完整安全框架等原理是相通的。2.2 核心业务模块拆解我们把系统横向切割成几个高内聚、低耦合的模块每个模块负责一块独立的业务功能用户模块负责用户消费者和商家的注册、登录、个人信息管理、地址簿管理。这里是系统的入口。商品菜品模块商家后台的核心。包括菜品分类管理、菜品信息管理名称、价格、图片、描述、状态、库存管理。这里要特别注意菜品上架/下架的状态控制。购物车模块用户在下单前暂存选择。注意购物车数据应该用Redis存储Key可为cart:userId保证读写速度快且用户刷新页面不丢失。数据结构设计要考虑同一菜品多次添加的情况。订单模块系统的核心与最复杂部分。包括订单生成、状态流转待支付、待接单、制作中、待配送、已送达、已完成、已取消、超时自动取消、订单详情查询等。订单表的设计必须考虑扩展性。支付模块集成第三方支付如模拟支付或支付宝/微信沙箱。处理支付回调更新订单状态。支付回调处理一定要做幂等性校验防止重复通知导致资金差错。商家后台模块提供给商家管理菜品、处理订单、查看营业数据的界面和接口。需要与C端用户系统在权限上完全隔离。系统管理模块管理员管理用户、商家、查看全平台数据等。2.3 数据库表结构设计要点数据库设计是系统的基石设计不好后期扩展和优化会非常痛苦。这里列举几个核心表及其关键字段的设计思路用户表 (user):id,username,password加密存储,phone,avatar,status0禁用1启用,create_time。区分用户类型可以通过一个type字段1消费者2商家或者干脆分两张表。地址表 (address):id,user_id,consignee收货人,phone,detail详细地址,is_default是否默认。一个用户可以有多个地址。菜品分类表 (category):id,name,sort排序字段,status。属于商家级数据可以加shop_id字段关联到具体商家。菜品表 (dish):id,name,category_id,price,image,description,status0停售1启售,create_time。价格字段务必用Decimal类型避免浮点数精度问题。套餐表 (setmeal):id,name,price,image,description,status。套餐和菜品是多对多关系需要一张关联表 (setmeal_dish)包含setmeal_id,dish_id,copies份数等字段。购物车表 (shopping_cart): 虽然Redis是主力但有时也需要持久化。可包含id,user_id,dish_id/setmeal_id,number数量,create_time。订单表 (orders): 这是最复杂的表。基础信息id订单号可使用时间戳随机数生成或雪花算法user_id,address_id,amount总金额。状态与时间status1待付款2待接单3已接单/制作中4待配送5配送中6已送达7已完成8已取消order_time,pay_time,estimated_delivery_time。关联信息remark备注phone,address下单时的快照防止用户后期修改地址影响历史订单。订单明细表 (order_detail): 与订单表是一对多关系。id,order_id,dish_id/setmeal_id,name菜品快照,image,price单价快照,number。为什么需要快照因为菜品信息如价格、名称可能会变但订单的历史记录必须保持下单时的原样。实操心得所有表都必须包含create_time和update_time字段便于问题追踪和数据审计。关于id主键推荐使用数据库自增或分布式ID如雪花算法而订单号等业务编号则应另外设计一个唯一字段如order_number。3. 核心业务逻辑与关键技术实现有了架构和设计图我们就可以动手搭建核心功能了。下面我会挑几个最具代表性、也最容易踩坑的环节详细讲讲实现思路和代码要点。3.1 用户登录与JWT令牌签发用户登录不是简单的查数据库比对密码。安全性和无状态是关键。密码加密存储绝对不能用明文。使用BCryptPasswordEncoder对密码进行单向哈希加密。注册和修改密码时加密存储登录时用它提供的matches方法进行比对。// 配置Bean Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 注册时加密 String encodedPassword passwordEncoder.encode(rawPassword); // 登录时校验 boolean matches passwordEncoder.matches(rawPassword, encodedPasswordFromDB);生成JWT令牌引入jjwt依赖。登录成功后生成一个包含用户ID和角色的Token。public String generateToken(Long userId, String role) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(role, role); return Jwts.builder() .setClaims(claims) // 自定义载荷 .setIssuedAt(new Date()) // 签发时间 .setExpiration(new Date(System.currentTimeMillis() EXPIRATION_TIME)) // 过期时间 .signWith(SignatureAlgorithm.HS512, SECRET_KEY) // 签名算法和密钥 .compact(); }校验与解析JWT编写一个拦截器JwtTokenInterceptor对需要认证的接口进行拦截。从请求头Authorization中提取Token进行校验并解析出用户信息存入ThreadLocal或请求属性中供后续业务使用。// 拦截器核心逻辑 String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); Long userId Long.valueOf(claims.get(userId).toString()); // 将userId存入ThreadLocal或Request作用域 UserContext.setCurrentUserId(userId); } catch (Exception e) { // Token无效或过期 throw new UnauthorizedException(令牌无效); } }踩坑记录JWT的SECRET_KEY要足够复杂且妥善保管。Token一旦签发在过期前无法主动使其失效这是JWT的一个特点。如需实现“踢下线”功能需要借助Redis维护一个黑名单但这会增加复杂度。对于外卖系统通常的过期时间如2小时是可以接受的。3.2 购物车功能实现Redis实战购物车要求实时、高频读写用Redis再合适不过。我们以Hash结构存储每个用户的购物车。Key设计cart:用户IDField设计菜品ID:口味如果支持口味的话或setmeal:套餐IDValue设计购物车项的数量IntegerService public class CartServiceImpl implements CartService { Autowired private RedisTemplateString, Object redisTemplate; public void addItem(Long userId, Long dishId, Integer number) { String key cart: userId; String field dishId.toString(); // 使用Hash操作如果field存在则增量不存在则新增 redisTemplate.opsForHash().increment(key, field, number); // 可以同时存储菜品快照信息到另一个Hash方便查询时直接获取 String itemKey cart:item: dishId; if (!redisTemplate.hasKey(itemKey)) { Dish dish dishMapper.selectById(dishId); redisTemplate.opsForHash().putAll(itemKey, BeanUtil.beanToMap(dish)); } } public ListCartItemVO list(Long userId) { String key cart: userId; MapObject, Object entries redisTemplate.opsForHash().entries(key); // 遍历entries结合菜品信息快照组装成CartItemVO列表返回 // ... } }注意事项Redis是内存数据库数据可能丢失。对于重要的购物车数据可以考虑定时或用户退出时将其持久化到MySQL中。另外要设置合理的Key过期时间如7天避免无用数据长期占用内存。3.3 下单流程与事务控制用户点击“去支付”触发下单流程。这个过程涉及多个数据库写操作必须保证事务性。校验校验用户地址、购物车是否为空、菜品库存/状态是否可售。计算总价遍历购物车根据菜品ID查询当前价格注意这里查的是实时价格但下单后需要快照计算总金额。这里有一个潜在并发问题查询价格和扣减库存之间数据可能已被其他订单修改。后面会讲解决方案。生成订单向orders表插入一条记录状态为“待支付”。生成订单明细遍历购物车为每一个商品向order_detail表插入记录这里存储的是菜品信息的快照名称、价格、图片。清空购物车从Redis中删除该用户的购物车数据。返回订单号前端根据订单号跳转到支付页面。Transactional(rollbackFor Exception.class) // 声明式事务管理 public OrderVO submitOrder(OrderSubmitDTO orderSubmitDTO) { Long userId UserContext.getCurrentUserId(); // 1. 校验地址、购物车 // ... // 2. 计算总金额此处有并发风险见下文分析 ListCartItemVO cartItems cartService.list(userId); BigDecimal amount calculateTotalAmount(cartItems); // 查询实时价格计算 // 3. 生成订单状态为待支付 Orders order new Orders(); order.setOrderNumber(generateOrderNumber()); order.setUserId(userId); order.setAmount(amount); order.setStatus(OrderStatusEnum.WAIT_PAY.getCode()); orderMapper.insert(order); // 4. 生成订单明细保存快照 ListOrderDetail detailList cartItems.stream().map(item - { OrderDetail detail new OrderDetail(); detail.setOrderId(order.getId()); detail.setDishId(item.getDishId()); detail.setName(item.getName()); // 快照 detail.setPrice(item.getPrice()); // 快照 detail.setNumber(item.getNumber()); return detail; }).collect(Collectors.toList()); // 批量插入明细 // ... // 5. 清空购物车 cartService.clean(userId); // 6. 返回VO对象包含订单号等 return assembleOrderVO(order); }关键问题并发下的超卖与数据不一致在计算总价和插入订单的间隙如果另一个用户也购买了同一菜品并导致库存扣减或价格变动就会出现问题。解决方案是悲观锁或乐观锁。悲观锁在查询菜品价格和库存时使用SELECT ... FOR UPDATE锁定相关行。但这在高并发下性能较差容易导致死锁。乐观锁推荐在菜品表中增加一个version版本号字段。更新库存时带上版本号条件。UPDATE dish SET stock stock - #{quantity}, version version 1 WHERE id #{id} AND version #{oldVersion} AND stock #{quantity};执行后检查影响行数如果为0说明更新失败版本不对或库存不足需要回滚事务并提示用户。在我们的下单流程中如果菜品库存是关键约束就应该在事务内使用乐观锁进行扣减。计算总价时可以先查询一次数据带版本号在最后更新库存时进行校验。3.4 订单状态机与超时自动取消订单状态流转是业务核心。我们可以在代码中用枚举清晰定义状态和可能的流转路径。public enum OrderStatusEnum { WAIT_PAY(1, 待支付), WAIT_ACCEPT(2, 待接单), ACCEPTED(3, 已接单), TO_BE_DELIVERED(4, 待配送), DELIVERING(5, 配送中), COMPLETED(6, 已完成), CANCELLED(7, 已取消); // ... 构造方法、getter // 可以增加一个方法判断是否能从当前状态转移到目标状态 public boolean canTransferTo(OrderStatusEnum targetStatus) { // 定义状态流转规则例如待支付 - 待接单/已取消 // ... } }超时自动取消是一个经典场景。用户下单后未支付15分钟后系统自动取消订单释放库存。实现方案有多种数据库定时任务写一个SQL脚本定时扫描status待支付且create_time超过15分钟的订单进行更新。简单但精度低实时性差。延迟消息队列推荐利用RabbitMQ的死信队列DLX或插件实现延迟消息。下单成功后向一个队列发送一条消息并设置TTL为15分钟。该队列不设消费者消息过期后成为死信被路由到另一个真正的处理队列由消费者执行关单逻辑。// 发送延迟消息 rabbitTemplate.convertAndSend(order.delay.exchange, order.delay.key, orderId, message - { message.getMessageProperties().setExpiration(900000); // 15分钟单位毫秒 return message; });时间轮算法更高效的定时任务调度Netty和Kafka都有使用。但对于大多数项目方案2已足够。3.5 支付回调与幂等性设计集成支付宝/微信支付时用户支付成功后支付平台会异步通知我们的服务器一个回调请求。处理这个回调是保证资金和订单状态一致的关键。核心流程接收回调请求验证签名确保请求来自可信的支付平台。解析回调参数获取商户订单号即我们的order_number和支付状态。根据订单号查询本地订单校验订单金额是否匹配、订单状态是否为“待支付”。更新订单状态为“待接单”并记录支付时间。返回给支付平台一个成功的响应如success或SUCCESS字符串。重中之重幂等性处理支付平台可能会因为网络等原因多次发送相同的回调。如果我们不加以处理就会导致订单被重复更新可能引发资金和库存的混乱。实现幂等性的常见方法数据库唯一索引在订单支付记录表里用order_id和out_trade_no商户订单号做联合唯一索引。插入支付记录时如果重复会报错从而避免重复处理。乐观锁在更新订单状态的SQL语句中增加状态条件。UPDATE orders SET status #{newStatus} WHERE order_number #{orderNumber} AND status #{oldStatus};执行后判断影响行数如果为0说明状态已不是待支付可能是重复回调直接返回成功即可。Redis分布式锁在处理回调前尝试获取一个以订单号为Key的Redis锁。获取成功才执行业务执行完毕后删除锁。确保同一订单的回调处理串行化。PostMapping(/notify) public String payNotify(HttpServletRequest request) { // 1. 验证签名略 // 2. 解析参数 String orderNumber request.getParameter(out_trade_no); String tradeStatus request.getParameter(trade_status); // 3. 使用Redis锁保证幂等性 String lockKey lock:pay_notify: orderNumber; Boolean lockAcquired redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.MINUTES); if (!lockAcquired) { // 获取锁失败说明正在处理中直接返回成功 return success; } try { // 4. 查询订单 Orders order orderMapper.selectByOrderNumber(orderNumber); if (order null || !OrderStatusEnum.WAIT_PAY.getCode().equals(order.getStatus())) { // 订单不存在或状态不对也返回成功避免支付平台重复通知 return success; } // 5. 更新订单状态使用乐观锁 int updated orderMapper.updateStatusWithOptimisticLock(order.getId(), OrderStatusEnum.WAIT_ACCEPT.getCode(), OrderStatusEnum.WAIT_PAY.getCode()); if (updated 0) { // 更新成功执行业务逻辑如记录支付记录、发送消息通知商家等 // ... } // 无论是否更新成功都返回success return success; } finally { // 释放锁 redisTemplate.delete(lockKey); } }4. 性能优化与常见问题排查当系统基本跑通后我们就要考虑如何让它跑得更快、更稳。下面是一些针对外卖场景的优化点和常见问题。4.1 缓存策略设计与缓存穿透/雪崩应对缓存用得好性能提升立竿见影用得不好就是灾难。缓存什么店铺/菜品分类变化不频繁访问量大。可以设置较长的过期时间如12小时并在后台更新时主动删除缓存。热门菜品详情同样设置合理过期时间。注意当菜品信息如价格被修改时必须同步更新或删除缓存。用户购物车如前所述用Redis Hash存储。秒杀库存将秒杀商品的库存预加载到Redis中下单时在Redis中做原子性扣减使用DECR命令扣减成功后再异步写回数据库。缓存穿透查询一个数据库中一定不存在的数据如id-1。请求会绕过缓存直接打到数据库。解决方案1.缓存空对象即使查不到也在缓存中存一个空值如null并设置一个较短的过期时间如30秒。2.布隆过滤器在查询缓存前先用布隆过滤器判断Key是否存在不存在则直接返回。缓存雪崩大量缓存Key在同一时间点过期导致所有请求瞬间涌向数据库。解决方案设置差异化的过期时间。比如在基础过期时间上增加一个随机值。// 设置缓存过期时间 基础时间 随机偏移量 int baseTime 3600; // 1小时 int randomTime new Random().nextInt(300); // 0-5分钟随机 redisTemplate.opsForValue().set(key, value, baseTime randomTime, TimeUnit.SECONDS);缓存击穿某个热点Key过期瞬间大量并发请求同时来查询这个Key全部打到数据库。解决方案使用互斥锁。第一个请求发现缓存失效时先去获取一个分布式锁如RedisSETNX获取成功后去数据库加载数据并回填缓存其他请求等待或轮询缓存。也可以考虑“逻辑过期”即缓存值永不过期但内部包含一个过期时间字段由后台线程异步更新。4.2 数据库查询优化与索引规划慢查询是系统性能的隐形杀手。为高频查询条件建立索引orders表user_id查用户订单、statuscreate_time按状态和时间查订单、order_number唯一。order_detail表order_id查订单详情。dish表category_id按分类查菜品、statuscreate_time查最新上架的菜品。address表user_idis_default查用户默认地址。避免SELECT *只查询需要的字段。复杂查询使用EXPLAIN分析查看SQL执行计划关注是否使用了索引是否有全表扫描。分库分表考虑当单表数据量过大如订单表超过千万可以考虑按用户ID哈希或按时间范围进行分表。4.3 分布式环境下的会话与锁问题当你的服务部署在多台机器上时一些在单机环境下不是问题的问题就会暴露出来。会话共享如果你用了Spring Session默认会将Session存储到Redis中实现多服务实例间的会话共享。但我们用JWT本身就是无状态的天然支持分布式。分布式锁前面提到的支付回调幂等性、秒杀库存扣减都需要分布式锁。Redis的SET key value NX PX timeout命令是实现分布式锁的经典方式但要处理好锁的续期和释放的原子性问题。更成熟的方案是使用Redisson客户端它提供了可重入锁、读写锁等多种分布式锁实现开箱即用。RLock lock redissonClient.getLock(lock:order: orderId); try { // 尝试加锁最多等待10秒上锁后30秒自动解锁 boolean isLocked lock.tryLock(10, 30, TimeUnit.SECONDS); if (isLocked) { // 执行业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }4.4 典型问题排查实录问题用户下单时偶尔提示“库存不足”但后台查看明明还有库存。排查检查下单逻辑是否在高并发下出现了“超卖”。回顾3.3节很可能是在“查询库存”和“更新库存”两个步骤之间没有加锁或使用乐观锁导致多个请求同时读到了相同的库存数都认为可以购买。解决在扣减库存的SQL语句中必须加上stock 0的条件并使用乐观锁版本控制。确保操作的原子性。问题支付成功后订单状态有时没变成“待接单”但用户的钱扣了。排查首先检查支付回调接口的日志看是否收到了回调。如果收到了检查回调处理逻辑中的幂等性判断和事务控制。很可能是在高并发下回调被处理了多次但第一次成功更新状态后后续的回调因为状态判断如乐观锁而失败但业务逻辑中可能有某些非幂等的操作如发送了多次消息被执行了。解决严格按照3.5节的幂等性设计方案结合分布式锁和数据库乐观锁确保整个回调处理链路是幂等的。问题商家后台查询订单列表当数据量很大时页面加载非常慢。排查查看数据库慢查询日志定位到查询订单列表的SQL。很可能是因为orders表没有对shop_id和create_time建立联合索引或者进行了大量的联表查询如关联用户表查用户名。解决为shop_id和create_time建立索引。对于联表查询考虑冗余存储在订单生成时就把商家名称、用户手机号等常用查询信息快照到订单表中避免查询时联表。对于真正的大数据量引入分页查询并且避免深度翻页limit 10000, 20这种可以使用基于create_time的游标分页。问题在IDEA中启动Spring Boot项目时控制台报错java: You aren‘t using a compiler supported by lombok...排查这是Lombok注解处理器的常见问题。Lombok通过在编译时修改AST抽象语法树来生成getter/setter等方法需要编译器支持。解决确保IDEA安装了Lombok插件File - Settings - Plugins。在IDEA设置中开启注解处理File - Settings - Build, Execution, Deployment - Compiler - Annotation Processors勾选“Enable annotation processing”。如果使用Maven检查pom.xml中Lombok依赖的scope是否为provided并且版本合适。最后执行Maven - Lifecycle - clean然后compile或者重启IDEA并Invalidate Caches / Restart。这个外卖点餐系统项目就像是一个微型的电商系统几乎涵盖了Web后端开发的所有核心知识点。从需求分析、表设计、到编码实现、性能优化每一步都需要仔细琢磨。我建议你在实现基本功能后不妨自己给自己提点“刁难”问题比如“如果同一时间有1万人抢购一份特价菜怎么办”、“如果支付回调延迟了1小时才收到怎么办”然后去思考解决方案这样成长会非常快。代码写多了你会发现框架和工具只是武器真正的内力是对业务的理解和对数据流动、状态变迁的掌控力。