资讯动态

Spring Task与WebSocket实战:订单超时取消与来单提醒实现

发布时间:2026/10/8 15:52:36 来源:尧图企业网站定制
这两天刚把苍穹外卖项目day10这块完整跑通核心就三件事订单超时未支付自动取消、下单后给商家来单提醒、用户等急了可以催单。听起来不多但里面牵扯到Spring Task定时任务、WebSocket即时通讯、订单状态机的正确流转还有一堆联调时才暴露出来的坑。我按照从头开始的思路把day10的实现过程和踩坑记录整理成文给正在刷这个项目或者自己做外卖/点单类系统的朋友做个参考。这篇文章适合三类人一是正在学苍穹外卖、已经做到day10附近的朋友可以直接对照代码理解每个配置和方法的用途二是想在项目里引入定时任务和WebSocket推送的新手我尽量把原理和为什么这么选讲清楚三是准备面试前复盘项目亮点的同学来单提醒、超时取消这种场景非常容易成为面试追问点把链路讲明白比背八股有用得多。1. 项目背景与整体设计思路1.1 这一天的功能到底解决什么问题先还原一下业务场景。苍穹外卖是一个典型的商家端加用户端双端应用用户在用户端下单商家在管理端处理订单。日常经营里有两个特别常见的痛点第一用户下单后一直不付款订单卡在“待支付”状态商家不知道这单还算不算数库存也被占着。实际平台通常会给一个支付时限超过时间自动取消释放资源。day10要做的就是给这种“僵尸订单”一个自动收尾机制。第二新订单到达时如果商家一直开着订单列表页面靠手动刷新才能看到新单体验很差。更合理的方式是服务端主动把“您有新订单”的消息推到商家页面上页面弹个提示甚至响个铃。用户那边也一样等久了点“催一下”商家端要立刻收到提醒不能靠用户打电话。这两个痛点对应的技术方案就是定时任务加WebSocket推送。注意知识点本身并不难真正的难点在“把这些能力嵌进现有业务代码时怎么保证状态流转正确、消息不丢不重、身份能对上”。1.2 技术选型定时任务加WebSocket的组合逻辑超时取消订单行业内可以选的方案其实不少数据库事件、消息队列延迟消息、XXL-Job等分布式任务调度、Spring Task。但放在苍穹外卖这个单体学习项目里最合理的就是Spring Task。原因很直接Spring Boot原生支持加一个EnableScheduling就能用不需要额外引入中间件也没有部署成本。毕竟这个项目核心是搞懂业务闭环不是搞基建。数据库事件比如MySQL的Event Scheduler确实也能做超时处理但业务逻辑一旦复杂就很难维护而且数据库层面做状态流转不够直观也不利于面试时讲清楚链路。延迟消息队列是生产级方案但为了一个学习项目专门搭RocketMQ延迟消息成本偏高容易喧宾夺主。至于WebSocket这里多说一句。来单提醒、催单本质上都是“服务端有消息要主动推到客户端”最朴素的实现是前端定时轮询接口比如每3秒查一次有没有新订单。这种做法在小项目里也能跑但有两个问题一是轮询有延迟用户体验一般二是大量无效请求白占带宽和数据库连接。WebSocket是长连接服务端可以随时主动推送实时性最好而且连接建立后消息几乎零额外开销。day10用WebSocket正是因为这两类提醒对实时性要求高轮询的“伪实时”不够用。1.3 功能流程串讲整个day10的功能链路我梳理一下后面所有代码都围绕这三条线第一条线定时任务。项目启动后Spring Task按照cron表达式每分钟扫描一次订单表找出“待支付状态且下单时间超过15分钟”的订单把它们统一改成“已取消”状态并写入取消原因和取消时间。第二条线来单提醒。用户支付成功后订单状态变为“待接单”这时候服务端在支付成功回调里调用WebSocket的群发方法把一条类型为1的消息推给所有在线的商家端页面商家页面收到后弹出来单提示层。第三条线用户催单。用户在订单详情页点击“催单”用户端通过HTTP接口把订单id传给服务端服务端校验订单存在且状态合理后通过WebSocket给商家端推一条类型为2的消息商家页面弹催单提示。这里有一个容易想错的点用户催单并不是用户端直接通过WebSocket发消息给商家端而是用户端照常发HTTP请求由服务端代发WebSocket推送。学习项目这么做是对的因为用户端和管理端连接的是同一个WebSocket服务端服务端才能统一做身份识别和消息转发。2. 技术底座Spring Task 与 WebSocket 的关键原理2.1 Spring Task 定时任务cron 表达式与执行机制Spring Task是Spring框架自带的轻量级任务调度能力核心就是Scheduled注解加调度器。用起来很简单但要理解两个底层问题。第一个是cron表达式。day10里用的表达式是0 0/1 * * * ?一共6位分别对应秒、分、时、日、月、周。这个表达式的含义是在秒为0的时刻每1分钟执行一次。注意最后一位是?而不是*这是cron在“日”和“周”两个字段上的冲突规避规则如果同时指定日和星期二者会形成“或”的关系容易产生歧义。所以当某个项目不希望同时约束日和星期时要在其中一个字段上写?。这个细节很多人第一次写会踩坑写成0 0/1 * * * *直接启动报错。第二个是调度器模型。Spring Task默认使用单线程调度器也就是说所有Scheduled任务共用一个线程。如果某个任务执行时间超过了调度周期后续任务会被阻塞造成任务堆积。对于苍穹外卖这种只有一个定时任务的项目问题不大但如果你以后把自己的项目里挂了好几个定时任务我建议显式配置ThreadPoolTaskScheduler线程池让不同任务并发执行避免互相拖累。2.2 WebSocket 为什么比轮询更适合来单提醒WebSocket的原理可以这样理解HTTP是“一问一答”模式客户端请求一次服务端返回一次连接就断了WebSocket则是先通过HTTP进行一次握手升级之后客户端和服务端之间建立一条全双工的长连接两边随时可以互发数据。这就好比打电话和发短信的区别轮询是不断发短信问“有新消息吗”WebSocket是直接通着电话有消息张嘴就说。对于来单提醒场景实时性是第一诉求。商家的接单效率直接影响用户体验如果靠轮询就算你设置1秒轮询一次商家端也要晚1秒才看到订单还白费了大量HTTP请求。更重要的是轮询把“服务端有数据”这个状态暴露给了客户端去猜而WebSocket是服务端主动通知数据一到就推语义上更干净。day10里的WebSocket用法是管理端商家端浏览器在页面加载后主动连接WebSocket服务端服务端用一个静态的Map维护所有在线session。推送消息时遍历Map给所有在线商家端发送同样的消息。因为学习项目里商家端一般就一个这个“群发”简单粗暴但完全够用。2.3 前提条件依赖与配置准备动手写代码前先把依赖和配置准备好。我的经验是配置一步到位后面会省很多排查时间。如果你用的Spring Boot项目引入WebSocket只要加一个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency然后在启动类或者配置类上加EnableScheduling开启定时任务。同时需要注册ServerEndpointExporter把ServerEndpoint注解声明的WebSocket端点交给Spring容器管理Configuration public class WebSocketConfiguration { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }这里我多说一句很多同学会漏掉ServerEndpointExporter导致WebSocket端点不能正常注册。它的作用是扫描并注册所有带ServerEndpoint注解的类如果没有它启动时容器根本不知道你的WebSocket端点存在浏览器连的时候直接404或握手失败。3. 订单状态定时处理超时未支付自动取消3.1 业务规则与SQL设计先明确苍穹外卖订单表的状态含义。orders表中的status字段1表示待付款2表示待接单3表示已接单4表示派送中5表示已完成6表示已取消。day10的超时取消处理就是要把“待付款状态超过15分钟仍未支付”的订单从1改成6。这个业务规则看起来简单写SQL时有个关键点判断条件不能只按创建时间算“早于某个时刻”还要同时带上状态条件。因为一个订单如果已经支付了哪怕创建时间很久远也不能再被改成已取消。所以查询条件必须是“状态等于待支付”和“下单时间早于当前时间减去15分钟”同时成立。我建议用LocalDateTime.now().minusMinutes(15)来算边界时间不要用数据库的NOW()函数这样时间边界在应用层统一控制既方便测试又方便在日志里看具体时间。以下是定时任务里实际执行的查询逻辑/** * 查询超时未支付订单 * 条件状态待付款(1)且下单时间早于十五分钟前 */ Select(select * from orders where status #{status} and order_time #{orderTime}) ListOrders getByStatusAndOrderTimeLT(Param(status) Integer status, Param(orderTime) LocalDateTime orderTime);这个SQL对应的是批量查询后面在定时任务里拿到这批订单后逐条更新。3.2 定时任务代码实现与细节定时任务方法放在OrderServiceImpl里即可Spring会自动扫描Scheduled注解的方法。具体代码是这样的/** * 处理超时订单 */ Scheduled(cron 0 0/1 * * * ?) public void processTimeoutOrder() { log.info(开始处理超时未支付订单); LocalDateTime now LocalDateTime.now(); LocalDateTime timeout now.minusMinutes(15); ListOrders orders orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, timeout); if (orders ! null orders.size() 0) { for (Orders order : orders) { order.setStatus(Orders.CANCELLED); order.setCancelReason(订单超时自动取消); order.setCancelTime(now); orderMapper.update(order); } } }这里的Orders.PENDING_PAYMENT和Orders.CANCELLED是订单实体类里定义的常量比直接写魔法数字1、6要安全得多。我自己看项目源码发现很多人喜欢在业务代码里到处写order.getStatus() 1看着明白实际上过两周你自己都得靠猜。定义常量的习惯越早养成越好。还有一个细节容易被忽略cancelTime字段一定要有值否则订单被取消后在后台列表里看不到“取消时间”这一列排查问题的时候就没法判断这个单是什么时候被自动取消的。我在第一次实现时就是漏写了setCancelTime结果测试时订单状态改成了已取消但界面上时间字段是空的看起来非常奇怪。3.3 状态流转与并发兜底这个定时任务每分钟跑一次理论上可能存在一种边界情况某个订单刚好在第15分钟临界点被用户支付成功同时定时任务扫到了它。两个操作并发执行就可能出现“用户已支付成功但订单被标记为已取消”的严重事故。怎么兜底我的做法是在更新语句里增加状态条件只有当前状态仍然是待付款时才允许改成已取消Update(update orders set status #{status}, cancel_time #{cancelTime}, cancel_reason #{cancelReason} where id #{id} and status #{pendingPaymentStatus}) int cancelOrder(Param(id) Long id, Param(status) Integer status, ...);这样即使定时任务和支付回调并发执行也只有先更新成功的那个操作生效另一个更新语句影响行数为0不会把已支付订单误取消。类似的思路在分布式系统里叫乐观锁本质上就是“更新时带上原状态条件”。另外processTimeoutOrder这个定时任务方法本身建议加上Transactional事务注解保证一批订单要么都更新成功要么都回滚。如果中间某一条订单更新报错前面的订单已经提交会造成部分取消部分没取消的数据不一致。4. 来单提醒服务端推送的完整链路4.1 WebSocket 服务端核心代码来单提醒是整个day10里WebSocket应用的关键先看服务端代码。day10用ServerEndpoint(/ws/{sid})声明端点sid是商家端的标识。这里要注意ServerEndpoint修饰的类的实例生命周期跟Spring Bean不一样默认每个WebSocket连接都会创建新实例所以WebSocketServer里用于保存session的集合必须是static的否则不同连接之间无法共享session数据。Component ServerEndpoint(/ws/{sid}) Slf4j public class WebSocketServer { // 保存所有在线连接key是商家端标识sidvalue是WebSocket会话 private static MapString, Session sessionMap new ConcurrentHashMap(); /** * 连接建立时调用 */ OnOpen public void onOpen(Session session, PathParam(sid) String sid) { sessionMap.put(sid, session); log.info(WebSocket连接建立sid{}, sid); } /** * 收到客户端消息时调用 */ OnMessage public void onMessage(String message, Session session) { log.info(收到客户端消息{}, message); } /** * 连接关闭时调用 */ OnClose public void onClose(Session session) { // 连接关闭时移除对应的session sessionMap.values().remove(session); log.info(WebSocket连接关闭); } /** * 连接出错时调用 */ OnError public void onError(Session session, Throwable error) { log.error(WebSocket出错, error); } /** * 服务端推送消息给所有在线客户端 */ public static void sendToAllClient(String message) { sessionMap.values().forEach(session - { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error(WebSocket推送消息失败, e); } }); } }有几个点我要特别提醒sessionMap一定要用ConcurrentHashMap因为多线程环境下可能有多个连接同时建立或断开普通HashMap会出并发问题。onClose里移除session时要小心不能直接sessionMap.remove(sid)因为你不知道sid是否和当前session对应用sessionMap.values().remove(session)这种按值移除的方式更稳妥。4.2 商家端连接与消息解析商家端也就是管理端页面在加载完成后会主动建立WebSocket连接。因为服务端端点是/ws/{sid}这里的sid对商家端来说就是商家的标识实际接项目时可以传商家id或者登录token。苍穹外卖里为了简单直接传了一个固定标识表示商家端。管理端页面里的核心逻辑是连接建立后监听onmessage事件收到服务端推送的JSON字符串后解析根据type字段判断消息类型。如果type等于1弹出来单提醒层如果type等于2弹出催单提醒。消息格式我强烈建议统一约定成一个JSON结构不要各写各的{ type: 1, orderId: 123, content: 您有新的订单 }这样的好处是前端解析逻辑简单后端只要拼好JSON序列化即可后续加新的消息类型比如订单状态变更通知只要扩展type值就行不用改连接层逻辑。4.3 推送触发的业务落点来单提醒的触发时机很关键。不是用户一提交订单就推送而是用户支付成功后才推。因为外卖业务里订单支付成功商家才真正需要开始备餐。苍穹外卖的支付是模拟的在支付成功回调方法里调用WebSocket推送。// 支付成功回调里触发来单提醒 webSocketServer.sendToAllClient( {\type\:1,\orderId\: orderId ,\content\:\您有新的订单\} );为什么放在支付成功这里而不是下单接口里因为未支付的订单商家端应该看到的是“待付款”状态给商家推来单提醒反而会造成困扰。用户付款成功那一刻订单正式生效这时候推送才符合业务直觉。这里我建议把拼JSON的代码封装成一个工具方法或者用对象序列化比如用Jackson的ObjectMapper把Map转成JSON字符串不要在业务代码里手动拼字符串。手动拼字符串一旦内容里有特殊字符很容易拼出非法JSON前端解析直接报错。5. 用户催单HTTP 接口驱动推送5.1 催单接口与状态校验催单业务相对简单但依然是一个完整的“Controller-Service-Mapper”链路。用户端点击催单按钮请求后端接口接口路径一般是/user/order/reminder/{id}其中id是订单id。GetMapping(/reminder/{id}) ApiOperation(用户催单) public Result reminder(PathVariable Long id) { orderService.reminder(id); return Result.success(); }Service层的reminder方法不要一上来就直接推送消息。先根据订单id查询订单做三个校验订单是否存在、订单是否属于当前登录用户、订单是否处于可以催单的状态。不校验直接推送一个是安全性问题另一个是业务上用户对已取消订单催单也没有意义。我实际开发时的催单业务规则是只有“待接单”状态的订单可以催单。用户已经付款、商家还没接单这个时候用户等急了催一下才合理。如果订单已经接单甚至派送中说明商家已经在处理再催反而会打扰商家。当然不同公司的业务规则可能不一样但“状态校验”这件事一定不能省。5.2 消息格式与服务端转发reminder方法里查询订单确认无误后把消息推送到商家端public void reminder(Long id) { Orders order orderMapper.getById(id); if (order null) { throw new OrderBusinessException(订单不存在); } if (order.getStatus() Orders.PENDING_CONFIRM) { // 拼装催单消息 String message {\type\:2,\orderId\: id ,\content\:\客户催单啦\}; webSocketServer.sendToAllClient(message); } }注意催单消息里的type是2与来单提醒的type1区分开。管理端页面在onmessage回调里根据type走不同的弹窗逻辑这样商家在忙别的操作时听到不同的提示音就能判断是“新订单来了”还是“客户催单了”。这里有一个设计层面的思考用户催单消息是从HTTP接口进到服务端的再通过WebSocket长连接推给商家端。也就是说WebSocket不仅仅可以用来“服务端主动推”也可以配合HTTP接口完成“A用户触发服务端推送B用户接收”的间接通信。面试时能把这条链路讲清楚比单纯背WebSocket概念有说服力得多。5.3 管理端交互展示管理端侧收到催单消息后页面上要能明显展示。实际项目里是通过layer弹窗组件实现收到type为2的消息后弹出提示框显示“客户催单”附上订单号商家点击后可以跳转到对应订单详情。接收消息的代码大致是websocket.onmessage function (event) { var data JSON.parse(event.data); if (data.type 1) { // 来单提醒 layer.msg(data.content); } else if (data.type 2) { // 催单提醒 layer.confirm(data.content 订单号 data.orderId, ...); } };这里有一个交互细节值得注意收到催单提醒后最好同时刷新订单列表。否则商家看到弹窗点了确认去订单页面一看还是旧数据体验很割裂。正确做法是在弹窗回调里重新请求订单列表接口让最新状态和提醒内容保持一致。6. 联调实测与常见问题排查6.1 联调步骤与验证清单功能都写完后最怕的就是代码写好了但联调时一头雾水。我建议按下面的顺序验证每步都有明确预期出问题好定位。第一步验证定时任务。启动项目造一个15分钟前的待支付订单可以直接改数据库的下单时间也可以用SQL把订单时间调早。等定时任务跑一个周期后查数据库确认订单状态变成了已取消取消原因和取消时间都有值。第二步验证WebSocket连接。打开管理端页面按F12看Network找到WebSocket请求确认状态是101Switching Protocols说明握手成功。如果一直是pending或者直接failed优先检查ServerEndpointExporter有没有注册。第三步验证来单提醒。登录用户端下单并模拟支付成功后立刻切到管理端页面应该能看到来单提醒弹窗。注意这时候如果管理端页面没有打开消息会丢失这是WebSocket的特性后面第6.2节会专门讲。第四步验证催单。用户在订单列表点“催单”管理端页面要能收到催单弹窗。如果收不到先看服务端日志有没有打印“收到客户端消息”再看管理端页面WebSocket还活着没。6.2 常见问题速查表我把实际开发中遇到的高频问题整理成一张表每个问题都附上排查思路问题现象可能原因排查与解决定时任务不执行启动类缺少EnableSchedulingcron表达式写错检查启动类注解在方法里加日志确认有没有进入方法Spring Boot启动报错提示cron表达式非法日和周同时用了*把日或周其中一个改成?0 0/1 * * * ?才是合法的管理端WebSocket连不上缺少ServerEndpointExporter端口或上下文路径不对确认配置类有没有注册浏览器控制台看具体报错信息推送消息时session为空或已关闭客户端连接断开后sessionMap没清理在onClose中按值移除session推送时捕获IOException并移除无效session来单提醒延迟推送逻辑放在了非支付成功回调位置确认触发时机应该放在支付成功回调里用户催单收不到订单状态校验没通过WebSocket连接断开服务端日志看状态判断管理端重新刷新页面建立连接定时任务把已支付订单取消更新语句没带状态条件更新时加and status 待支付并发兜底6.3 定时任务与WebSocket的几个进阶避坑最后再说几个不是必踩但踩了很疼的坑。第一个坑定时任务默认单线程。如果你在项目里加了多个Scheduled方法只要有一个方法执行时间过长其他任务全部排队。解决办法是自定义TaskScheduler线程池。学习项目里可以不管但你要知道有这回事面试时被问到“多个定时任务互相阻塞怎么办”就从这个角度答。第二个坑WebSocket消息不会离线补发。商家端关闭页面期间服务端推的来单提醒消息直接丢失。生产环境通常会配合消息表或推送记录在客户端重新连接时拉取未读提醒。这个项目里不用做但你理解了这个限制以后做生产系统设计消息可靠性时就有概念了。第三个坑onClose里的并发问题。WebSocket的onOpen、onClose可能在不同线程触发操作sessionMap还是那句话必须用ConcurrentHashMap并且移除session时要判断是否存在。第四个坑多实例部署时WebSocket的sessionMap只存在于单台机器内存里。这个项目是单体应用但在生产环境中如果你的项目部署了两个实例用户在A实例建立了WebSocket连接而推送发生在了B实例B实例的sessionMap里并没有这个session消息就丢了。这个时候需要借助Redis发布订阅或者MQ广播来分发推送事件。如果面试问你“分布式环境下WebSocket消息怎么推送”核心就是这个答案。写在最后把day10完整跑通之后我对订单状态机、定时任务、WebSocket这三块的理解比看文档清晰多了。项目里每个功能单独拎出来都不算难但把它们串成一个业务闭环需要考虑的细节就一下子多了起来状态更新时的并发兜底、定时任务的时间边界、WebSocket连接的生命周期、消息格式的约定。我个人实际开发中的体会是这种带状态流转的功能写代码前一定先把状态图画清楚哪怕在纸上画都行。你如果不确定“待支付”下一步能转到哪些状态“超时取消”和“用户取消”有什么区别写出来的代码大概率会有边界漏洞。day10恰好帮你把这些点都过了一遍认真做一遍后面自己做订单类系统会顺手很多。最后分享一个小技巧调试WebSocket时别光靠前端页面可以装一个简单的WebSocket客户端工具手动连接ws://localhost:8080/ws/xxx直接看服务端推了什么消息。这样能把“前端交互问题”和“服务端推送问题”快速分开排查效率翻倍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑