☰
苍穹外卖订单状态闭环:Spring Task定时任务与WebSocket实时推送实战
2026/10/6 8:51:06 网站建设 项目流程

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 实时推送方案的对比

来单提醒和客户催单的本质,是把服务端的事件实时推送给浏览器端。实现实时推送有几种常见手段:前端轮询、SSE(Server-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); List<Orders> 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 id="getByStatusAndOrderTimeLT" resultType="Orders"> select * from orders where status = #{status} and order_time &lt; #{time} </select>

这段代码的关键不在"查出来",而在"怎么更新"。很多人写到这里直接orderMapper.update(order),把整个订单对象传进去,然后SQL里把所有字段都更新一遍。这样做有两个隐患:一是并发场景下可能把用户刚刚完成的支付状态覆盖掉;二是一些业务字段被意外改动。更稳的写法是专门写一个按条件更新状态的SQL:

update 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); List<Orders> 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浏览器各开了一个商家后台,那就会建立两条连接。所以不能用简单的"一个商家一条连接"来设计,我的做法是用ConcurrentHashMap<Long, Set<Session>>,key是userId,value是session集合。

@Component @ServerEndpoint("/ws/{sid}") @Slf4j public class WebSocketServer { private static Map<Long, Set<Session>> sessionMap = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("sid") Long sid) { Set<Session> 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) { Set<Session> sessions = sessionMap.get(sid); if (sessions != null) { sessions.remove(session); if (sessions.isEmpty()) { sessionMap.remove(sid); } } } public void sendToUser(Long sid, String message) { Set<Session> 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); // 组装要推送的消息 Map<String, 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区分消息类型,不然前端拿到的所有消息都是一个结构,没法区分"新订单来了"和"客户催单了"。这里type=1是来单提醒,type=2是催单提醒,后面接终端的同事处理起来很清晰。

我见过一些项目的推送消息直接拼一个字符串或者只推一个订单号,前端为了展示信息又回查一次订单接口。这么做不是不行,但一来多一次请求,二来如果订单状态在推送过程中变化了,前端回查到的内容可能跟推送时不一致。直接在推送消息里把展示需要的字段都带上,前端拿到就能渲染,保持消息的即时性,这是更推荐的做法。

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("订单不存在"); } Map<String, 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("当前订单状态不支持催单"); }

前端收到type=2的消息后,一般会做两件事:弹窗提醒并播放一段提示音。商家声音开到最大,听到催单音就知道有用户等急了,赶紧安排出餐。

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.timezone=GMT+8,或者在配置里写明:

spring: jackson: time-zone: GMT+8

这配置主要影响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可以接一个消息推送中间件,处理连接数暴涨后的压力;催单和提醒可以做成独立的消息服务。但核心业务逻辑不会变,还是那套订单状态机加推送链路。把单体阶段的地基打牢,后面怎么扩展都不慌。

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

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

立即咨询