RocketMq延时消息实战:订单超时关闭、超时未支付自动撤销方案
延时消息实战订单超时关闭、超时未支付自动撤销方案作者黒漂技术佬适用场景无人售货柜、无人零售、智慧农业巡检一、什么是延时消息普通消息发出去消费者立刻就能收到。而延时消息发出去后不会马上被消费而是等待指定的延迟时间后才投递给消费者。生活中有个很贴切的例子你设了个闹钟“30分钟后提醒我关火”。这个闹钟就是一种延时消息——30分钟后才会响不是现在响。在业务系统中延时消息最常见的用途就是超时自动处理下单后30分钟未支付 → 自动关闭订单设备故障后5分钟 → 自动重试检测用户注册后24小时 → 发送引导邮件二、RocketMQ延时消息原理2.1 开源版18个固定延迟级别RocketMQ 4.x 开源版不支持任意时间延时而是预设了18个延迟级别级别延迟时间级别延迟时间11s106m25s117m310s128m430s139m51m1410m62m1520m73m1630m84m171h95m182h小白提示为什么是固定级别而不是任意时间因为RocketMQ内部用一个定时任务按级别扫描消息固定级别可以用延迟队列数组高效实现。任意时间延时需要更复杂的排序结构时间轮性能开销更大。2.2 内部实现原理延时消息的流转过程生产者发送消息(设置delayLevel) ↓ Broker收到消息发现设置了延迟级别 ↓ 把消息存入内部Topic: SCHEDULE_TOPIC_XXXX (按照delayLevel分别存入不同队列) ↓ 定时任务(ScheduleMessageService)每隔一定时间扫描 ↓ 发现消息到达延迟时间 → 把消息从SCHEDULE_TOPIC取出 ↓ 重新投递到原始Topic ↓ 消费者正常消费简单来说Broker先把延时消息藏到一个内部Topic里等时间到了再搬到真正的Topic。对消费者来说它收到的就是一条普通消息完全无感知。2.3 RocketMQ 5.x任意时间延时RocketMQ 5.x 引入了**Timer Wheel时间轮**机制支持指定任意延迟时间// 5.x 支持任意时间精确到毫秒MessagemsgnewMessage(OrderTopic,订单超时检查.getBytes());// 设置延迟到指定时刻例如30分钟后msg.setDeliverTimeMs(System.currentTimeMillis()30*60*1000);producer.send(msg);注意5.x同时兼容4.x的setDelayTimeLevel方式。三、延时消息使用场景3.1 订单超时关闭最经典的场景。用户下单后有30分钟支付时间超时未支付自动关闭订单释放锁定的库存。3.2 超时未支付自动撤销无人售货柜场景中用户开门拿货后系统生成订单。如果用户一直不支付需要自动撤销订单恢复库存状态。3.3 延迟通知设备故障告警后5分钟自动重试检测避免瞬时故障导致的误告警。四、代码示例4.1 生产者设置延迟级别发送消息ServicepublicclassOrderDelayMessageProducer{AutowiredprivateDefaultMQProducerproducer;/** * 发送订单超时关闭的延时消息 * param orderId 订单ID * param delayMinutes 延迟分钟数 */publicvoidsendOrderTimeoutMessage(StringorderId,intdelayMinutes){try{MessagemsgnewMessage(order_timeout_topic,tag_timeout,orderId.getBytes());// 根据延迟分钟数选择最接近的延迟级别// 30分钟 → 级别16intdelayLevelmapToDelayLevel(delayMinutes);msg.setDelayTimeLevel(delayLevel);SendResultresultproducer.send(msg);log.info(延时消息发送成功, orderId{}, delayLevel{}, msgId{},orderId,delayLevel,result.getMsgId());}catch(Exceptione){log.error(延时消息发送失败, orderId{},orderId,e);thrownewRuntimeException(延时消息发送失败,e);}}/** * 分钟数映射到RocketMQ延迟级别 */privateintmapToDelayLevel(intminutes){if(minutes0)return1;// 1sif(minutes1)return5;// 1mif(minutes2)return6;// 2mif(minutes3)return7;// 3mif(minutes5)return9;// 5mif(minutes10)return14;// 10mif(minutes20)return15;// 20mif(minutes30)return16;// 30mif(minutes60)return17;// 1hreturn18;// 2h}}4.2 消费者处理超时关闭逻辑ComponentRocketMQMessageListener(topicorder_timeout_topic,consumerGrouporder_timeout_consumer_group)publicclassOrderTimeoutConsumerimplementsRocketMQListenerMessageExt{AutowiredprivateOrderServiceorderService;AutowiredprivateInventoryServiceinventoryService;OverridepublicvoidonMessage(MessageExtmessage){StringorderIdnewString(message.getBody());log.info(收到订单超时检查消息, orderId{},orderId);try{// 查询订单当前状态OrderorderorderService.getById(orderId);if(ordernull){log.warn(订单不存在, orderId{},orderId);return;}// 幂等校验只有待支付状态才需要关闭if(!CREATED.equals(order.getStatus())){log.info(订单已处理状态{}, 跳过超时关闭, orderId{},order.getStatus(),orderId);return;}// 执行超时关闭orderService.closeOrder(orderId,超时未支付自动关闭);// 恢复库存inventoryService.restoreStock(order);log.info(订单超时关闭成功, orderId{},orderId);}catch(Exceptione){log.error(订单超时关闭失败, orderId{},orderId,e);thrownewRuntimeException(e);// 触发重试}}}五、无人售货柜订单超时关闭完整方案这是整个售货柜项目的核心流程之一我们完整梳理一下5.1 业务流程用户扫码开门 ↓ 系统生成订单(状态CREATED) → 发送30分钟延时消息 ↓ 用户拿货关门 → 设备上报商品列表 ↓ 系统更新订单(金额、商品明细) ↓ ├── 30分钟内支付 → 订单状态改为PAID → 延时消息到达时被幂等跳过 │ └── 30分钟未支付 → 延时消息到达 → 关闭订单 → 恢复库存5.2 完整代码步骤1用户开门生成订单并发送延时消息ServicepublicclassVendingOrderService{AutowiredprivateOrderMapperorderMapper;AutowiredprivateOrderDelayMessageProducerdelayProducer;/** * 用户开门事件处理 */TransactionalpublicStringhandleDoorOpen(DoorOpenEventevent){// 1. 创建订单OrderordernewOrder();order.setOrderId(OrderIdGenerator.next());order.setUserId(event.getUserId());order.setDeviceId(event.getDeviceId());order.setStatus(CREATED);order.setCreateTime(LocalDateTime.now());orderMapper.insert(order);// 2. 发送30分钟延时消息用于超时关闭delayProducer.sendOrderTimeoutMessage(order.getOrderId(),30);log.info(订单创建成功已设置30分钟超时关闭, orderId{},order.getOrderId());returnorder.getOrderId();}}步骤2用户支付成功更新订单状态ServicepublicclassPaymentService{AutowiredprivateOrderMapperorderMapper;/** * 支付成功回调 */TransactionalpublicvoidonPaymentSuccess(StringorderId,StringpayNo){OrderorderorderMapper.selectById(orderId);// 幂等校验只有待支付订单才能支付if(!CREATED.equals(order.getStatus())){log.info(订单状态不允许支付, orderId{}, status{},orderId,order.getStatus());return;}// 更新订单状态order.setStatus(PAID);order.setPayNo(payNo);order.setPayTime(LocalDateTime.now());orderMapper.updateById(order);log.info(支付成功, orderId{},orderId);// 注意不需要取消延时消息因为延时消息到达时会做幂等校验}}步骤3延时消息到达执行超时关闭// 即上面的 OrderTimeoutConsumer// 核心逻辑// 1. 查询订单状态// 2. 如果已支付(PAID) → 跳过// 3. 如果未支付(CREATED) → 关闭订单 恢复库存5.3 为什么不取消延时消息而是做幂等校验RocketMQ开源版不支持取消已发送的延时消息。所以不能在支付成功后取消延时消息只能在消费时做幂等校验——检查订单状态已支付的跳过即可。这也印证了上一篇幂等性设计的重要性延时消息 幂等校验 可靠的超时处理方案。六、延时消息的注意事项6.1 延迟精度问题开源版RocketMQ的延迟级别不是精确的。比如级别5是1分钟但实际延迟可能在1分0秒到1分10秒之间。原因是定时任务的扫描间隔默认不是实时扫描而是按一定频率轮询。如果业务对延迟精度要求高比如精确到秒级可以考虑RocketMQ 5.x 的setDeliverTimeMs毫秒级精度或者用Redis的有序集合ZSET自己实现延迟队列6.2 消息堆积风险延时消息在等待期间存放在SCHEDULE_TOPIC_XXXX内部Topic中。如果发送量很大会有消息堆积风险。// 不好的做法每个操作都发延时消息for(inti0;i100000;i){msg.setDelayTimeLevel(5);// 1分钟延迟producer.send(msg);}// 10万条延时消息堆积在内部Topic中建议控制延时消息的发送量只对必要的业务使用监控SCHEDULE_TOPIC_XXXX的消息堆积情况避免设置过长的延迟时间最大2小时6.3 延时消息与重试的叠加效应如果延时消息消费失败触发重试重试本身也有延迟。叠加后实际处理时间可能远超预期30分钟延时消息到达 → 消费失败 → 重试1(10s后) → 失败 → 重试2(30s后) → ...最坏情况下30分钟超时关闭可能变成35分钟才真正关闭。对于资金敏感场景需要评估这个延迟是否可接受。6.4 延迟级别选择建议业务场景推荐延迟级别原因订单超时关闭1630分钟30分钟支付时限支付超时撤销1410分钟10分钟支付窗口设备故障重检95分钟5分钟后重试检测延迟通知51分钟快速通知注册引导邮件171小时1小时后发送七、小结延时消息的核心价值把定时检查变成到点触发。传统方案需要定时任务轮询数据库每隔1分钟扫一次随着数据量增长性能越来越差。延时消息则是精准触发到点就处理不需要轮询。对比项定时任务轮询延时消息触发方式轮询扫描精准触发数据库压力每次扫描全表无额外查询实时性取决于扫描间隔延迟时间到达即触发扩展性数据量大时性能差不受数据量影响记住三句话发消息时设延迟级别消费时做幂等校验监控内部Topic堆积