☰
苍穹外卖订单模块实战:超时处理、来单提醒与催单机制的落地
2026/10/5 11:39:25 网站建设 项目流程

1. 项目概述与整体设计思路

1.1 这个模块到底要解决什么问题

苍穹外卖的订单模块,光是把“用户下单→商家接单→配送完成”这条主链路跑通,其实只是完成了最基本的功能。真正让系统“好用”的,反而是那些不起眼的旁路逻辑:订单超时了没人处理怎么办、用户下单后商家没听见怎么办、用户等急了怎么催单。这三件事看起来简单,但在真实业务里,每一件都会直接影响用户体验和商家口碑。

我最初接手这个模块时,第一反应是“这不就是三个定时任务加几个接口嘛”。真做起来才发现,订单状态定时处理、来单提醒、客户催单这三块,每一块背后都牵扯到状态机设计、任务调度策略、实时推送通道、并发控制等等一系列问题。如果只盯着“把功能实现出来”,那代码可能很快就能跑通,但一旦遇到高并发、服务器重启、订单状态错乱这些场景,就会连环踩坑。

这个模块适合谁参考?一种是正在做苍穹外卖项目、想把订单模块做得更扎实的同学,另一种是工作中要处理类似“超时关单+实时提醒+用户主动触发”这类业务场景的开发者。我会把三块功能拆开讲,每块都会给出可落地的方案和我在实践中踩过的坑。

1.2 三块功能的技术选型概览

在设计这个模块时,我首先梳理了三块功能各自的本质:

  • 订单状态定时处理:本质是一个“到了某个时间点,批量扫描并更新状态”的定时任务。核心难点在于任务执行时机、扫描效率、状态流转的幂等性。
  • 来单提醒:本质是一个“订单创建后,把消息实时推送给商家”的异步通知。核心难点在于推送通道的选择、消息不丢失、商家在线/离线的差异化处理。
  • 客户催单:本质是一个“用户主动触发,系统检查订单状态并再次提醒商家”的交互接口。核心难点在于催单的频率控制、状态校验、与定时任务的配合。

方案选型上,我的思路是这样的:定时任务先用Spring自带的@Scheduled把功能跑通,后续再考虑引入分布式任务调度平台;提醒推送优先走WebSocket,同时保留浏览器通知作为降级方案;催单接口则基于订单状态机做严格校验,避免用户乱催、重复催。下面逐个展开。

2. 来单提醒的推送链路与信息触达设计

2.1 为什么来单提醒要“先抄后推”

来单提醒看起来就是“商家端收到一个新订单”,但如果直接在前端轮询订单列表,又费流量又不实时。轮询间隔短了,数据库压力大;间隔长了,用户下单后商家要等好几秒才知道,体验很差。

更好的做法是“先抄后推”:订单创建成功后,先把数据落库,确保订单一定不会丢,然后再通过WebSocket把“有新订单”这个事件推给商家端。也就是订单主流程不依赖推送结果,推送成功与否都不影响订单正常生成。我刚开始做的时候,差点把推送写成同步调用,后来想明白一个道理:如果推送服务挂了,难道用户连单都下不了吗?肯定不行。

这种设计的好处有三个:第一,订单主链路简单可靠;第二,推送是异步的,不拖慢下单响应时间;第三,即使推送失败,商家端仍然可以通过订单列表主动刷新看到新订单,只是“提醒”这个增强体验丢了而已。所以我把推送逻辑放在订单创建成功之后的事件发布环节,通过Spring的ApplicationEventPublisher发一个事件,再由监听器异步处理推送,实现了主链路和解耦。

2.2 多端触达:浏览器通知、WebSocket、短信的取舍

来单提醒不能只依赖一条通道,因为商家端可能处于不同的状态:网页开着但不一定盯着看、浏览器标签页被切走了、甚至商家根本不在电脑前。我在实际开发中把触达渠道按优先级分成了三层:

  • WebSocket实时推送:商家端在线时最优先使用。连接建立后,服务端一旦检测到新订单,立即推送“来单提醒”消息,商家端弹窗+提示音。这是整个提醒功能的主力通道。
  • 浏览器通知(Notification API):当商家端的WebSocket连接存在,但页面处于后台标签页时,单纯靠页面内弹窗是看不见的。这时用浏览器原生通知,即使不在当前标签页也能看到系统级弹窗。
  • 短信/电话提醒:这属于重型触达,一般只在商家长时间不接单、订单即将超时或被客户催单时才会触发。因为短信有成本,而且容易造成骚扰,所以不能当作默认通道。

我的建议是:先把WebSocket和浏览器通知做好,短信通道做成一个可扩展的接口,后续接第三方短信平台时只需要实现同一个接口即可。这样既不会在初期被短信成本拖累,又保留了后续迭代的空间。

2.3 提醒内容模板与防骚扰策略

来单提醒的内容设计也有讲究。最初我只推了一个“您有新的订单”,商家点进去还要再查一遍订单详情,多了一步操作。后来我把推送内容直接拼装成包含关键信息的模板,商家在弹窗里就能看到主要内容,例如订单号、用户手机号后四位、订单金额、预计送达时间、备注信息等。

{ "type": "NEW_ORDER", "data": { "orderId": 1024, "orderNumber": "2025011512345678", "amount": 68.5, "expectDeliveryTime": "2025-01-15 12:30", "remark": "不要辣,多放葱", "timestamp": 1736901234000 } }

防骚扰策略这块很多人会忽略。我遇到过一个问题:商家端WebSocket重连时,服务端会把连接期间积累的订单全部补推一遍,结果商家收到十几个提醒弹窗,体验极差。后来我加了一个规则:同一个商家端连接,收到重复推送时前端做去重;服务端则只推送“当前时间点之后的新订单”,历史订单通过列表接口加载,不通过推送补发。另外,为了避免同一订单被反复提醒,我在订单表里加了remind_status字段,标记该订单是否已经推送过,推送成功则置为已推送,防止重复提醒。

3. 客户催单的实现与超时订单的自动化闭环

3.1 催单按钮的前后端联动

客户催单的业务背景很好理解:用户下单后,如果商家迟迟不接单,用户等得着急,于是发起催单,系统需要把这个诉求转达给商家,并且要给出反馈。用户在C端“催单”按钮点击后,前端调用后端接口,后端需要做三件事:校验订单是否可以催单、记录催单事件、触发对商家的再次提醒。

后端接口的核心逻辑我设计成下面这样:

@PostMapping("/remind/{orderId}") @ApiOperation("客户催单") public Result<String> remind(@PathVariable Long orderId) { // 1. 查询订单 Order order = orderMapper.getById(orderId); if (order == null) { throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND); } // 2. 校验订单状态,只有待接单状态才允许催单 if (!OrderStatus.PENDING_PAYMENT.equals(order.getStatus())) { throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR); } // 3. 记录催单事件,同一订单短时间内不能重复催 if (remindService.isFrequent(orderId, LocalDateTime.now())) { throw new OrderBusinessException(MessageConstant.REMIND_TOO_FREQUENT); } remindService.save(orderId, userId, LocalDateTime.now()); // 4. 触发商家提醒 orderRemindService.sendRemindAgain(order); return Result.success(); }

前端收到成功响应后,会弹一个“已提醒商家尽快接单”的提示,同时按钮进入冷却状态,比如3分钟内不可再次点击。这个冷却时间其实是我在实操中根据用户反馈调整的,最开始设的1分钟,结果有用户连续点了好几次,商家端被反复打扰,后来统一调整为3分钟,体验才正常。

3.2 状态机校验:催单不是“想催就能催”

催单接口最容易漏掉的一点是状态校验。如果不加状态判断,用户对已完成的订单也能发起催单,这显然不合理。我维护了一套订单状态机,定义了订单从“待支付→待接单→已接单→派送中→已完成→已取消”的流转规则,然后把催单接口死死地限制在“待接单”和“派送中”两个状态。

为什么不给“待支付”状态开放催单?因为用户还没付钱,压根不存在“商家不处理”的问题。为什么不给“已接单”开放催单?商家已经在做了,再催就是打扰干活。真正需要催的,是商家迟迟不接单(待接单)或配送太久(派送中)这两种场景。状态机的价值就在这里,它不是限制功能,而是让功能出现在真正需要它的场景里。

3.3 超时未处理的自动提醒链路

只靠用户手动催单是不够的,很多用户不会主动点催单,或者压根没注意到订单卡住了。所以系统还需要一条“自动催”的链路:订单进入待接单状态后,超过一定时间商家仍未接单,系统就自动执行一次提醒,甚至同步触发短信通知商家。

这条链路本质上依赖定时任务。我在苍穹外卖里做的是:每分钟扫描一次订单表,筛选出“状态为待接单、且下单时间超过5分钟”的订单,对这些订单发起一次催单提醒。同时,为了不让商家被无限骚扰,同一订单的自动提醒最多触发3次,超过次数后会转入异常订单列表,后续由人工介入处理。

这种设计把“用户主动催”和“系统自动催”结合了起来,覆盖了大多数超时场景。做的时候我特意留意了扫描SQL的写法,一定要在status和create_time上建联合索引,否则订单量一旦上来,每分钟一次的全表扫描会把数据库拖垮。

4. 定时任务框架选型:从Spring Schedule到分布式方案

4.1 Spring Schedule的适用边界

苍穹外卖项目里,定时任务我用的Spring自带的@Scheduled,原因很直接:项目规模可控、不需要复杂的任务编排、部署方式还是单机部署,杀鸡不用牛刀。Spring Schedule用起来也确实方便,一个注解加一个方法就能搞定。

@Component @Slf4j public class OrderTask { @Scheduled(cron = "0 * * * * ?") // 每分钟执行一次 public void processTimeoutOrder() { log.info("定时处理超时订单:开始"); // 处理待支付超时订单、待接单超时订单等 orderService.processTimeoutOrder(); log.info("定时处理超时订单:结束"); } }

但Spring Schedule的短板也很明确:单机执行,没有任务分片能力,没有失败重试机制,没有任务执行记录的可视化界面。一旦服务部署多个实例,同一个任务会在每个实例上都执行一遍,如果没有做好幂等控制,就可能出现重复处理。所以我对Spring Schedule的定位是“单机场景够用,分布场景慎用”。

4.2 分布式定时任务的演进路线

如果苍穹外卖后续要做成微服务架构、多实例部署,那定时任务必须迁移到分布式方案。目前主流的思路有两条:

  • 引入XXL-JOB:一个轻量级的分布式任务调度平台,支持任务分片广播、失败重试、动态调整cron表达式、执行日志可视化。部署时通过调度中心统一触发任务,执行器节点只负责干活,天然解决了多实例重复执行的问题。
  • 基于Spring Cloud + Redis分布式锁:不引入额外中间件,通过Redis的SETNX实现一个分布式锁,多实例抢到锁的节点才执行定时任务。优点是轻量,缺点是任务没有分片能力,同一时刻只有一个节点在跑,并发吞吐受限。

这两条路我都实地调研过。对于苍穹外卖这种体量的项目,我倾向于先上Redis分布式锁,因为成本低、改造快,足以支撑日常流量;等业务量明显增长、需要任务拆分并行执行时,再平滑迁移到XXL-JOB。定时任务这件事,最忌讳一上来就上重武器,先把简单的方案用好,比盲目堆架构更实在。

4.3 苍穹外卖采用的方案与取舍

回到苍穹外卖这个具体项目。我的最终落地组合是:Spring Schedule做基础定时调度 + Redis分布式锁做多实例互斥 + 状态字段做业务幂等。也就是说,即使以后部署了多个实例,同一个任务同一时刻只有一个实例在跑;即使任务执行过程中出现异常中断,重跑时也不会把已处理过的订单再处理一遍。

这里有个细节值得多说一句:幂等控制不能只靠分布式锁,因为锁只能管住“同一时刻”,管不住“下次执行”。如果处理逻辑本身就存在脏数据问题,即使锁完全没问题,也可能重复更新状态。所以我在更新订单状态的SQL里永远带上状态条件,例如只有当前状态是“待接单”才能更新为“已接单”,这样即使任务重跑,也不会影响已经流转到下一状态的订单。

5. 实操中的坑与排查实录

5.1 定时任务“丢跑”与重复执行的根因

定时任务最常见的问题就是“丢了”或者“重复跑”。我在测试阶段遇到过这么一件事:定时任务设置为每分钟执行一次,某次手动触发后,日志里出现了两条“开始处理”记录。排查后发现,是因为测试环境的订单服务部署了两个实例,两个实例的@Scheduled同时触发,导致同一批订单被扫描了两次。虽然在SQL层面做了状态条件拼接,数据没出大问题,但日志里看起来非常瘆人。

解决办法就是我前面提到的Redis分布式锁。我在任务入口处加了一个SETNX锁,锁的key用任务名称,过期时间设为55秒,这样即使两个实例同时触发,也只有抢到锁的那个实例能继续执行,另一个直接返回。这里还有一个小坑:锁的过期时间如果小于任务实际执行时间,任务还没跑完锁就自动释放了,另一个实例会再次抢到锁,导致重复执行。所以过期时间一定要大于任务的最大执行时长,最好留出余量,并在任务结束后主动释放锁。

5.2 数据库时间与服务器时区不一致

这个坑非常隐蔽,也很致命。某个订单的超时时间明明设置的是12:00,但定时任务执行后,发现订单在13:00才被处理。排查了半天,最终发现是服务器时区和数据库时区不一致:数据库连接串里设置的serverTimezone=Asia/Shanghai,但服务器系统时区是UTC,导致NOW()函数取到的时间和Java本地时间差了8个小时。

从那以后,我定了个规矩:所有时间字段一律以数据库时间为准,Java侧不依赖系统默认时区,连接串显式指定serverTimezOne,并且在项目启动时统一设置TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"))。定时任务涉及时间判断的SQL,能不用应用层时间就不用,直接写数据库函数,从根源上避免时区偏差。

5.3 WebSocket连接断开与重连

来单提醒的WebSocket连接,在实际运营中经常会出现“悄悄断开”的情况。服务端推送时报错,才发现商家端连接早就断了,但前端还不知道。我做了一个简单的保活机制:前端每30秒发送一个心跳ping,服务端收到后回一个pong;如果连续三次心跳都没收到服务端回复,前端就主动断开并重新建立连接;服务端也在注册一个空闲检测,超过一定时间没收到心跳就主动关闭连接。

另外,服务端推送消息时,需要捕获连接断开异常,不能因为某个商家端连接异常就把整个推送线程搞崩。我封装了一个sendToUser方法,推送时先检查session.isOpen(),再发送,发送失败就记录日志并移除这个连接。不要小看这个细节,在真实环境中,商家的电脑可能随时休眠、网络可能随时切换,WebSocket连接稳定性是来单提醒功能能否真正落地的关键。

6. 几点经验总结

6.1 状态机是订单系统的灵魂

做了这个模块之后,我最大的一个体会是:订单系统的核心不是接口写得多花哨,而是状态机设计得够不够严谨。定时处理、来单提醒、客户催单,这三块功能最终都是在跟订单状态打交道。状态流转定义清楚了,代码自然好写;状态流转模糊,再简单的功能也会改出各种Bug。

我建议大家在动手写代码之前,先花半小时把订单的完整状态图画出来,把“谁触发了流转”“流转前需要满足什么条件”“流转后要做哪些后续动作”都列出来。这张图就是整个订单模块的设计蓝图,后面所有功能的开发都围绕它展开,能省下很多返工时间。

6.2 定时任务的监控与告警

定时任务跑得久了,最容易出现的问题就是“静默失败”:任务启动时报了个异常,日志被打到某个不起眼的文件里,然后整个订单模块的超时处理就瘫痪了,而没有任何人发现。所以我给大家一个很实用的建议:定时任务必须有监控。

最简单的方式是在任务开始和结束时各打一条日志,关键任务还要往数据库写一张task_execution_log表,记录每次执行的起始时间、结束时间、处理数量、执行结果。日常巡检只看这张表就能知道任务有没有准时跑、跑了多少单、有没有报错。更进一步,可以接一下企业微信/钉钉/飞书的webhook,任务执行异常时自动发消息到工作群,这样不用人工盯日志就能第一时间发现问题。

6.3 后续扩展方向

如果这个项目要继续往下做,我觉得有几个方向可以延伸:一是引入消息队列(如RocketMQ)替代WebSocket直推,让推送能力与业务服务彻底解耦,推送失败时还能靠消息重试兜底;二是把催单记录沉淀成数据报表,分析哪些时段商家接单最慢、哪些区域催单率最高,反向指导商家改善出餐流程;三是把订单超时处理从“定时扫描”升级为“延迟消息”,利用消息队列的延迟队列实现更精准、更及时的超时触发,减少扫描带来的无效计算。

这些扩展不一定要马上做,但心里得有这个图谱,等业务量上来的时候,才知道该往哪个方向走。我到现在还记得第一次把这三块功能完整跑通、商家在客户端收到来单弹窗的时刻,那种“系统真的活起来了”的感受,是做技术的人最享受的瞬间。

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

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

立即咨询