ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

苍穹外卖day10:Spring Task与WebSocket打造订单状态实时闭环

苍穹外卖day10:Spring Task与WebSocket打造订单状态实时闭环 我一直觉得学 Java 项目光啃语法、刷题是不够的真正拉开差距的是你能不能把一个业务需求翻译成一串靠谱的代码。今天要聊的“苍穹外卖”day10就是一个特别典型的实战节点——订单状态定时处理、来单提醒、用户催单。这三个东西看着是三个独立的小功能但它们几乎覆盖了企业级开发中最常用的三类技术定时任务、实时通信、业务状态机。如果你正在从零学 Java或者跟着视频做了一个外卖项目卡在这里那这一天的内容值得拆开了细看。先说结论day10 没有引入新的高深框架就是在 Spring Boot MyBatis 的项目里把 Spring Task、WebSocket、以及一些订单表的状态更新逻辑串联成完整的业务闭环。但“简单”不等于“容易”很多同学抄代码抄得飞快结果定时任务不跑、WebSocket 连不上、催单消息发不出去折腾一晚上找不到原因。这篇文章我按自己实际写过这类订单系统的经验把这一天背后的原理、代码细节、还有踩过的坑一次讲清楚。1. 项目背景与这一天的核心任务“苍穹外卖”是一个典型的前后端分离的 Java 实战项目模仿真实外卖平台的商家端和管理端。前面几天的内容基本都在围绕着菜品、分类、套餐这些基础数据折腾到了 day10业务流程开始真正“活”起来了——订单一旦产生系统就不能光把它存进数据库就完事还得在合适的时机自动处理状态变更主动通知相关的人。这一天要做的三个事梳理一下是这样订单状态定时处理用户下单后如果一直不支付超过一定时间就要自动取消释放库存如果订单已经派送超过一定时间没确认送达系统也要自动把它置为完成状态。来单提醒用户在前台下单支付成功后商家端能第一时间收到一条新订单的提醒最好带声音、带弹窗而不是靠商家手动刷新页面。用户催单用户等急了点一下“催单”这个催促信息要立刻传递到商家端让商家看到是哪一单用户在催。这三个需求覆盖了三种完全不同的技术维度定时处理属于“后台任务调度”来单提醒属于“服务端主动推送”催单则是“在已有业务上叠加一个带实时性的交互动作”。把这套组合拳打明白你对 Web 后端项目的理解会上一个台阶面试聊到“怎么实现在线通知”“订单超时怎么处理”的时候也能从原理讲到落地而不是背八股文。2. 订单状态定时处理Spring Task 与 Cron 表达式的落地2.1 需求场景和状态机分析外卖订单的状态一般有一条清晰的流转链路待支付 → 已支付/待接单 → 已接单/待配送 → 配送中 → 已完成。中间还有用户取消、商家拒单、超时自动取消等旁路状态。日常开发里最容易被忽略的就是“时间”这个维度。用户下单了但没付款这笔订单压在待支付状态如果一直没人管它会永远占着库存商家也不知道该不该备货。同理骑手配送中如果一直不点“送达”订单也会一直挂着影响数据统计和结算。所以我们需要一个“后台哨兵”定时扫描这些超时订单自动推进状态。梳理一下定时任务要处理的规则状态触发条件目标状态待支付下单后超过15分钟未支付已取消配送中配送超过60分钟未确认送达已完成这两个规则看着简单但真正实现起来有几个容易拌脚的地方一是超过时间怎么计算二是扫描范围怎么划定三是怎么避免重复执行导致状态错乱。2.2 技术选型为什么用 Spring TaskJava 生态里做定时任务常见选择有 Spring Task、Quartz、XXL-Job。对一个单体学习项目来说Spring Task 足够轻量不需要额外引入中间件注解一把梭Spring Boot 天然支持。Quartz 功能强大但配置偏重适合需要持久化调度信息、复杂触发规则的场景。XXL-Job 则是分布式任务调度平台得部署调度中心对于当前阶段属于杀鸡用牛刀。所以项目的选择是务实的使用 Spring Boot 自带的 Spring Task通过Scheduled注解驱动定时方法。要让定时任务生效启动类上要加一个注解SpringBootApplication EnableScheduling public class SkyApplication { public static void main(String[] args) { SpringApplication.run(SkyApplication.class, args); } }EnableScheduling是这个环节的“总开关”。没有它后面哪怕写了Scheduled方法也不会执行。接着新建一个任务类比如OrderTaskComponent Slf4j public class OrderTask { Autowired private OrderMapper orderMapper; /** * 处理超时未支付订单 * 每天每分钟执行一次 */ Scheduled(cron 0 * * * * ?) public void processTimeoutOrder() { log.info(定时处理超时订单{}, LocalDateTime.now()); // 查询超时订单状态改为已取消 } /** * 处理一直处于配送中状态的订单 */ Scheduled(cron 0 0 1 * * ?) public void processDeliveryOrder() { log.info(定时处理派送中订单{}, LocalDateTime.now()); } }2.3 Cron 表达式的坑我踩过的和你要防的Cron 表达式是这一刻最容易翻车的地方。Spring Task 的 Cron 和 Linux 的有点区别它一共 6 位或 7 位分别表示秒、分、时、日、月、周、年可省略。我第一次写的时候想表达“每5分钟执行一次”写成了Scheduled(cron 5 * * * * ?)结果任务只会在每分钟的第5秒跑一次而不是每5分钟跑一次。这里要留意秒的位置。正确的写法应该是Scheduled(cron 0 */5 * * * ?)这个表达的含义是秒为0分钟位置上是“每5分钟”所以任务会在 0分、5分、10分……这些时间点的零秒准时执行。还有一个更隐蔽的问题日和周两个字段不能同时指定值必须有一个为?。比如0 0 1 * * 意思是每天凌晨1点执行其中?表示“不指定”。如果你把?换成*会直接报错。日常建议给每组定时方法配上日志记录并且用一个开关控制是否启用。比如sky.task.order-cancel-cron0 */1 * * * ?再通过Scheduled(cron ${sky.task.order-cancel-cron})读取配置。这样上线后如果任务太频繁不用改代码改配置就能调整频率非常实用。2.4 查询超时订单SQL 怎么写才安全定时任务的核心逻辑是查出符合条件的订单并发起状态更新。这里的难点不在写 SQL而在“并发”和“幂等”。假设我们每分钟执行一次第一次执行时把超时订单查出来准备更新状态。如果应用是多实例部署的两个实例同时执行同一段任务就会发生“重复处理”。单体项目虽然只有一个实例但也不是完全安全如果你手动触发一次任务定时任务又刚好触发同样可能重复执行。一个稳妥的办法是把“查询”和“更新”合成一条 SQL条件里带上状态update idupdateStatusByStatusAndOrderTime update orders set status #{targetStatus}, cancel_time #{cancelTime} where status #{currentStatus} and order_time lt; #{time} /update这样即使两个请求同时进来第一个请求把符合条件的行状态改了第二个请求再执行时会发现status已经不匹配影响行数为0自然不会重复修改。在定时任务方法里我可以这样写Scheduled(cron 0 */1 * * * ?) public void processTimeoutOrder() { LocalDateTime timeoutTime LocalDateTime.now().minusMinutes(15); int affected orderMapper.updateStatusByOrderTime(Orders.CANCELLED, timeoutTime); if (affected 0) { log.info(本次处理 {} 笔超时未支付订单, affected); } }这里的时间计算要特别注意minusMinutes(15)是在当前时间往前推15分钟。比如现在是 12:10那么订单时间在 11:55 之前、且状态为“待支付”的订单就会被打成“已取消”。这是按“早于某个时间点”来判定超时逻辑正确。不过这样做有个潜在问题如果某段时间系统故障没有及时跑定时任务等到恢复时会一下子处理掉一大批“过期订单”。这种情况一般可以接受但如果你做的系统对时效性要求很高可能要设计“扫描时间窗口”或者补偿机制。对于外卖项目15分钟自动取消允许延迟一两分钟影响不大。2.5 定时任务的“副作用”控制定时任务改数据时还要考虑下游依赖。比如订单取消后可能涉及退款、释放优惠券、回退库存。这些操作如果都在定时任务里同步执行一旦某个环节报错会导致整个事务回滚前面已经标记的取消状态也没了。比较好的策略是定时任务只负责“状态置为取消”然后通过消息队列或者异步事件触发后续的库存回退、退款。但苍穹外卖这个阶段消息队列还没引入我们可以直接在 Service 层把状态更新和库存回退写在一个事务方法里保证原子性即可。注意千万不能在Scheduled方法里直接标注Transactional后搞一个超长事务如果你后续要调外部接口事务会一直持有数据库连接高并发下容易出现连接池耗尽。3. 来单提醒WebSocket 如何把消息塞给浏览器3.1 为什么不用轮询和 SSE商家端网页要实时显示新订单最简单的想法是前端每秒钟请求一次后端接口“有没有新订单”。这叫轮询缺点是浪费服务器资源而且延迟高真实环境里几乎不会这么干。另一种方案是 Server-Sent EventsSSE只能服务端单向推送给客户端而且基于 HTTP实现简单但消息格式和重连机制不如 WebSocket 灵活。对于需要双向通信、并且已经在现有系统里管理大量连接的场景WebSocket 更合适。来单提醒的核心流程是用户下单成功 → 后端收到支付回调 → 服务端主动给商家端推送“新订单”消息 → 商家端收到后弹窗或播放提示音。这个过程天然适合 WebSocket。3.2 WebSocket 与 Spring Boot 的整合步骤Spring Boot 整合 WebSocket一般有两种风格一种是用 Spring 的WebSocketHandler另一种是用ServerEndpoint。ServerEndpoint更接近原生 Java WebSocket 的写法在 Spring Boot 里使用也不难但要手动注入 Bean。我这里说下用ServerEndpoint的做法第一步引入依赖。Spring Boot 的spring-boot-starter-web已经带了 WebSocket 支持我们只需要在 pom 里加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency第二步配置 WebSocket 端点注册。因为 Spring Boot 内嵌 Tomcat 默认不会扫描ServerEndpoint需要注册一个ServerEndpointExporterConfiguration public class WebSocketConfiguration { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }第三步创建一个 WebSocket 服务类用ServerEndpoint标记并且包含生命周期方法Component ServerEndpoint(/ws/order) Slf4j public class OrderWebSocketServer { // 保存当前在线的客户端 sessionkey 可以是商家 id private static final MapLong, Session SESSION_MAP new ConcurrentHashMap(); OnOpen public void onOpen(Session session) { log.info(WebSocket 连接建立 session.getId()); } OnClose public void onClose(Session session) { log.info(WebSocket 连接关闭 session.getId()); // 从 map 中移除 } OnError public void onError(Session session, Throwable error) { log.error(WebSocket 连接异常, error); } OnMessage public void onMessage(String message, Session session) { log.info(收到客户端消息 message); } }这样写端点只能建立连接还不知道这个连接属于哪个商家。所以需要“鉴权握手”在建立连接时确认身份。一般做法是前端把 token 放在 URL 参数里或者通过拦截器从 request 里取。由于ServerEndpoint不是 Spring 管理的标准 Controller拿不到 HttpSession我们往往通过session.getRequestURI()解析参数。我见过很多项目是前端连接时带上商家 idws://localhost:8080/ws/order?shopId1然后在 onOpen 方法里解析 query param OnOpen public void onOpen(Session session) { String queryString session.getQueryString(); // 解析 shopId Long shopId parseShopId(queryString); SESSION_MAP.put(shopId, session); }这个方式简单直观。现实中更安全的方式是结合 JWT在握手时校验 token再从 token 里解析商家身份。这里不展开但你要知道生产环境不可能裸奔传 id。3.3 下单后如何触发推送WebSocket 只是通道核心问题是打通“订单业务”和“推送服务”。在下单支付成功的地方我们需要把新订单的消息发出去。假设订单 Controller 的支付成功后回调方法里调用了OrderService.paySuccess(...)那么我们在里面加一段// 支付成功后向商家端推送来单提醒 OrderMessage orderMessage OrderMessage.builder() .type(NEW_ORDER) .orderId(orders.getId()) .orderTime(LocalDateTime.now()) .build(); webSocketServer.sendMessageToShop(orders.getShopId(), JSON.toJSONString(orderMessage));sendMessageToShop方法内部先去SESSION_MAP里找到该商家对应的 Session然后调用session.getBasicRemote().sendText(message)。这段逻辑有几个关键点推送动作不能影响主业务流程。如果 WebSocket 发送失败不能导致支付回调失败。所以一般会用 try-catch 包住记录错误日志继续主流程。你也可以用异步线程池来处理推送进一步提高隔离性。消息体要固定结构。前端收到消息后才能根据type区分是新订单还是催单。我建议使用一个统一的 JSON 格式包含type、data、timestamp。如果商家不在线SESSION_MAP里找不到 session推送要静默失败不能抛出异常把支付流程带崩。3.4 为什么很多人的 WebSocket 连不上WebSocket 对入门同学来说最大的困扰不是写代码而是跑不通。常见情况按严重程度排列连接 URL 写错。项目是前后端分离前端地址可能是 localhost:8080 或 8081后端 WebSocket 挂在 8080跨域、端口不一致导致连接失败。要检查前端连接地址要和 WebSocket 端点完全一致。部署环境没放开端口。WebSocket 握手先走 HTTP 升级如果 Nginx 没配Upgrade和Connection头代理就会断掉。自己改了 ServerEndpointExporter但依赖没有引入完整。有些项目用了其它 WebSocket 库冲突后ServerEndpoint未被扫描。连接建立后长时间没有消息被中间的网络设备断开。前端没有心跳机制过几分钟就断。WebSocket 虽然有 keep-alive但实际环境里代理层可能不管这个所以最好前端定时发 ping 消息或者后端定时给前端发心跳保活连接。我在实际项目里习惯在后端加一个定时任务每 30 秒给所有在线 Session 发送一个空消息或者心跳包既能探测连接是否存活也能让中间设备认为连接活跃。当发送心跳时抛异常就把这个 Session 从 map 里移除避免垃圾连接堆积。4. 用户催单给真实业务装上提醒的“小翅膀”4.1 催单的业务逻辑拆解催单这个功能不是简单的“前端发一条消息给后端后端再转发给商家”。它背后有校验、有权衡、有边界。用户场景是下单已经有一段时间但商家迟迟没有接单于是用户点击“催一下商家”。此时前端会把订单当前状态、下单时间、催单时间发给后端。后端要做的校验这个订单是否存在且属于当前用户。校验订单是否处于可以被催单的状态比如待接单。校验催单频率防止用户躺着疯狂点催单导致商家崩溃。组装一条催单消息推送给对应商家的 WebSocket。记录催单日志方便后续排查。4.2 Controller 和 Service 的实现要点Controller 层尽量薄只做参数接收和结果返回。例如PostMapping(/reminder/{orderId}) ApiOperation(用户催单) public Result remind(PathVariable Long orderId) { orderService.remind(orderId); return Result.success(); }Service 里我的实现思路是这样Transactional public void remind(Long orderId) { Orders orders orderMapper.getById(orderId); if (orders null) { throw new OrderBusinessException(订单不存在); } // 这里省略用户归属校验 // 校验催单时间间隔距离上次催单不足1分钟提示操作频繁 LocalDateTime lastRemindTime orders.getRemindTime(); if (lastRemindTime ! null lastRemindTime.isAfter(LocalDateTime.now().minusMinutes(1))) { throw new OrderBusinessException(您催单太频繁请稍后再试); } // 更新催单时间 orderMapper.updateRemindTime(orders.getId(), LocalDateTime.now()); // 组装消息 MapString, Object pushMsg new HashMap(); pushMsg.put(type, REMIND); pushMsg.put(orderId, orderId); pushMsg.put(userId, orders.getUserId()); pushMsg.put(msg, 顾客催单了请及时处理); webSocketServer.sendMessageToShop(orders.getShopId(), JSON.toJSONString(pushMsg)); }这里用到了orders表里的一个字段remind_time是我后来加的。很多原始项目里没有这个字段但为了防刷加个时间戳非常值得。从状态机的角度催单只能发生在“待接单”状态。如果订单已经在“配送中”催单没有意义甚至会让商家困惑。所以严谨一点应该判断orders.getStatus() 待接单。这一点很多练手项目没做但面试时你主动说出来会加分。4.3 防刷与限流的小细节催单是一个高频会被人恶意刷的接口。用户嘴上说“催单”实际可能用脚本每秒刷十次。后端的防线至少要有两层业务层防刷每个订单的remind_time判断时间间隔。用数据库更新时间的方式天然支持并发安全因为update ... set remind_time now() where id ? and remind_time now() - interval 1 minute影响行数为0就说明在频控时间内。逻辑层校验只有当前用户自己的订单才能催防止打扰别的商家。这里我踩过一个坑如果在同一事务里先查订单再更新两个请求同时进来都查到“上次催单是5分钟前”都通过校验就会被更新两次。解决办法就是利用数据库条件更新或者给订单记录加乐观锁版本号。对于学习项目吃透这个思路就够了。4.4 前端怎么配合严格来说前端不属于 Java 后端范围但作为全栈方向了解一下会让联调更顺畅。商家端网页在收到 WebSocket 消息后解析 JSON然后做三件事弹一个提示框内容包含订单号和用户留言。播一段提示音比如使用短音频文件。更新当前订单列表把新订单置顶展示。我自己的经验是前端不要依赖后端频繁推送“全部订单数据”因为那样无用流量很多。后端只推“事件信号”前端收到信号后主动调用 REST API 拉取最新数据。这样 WebSocket 只管轻量通知数据的可靠性和回放能力都落到 HTTP 接口上出问题也容易排查。5. 联调与排查经验定时任务和 WebSocket 的现场急救5.1 定时任务不执行的排查清单我辅导过不少同学遇到“定时任务没跑”普遍先怀疑代码写错了。其实第一步应该确认 Spring Boot 启动类上有没有EnableScheduling。这个注解加在配置类或启动类上都行但一定不能漏。第二步看控制台日志如果加了日志但看不到说明方法本身没被调用检查 cron 表达式是不是写在字符串里但没生效。第三步看是不是配置了spring.task.scheduling.pool.size这个值默认是1也就是同一时间只能有一个定时任务在执行。如果你有多个任务其中一个阻塞了其他任务也会排队。所以生产环境建议调大线程池spring.task.scheduling.pool.size10第三点容易被忽略尤其当你在定时任务里调用了外部 API某个任务卡住了后面的任务全都等不到线程看起来就像“系统不执行定时任务”。5.2 WebSocket 推送失败十大理由我做了一个速查表供你和团队排错用。问题现象可能原因排查方式客户端报 WebSocket connection failed端口不对或没有配置 ServerEndpointExporter检查连接地址、pom 依赖、配置类能连接但收不到消息推送时没找到对应 Session检查商家 id 解析是否一致、SESSION_MAP 是否有值推送一段时间后连接断开网络设备空闲超时后端或前端增加心跳连接报 403跨域问题握手请求 Origin 被拒绝配置允许来源或者使用代理转发Session 泄漏内存越来越大连接关闭时没移除 Map 里的 Session在 onClose 和 onError 中统一清理消息乱码编码问题使用 UTF-8、JSON 工具统一字符集特别提一句Session 管理一定要用ConcurrentHashMap并且保证移除逻辑放在 finally 里。我在一个项目里见过只在onClose移除但客户端直接断电时走的是onError结果连接变成僵尸推送时还会因为调用了不可用的 session 报错。5.3 手动触发与日志留痕排查问题光靠看不行要能“手动制造事故现场”。我常用的办法在 Controller 里临时加一个接口手动调用定时任务的处理方法比如GET /admin/test/processTimeoutOrder方便测试。生产环境上线前删除这个接口或者加权限控制。给 WebSocket 的每个操作加上 session id 和商家 id 的日志例如“推送消息给商家 12session 存在发送成功”。联调时前后端对日志秒级定位数据是传丢了还是没发出去。推送的内容统一打一条 JSON 日志保留消息体和推送时间出问题可以回放。5.4 自己动手扩展的几种玩法学完 day10这几个功能不是终点。我建议按照自己的兴趣把项目再往前推进一步。把定时任务替换成 Quartz 或 XXL-Job感受一下分布式任务调度和单机 task 的区别。来单提醒里加入“持久化消息表”即使商家当时不在线重新登录后依然可以看到未读提醒。催单功能增加推送渠道扩展比如短信、邮件这时你会发现 WebSocket 只负责实时在线推送离线场景需要新的消息路由。引入消息队列把“支付成功 → 发消息提醒”改成异步解耦更能贴近生产架构。我在实际做过的一个商户通知系统里就是先用 WebSocket 撑过了第一版后来量大了才引入 RocketMQ。也就是说你现在学的这套方案不是玩具而是很多小系统能直接上生产的妥帖方案。把它吃透后面再学 MQ 会轻松很多。最后再分享一个小技巧无论定时任务还是 WebSocket只要涉及高并发场景所有共享的数据结构都要考虑并发安全性所有外部依赖的调用都要 try-catch所有状态变更都要带上前置状态条件。代码写保守一点线上才能睡得安稳。苍穹外卖做到 day10项目的技术深度已经超出了“增删改查”的层面你可以开始享受“设计业务系统”的乐趣了。
返回列表