☰
苍穹外卖day10实战:缓存、下单、支付回调与WebSocket推送
2026/10/14 3:55:33 网站建设 项目流程

做到苍穹外卖day10这个节点,项目其实已经从前几天的“管理端后台功能”切换到了一个完全不同氛围的战场:用户端核心交易链路。购物车、下单、支付、来单提醒,这一串操作一旦跑通,整个项目的完整度会立刻上一个台阶。我在这个阶段最大的感受是:前几天的代码都是“自己操作自己看”,从 day10 开始,代码里开始有另一个角色——商家端——在实时响应你的业务动作,这种前后端、多端联动的感觉,确实是前面十几个接口堆不出​​来的。

如果你也是用这套外卖项目在练手,到了 day10 一般意味着:你还没接触复杂分布式,但已经开始实践缓存、WebSocket、状态机、回调幂等这类“真实系统必考”的东西。所以这篇文章我不按官方文档顺序讲,而是按我实际操盘时的思路拆:先搞清楚 day10 要解决什么问题,再逐个击破缓存、购物车、下单、支付回调、来单推送这几个环节,最后把踩过的坑全部摊开给你。

1. 先想清楚:day10 到底在解决什么问题?

1.1 day10 在整个项目里的位置

管理端做了员工、分类、菜品、套餐这些基础维护之后,项目还缺一个真正面向消费者的闭环。day10 的核心任务就是把“用户能点餐”这条链路完整打通。从代码模块看,它横跨用户端接口、服务端业务处理、Redis 缓存、WebSocket 推送等多个层次;从业务流看,它覆盖了用户从打开客户端、加购、提交订单、支付成功到商家端收到提示的完整过程。

这个节点之所以重要,是因为它第一次把“管理端数据”和“用户端操作”串了起来。比如菜品管理后台改了起售状态,用户客户端必须同步感知到;用户支付成功后,商家端必须立刻弹提醒。跨角色、跨端的状态一致性,是之前简单 CRUD 练习不会涉及的压力。

1.2 涉及的四个技术点和选型理由
  • Redis + Spring Cache:菜品是典型的读多写少数据,每次刷列表都查数据库不可能,所以用缓存挡一层。Spring Cache 注解把缓存操作收敛到方法级别,比手动写 RedisTemplate 更干净,也容易理解。
  • 购物车:属于用户会话级别的临时数据,需要按用户隔离,还要支持随时变更数量和清空,直接操作 MySQL 表也能做,但穿透 Redis 会更贴近真实场景。
  • 下单和支付回调:这是交易链路的“承重墙”。下单必须保证库存、金额、订单状态的一致性,支付回调必须处理验签、幂等、异常补单。
  • WebSocket:商家端要实时感知新订单,轮询太蠢也延迟高,所以用长连接做服务端推送。

这四块合起来恰好是一条完整业务链,而不是零散的技术点。理清这个整体视图再动手,后面写代码不会照着模板硬套。

1.3 订单状态流转是隐藏主线

下单之前先设计好订单状态,不然支付写一半会乱。我在项目里维持了这套枚举:

  • 待支付(下单后,用户未完成支付)
  • 已支付/待接单(支付回调成功写入)
  • 已接单/制作中(商家端点击接单)
  • 派送中
  • 已完成
  • 已取消

day10 只涉及前两个状态和取消逻辑,但你需要把整条状态机先画出来。不然支付回调成功后直接改状态,后面接单、配送接口接不上。

2. 菜品缓存与购物车:用户端高频操作的落地细节

2.1 菜品缓存怎么加才不踩坑

菜品列表接口是最典型的“突发读”场景。用户在分类间反复切换,每次查询走数据库、组装 VO、再返回,压力全在 DB 上。所以我在 day10 里做了基于分类维度的缓存:

@Cacheable(cacheNames = "dish", key = "#categoryId") public List<DishVO> listWithFlavorByCategoryId(Long categoryId) { // 查数据库、查口味、组装 VO 返回 }

这段注解只做了“缓存读”,但“缓存写”才是难点。后台管理端一旦修改了菜品状态或内容,缓存里的旧数据必须清掉。我用的是:

@CacheEvict(cacheNames = "dish", allEntries = true) public void updateDish(DishDTO dishDTO) { ... }

这里留意两点。第一,allEntries = true会清空整个 dish 缓存,而不是只删某个分类。分类之间可能有关联(同一个菜品出现在多个分类),删整个命名空间最稳妥,代价是缓存命中率暂时降低,但数据一致性有保障。第二,@Cacheable默认会把返回值直接序列化进 Redis,如果返回的 VO 对象里带了不容易序列化的字段,会出现类型转换异常。解决办法要么对 VO 做 Serializable,要么自定义序列化配置,我建议直接让 VO 实现Serializable最快。

2.2 缓存穿透和击穿,不能只在纸上谈兵

在真实联调时,客户端经常会传一个不存在的分类 ID 来刷接口,这类请求会直接打到数据库,这就是缓存穿透。我用了一个很朴素的办法:在 Service 里对空结果也做缓存,缓存一个空对象或标识值,过期时间比正常缓存短,比如 2 分钟。

击穿问题出现在某个热点分类缓存失效的一瞬间,大量请求同时回源数据库。完全化解需要互斥锁,但 day10 阶段我建议先用“逻辑过期”过渡:缓存里不设置物理过期时间,而是存一个带过期时刻的包装对象,后台定时任务负责刷新热点数据。这样虽然代码量会增加,但很能锻炼你对缓存底层的理解。如果觉得太重,至少给缓存加上随机过期时间,避免大量 key 同时到期造成雪崩。

2.3 购物车:MySQL 表也能跑,但要注意会话隔离

购物车数据结构不复杂,只记录用户 ID、商品 ID、口味、数量、金额。我见过有的版本直接塞 Redis Hash,key 是cart:{userId},field 是商品标识,value 是购物项对象。这么做在“频繁加减商品”时性能很好,但项目为了统一持久化,把购物车落到了 MySQL 表里。

如果跟我一样用表实现,记得每次请求都要靠 token 解析出当前用户 ID,再作为所有购物车操作的前置条件。我用 ThreadLocal 保存当前登录用户,在拦截器里解析 token 并放入 ThreadLocal,Service 层直接用,免得到处传参数。购物车加购的幂等逻辑也很关键:同一个用户、同一个商品、同一种口味,如果已经存在,就把数量加一,而不是插入新记录。

Cart cart = cartMapper.getByUserIdAndDishId(userId, dishId); if (cart != null) { cart.setNumber(cart.getNumber() + 1); cartMapper.updateNumber(cart); } else { cart.setUserId(userId); cart.setDishId(dishId); cart.setNumber(1); cartMapper.insert(cart); }

这个逻辑看起来简单,但很容易漏掉“口味”这个维度。一个不辣的菜品和一个加辣的菜品不能合并成同一条购物车记录。所以在查询条件里要带上口味字段,否则会出现数量对不上的问题。

2.4 为什么“先改数据库,再删缓存”是默认选择

day10 里修改菜品后要清缓存,这就涉及缓存一致性。我见过很多人在更新数据库之后顺手把最新值写进缓存,这个做法看着对,但有个隐患:并发写操作下,后到的写请求可能把旧数据覆盖回缓存。所以我的习惯是“先更新 DB,再淘汰 Cache”。下一次读请求发现缓存没有,再回源数据库并重建缓存。这样虽然会有一个短暂的不一致窗口,但比反向写缓存造成长期脏数据安全得多。

极端场景下,如果删缓存失败,DB 和缓存会不一致。我的兜底方案是给 Redis 里的菜品缓存设置较短过期时间(比如 15 分钟),即使某次删除失败,数据最多错乱一小段时间,不会一直错下去。

3. 从下单到支付回调和 WebSocket 推送:完整实现记录

3.1 下单接口:事务、订单号、库存三件事

下单是整个 day10 业务最重的操作。我写submitOrder时把它拆成四步:校验购物车、生成订单主表记录、生成订单明细、清空购物车,另外还要扣减库存。整个过程必须在一个事务里,不然明细保存成功、购物车没清空,用户下次下单就会重复。

事务要放在公开的 Service 入口方法上,千万不要放在 Controller 层,也不要出现同类内部方法自调用。我一开始图省事,在一个OrderServiceImpl里写了orderService.submit(),内部又调用了另一个@Transactional方法,结果事务根本没生效。原因是 Spring 事务基于代理,同一类内部自调用不会经过代理对象。解决办法是拆分 Service,或通过代理对象调用,但最省心的还是把事务放在入口方法上,内部不使用自调用。

订单号不能依赖数据库自增主键,因为主键暴露业务量,而且并发下可能冲突。我用的是:时间戳 + 用户 ID + 随机数,生成一个全局唯一订单号。我们不用分布式 ID 组件时,这个字符串唯一性足够测试用。

库存扣减我建议用条件更新:

int rows = dishMapper.deductStock(dishId, number); if (rows == 0) { throw new BusinessException("库存不足"); }

对应的 SQL 要加上stock >= #{number}条件,保证库存在并发下不会被扣成负数。

3.2 支付回调:验签、幂等、改状态

支付回调是 day10 里最容易出事故的地方。真实第三方支付平台回调的特点是:可能延迟、可能重复、可能携带非法参数。所以处理回调时顺序很重要。我当时的伪代码如下:

// 1. 验证签名,确保请求来自支付平台 boolean signValid = payService.verifySign(requestParams); if (!signValid) { log.warn("支付回调验签失败:{}", requestParams); return "FAIL"; } // 2. 根据订单号查订单 Order order = orderMapper.getByOrderNumber(outTradeNo); if (order == null) { return "FAIL"; } // 3. 校验金额是否一致 if (!order.getAmount().equals(payAmount)) { return "FAIL"; } // 4. 幂等处理:防止重复回调 if (order.getStatus() == OrderStatus.PAID) { return "SUCCESS"; } // 5. 更新订单状态 orderMapper.updateStatus(order.getId(), OrderStatus.PAID); // 6. 触发后续动作:推送商家端,记录支付日志 websocketServer.sendMsgTo(order.getStoreId(), "新订单");

很多人会把第 4 步省掉,然后重复回调时会执行两次改状态,甚至推送两次提醒。我在联调时确实遇到过,一次下单收到两次回调,商家端弹了两个提醒。从那以后我把幂等校验放在金额校验之后、业务操作之前,而且回调统一返回固定的SUCCESS串给支付平台,不能返回乱写的内容。

3.3 WebSocket 服务端:用 ConcurrentHashMap 管连接

来单提醒用 WebSocket 再合适不过。商家端管理后台在页面加载时建立连接,服务端把连接对象存在内存里。我维护了一个静态的ConcurrentHashMap,key 是商家端登录员工的 ID,value 是 WebSocket Session 对象。为什么要 ConcurrentHashMap?因为推送和连接建立、断开是不同线程在操作,普通 HashMap 会并发修改报错。

@Component @ServerEndpoint("/ws/order/{storeId}") public class OrderWebSocketServer { private static final Map<Long, Session> SESSION_MAP = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("storeId") Long storeId) { SESSION_MAP.put(storeId, session); } @OnClose public void onClose(@PathParam("storeId") Long storeId) { SESSION_MAP.remove(storeId); } @OnError public void onError(Session session, Throwable error) { // 打印日志,不要把堆栈直接推给前端 } public static void sendMessageToStore(Long storeId, String message) { Session session = SESSION_MAP.get(storeId); if (session != null && session.isOpen()) { session.getBasicRemote().sendText(message); } } }

注意一个 Spring 集成 WebSocket 的典型坑:@ServerEndpoint标注的类如果作为 Spring Bean,每次连接都会创建新实例,所以千万别把请求范围的数据放到实例变量里。用静态 Map 存连接是安全的,但如果你想在onOpen里调用 Spring Service 方法,直接@Autowired会为 null,因为实例不是 Spring 容器创建的。解决办法是把 Spring 容器工具类静态化,或者通过配置类把 WebSocket 的 ServerEndpointExporter 和 Bean 的生命周期理顺。day10 阶段不需要做聊天室,只需保证推送方法的异常不影响到业务主流程,所以我把sendText包了一层 try-catch。

3.4 缓存、下单、推送如何串联成一条完整链路

我把整个流程在代码里串一遍:用户点“去结算”,调用submitOrder,事务保存订单和明细,同时清空购物车;用户跳转到支付页,前端调用支付下单接口拿到支付参数;支付完成后,第三方异步回调服务端接口,服务端验签、幂等、改状态;随后在同一个 Service 方法里调用OrderWebSocketServer.sendMessageToStore(storeId, message),商家端收到提醒,点“接单”,订单状态继续流转。

这里新手很容易犯的错是把 WebSocket 推送放到事务提交前。如果事务还没提交,订单状态改了但后面回滚,推送已经发出去了,商家端点击查看发现订单不存在。我的做法是:先用TransactionSynchronizationManager.registerSynchronization在事务提交后执行推送,或者干脆在事务方法外先更新状态,事务提交成功后再调用 WebSocket。但 day10 项目中事务边界还没设计得那么精致,最简单的手段是把推送放到 Controller 层调用,Service 只负责核心业务和事务。

4. day10 踩坑实录:这里几乎全是实战里才能发现的问题

4.1 菜品上下架之后,用户端总是旧数据

菜品的缓存清空时机和位置如果漏了一个管理端入口,用户端就会看到已经停售的菜品。原因基本都是@CacheEvict只加在修改菜品的 Service 方法上,但上下架走的是另一个方法,没触发清缓存。排查时不要只盯着注解,先看 Redis 里的 key 是否还存在,再用keys dish*看缓存名空间。我后来把清缓存动作统一放进一个内部方法里,所有会改动菜品和套餐状态的入口都调它,才算彻底解决。

4.2 WebSocket 连接经常断,或者商家端收不到消息

收不到消息的原因分两种。第一种是连接没建立成功,前端路径里的 storeId 和服务端@PathParam对不上;第二种是连接建立了,但推送的时候拿到的 Session 已经处于关闭状态。我调试时会在onOpen和onClose里打日志,并把 Session 是否 open 作为推送前判断条件。另外,开发环境经常因为页面刷新导致连接重建,旧的 Session 没有及时清除,推送时找到的是死连接。所以onClose里移除 key 一定要写,并且onError里也要清理。

4.3 支付回调验签失败,但订单还是被创建了

这通常是测试环境回调参数不完整导致。真实开发中,回调参数包含订单号、支付金额、签名等字段,验签逻辑如果放在数据库操作之前,验签失败就直接拒绝,并不会污染业务。但最常见的问题是本地环境无法接收到回调,于是有人手动模拟回调,直接把订单状态改成已支付,结果跳过了验签和幂等,后面测试接单、配送时状态错乱。我建议本地至少用“沙箱支付”或“手动触发接口模拟回调”的方式,让回调逻辑走一遍完整主流程,而不是靠改数据库凑状态。

4.4 库存扣成负数

并发情况下,两个用户同时对一个菜品下单,如果扣库存 SQL 是update dish set stock = stock - 1,在数据库隔离级别下可能出现超卖。解决办法就是我在前面提到的条件更新:update dish set stock = stock - #{number} where id = #{id} and stock >= #{number}。同时下单事务里如果扣减影响行数为 0,要抛出异常触发回滚,否则购物车清了,库存没扣成,订单里却出现缺货商品。

4.5@Cacheable不生效或缓存数据是 Java 序列化乱码

这个问题很常见。不生效的原因往往是方法在同类内部调用,或者不是通过 Spring 代理调用。缓存注解适合放在 Service 方法上,而且要从 Controller 或者另一个 Service 调用才有效。乱码问题则出在 Redis 序列化器配置上,我直接把默认的 JDK 序列化换成了 Jackson 序列化,缓存里的值是 JSON,可读性好,排查也方便。如果返回的 VO 里带了 LocalDate、LocalDateTime,记得加 JavaTimeModule,否则序列化会报错。

4.6 下单后支付完成,商家端弹提醒,但用户端还停在“待支付”

这基本是前端刷新和状态轮询的问题。服务端状态确实改了,但用户端页面没有主动查询最新状态。我让前端在支付页面通过定时查询订单状态,每 2 秒查一次,支付成功或超过时限再停止轮询。不要依赖 WebSocket 再给用户端推一次,因为用户端可能随时切换页面,长连接不一定还在。支付状态以服务端查询接口为准,页面显示权交给前端即可。

5. 个人实操体会

项目做到 day10,我终于理解了为什么很多教程把这个阶段安排在 Redis、Spring Cache、WebSocket 之后。它不是让你背这些技术名词,而是逼你在一条真实业务链中做选择:什么时候该用缓存,什么时候缓存反而是负担;什么时候该用事务,什么时候事务也救不了并发;什么时候该等待外部回调,什么时候必须自己兜底。我个人最建议你手动把所有关键步骤的日志完整打一遍,尤其是“下单 -> 支付回调 -> 改状态 -> 推送商家端”这条链路,把时间戳对齐,你会看到很多靠 review 代码发现不了的问题。

最后提醒一点:这个阶段别急着加复杂设计。先把主流程跑通,用最简单的缓存策略、最清晰的事务边界、最朴素的 WebSocket 推送把业务闭环做出来,然后再去想分布式锁、消息队列、延迟队列。基础链路稳定之后,你自然知道哪些地方值得加固,哪些地方过度设计了。day10 的成就感来源,应该是你把这条“用户点餐、商家接单”的链路真实跑通的那一刻。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询