资讯动态

SpringBoot外卖系统源码拆解:从订单状态机到并发扣库存

发布时间:2026/10/13 15:12:04 来源:尧图企业网站定制
简介这套基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;读这套源码时重点看订单状态是怎么流转的。最简单的做法是在 service 层写一堆if判断比如「只有待支付才能转已支付」但写得好的源码会用一个状态机来管理。核心逻辑就一句话状态只能沿合法路径单向流转不允许跳跃。实践中我会用一个MapInteger, ListInteger存合法流转路径例如待支付 → 已支付 / 已取消、已支付 → 商家接单 / 退款中每次更新状态前先校验不合法直接抛异常。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 LambdaQueryWrapperUser() .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 签发两个 TokenaccessToken 有效期 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 空格 TokenUserContext是一个 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 列表 ListCartItem 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 推送改成定时任务每隔 23 秒批量推一次离线缓存的位置。这样推送延迟从秒级降到毫秒级业务接口的 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的外卖系统源码不是说它的代码有多金贵而是它把外卖业务里所有经典的并发和状态问题都摆在你面前了一个坑一个坑踩过去你收获的是对业务系统确定性的理解希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑