简介:这套基于Spring Boot构建的外卖系统源码,面向Java Web初学者、毕业设计及课程大作业开发人群,覆盖用户注册登录、商家入驻、商品发布、订单管理、支付对接等核心业务,帮助理解真实项目的分层架构与前后端分离开发流程。压缩包为zip格式,共623个文件,包含67个Java源文件、22个JS、20个HTML、18个CSS等前后端代码,45张PNG图片以及XML、JSON、YAML等配置资源,整体体积61.43MB。源码采用Spring Boot、Spring Data JPA、Spring Security等主流技术栈,涉及Redis缓存、JWT无状态认证、RESTful API设计、CRUD操作与权限控制,可学习从数据库表设计到接口调试的完整链路。核心模块包括用户、商家、商品、订单、支付等,既相对独立又相互协作,便于理解业务边界和接口设计,也能了解前端框架与后端Ajax联调、项目打包部署等实用场景。目前已有248人学习下载,适合需要快速搭建外卖系统做参考,也可作为毕业设计或实训项目的完整模板,便于按模块检索、二次扩展与改造。通过源码分析还可加深对Spring Boot特性、数据持久化及安全认证机制的理解,目录结构清晰,方便裁剪复用。
1. 基于SpringBoot的外卖系统源码:先弄明白你能拿来干什么
很多做了两三年后端的人,拿到一套基于SpringBoot的外卖系统源码,第一反应是「这不就是个 CRUD 练手项目吗」。真不是。外卖业务是少数能把用户端、商家端、配送端、营销端、支付回调全部串在一个闭环里的场景,订单状态流转、并发扣库存、超时取消、配送位置推送,每一个点都是真实生产环境天天在打的问题。这套源码的价值不在于 SpringBoot 本身,而在于它把一套完整的业务闭环用最朴素的单体架构跑通了——你读它,是在读「一个真实生意怎么被代码撑起来」,而不是在读框架。
这篇不带你过一遍「启动项目、点几个接口」的流程,而是按一条落地的线来拆:整体架构与表设计怎么理解、核心链路的关键代码怎么写、最常见的坑在哪、上线前怎么自检。看完你至少能独立改需求、加功能、修并发问题,而不是拿到源码只会启动。
2. 拆一套可用的外卖系统:先看清模块边界再动手改代码
2.1 为什么外卖这类项目更适合 SpringBoot 单体:不急着上微服务的三个理由
拿到源码先别急着看代码,先回答一个问题:为什么作者用 SpringBoot 单体而不是微服务?我在实际维护某外卖项目时也反复权衡过,结论是外卖这类业务在早期甚至中期,单体是更优解,理由有三个。
第一,业务复杂度还没到必须拆分的地步。外卖的核心链路是「用户下单 → 商家接单 → 骑手配送 → 用户确认」,这条链路上的数据强一致要求高,订单、库存、支付状态必须在一个事务边界内完成。拆成微服务后,跨服务事务马上变成分布式事务问题,Seata 那套引入成本极高。单体架构下,一个@Transactional就能把订单和库存扣减绑在同一个事务里,省掉的是一整类问题。
第二,运营成本。微服务要配注册中心、配置中心、网关、链路追踪,一个小团队光维护这套基础设施就够呛。单体应用部署一个 Jar 包就行,运维心智负担小得多。我见过不少团队,业务量一天几千单就迫不及待拆微服务,结果光排查跨服务调用超时就花了大半个月,这是典型的用架构复杂度换了个寂寞。
第三,水平扩展对单体同样有效。外卖系统的瓶颈通常在数据库和缓存,不在应用层。单体应用前面挂负载均衡,后面挂 Redis 和 MySQL 主从,照样能扛住几万日单量。只有到需要按团队边界独立部署、独立发版时,微服务的价值才真正体现出来。
所以读这套源码时,别带着「怎么这么土,没上微服务」的偏见。先理解作者为什么这么设计,再去看代码,会发现单体的每行代码都落在业务上,没有多余的东西。推荐阅读顺序是:先看pom.xml依赖清单,再看目录结构,最后从 controller 层往下走。目录结构一般长这样:
src/main/java/com/example/mall ├── controller # 接口层:用户、商家、订单、配送、营销 ├── service # 业务层:订单状态机、库存扣减、支付回调 ├── mapper # 数据访问层:MyBatis-Plus 的 BaseMapper ├── entity # 实体类:订单、sku、用户、地址 ├── config # 配置类:Redis、WebSocket、拦截器 ├── common # 公共类:统一返回、异常、工具 └── task # 定时任务:超时订单处理,配送超时提醒2.2 核心模块划分:从用户、商家到配送,一张图理清职责边界
头一回读这套源码的人最容易懵的是:类太多了,不知道从哪看起。我的习惯是先把模块边界划出来。外卖系统不管写得怎么样,一定逃不出这六个核心域:
用户域负责注册、登录、地址管理;商家域负责店铺信息、菜品、库存;订单域是核心中的核心,负责下单、支付、状态流转;配送域负责骑手派单、位置上报;营销域负责优惠券、满减活动;支付域负责对接第三方支付回调。这六个域在单体里不是六个服务,而是六个包,包之间通过 service 层互相调用,谁都不允许直接跨层调别的模块的 mapper,这一点是判断源码写得好不好的第一道标准。
实际读代码时,我建议你按订单域为主线去读,因为它是整个系统的脊椎。用户把商品加入购物车、提交订单、支付、商家接单、骑手取餐配送、用户收货,这一条链路走完,你基本就把系统读透了。读的时候抓住三个关键对象:购物车里的 sku、订单主表和订单明细表、支付流水表。它们之间的关系是:购物车是临时态,订单是持久态,支付流水是凭证。
模块间的调用关系,用文字描述就是:用户提交订单 → 订单服务校验库存并锁定 sku → 创建订单主表和明细表 → 发起支付 → 支付回调更新订单状态 → 商家服务收到新订单通知 → 接单后创建配送单 → 骑手更新位置 → 用户确认收货。这个链路里如果有一步你找不到对应的类和表,说明这套源码要么不完整,要么做了简化,要特别留意。
2.3 落库前的关键设计:订单表、sku 表和状态机
表设计是这套源码里最值得抄的部分,因为外卖的表设计踩过很多坑,比如库存放哪、金额怎么存、状态怎么流转。我先把关键的订单主表和 sku 表的建表 SQL 贴出来,这套结构在多数外卖源码里都能直接复用。
-- 订单主表:一单一条记录,金额单位统一用分 CREATE TABLE t_order_master ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '订单号,业务唯一', user_id BIGINT NOT NULL COMMENT '用户ID', shop_id BIGINT NOT NULL COMMENT '店铺ID', total_amount INT NOT NULL COMMENT '订单总金额,单位分', discount_amount INT DEFAULT 0 COMMENT '优惠金额,单位分', pay_amount INT NOT NULL COMMENT '实付金额,单位分', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已支付 2商家接单 3配送中 4已完成 5已取消 6退款中 7已退款', address_snapshot VARCHAR(255) NOT NULL COMMENT '收货地址快照,防止地址修改影响历史订单', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_shop_id (shop_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; -- 菜品 sku 表:库存字段是并发热点,单独拿出来设计 CREATE TABLE t_sku ( id BIGINT AUTO_INCREMENT PRIMARY KEY, shop_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, price INT NOT NULL COMMENT '单价,单位分', stock INT NOT NULL COMMENT '库存,每次扣减走乐观锁', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_shop_id (shop_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品SKU表';订单主表和明细表我把它们拆开了,明细表里记录的是下单那一刻的菜品快照,包括菜名、单价、数量,这样商家改价或下架都不会影响已有订单。金额全部用INT存分,不用DECIMAL,避免浮点误差和精度转换的麻烦,这也是电商系统的通行做法。地址存快照是因为用户改地址后,历史订单不能跟着变。
-- 订单明细表:每个菜品一行,带下单时的价格快照 CREATE TABLE t_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, sku_name VARCHAR(64) NOT NULL COMMENT '菜品名称快照', price INT NOT NULL COMMENT '下单时单价快照,单位分', quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';读这套源码时,重点看订单状态是怎么流转的。最简单的做法是在 service 层写一堆if判断,比如「只有待支付才能转已支付」,但写得好的源码会用一个状态机来管理。核心逻辑就一句话:状态只能沿合法路径单向流转,不允许跳跃。实践中我会用一个Map<Integer, List<Integer>>存合法流转路径,例如待支付 → 已支付 / 已取消、已支付 → 商家接单 / 退款中,每次更新状态前先校验,不合法直接抛异常。
3. 把核心链路跑通:从登录鉴权到支付回调的关键代码
3.1 登录与双 Token 鉴权:JWT 实现无状态登录
外卖系统的用户端是手机号登录,商家端是账号密码登录,两套登录逻辑共用一套 Token 鉴权方案是最常见的做法。我推荐用 SpringBoot 内置拦截器配合 JWT 来做,不用引入 Spring Security 那套重框架,因为外卖系统里角色就两种,没必要上 RBAC 那一堆表。
先看登录接口怎么签发 Token。这里用的是双 Token 方案:一个短时效的 accessToken 用于业务请求,一个长时效的 refreshToken 用于刷新,避免用户每半小时重新登录。
// LoginService.java - 核心方法:签发双 Token public LoginResponse login(String phone, String password) { User user = userMapper.selectOne( new LambdaQueryWrapper<User>() .eq(User::getPhone, phone) .eq(User::getPassword, DigestUtils.md5DigestAsHex(password.getBytes())) ); if (user == null) { throw new BizException("手机号或密码错误"); } String accessToken = JwtUtil.createToken(user.getId(), user.getRole(), 30 * 60 * 1000L); String refreshToken = JwtUtil.createToken(user.getId(), user.getRole(), 7 * 24 * 60 * 60 * 1000L); return new LoginResponse(accessToken, refreshToken); }这里的逻辑分三步:先校验手机号和密码,注意密码存的是 MD5 摘要,实际生产建议升级为 BCrypt;校验通过后用 JwtUtil 签发两个 Token,accessToken 有效期 30 分钟,refreshToken 有效期 7 天;最后返回给前端。两个 Token 用同一个密钥签发,区别只在过期时间,所以后端只要解析出过期时间就知道是哪种 Token。
拦截器里校验 accessToken 的写法是:每个需要登录的接口都会先经过这个拦截器,解析 Token 里的 userId 放到 ThreadLocal 里,后续业务代码直接取当前用户,不用每个接口都传 userId。
// AuthInterceptor.java - 每次请求都走这里 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException("未登录"); } Claims claims = JwtUtil.parseToken(token.substring(7)); if (claims == null) { throw new BizException("Token 无效或已过期"); } UserContext.set(claims.get("userId", Long.class), claims.get("role", Integer.class)); return true; }参数说明:Authorization头是前后端约定好的传参位置,格式是Bearer 空格 + Token;UserContext是一个 ThreadLocal 工具类,请求结束记得在afterCompletion里清除,否则线程池复用会串数据。刷新 Token 的逻辑是前端发现 accessToken 过期时,拿 refreshToken 来换新的,后端只校验 refreshToken 的有效期,不校验角色是否变更。
3.2 下单扣库存:用乐观锁 + 分布式锁把超卖堵住
下单是外卖系统并发压力最大的入口,尤其是秒杀、限时优惠场景。如果只是简单地「先查库存,够就减」,并发请求下必然超卖。这套源码里我比较认可的做法是两层防护:前置的分布式锁挡住同一用户重复提交,后置的乐观锁保证数据库层面的扣减不超卖。
先看下单接口的主流程,重点是锁的粒度:
// OrderService.java - 提交订单 @Transactional(rollbackFor = Exception.class) public OrderSubmitResult submitOrder(OrderSubmitRequest request) { String lockKey = "order:user:" + request.getUserId(); boolean locked = redisLock.tryLock(lockKey, 10 * 1000L); if (!locked) { throw new BizException("请勿重复提交订单"); } try { // 1. 查询购物车选中的 sku 列表 List<CartItem> cartItems = cartMapper.selectSelectedItems(request.getUserId()); // 2. 计算订单金额 int totalAmount = calcAmount(cartItems); // 3. 锁定库存(关键步骤) for (CartItem item : cartItems) { int rows = skuMapper.deductStock(item.getSkuId(), item.getQuantity()); if (rows == 0) { throw new BizException("菜品库存不足:" + item.getSkuId()); } } // 4. 创建订单主表和明细表 // 5. 清空购物车中已下单的商品 return buildResult(orderMaster, totalAmount); } finally { redisLock.unlock(lockKey); } }这里有几个关键参数要说明。lockKey的粒度是用户维度,即同一个用户同一时间只能下一单,这个粒度既挡住了重复点击,又不会像全局锁那样把所有用户的订单都串行化。锁的过期时间设 10 秒,这个值要结合业务评估:正常下单 2 秒内完成,但遇到库存不足要抛异常回滚时,锁也要在业务结束后立刻释放,所以用finally保证释放。
扣库存的 SQL 是防超卖的核心,用乐观锁替代了先查后改,代码长这样:
-- SkuMapper.xml 中的扣库存 SQL UPDATE t_sku SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{skuId} AND stock >= #{quantity}逻辑说明:这条 SQL 把「检查剩余库存」和「扣减库存」合并成一条原子操作,WHERE stock >= #{quantity}保证了扣减后库存不会为负。MyBatis 执行 UPDATE 后返回rows = 1表示扣减成功,rows = 0表示库存不足。用返回行数判断,而不是查完再改,这就是乐观锁的精髓——并发扣减时数据库的行锁会保证操作串行化,谁先执行谁成功,后执行的因为stock不满足条件而失效。
3.3 支付回调与订单流转:幂等处理是底线
支付回调是外卖系统里最容易出生产事故的环节。第三方支付平台的通知机制是「不收到你的成功响应就一直重发」,同一个支付结果可能回调 8 次、15 次甚至更多。如果回调处理不幂等,订单状态会被反复覆盖,退款金额会被重复计算。
先说回调接口的标准写法,我把核心逻辑抽出来:
// PayNotifyController.java - 支付回调入口 @PostMapping("/api/pay/notify") public String payNotify(@RequestBody String notifyData) { // 1. 验签:用平台公钥验证通知签名,防止伪造回调 boolean valid = payService.verifySign(notifyData); if (!valid) { return "fail"; } // 2. 解析通知内容,拿到商户订单号和支付流水号 PayNotifyDTO notify = JsonUtil.parse(notifyData, PayNotifyDTO.class); // 3. 幂等处理:订单状态已经是已支付就忽略 int rows = orderMapper.updateStatusIfCurrent( notify.getOrderNo(), 0, 1); if (rows == 0) { // 说明订单不是待支付状态,可能是重复通知,直接返回成功 return "success"; } // 4. 写支付流水表,记录第三方流水号 paymentMapper.insert(PaymentRecord.builder() .orderNo(notify.getOrderNo()) .payNo(notify.getPayNo()) .amount(notify.getAmount()) .status(1) .build()); // 5. 触发商家接单通知等后续动作 return "success"; }这段代码的核心是第 3 步的updateStatusIfCurrent,它的 SQL 条件是「订单状态等于 0(待支付)时才更新为 1(已支付)」,这样保证了状态只能从待支付跳转到已支付,重复回调时状态已经不是 0,更新行数为 0,直接返回 success 不处理。
第 5 步是很多新手会忽略的:支付成功后要通知商家接单、通知用户支付成功、给用户发优惠券,这些动作如果都放在回调接口里同步执行,第三方平台会等很久,容易超时。我一般是把回调里除了状态更新和流水落库之外的后续动作都丢进消息队列,回调接口只保证「支付结果已落库」,商家通知、短信通知这些异步处理。
常见错误是把「把订单号改成已支付」这一步放在支付流水判断之后,这样回调重复时流水表会重复插入,产生重复数据。正确顺序一定是:先幂等更新订单状态,状态更新成功了再写流水。
4. 外卖项目最常见的 5 个坑:踩坑记录与排查思路
4.1 库存超卖:缓存里减了,数据库没减
某次自测时发现一个现象:下单接口压测 200 并发,数据库里库存还剩 30 件,但前端的可售数量已经显示为 0。查下去发现业务代码在扣库存前先更新了 Redis 缓存里的库存redisTemplate.opsForValue().decrement(),扣减成功才去走 SQL。缓存扣成功了,SQL 因为库存不足失败了,于是整个事务回滚,但 Redis 的那次自增没有回滚,缓存和数据库就对不上了。
原因很清楚:Redis 操作不在事务管理范围内,事务回滚不会自动回退 Redis。解决方法是把缓存当纯读缓存,实时库存只以数据库为准,每次读库存都查库或让缓存过期后重建。如果一定要用 Redis 挡高并发,那就得把扣减动作也封装成「先扣 Redis,失败直接拒绝,成功后再异步同步数据库,同步失败走对账」的双写链路,复杂度一下子上去很多,不是这个体量的项目该做的事。
我后来把这套源码里的库存读取改成了:列表页显示缓存库存(允许短暂不一致),下单时强校验数据库库存。这样既避免了缓存穿透,又保证了扣减的强一致。
4.2 支付回调重复通知:没有幂等表导致状态覆盖
现象是:一笔订单用户在客户端已经看到支付成功,但隔了几分钟,订单状态又从已支付变成了已取消。查日志发现,定时取消任务和支付回调几乎同时执行:取消任务先把状态改成了已取消,支付回调后到,走的是「直接把状态改成已支付」的更新 SQL,于是把已取消覆盖成了已支付。再下一个重复回调来了,状态符合判断条件,又触发了一次商家接单,商家那边弹出两单。
根子在于回调更新状态时没有做条件更新。解决就是我前面写的updateStatusIfCurrent,必须在 SQL 的 WHERE 条件里带上当前状态。定时的取消任务同理,也要写成「只有待支付才能取消」。状态机设计不是摆设,每个状态流转都要在 SQL 条件里体现,否则代码写得再漂亮也会被并发打穿。
4.3 订单超时未支付:Redis 过期监听丢消息,用定时任务兜底
第一次给这套系统加超时取消功能时,我图省事,直接把订单号写进 Redis 的 key,用set(key, orderNo, EX, 30 * 60),然后监听 Redis 的 key 过期事件来取消订单。测试时单条订单挺好,一上并发就翻车:Redis 的过期事件不是精确的,延迟几秒是常态,量大时直接丢了过期通知,订单永远不取消。
换成了定时任务扫表兜底:每分钟扫一次t_order_master,把「创建时间超过 30 分钟且状态为待支付」的订单批量改为已取消。注意这一步也要用条件更新,WHERE status = 0 AND created_at < NOW() - INTERVAL 30 MINUTE,然后把触发取消的动作做成一个独立方法,方便之后替换成延迟队列。这套方案每次扫描几百条订单开销很小,完全扛得住。如果你的场景对取消实时性要求高,再考虑引入消息队列的延迟消息,Redis 过期监听这个方案最好别碰。
4.4 配送位置乱跳:WebSocket 推送与业务线程互相阻塞
商家端和用户端的地图页要实时看骑手位置,源码里用的是 WebSocket 推送。现象是高峰时段骑手位置更新延迟十几秒,偶尔还出现位置回跳。排查日志发现骑手端上报位置走的是/api/rider/location这个 REST 接口,接口里同步调了 WebSocket 的sendToUser方法,而推送目标用户如果不在线,有连接超时,导致接口线程被阻塞。
原因不止一个:一是 WebSocket 发送是同步阻塞的,不该放在业务接口里同步执行;二是骑手位置更新频率高,每秒一次,业务线程被推送占满后,正常下单接口也变慢了。解决方法是把位置上报和实时推送解耦:骑手上报位置只写 Redis 的 GEO 数据结构,WebSocket 推送改成定时任务每隔 2~3 秒批量推一次离线缓存的位置。这样推送延迟从秒级降到毫秒级,业务接口的 RT 也恢复了。
4.5 缓存雪崩与穿透:大量优惠券时的缓存空值处理
外卖系统里最典型的缓存问题是首页推荐菜品的 key 同时过期,导致大量请求直接打到数据库。配合营销活动的优惠券列表,更是雪上加霜。现象是运营后台一更新活动状态,首页接口的响应时间从 50ms 涨到 1.5s,数据库连接数打满。
解决的套路是两层。第一层,缓存 key 的过期时间加随机值,比如expire = base + random.nextInt(300),避免大量 key 在同一秒过期。第二层,对查不到的数据也缓存一个空值,过期时间 60 秒,防止恶意请求用不存在的菜品 ID 反复穿透数据库。代码写起来不复杂:
// CacheService.java - 缓存查询标准写法 public Object queryWithNullCache(String key, int expireSeconds) { Object value = redisTemplate.opsForValue().get(key); if (value != null) { return value; } Object dbValue = queryFromDb(key); if (dbValue == null) { // 空值也缓存,防止穿透 redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS); return null; } // 过期时间加随机值,防止雪崩 int randomExpire = expireSeconds + ThreadLocalRandom.current().nextInt(300); redisTemplate.opsForValue().set(key, dbValue, randomExpire, TimeUnit.SECONDS); return dbValue; }这里的核心差别是:很多人在哪个环节处理空值没想清楚,其实应该把空值缓存当成标准流程固化在缓存工具类里,而不是每个业务代码都自己判断。另外注意 Redis 里存空串和存 null 是不同的,取出来要判断""也当成无数据。
5. 上线前做一轮完整自检:缓存一致性、重试机制与链路日志
5.1 缓存与数据库一致性:先更新数据库还是先删缓存
外卖项目里缓存用得最重的就是菜品详情和店铺信息。做自检时第一个要回答的问题:更新菜品价格后,用户端看到的价格多久能变?这直接取决于缓存更新策略。我见过最多的错误写法是「先删缓存,再更新数据库」,这在并发读下会出现脏数据:请求 A 删缓存后还没来得及改库,请求 B 查库拿到旧值回填了缓存,于是缓存里又变成旧价格。
正确的顺序是「先更新数据库,再删缓存」。为什么先更新库是对的?因为删缓存这个动作即使失败,最多是缓存里保留旧值,到过期时间自然失效,不会长期脏。而先删缓存再改库,一旦改库失败,缓存是空的,下次请求打到数据库,数据反而是对的。在两个方案里,先更新库再删缓存,脏数据窗口最小。
自检时可以这样压测:写一个循环,一个线程不停改价格,另一个线程不停读价格,记录读到的值与库中最新值不一致的次数。如果发现不一致次数偏高,检查是不是删缓存用的delete方法因为网络抖动失败了。生产上我一般用「延迟双删」兜底:先删缓存,再更新数据库,隔 500ms 再删一次缓存,把并发读回填旧缓存的窗口也干掉。代价是复杂度上升,小体量外卖项目用「先更新库再删缓存」就够了。
5.2 消息重试与死信:订单状态回调失败别让数据卡死
如果源码里用了消息队列处理支付后的后续动作,比如发通知、发优惠券,自检的重点是消息失败重试逻辑。我见过一种情况:某条消息处理失败后抛异常,队列默认的重试机制把它塞回队首,业务代码一碰到这条消息就抛异常,后面所有的正常消息全被堵住,形成消息阻塞。
正确的设计是区分「可重试错误」和「不可重试错误」。可重试的,比如数据库临时连接失败,设置重试次数 3 次,间隔 1 秒、5 秒、30 秒递增;不可重试的,比如消息内容本身解析不了,直接投递到死信队列,人工事后处理。实现上用 Spring 的@Retryable注解即可,注意给死信队列配上告警,否则死信堆积没人发现,用户反馈「券没到账」才排查就晚了。
你拿到源码后可以重点看它的 MQ 消费者类上有没有区分这两类异常。如果所有异常都catch (Exception e)然后返回重试,那这个模块上线前必须改,否则就是定时炸弹。
5.3 用日志追踪整条订单链路:traceId 贯穿下单到配送
外卖系统的排查难点在于:一个订单从下单到配送,跨了用户服务、订单、支付回调、WebSocket 通知四五个环节,如果每个环节的日志没有关联 ID,出了问题时只能拿时间点一个个去翻日志,效率极低。所以在最后自检阶段,我会要求源码里必须带一套 traceId 贯穿方案。
实现不复杂:在拦截器的preHandle里生成一个 UUID 放进日志的 MDC,打印日志时自动带上 traceId。代码大概是这样:
// TraceIdInterceptor.java - 生成并传递 traceId @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put("traceId", traceId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { MDC.remove("traceId"); }同时要在logback.xml的 pattern 里加入[%X{traceId}],比如%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n。这样排查问题时,拿订单号在日志平台搜一下,从下单接口到支付回调到定时任务,全链路日志都能串起来。
自检时可以故意造一个异常订单,比如把菜品下架后下单,然后直接搜 traceId 看日志链路是否完整。如果哪个环节日志里 traceId 丢了,说明那个环节没走拦截器,或者走了异步线程没有传递,这就是要修的点。异步线程传 traceId 用MDC.getCopyOfContextMap()绑定到子线程,代码不多,但容易漏。
6. 一个能立刻抄的小技巧:用一张状态日志表把订单流转过程完整记录下来
都知道订单状态机要有,但怎么证明状态流转是对的?光靠业务代码判断不够,生产环境出问题时,你需要知道这个订单从待支付到已支付经历了多久、中间有没有被取消任务碰过。我的做法是加一张t_order_status_log表,把每次状态变更都记下来,这比任何监控都好排查。
表结构很简单:id、order_no、from_status、to_status、operator、remark、created_at。operator记是谁触发的变更,是用户支付、系统定时任务还是商家操作,remark记关键上下文,比如支付回调的流水号、定时取消任务扫描标记。每次状态变更时,在同一个事务里插入日志表,跟着主订单一起提交。
// OrderService.java - 状态变更统一入口 @Transactional(rollbackFor = Exception.class) public void changeOrderStatus(String orderNo, Integer fromStatus, Integer toStatus, String operator, String remark) { int rows = orderMapper.updateStatus(fromStatus, toStatus, orderNo); if (rows == 0) { throw new BizException("订单状态不允许流转:" + fromStatus + " -> " + toStatus); } orderStatusLogMapper.insert(OrderStatusLog.builder() .orderNo(orderNo) .fromStatus(fromStatus) .toStatus(toStatus) .operator(operator) .remark(remark) .build()); }所有状态变更必须走这个方法,不允许任何业务代码直接update t_order_master set status = ?。这样做了之后,排查线上问题的效率提升是质变的:用户说「我没操作,订单怎么就取消了」,查order_status_log看是不是定时任务扫掉了,扫掉的remark里带的是「超时未支付,系统自动取消」,就是一句话的事。之前没有这张表时,碰上这种问题只能靠猜,吃过大亏。
除此之外还有个习惯想分享给你:每次在这个系统里加新功能,我都会先问自己一句,「这个新功能会不会让某一张表的某个字段在并发下产生非预期值」,如果不确定,就去看对应的 pass-through 链路有没有状态机保护和幂等兜底。这套基于SpringBoot的外卖系统源码,不是说它的代码有多金贵,而是它把外卖业务里所有经典的并发和状态问题都摆在你面前了,一个坑一个坑踩过去,你收获的是对业务系统确定性的理解,希望帮到你。
本文还有配套的精品资源,点击获取