简介:这是一份基于SpringBoot构建的外卖系统完整源码,面向JavaWeb学习者、课程设计及毕业设计人群,提供了从后端业务到前端页面的可运行项目范例。压缩包内含623个文件,大小约61.43MB,以67个Java源文件、20个HTML页面、22个JavaScript脚本、18个CSS样式表为核心,并附带45张界面截图、字体文件、示例数据及配置信息,能够还原完整的项目目录结构。源码覆盖用户、商家、订单、支付等典型业务模块,在用户认证、数据持久化、缓存处理等环节分别展示了Spring Security、Spring Data JPA与Redis的常见整合写法,同时通过RESTful接口实现前后端分离,便于理解真实开发中的分层与协作方式。目前已有248人学习下载,适合用来对照梳理项目启动流程、模块调用关系以及部署打包方法,可作为毕业设计或实训项目的直接参考。
1. 基于 SpringBoot 的外卖系统源码:先别堆接口,先理清订单状态机
很多人第一次拿到一套基于 SpringBoot 的外卖系统源码,第一反应是赶紧跑起来看看界面,然后顺着 Controller 一层层往下读。但真正决定这套外卖系统值不值得留下来做二次开发的,往往不在增删改查里,而是藏在三个地方:订单状态怎么流转、并发场景下库存怎么扣、以及商家和骑手两端的数据怎么同步。这三个点没理清,前端页面做得再花哨,上线第一天就会被真实订单打穿。
我见过不少同学把外卖系统当成普通的管理系统来写,接口倒是齐全,但一压测就出问题:同一个菜品在并发下单时库存变负数,订单状态被后续请求覆盖成错乱,骑手接单后用户又同时申请退款。这些问题本质上不是代码量不够,而是业务建模阶段就没把状态机和并发边界定清楚。本文从一个可复现的 SpringBoot 外卖后端项目角度出发,把模块划分、表结构、核心下单链路和避坑点完整拆开讲,适合准备做毕设、想转型 Java 后端、或者需要快速搭一套外卖系统原型再迭代的开发者。
2. 选型与工程结构:为什么是 SpringBoot + MyBatis-Plus + Redis 这套组合
2.1 技术选型背后的理由:不是越新越好,是够用且好维护
常见的外卖系统源码,后端技术栈十套里有七八套是 SpringBoot + MyBatis-Plus + MySQL + Redis,有的加了 RabbitMQ 做消息异步,前端配 Vue 或者微信小程序。这个组合不是图新鲜,而是每层都有明确分工。
SpringBoot 负责把 Starter 依赖、自动配置和内嵌 Tomcat 打包到一起,让项目能一键启动。MyBatis-Plus 解决的是单表 CRUD 的效率问题,内置的BaseMapper让你不用为每个实体类写重复的 XML。Redis 在系统里承担两个角色:一是缓存菜品分类、店铺信息这种读多写少的数据,二是承担分布式锁和库存预扣,这是订单并发最关键的防线。RabbitMQ 不是必须的,但一套要支撑真实业务的外卖系统,订单创建后的推送、短信通知、商家接单提醒都适合走消息队列,避免下单接口被非核心逻辑拖慢。
我一般建议选 SpringBoot 2.7.x 版本,兼容性最好,不推荐直接上 3.x,因为部分老版本依赖(比如某些方言生成器)会出现兼容问题,新手排查起来容易卡住。
2.2 模块划分:按业务域拆包,不要按三层架构拆包
很多教学项目喜欢用controller / service / mapper / entity四层包结构,但外卖系统业务域多,这样拆会让包越来越臃肿。做成单体应用没问题,但内部要按业务域分模块,这样后续要拆微服务时边界也是清晰的。
com.example.delivery ├── common # 统一返回体、异常处理、常量、工具类 ├── user # 用户端:地址、登录、C端接口 ├── merchant # 商家端:菜品、店铺、接单 ├── order # 订单:下单、支付回调、订单状态机 ├── rider # 骑手端:抢单、配送状态流转 ├── notification # 消息:站内信、WebSocket 推送 ├── config # Redis、RabbitMQ、WebSocket 等配置 └── infrastructure # 与外部系统的对接,如支付、地图这个结构不是把所有逻辑塞进每个 Controller,而是按「谁负责什么业务」来划分。下单接口要同时操作订单、扣库存、发消息,那它就应该横跨 user、order、notification 三个模块,但核心事务边界落在 order 里。
2.3 启动一个可运行的最小工程
拿到源码后,第一步不是读代码,而是先跑起来。一个标准的步骤是:建库、执行 SQL 脚本、改配置文件、启动。
# 1. 创建数据库并导入初始化脚本(脚本一般在 sql/ 目录下) mysql -uroot -p CREATE DATABASE delivery_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入数据表结构和基础数据 mysql -uroot -p delivery_system < sql/init.sql # 3. 修改配置文件 cd delivery-backend vim src/main/resources/application-dev.yml配置文件里最常踩的坑是 MySQL 连接串忘记加useSSL=false和serverTimezone=Asia/Shanghai。前者在 MySQL 8.x 默认开启 SSL 后会报警告,后者不设置时区会导致时间字段差 8 小时。
spring: datasource: url: jdbc:mysql://localhost:3306/delivery_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 timeout: 3000ms配置改完后直接启动:
mvn spring-boot:run启动成功后在浏览器访问http://localhost:8080/doc.html或者 Swagger 地址,能看到所有接口列表就说明环境通了。这里有个细节:如果源码里用了 knife4j 做接口文档,访问路径通常是/doc.html,不是传统的/swagger-ui.html,这个和依赖版本绑定。
3. 核心表结构与订单状态机设计:一套外卖系统的地基
3.1 六张必须理解的核心表
外卖系统的表可能有几十张,但刚开始读源码不需要全看,只看这六张就能理清数据流向:用户表、店铺表、菜品表、购物车表、订单主表、订单明细表。其中订单主表和订单明细表是整个系统最复杂的部分。
| 表名 | 关键字段 | 作用 |
|---|---|---|
| user | id, nickname, phone, status | 用户基本信息,C 端登录态 |
| merchant | id, name, address, longitude, latitude, status | 店铺信息,接单状态,坐标 |
| dish | id, merchant_id, name, price, stock, sales | 菜品,价格用 decimal,库存用 int |
| cart | id, user_id, dish_id, quantity, checked | 购物车,冗余 dish 快照还是关联查询要看设计 |
| orders | id, order_no, user_id, merchant_id, rider_id, amount, status, address_snapshot | 订单主表,状态字段是核心 |
| order_item | id, order_id, dish_id, dish_name, price, quantity | 订单明细,需要冗余菜品名和价格快照 |
金额字段必须用DECIMAL(10,2),不要用 float 或 double,否则金额对账会出现分位误差。订单表里要冗余一份address_snapshot,因为用户地址后续改了,历史订单不能跟着变,否则打印小票和配送对不上。
3.2 订单状态机的完整流转路径
订单状态是外卖系统的灵魂。一套标准的 C 端外卖订单状态是这样的:
待支付 → 已支付/待接单 → 商家已接单 → 骑手已取餐 → 配送中 → 已完成在这个主线之外,还有两条分支:用户下单后未支付超时自动取消,以及商家接单后用户申请退款。退款分支最容易被新手写错——有的系统直接让订单从「商家已接单」跳到「已取消」,导致库存没有回补,菜品销量却还在累加。
正确的做法是单独设计一个order_status_log表,记录每一次状态变更的时间、操作人、原因。订单主表的 status 只存当前状态,但所有流转痕迹存在日志表里。这样售后排查的时候,能直接看到「谁在什么时间把订单从 A 状态改成了 B 状态」,而不是靠猜。
3.3 状态变更为什么不能直接 UPDATE
最直接的做法是UPDATE orders SET status = 新状态 WHERE id = ?,但这在状态机设计里是个坑。状态流转必须同时满足两个条件:当前状态必须是允许流转的源状态,并且变更动作要加乐观锁,防止两个请求同时修改。
-- 正确的状态流转 SQL,带前置状态校验 UPDATE orders SET status = #{newStatus}, update_time = NOW() WHERE id = #{orderId} AND status = #{expectedStatus} AND version = #{version}这句 SQL 是关键。如果affected rows = 0,说明当前状态不是你以为的那个状态,这时候必须抛出异常,而不是假装更新成功。很多脏数据就是这么产生的:用户点击「取消订单」的同时,商家恰好点了「接单」,两个请求同时进来,后执行的覆盖了先执行的,订单状态彻底错乱。
3.4 超时未支付自动关单的实现
超时关单不要用Timer或者ScheduledThreadPool在内存里扫表,单机玩玩可以,但服务重启或集群部署时会丢任务。常见做法是延迟消息或者定时任务扫表。
// 定时任务:每分钟扫描超时未支付订单 @Scheduled(cron = "0 */1 * * * *") public void autoCancelExpiredOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(15); List<Order> expiredOrders = orderMapper.selectExpired(deadline, OrderStatus.WAIT_PAY); for (Order order : expiredOrders) { cancelOrder(order.getId(), "SYSTEM_TIMEOUT"); } }这个方案有一个注意点:只处理待支付状态超过 15 分钟的订单。扫描条件必须带状态约束,否则会把刚创建的订单也取消掉。cancelOrder方法内部要执行回补库存、记录取消原因、更新订单状态三件事,这三件事必须放在同一事务里。
4. 从下单到配送:核心接口的代码实现与参数详解
4.1 下单接口:事务、锁与库存的三角关系
下单是外卖系统并发压力最大的接口。用户点击结算后,后端要做的事包括:创建购物车快照、校验菜品库存、计算订单金额、扣减库存、生成订单号和订单记录、发送消息通知商家。核心代码逻辑如下:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderRequest request) { // 1. 参数校验:用户、店铺、购物车必须有效 User user = userMapper.selectById(request.getUserId()); Merchant merchant = merchantMapper.selectById(request.getMerchantId()); if (user == null || merchant == null) { throw new BizException("用户或店铺不存在"); } // 2. 遍历购物车明细,基于菜品快照校验库存 List<CartItem> cartItems = cartMapper.selectByUserId(request.getUserId()); List<OrderItem> orderItems = new ArrayList<>(); BigDecimal totalAmount = BigDecimal.ZERO; for (CartItem cartItem : cartItems) { Dish dish = dishMapper.selectById(cartItem.getDishId()); if (dish == null || dish.getStatus() != 1) { throw new BizException("菜品已下架"); } // 3. Redis 原子扣减库存,防止超卖 Long remain = redisTemplate.opsForValue() .decrement("dish:stock:" + dish.getId(), cartItem.getQuantity()); if (remain == null || remain < 0) { // 扣减失败要回补,这里不能用 increment 直接回,因为可能多扣 redisTemplate.opsForValue().increment("dish:stock:" + dish.getId(), cartItem.getQuantity()); throw new BizException("菜品库存不足"); } OrderItem orderItem = new OrderItem(); orderItem.setDishId(dish.getId()); orderItem.setDishName(dish.getName()); orderItem.setPrice(dish.getPrice()); orderItem.setQuantity(cartItem.getQuantity()); orderItems.add(orderItem); totalAmount = totalAmount.add( dish.getPrice().multiply(BigDecimal.valueOf(cartItem.getQuantity())) ); } // 4. 生成订单主记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(user.getId()); order.setMerchantId(merchant.getId()); order.setAmount(totalAmount); order.setStatus(OrderStatus.WAIT_PAY); order.setAddressSnapshot(request.getAddressSnapshot()); orderMapper.insert(order); // 5. 插入明细,明细必须带 orderId for (OrderItem item : orderItems) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } // 6. 清理购物车 cartMapper.deleteByUserId(user.getId()); // 7. 发送 MQ 消息通知商家端有新订单 mqTemplate.convertAndSend(ExchangeConstants.ORDER_TOPIC, RoutingKeyConstants.NEW_ORDER, order.getId()); return convertToVO(order, orderItems); }这段代码有几个关键参数要说明。rollbackFor = Exception.class确保任何异常都回滚数据库事务,如果只写@Transactional,默认只回滚 RuntimeException,检查异常会导致库存扣了但订单没创建。Redis 扣库存用的是decrement命令,这是原子操作,比先get再set安全;但要注意,扣到负数时必须回补,不能直接抛异常,否则 Redis 里的库存和数据库的库存就对不上了。
4.2 商家接单与骑手配送的状态推进
商家端接单接口相对简单,但它的核心逻辑是「状态校验」。
public void acceptOrder(Long orderId, Long merchantId) { int rows = orderMapper.updateStatus( orderId, OrderStatus.MERCHANT_ACCEPTED, // 目标状态 OrderStatus.WAIT_ACCEPT, // 期望源状态 "MERCHANT_ACCEPT" ); if (rows == 0) { throw new BizException("订单状态已变化,请刷新后重试"); } // 接单成功后,推送通知给用户端 notificationService.pushToUser(orderId, "商家已接单,正在备餐"); }updateStatus的 SQL 就是 3.3 节里的那一条,带了源状态校验。这里有个容易被忽略的点:当你用乐观锁检查状态时,affected rows = 0不能吞掉,必须转换成明确的业务异常。否则调用方以为接单成功了,界面却不刷新,用户反复点击,产生更多无效请求。
骑手端的取餐和送达流程也是同理,只是多了一个骑手 ID 的写入:
public void riderPickUp(Long orderId, Long riderId) { int rows = orderMapper.updateStatusAndRider( orderId, OrderStatus.RIDER_DELIVERING, OrderStatus.MERCHANT_ACCEPTED, riderId ); if (rows == 0) { throw new BizException("取餐失败,订单可能已被其他骑手处理"); } }这里要处理一个业务边界:一个订单不能被两个骑手同时取走,所以状态从「商家已接单」到「骑手配送中」的过程中,必须在同一个 SQL 里把 riderId 也写进去,保证归属权唯一。
4.3 消息推送如何不阻塞主流程
下单后要给商家推送单子,商家接单后要给用户推送通知。如果直接在事务里调用 WebSocket 推送,假设 WebSocket 连接数太多或者有客户端断连,推送超时会导致事务回滚,订单就白下了。常见做法是把推送动作丢到消息队列里异步执行。
@RabbitListener(queues = "order.notify.queue", concurrency = "4-8") public void handleOrderNotify(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null) { log.warn("order not found, id={}", orderId); return; } // 推送逻辑:用户端 WebSocket 推送 + 商家端语音提醒 wsPushService.pushNewOrderToMerchant(order); }concurrency = "4-8"表示消费者线程数在 4 到 8 之间动态伸缩,避免单个消费者处理太慢导致消息堆积。如果源码里没有引入 RabbitMQ,用@Async加线程池也是一个可行方案,但要考虑系统重启时消息丢失的风险。
5. 外卖系统避坑指南:并发、状态与数据一致性
5.1 超卖问题:库存扣成了负数
现象:在 JMeter 里模拟 100 个用户同时下单同一个菜品,压测结束后数据库里该菜品库存变成了 -12。
原因:代码里用的是「先查库存,判断是否大于 0,再 UPDATE 扣减」的逻辑,没有锁保护或者只有乐观锁但扣减 SQL 没有加库存条件。这属于经典的竞态条件,多个请求同时读到库存为 3,都判断可以下单,然后都执行扣减。
解决:扣减 SQL 必须带stock >= #{quantity}条件。
UPDATE dish SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{dishId} AND stock >= #{quantity}这种写法比任何分布式锁都简单可靠,affected rows = 0就说明库存不足。同时,Redis 预扣库存的方案要在 Redis 扣减成功后,MySQL 更新失败时回补 Redis,两边的操作顺序有讲究:先操作 Redis 再操作 MySQL,失败要补偿回滚。
5.2 订单状态被覆盖成错乱
现象:用户下单后想取消,但商家恰好接单了。最终订单状态变成了已取消,商家那边却已经打印了小票在备餐。
原因:两个请求并发更新同一行订单记录,后执行的覆盖了先执行的状态,没有状态机校验。
解决:所有的状态流转 SQL 都必须带WHERE status = 期望的源状态,并且更新行数为 0 时要抛出异常。另外一个更保险的做法是:在订单主表上加一个status_version字段,每次更新版本号加 1,更新时校验版本号。参考 3.3 节给出的方案实现。
5.3 用户取消订单后库存没有回补
现象:用户下单后 5 分钟取消订单,菜品库存没有恢复,商家后台显示已售罄,但实际还有库存没卖出去。
原因:取消订单和回补库存不在同一个事务里,或者取消订单的代码里压根没有写回补库存的逻辑。
解决:取消订单必须在一个事务里同时做「更新订单状态 + 回补库存 + 记录日志」。如果订单用了 Redis 预扣库存,还要同步回补 Redis,这里最容易出现双写不一致。
@Transactional(rollbackFor = Exception.class) public void cancelOrder(Long orderId, String reason) { List<OrderItem> items = orderItemMapper.selectByOrderId(orderId); // 回补数据库库存 for (OrderItem item : items) { dishMapper.increaseStock(item.getDishId(), item.getQuantity()); } // 回补 Redis 库存 for (OrderItem item : items) { redisTemplate.opsForValue() .increment("dish:stock:" + item.getDishId(), item.getQuantity()); } // 更新订单状态 orderMapper.updateStatus(orderId, OrderStatus.CANCELED, OrderStatus.WAIT_PAY, reason); }5.4 金额精度丢失
现象:订单金额算出来是 19.789999,传给前端显示成 19.78,但用户支付的金额是 19.79,对账差了 1 分钱。
原因:用了double或float做金额运算,浮点数精度误差累计。
解决:数据库字段用DECIMAL(10,2),Java 实体用BigDecimal,所有乘法加法都用 BigDecimal 的add和multiply方法。任何一个金额字段用基本类型 double 的代码段,都应该在 code review 中被拦下来。
5.5 延迟关单和消息补偿
现象:订单 15 分钟未支付应该自动取消,但偶尔有订单超时 20 分钟还挂在待支付状态。
原因:定时任务扫描时间不准,或者服务在扫描周期内重启过,漏掉了那批订单。
解决:定时任务扫描时,把边界时间往前多推 1 分钟作为缓冲区,同时扫描条件要覆盖「当前状态 = 待支付」和「创建时间 < 阈值」。更稳妥的方案是引入延迟消息组件,下单时发送一条 15 分钟后的延迟消息,消息到期后校验订单状态,如果是待支付才执行取消。定时扫描兜底 + 延迟消息准点触发,双保险才能避免漏单。
6. 拿到源码后的二次开发技巧:从改得动到改得对
6.1 先用两条链路快速摸清项目脉络
拿到一套源码,不要从 Controller 开始读,先跑通「下单全链路」:用户登录 → 浏览菜品 → 加购物车 → 提交订单 → 支付回调 → 商家接单 → 骑手配送 → 完成。这条链路走通,核心表都过了一遍。然后再走「取消退款链路」:下单 → 取消 → 库存回补 → 退款入账。两步走完,外卖系统的骨架已经在你脑子里了。
调试时有几个常用手段:开启 MyBatis SQL 日志、用 Arthas 在线排查接口耗时、在OrderServiceImpl里临时打断点看状态变化。
# 开发阶段开启 SQL 输出 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl生产环境千万别开这个,会把 SQL 全部打到日志文件里,磁盘很快就会被撑满。开发环境看看没问题。
6.2 二次开发最常见的三个扩展点
第一,增加「多门店」支持。很多外卖系统源码只有单店铺,但如果业务需要商家开分店,merchant_id设计得好不好就直接决定改动量。如果订单表只存了merchant_id,没有冗余店铺名称和地址,扩展多门店就要改表结构加冗余字段。第二,增加「预订单」功能。用户指定明天中午送达,下单时创建一个待调度订单,需要为订单表增加expect_time字段,并且配送调度逻辑要根据这个时间倒排。第三,增加「退款」流程细粒度,例如用户申请退款后,商家可拒绝、平台可介入,订单状态机需要再加一层。
// 增加一个退款状态字段,不直接复用 order.status ALTER TABLE orders ADD COLUMN refund_status TINYINT DEFAULT 0 COMMENT '0-无退款 1-申请中 2-已同意 3-已拒绝';建议新加字段而不是复用status,原因是退款状态和订单主流程状态是两条维度,混在一个字段里会让所有状态判断都变得混乱。
6.3 部署上线前必须检查的三件事
第一,JVM 参数。不设置堆内存默认只有物理内存的 1/4,并发一高频繁 Full GC,接口响应直接飙到几秒。参考设置:
java -jar delivery-backend.jar \ -Xms512m -Xmx1024m \ -XX:+UseG1GC \ -Dspring.profiles.active=prod \ -Duser.timezone=Asia/Shanghai第二,数据库连接池。SpringBoot 默认的 HikariCP 参数在低并发下没问题,但压测时maximum-pool-size默认 10 往往偏小,建议调到 20 到 50 之间。
spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 5 connection-timeout: 30000第三,Redis 的持久化策略。外卖系统的库存数据在 Redis 里,如果只开 RDB 默认配置,极端情况下会丢最近几秒的库存变化。开发测试无所谓,生产环境建议至少开启 AOFappendfsync everysec。我自己吃过一次亏:压测时 Redis 重启后所有菜品库存都回到了初始值,数据库销量还在,两边对不上,排查了半天才发现是 Redis 没开持久化,从那以后我拿到任何项目的第一件事就是查 Redis 持久化配置和 MySQL 事务隔离级别。
希望这些经验帮到你,至少能让你在二次开发时少踩几个坑。每次改完状态机逻辑,我习惯在测试环境跑一遍「下单 → 取消 → 再下单」的完整循环,确认库存和状态都正常,才敢往生产上推。
本文还有配套的精品资源,点击获取