ARTICLE DETAIL

资讯详情

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

苍穹外卖订单模块实战:超时处理、来单提醒与催单机制的落地

苍穹外卖订单模块实战:超时处理、来单提醒与催单机制的落地 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 ResultString 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才被处理。排查了半天最终发现是服务器时区和数据库时区不一致数据库连接串里设置的serverTimezoneAsia/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直推让推送能力与业务服务彻底解耦推送失败时还能靠消息重试兜底二是把催单记录沉淀成数据报表分析哪些时段商家接单最慢、哪些区域催单率最高反向指导商家改善出餐流程三是把订单超时处理从“定时扫描”升级为“延迟消息”利用消息队列的延迟队列实现更精准、更及时的超时触发减少扫描带来的无效计算。这些扩展不一定要马上做但心里得有这个图谱等业务量上来的时候才知道该往哪个方向走。我到现在还记得第一次把这三块功能完整跑通、商家在客户端收到来单弹窗的时刻那种“系统真的活起来了”的感受是做技术的人最享受的瞬间。
返回列表