ARTICLE DETAIL

资讯详情

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

苍穹外卖订单状态闭环:Spring Task定时任务与WebSocket实时推送实战

苍穹外卖订单状态闭环:Spring Task定时任务与WebSocket实时推送实战 1. 首先说透这三个需求到底在解决什么问题做苍穹外卖项目做到订单模块很多同学会遇到一个绕不开的坎订单状态怎么自动流转商家怎么第一时间知道有新单用户催单了消息怎么触达商家这三个功能看着零散实际上是外卖系统最核心的状态闭环。先回顾一下苍穹外卖里的订单状态机。订单状态用数字表示1待付款、2待接单、3已接单、4派送中、5已完成、6已取消。用户下单后先进入待付款支付成功进入待接单商家接单后变成已接单骑手取餐后变派送中配送完成变成已完成。整个过程看起来是一条直线但实际运营中会冒出各种边界情况用户下了单但一直不付款订单卡在待付款状态如果不处理商家端的待办列表会堆满僵尸订单。骑手配送超时甚至完成配送后没有点击送达订单永远卡在派送中结算和报表数据全乱。商家忙起来根本没空一直盯着页面刷新新订单进来如果不提醒漏单了用户体验极差。用户等了半天没收到餐催单了商家那边没反应投诉直接拉满。这三个需求解决的正是上面四个边界情况。定时任务处理负责收尾——把卡住不动的订单自动推到最终状态来单提醒负责开局——用最快的速度让商家感知到新订单客户催单负责催办——给用户一个与商家实时沟通的通道。三条链路配合起来订单状态机才算真正完整。这套设计思路不只是苍穹外卖项目适用任何带订单体系的业务系统——比如门店收银、预约挂号、维修工单——都可以直接平移这套方案。所以这篇文章我不会只贴代码而是把整个问题的拆解路径、技术选型逻辑、代码实现细节和踩坑记录都过一遍。适合正在做苍穹外卖实战练手的人也适合刚接触Spring Boot定时任务和WebSocket实战的开发者参考。2. 技术选型定时任务和实时推送到底用什么2.1 定时任务的三种方案选型先说定时任务。市面上的方案大致分三类Spring自带的Task、Quartz、分布式任务调度平台最典型的是XXL-JOB。选哪个取决于你的项目规模和部署架构。苍穹外卖作为单体应用直接选Spring Task就够了。理由很实在Spring boot starter里自带零额外依赖Scheduled注解两行代码就能跑起来cron表达式控制执行规则学习成本几乎为零。Quartz跟Spring Task相比最大的优势是支持持久化、故障恢复和复杂的触发器但代价是配置繁琐、学习曲线陡对一个小外卖项目来说属于杀鸡用牛刀。XXL-JOB这类分布式方案更不用说了它解决的是集群部署下多个实例重复执行任务的问题单机单体项目根本用不上。但如果你的项目后面要拆微服务、要横向扩容那就要提前留好改造空间。苍穹外卖在这个模块上做分布式扩展时常见的做法就是把这两个定时任务迁移到XXL-JOB上用调度中心的控制台配cron任务代码里通过XxlJob注解声明JobHandler。我见过不少团队在这个阶段的改造方案是保留Spring Task的代码逻辑只把触发方式从Scheduled改成XxlJob因为核心业务代码是通用的换的就是谁在什么时间触发它。单体阶段用Spring Task演进到微服务阶段换XXL-JOB这是我认为比较务实的路线也是目前外卖类项目最主流的做法。2.2 实时推送方案的对比来单提醒和客户催单的本质是把服务端的事件实时推送给浏览器端。实现实时推送有几种常见手段前端轮询、SSEServer-Sent Events、WebSocket。前端轮询最笨也最简单前端每2秒调一次接口查新订单实现起来没有任何门槛但问题是消息实时性差高峰时2秒的延迟都会漏掉关键消息请求频繁服务端压力大而且它无法做真正的服务端主动推送。SSE是单向的只能服务端往客户端推对提醒这个场景其实够用但浏览器连接数和Nginx代理配置上限制比较多。WebSocket是标准的全双工通信服务端可以随时把消息推到指定连接上是外卖来单提醒这类强实时场景的主力方案。那为什么外卖项目里普遍选择WebSocket而不是SSE核心原因是这类提醒功能最终都会演进成交互功能——比如用户催单后商家端不仅要收到提醒还可能要直接回复、改派、标记异常这些操作需要客户端向服务端发消息。WebSocket一条连接双向通信省掉了额外的请求通道。再说Nginx从1.3版本开始就支持WebSocket代理配置也不算复杂。苍穹外卖项目里WebSocket的位置一般是ws://域名/api/ws/{sid}商家后台页面加载时建立连接之后订单事件通过这个连接推送过来。另一个容易踩坑的点是Nginx代理WebSocket的配置。如果项目上线后用了Nginx反代一定要加这两行proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade;不加的话WebSocket握手阶段大概率失败或者连接建立后几十秒就被服务端切断。还要把proxy_read_timeout调大比如300秒让长连接更稳定。选型这块总结一句话定时任务用Spring Task撑住单体阶段实时推送直接上WebSocket把消息和服务端的连接管理设计好这个方案能支撑的业务量远超你的想象。3. 核心实现订单状态定时处理的完整落地3.1 Spring Task的配置与定时规则设计定时任务在Spring Boot里用起来确实简单但跑起来和正确地跑是两回事。第一步是在启动类或配置类上加EnableScheduling开启调度功能然后在定时任务类上加Scheduled注解。Configuration EnableScheduling public class SchedulingConfig { }订单定时处理在这个项目里有两个任务我拆成两个方法独立管理订单超时未支付处理每1分钟扫描一次把超过15分钟仍未支付的待付款订单自动取消。配送中超时订单处理每1分钟扫描一次把配送超过1小时的派送中订单自动标记为已完成。再在每天凌晨单独触发一次兜底扫描处理所有遗留订单。Component Slf4j public class OrderTask { // 每隔1分钟触发订单超时未支付自动取消 Scheduled(cron 0 * * * * ?) public void processTimeoutOrder() { log.info(定时处理超时订单开始执行); // 业务逻辑... } // 每天凌晨1点触发配送超时订单自动完成 Scheduled(cron 0 0 1 * * ?) public void processDeliveryOrder() { log.info(定时处理派送中订单开始执行); // 业务逻辑... } }这里有个细节容易搞错Scheduled的cron表达式是6位秒、分、时、日、月、周不是Quartz那种7位的别把第一位当成秒就稀里糊涂填成7个字段。另外cron表达式解析的是服务器本地时区如果部署的机器时区不对凌晨1点的任务可能在中午1点跑这个用date命令检查一下系统时区就能避免。更重要的设计是任务的间隔周期。超时未支付订单为什么用1分钟扫描而不是精确按15分钟延时触发因为外卖场景对超时取消的精度要求没那么高晚几十秒取消对用户和商家都没实质影响。而如果为了追求精确去搞延时队列反而要引入消息队列的复杂度。反过来如果业务要求高精度比如预约订单逾时释放库存那Spring Task这种固定周期扫描的模型就不合适了得用RabbitMQ或延迟队列来做这就是另一个课题了。在这个项目阶段1分钟扫描一次每次把符合条件的订单批量更新性能上完全没问题。我后面会给出批量更新的写法避免一条条UPDATE。3.2 超时未支付订单自动取消先看定时任务要处理的订单数据。查订单表时条件很清晰状态是待付款1且下单时间小于15分钟之前。注意这里比较的是order_time而不是create_time因为订单表里order_time才是用户下单动作的时间戳。Scheduled(cron 0 * * * * ?) public void processTimeoutOrder() { log.info(定时处理超时订单{}, new Date()); LocalDateTime time LocalDateTime.now().minusMinutes(15); ListOrders ordersList orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time); if (ordersList ! null !ordersList.isEmpty()) { ordersList.forEach(order - { order.setStatus(Orders.CANCELLED); order.setCancelReason(订单超时未支付); order.setCancelTime(LocalDateTime.now()); orderMapper.update(order); }); } }DAO层对应的查询语句是这样select idgetByStatusAndOrderTimeLT resultTypeOrders select * from orders where status #{status} and order_time lt; #{time} /select这段代码的关键不在查出来而在怎么更新。很多人写到这里直接orderMapper.update(order)把整个订单对象传进去然后SQL里把所有字段都更新一遍。这样做有两个隐患一是并发场景下可能把用户刚刚完成的支付状态覆盖掉二是一些业务字段被意外改动。更稳的写法是专门写一个按条件更新状态的SQLupdate orders set status 6, cancel_reason 订单超时未支付, cancel_time #{cancelTime} where id #{id} and status 1注意末尾的and status 1这是防止超卖式的状态误判——假设在定时任务查出订单之后、更新状态之前用户刚好完成了支付订单状态已经变成2了那这条UPDATE因为条件不匹配就影响不了它白白保护了一次用户订单。这种条件更新在实际开发里极其重要尤其是定时任务和用户操作可能同时触发同一个订单的修改时。另外订单取消后别忘了处理关联的库存。苍穹外卖里菜品有库存字段的话这个定时任务除了改订单状态还应该回滚菜品库存。这个逻辑在用户手动取消订单的代码里应该有定时任务这里要复用而不是各写一套否则后面改库存规则时容易漏改一个地方。我在实际开发里就见过手动取消的库存回滚了定时任务的没回滚月底对账盘亏才发现非常尴尬。3.3 配送超时订单自动完成第二个定时任务是处理派送中的订单。这类订单是骑手已经取餐、开始配送的订单卡在状态4派送中。正常情况下骑手送达后确认完成状态变成5已完成。不过总有意外骑手忘了点送达、系统异常、订单长时间无进度。如果放任不管账单一直挂起统计口径全乱。处理逻辑状态为派送中4且当前时间距离下单时间超过60分钟的自动把状态改为已完成。Scheduled(cron 0 0 1 * * ?) public void processDeliveryOrder() { log.info(定时处理派送中订单{}, new Date()); LocalDateTime time LocalDateTime.now().minusMinutes(60); ListOrders ordersList orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time); if (ordersList ! null !ordersList.isEmpty()) { ordersList.forEach(order - { order.setStatus(Orders.COMPLETED); orderMapper.update(order); }); } }这个任务里的预设值60分钟是业务规则不是写死的常量。在实际项目里这类超时阈值一般放到配置中心或数据库字典表方便运营随时调。苍穹外卖里我习惯放到application.yml的自定义配置里sky: order: timeout-minutes: 15 delivery-timeout-minutes: 60用ConfigurationProperties或Value注入到任务类里比在代码里写数字清楚得多。后来交接给同事维护的时候对方不用看懂代码也能改配置。还一个细节这个任务里订单的预计送达时间可能不同更精细的做法是判断预计送达时间 缓冲时间而不是统一按下单时间加60分钟。但如果业务上没有单独记录预计送达时间统一按下单时间算也足够满足教学项目的需求。4. 来单提醒与客户催单的推送链路4.1 WebSocket连接管理连接如何建立与保存来单提醒和客户催单共用一个WebSocket通道。先把连接管理做好下面两个功能就是往这条通道里发不同消息的事。苍穹外卖里一般写一个WebSocketServer类核心是维护商家连接。商家后台页面加载后通过JS建立连接var socket new WebSocket(ws://localhost:8080/api/ws/ token);服务端要做的一是定义/ws/{sid}端点二是在连接建立时把session保存起来。注意一个商家可能会开多个页面比如同一个管理员在谷歌浏览器和Edge浏览器各开了一个商家后台那就会建立两条连接。所以不能用简单的一个商家一条连接来设计我的做法是用ConcurrentHashMapLong, SetSessionkey是userIdvalue是session集合。Component ServerEndpoint(/ws/{sid}) Slf4j public class WebSocketServer { private static MapLong, SetSession sessionMap new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(sid) Long sid) { SetSession sessions sessionMap.computeIfAbsent(sid, k - new ConcurrentHashSet()); sessions.add(session); log.info(用户连接成功sid{}, sessionId{}, sid, session.getId()); } OnClose public void onClose(Session session, PathParam(sid) Long sid) { SetSession sessions sessionMap.get(sid); if (sessions ! null) { sessions.remove(session); if (sessions.isEmpty()) { sessionMap.remove(sid); } } } public void sendToUser(Long sid, String message) { SetSession sessions sessionMap.get(sid); if (sessions null || sessions.isEmpty()) { log.info(用户 {} 不在线消息暂不发送, sid); return; } for (Session session : sessions) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error(WebSocket发送消息失败{}, e.getMessage()); } } } }有一个实战细节必须提醒ServerEndpoint创建的类是多例的每个连接都会new一个实例但sessionMap必须是静态共享的否则连接A保存的session在连接B里访问不到。很多新手在这里踩坑加了Component以为Spring管理了单例结果sessionMap每个实例各自持有一份消息永远发不出去。如果你用的是Spring原生的WebSocketHandler实现那类是单例的反而没有这个问题。苍穹外卖里ServerEndpoint的方案更常见所以要记得静态Map。Session存储这块虽然ConcurrentHashMap的key操作是线程安全的但value里的Set如果不做并发处理多个页面同时连接和断开就会有并发修改异常。用ConcurrentHashSet或者Collections.synchronizedSet包一下比较稳。4.2 来单提醒用户支付成功后的即时推送来单提醒的业务逻辑不复杂用户下单并支付成功后服务端把订单的关键信息组装成消息推送给对应商家的WebSocket连接。这个动作发生在用户支付成功这个事件点上而不是下单成功——因为待付款的订单还不算真正的生意商家端收到提醒去备餐结果用户转头不付款把单取消了这提醒就白推了还会给商家增加不必要的操作。支付成功后的推送代码public void reminder(Long orderId) { Orders orders orderMapper.getById(orderId); // 组装要推送的消息 MapString, Object map new HashMap(); map.put(type, 1); // 1表示来单提醒 map.put(orderId, orders.getId()); map.put(orderNumber, orders.getNumber()); map.put(amount, orders.getAmount()); map.put(status, orders.getStatus()); String jsonString JSON.toJSONString(map); webSocketServer.sendToUser(orders.getShopId(), jsonString); }推送给哪个商家这个信息不用额外关联查询订单表里本来就有shop_id字段直接取出来作为WebSocket的发送目标就行。消息体里带上订单号、金额、桌号或配送地址、预计送达时间字段商家端收到后弹窗展示。写消息结构时建议定义一个type区分消息类型不然前端拿到的所有消息都是一个结构没法区分新订单来了和客户催单了。这里type1是来单提醒type2是催单提醒后面接终端的同事处理起来很清晰。我见过一些项目的推送消息直接拼一个字符串或者只推一个订单号前端为了展示信息又回查一次订单接口。这么做不是不行但一来多一次请求二来如果订单状态在推送过程中变化了前端回查到的内容可能跟推送时不一致。直接在推送消息里把展示需要的字段都带上前端拿到就能渲染保持消息的即时性这是更推荐的做法。4.3 客户催单从用户点击到商家弹窗的全链路客户催单的逻辑相对独立它发生在用户端小程序里。用户看到自己的订单在待接单状态卡了很久点一下催单按钮后端收到请求后向商家端推送一条催单消息。苍穹外卖里的controller和service是这样组织的GetMapping(/reminder/{id}) public Result reminder(PathVariable Long id) { orderService.reminder(id); return Result.success(); }public void reminder(Long orderId) { Orders orders orderMapper.getById(orderId); if (orders null) { throw new OrderBusinessException(订单不存在); } MapString, Object map new HashMap(); map.put(type, 2); // 2表示客户催单 map.put(orderId, orders.getId()); map.put(orderNumber, orders.getNumber()); map.put(status, orders.getStatus()); String jsonString JSON.toJSONString(map); webSocketServer.sendToUser(orders.getShopId(), jsonString); }对用户来说操作就一步——点催单按钮。但对后端来说催单不能是无限制的。如果用户每10秒点一次催单商家那边的弹窗就没停过提醒功能反而变成了骚扰。我在做这个功能的时候加了一个简单的频率限制同一个订单5分钟内只能催一次。这个限制用Redis实现最简单String key order:reminder: orderId; Boolean flag redisTemplate.hasKey(key); if (Boolean.TRUE.equals(flag)) { throw new OrderBusinessException(您已催单过请稍后再试); } redisTemplate.opsForValue().set(key, 1, 5, TimeUnit.MINUTES);如果项目里没接Redis用本地Caffeine缓存甚至一个ConcurrentHashMap加时间戳也能实现毕竟催单频率限制不需要多强的分布式一致性。但苍穹外卖这个项目本身就有Redis直接用起来就行。催单的时机也值得多说一句。状态为待接单或已接单的订单催单才有意义已完成、已取消的订单催了也白催。接口里最好校验一下订单状态否则用户对配送中的订单疯狂催单消息推到商家端纯属干扰。可以加上if (orders.getStatus() ! 2 orders.getStatus() ! 3) { throw new OrderBusinessException(当前订单状态不支持催单); }前端收到type2的消息后一般会做两件事弹窗提醒并播放一段提示音。商家声音开到最大听到催单音就知道有用户等急了赶紧安排出餐。4.4 前端如何接收消息这部分虽然偏前端但后端开发也必须了解否则调试的时候不知道怎么模拟商家端收消息。商家后台页面在加载时建立WebSocket连接并注册接收消息的回调var socket new WebSocket(ws://localhost:8080/api/ws/ userId); socket.onmessage function(event) { var message JSON.parse(event.data); if (message.type 1) { // 来单提醒 alert(您有新的订单订单号 message.orderNumber); // 同时可以调用回调函数刷新订单列表 } else if (message.type 2) { // 客户催单提醒 alert(客户催单订单 message.orderNumber 等待处理); } };实际开发里用alert会阻塞页面操作更好的做法是弹一个非阻塞的自定义模态框加上声音播放。不过这只是锦上添花核心还是后端推送链路要稳定。自己调试的时候最简单的验证方式是用浏览器控制台手动new一个WebSocket连接连接到ws://localhost:8080/api/ws/{商家id}然后直接调后端接口模拟下单/催单看控制台会不会打印接收到的消息。这一步跑通推送链路基本就通了。5. 定时任务和WebSocket的常见坑5.1 cron表达式的误区和时区问题Scheduled的cron表达式用6位配置秒 分 时 日 月 周。一个典型的表达式0 0 1 * * ?表示每天凌晨1点整执行0 * * * * ?表示每分种第0秒执行。两个误区最常见第一把表达式写成7位。Quartz的cron自带年份位但Spring的Scheduled只有6位多写一个字段直接启动报错报错信息Cron expression must consist of 6 fields就是这个问题。论坛和GitHub上抄Quartz表达式时特别容易带进来。第二日和周字段不能同时指定具体的值。比如想在每月15号的周一执行写成0 0 0 15 * 1是非法表达式这两个字段必须有一个是?。很多人不理解?的含义它在cron里就是不指定专门用来规避日和周冲突。时区问题则更隐蔽。Spring的Scheduled默认使用服务器所在的系统时区。如果部署环境时区是UTC写0 0 1 * * ?原本想凌晨1点跑实际是北京时间早上9点跑整批定时任务全部错位。排查这么问题时会发现cron表达式明明没错任务就是不按预期执行。针对这个问题我习惯在项目启动脚本里显式设置-Duser.timezoneGMT8或者在配置里写明spring: jackson: time-zone: GMT8这配置主要影响Jackson序列化但能侧面提醒部署时要注意时区。最稳的还是运维层面统一用Asia/Shanghai时区。5.2 定时任务重复执行怎么办单体部署一般不会重复执行但一旦上了集群或者本地起了多个实例同一个Scheduled方法就会在每个实例上各跑一遍出现重复取消订单、重复推送重复消息的问题。这个问题的根治方案是引入分布式锁。Spring Task本身不带分布式锁能力通常配合Redis实现任务执行前先尝试加锁加锁失败说明其他实例已经在执行了直接跳过本次。Scheduled(cron 0 * * * * ?) public void processTimeoutOrder() { // SETNX加锁10秒自动过期 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:order:timeout, 1, 10, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(locked)) { log.info(定时任务已被其他实例执行本次跳过); return; } try { // 业务逻辑... } finally { redisTemplate.delete(lock:order:timeout); } }注意加锁和释放锁之间的业务逻辑一定要控制在锁过期时间之内否则第一个实例还在跑锁过期了第二个实例又拿到锁重复执行。所以锁的过期时间要按业务最大耗时来设不能拍脑袋写个3秒。如果你用了XXL-JOB这问题天然就不存在——调度中心保证每个任务在集群中只被一个实例执行这也是为什么分布式架构下大家会切换到XXL-JOB。但在单体阶段用Redis分布式锁做个预防成本很低值得写上。5.3 WebSocket连接不稳定和Session复用问题WebSocket是长连接最怕的是连接被中间设备切断后没有及时清理。Nginx默认的proxy_read_timeout是60秒如果客户端和服务端之间60秒没有数据往来Nginx会主动断开连接。而客户端和服务器双方都不知道连接已经断了就会出现消息发过去没啥反应、页面也不报错的情况。解决办法有两个一是Nginx把超时时间调大二是前端定时发送心跳消息。心跳比调超时更可靠因为哪怕Nginx的超时时间调到10分钟运营商或防火墙级别的空闲连接超时也不是你能控制的。前端可以每30秒发一次ping后端收到后回一个pong连接就一直保持活跃状态。服务端Session还有一个常见问题浏览器刷新页面后旧的WebSocket连接不会立刻消失但可能已经失效。服务端往旧Session发消息时会抛异常。所以在OnClose里把Session从Map里移除的逻辑一定要严谨同时发送消息时要把异常捕获掉不能因为一个失效连接拖垮整个推送流程。我在前面代码里已经写到了try/catch包裹发送逻辑这个习惯要保留线上环境里失效连接远比你想的多。5.4 并发更新订单状态的条件保护最后一个坑是关于订单状态更新的并发问题。订单状态变更的场景很多用户支付、商家接单、骑手取餐、用户取消、定时任务取消。这些操作可能在极短时间内同时发生如果代码里都是读出来、改掉、写回去的模式状态覆盖就避免不了。比如用户下单后忘了支付过了20分钟才发现想支付刚好定时任务也在跑两条链路同时操作这个订单。定时任务把订单改成已取消用户支付成功的回调又把订单改成待接单最终订单状态以用户支付为准但商家那边已经被推送了一次新订单库存在定时任务取消时被回滚了支付成功后没有恢复……整个链路全乱。解决的关键就是更新语句带上状态条件用数据库的行锁保证读改写是原子的update orders set status #{newStatus}, cancel_reason #{cancelReason}, cancel_time #{cancelTime} where id #{id} and status #{currentStatus}这样只有订单当前状态确实是待付款时它才会被改成已取消。如果用户已经支付、状态变成待接单这条UPDATE不会影响任何记录定时任务这轮扑空也没关系下一轮扫描时这个订单已经不在待付款集合里了。这个思想不仅仅适用于订单超时取消也适用于所有订单状态流转的代码。苍穹外卖这种教学项目虽然在并发量上没那么极端但把它当成生产标准来写能帮你养成好习惯。我现在的习惯是任何状态更新操作一律在where里带上现状状态条件多一行代码少一堆线上数据对不上的麻烦。6. 我做完这三个功能后的一些经验这套功能完整跑通之后我最大的体会是订单模块的价值不在于某个花哨的接口而在于把订单的各种边界情况处理得足够平滑。定时任务是把异常状态拉回正轨的守门员WebSocket推送是把状态变化及时同步给相关方的布告板催单则是给用户的情绪一个出口。三个功能本质上都服务于同一个目标让订单状态机高效、准确、可感知地运转。另外每一个细节都不要小看。定时任务扫描周期设多少、消息里要不要带type字段、催单频率限制多久、更新SQL要不要带状态条件——这些决定不是写代码的时候顺手拍脑袋而是要考虑清楚这个功能的边界场景是什么用户和商家分别会怎么使用它。多从实际运营的角度去推演一遍代码质量和可用性都会上一个台阶。如果你后面要把这个项目做大从单体切成微服务最先要改造的也是这三个模块定时任务交给XXL-JOB管理减少重复执行风险WebSocket可以接一个消息推送中间件处理连接数暴涨后的压力催单和提醒可以做成独立的消息服务。但核心业务逻辑不会变还是那套订单状态机加推送链路。把单体阶段的地基打牢后面怎么扩展都不慌。
返回列表