延时消息实战:订单超时关闭、超时未支付自动撤销方案
作者:黒漂技术佬
适用场景:无人售货柜、无人零售、智慧农业巡检
一、什么是延时消息?
普通消息发出去,消费者立刻就能收到。而延时消息发出去后,不会马上被消费,而是等待指定的延迟时间后才投递给消费者。
生活中有个很贴切的例子:你设了个闹钟,“30分钟后提醒我关火”。这个闹钟就是一种延时消息——30分钟后才会响,不是现在响。
在业务系统中,延时消息最常见的用途就是超时自动处理:
- 下单后30分钟未支付 → 自动关闭订单
- 设备故障后5分钟 → 自动重试检测
- 用户注册后24小时 → 发送引导邮件
二、RocketMQ延时消息原理
2.1 开源版:18个固定延迟级别
RocketMQ 4.x 开源版不支持任意时间延时,而是预设了18个延迟级别:
| 级别 | 延迟时间 | 级别 | 延迟时间 |
|---|---|---|---|
| 1 | 1s | 10 | 6m |
| 2 | 5s | 11 | 7m |
| 3 | 10s | 12 | 8m |
| 4 | 30s | 13 | 9m |
| 5 | 1m | 14 | 10m |
| 6 | 2m | 15 | 20m |
| 7 | 3m | 16 | 30m |
| 8 | 4m | 17 | 1h |
| 9 | 5m | 18 | 2h |
小白提示:为什么是固定级别而不是任意时间?因为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 支持任意时间(精确到毫秒)Messagemsg=newMessage("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{Messagemsg=newMessage("order_timeout_topic","tag_timeout",orderId.getBytes());// 根据延迟分钟数选择最接近的延迟级别// 30分钟 → 级别16intdelayLevel=mapToDelayLevel(delayMinutes);msg.setDelayTimeLevel(delayLevel);SendResultresult=producer.send(msg);log.info("延时消息发送成功, orderId={}, delayLevel={}, msgId={}",orderId,delayLevel,result.getMsgId());}catch(Exceptione){log.error("延时消息发送失败, orderId={}",orderId,e);thrownewRuntimeException("延时消息发送失败",e);}}/** * 分钟数映射到RocketMQ延迟级别 */privateintmapToDelayLevel(intminutes){if(minutes<=0)return1;// 1sif(minutes<=1)return5;// 1mif(minutes<=2)return6;// 2mif(minutes<=3)return7;// 3mif(minutes<=5)return9;// 5mif(minutes<=10)return14;// 10mif(minutes<=20)return15;// 20mif(minutes<=30)return16;// 30mif(minutes<=60)return17;// 1hreturn18;// 2h}}4.2 消费者:处理超时关闭逻辑
@Component@RocketMQMessageListener(topic="order_timeout_topic",consumerGroup="order_timeout_consumer_group")publicclassOrderTimeoutConsumerimplementsRocketMQListener<MessageExt>{@AutowiredprivateOrderServiceorderService;@AutowiredprivateInventoryServiceinventoryService;@OverridepublicvoidonMessage(MessageExtmessage){StringorderId=newString(message.getBody());log.info("收到订单超时检查消息, orderId={}",orderId);try{// 查询订单当前状态Orderorder=orderService.getById(orderId);if(order==null){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. 创建订单Orderorder=newOrder();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){Orderorder=orderMapper.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(inti=0;i<100000;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 延迟级别选择建议
| 业务场景 | 推荐延迟级别 | 原因 |
|---|---|---|
| 订单超时关闭 | 16(30分钟) | 30分钟支付时限 |
| 支付超时撤销 | 14(10分钟) | 10分钟支付窗口 |
| 设备故障重检 | 9(5分钟) | 5分钟后重试检测 |
| 延迟通知 | 5(1分钟) | 快速通知 |
| 注册引导邮件 | 17(1小时) | 1小时后发送 |
七、小结
延时消息的核心价值:把"定时检查"变成"到点触发"。
传统方案需要定时任务轮询数据库,每隔1分钟扫一次,随着数据量增长性能越来越差。延时消息则是精准触发,到点就处理,不需要轮询。
| 对比项 | 定时任务轮询 | 延时消息 |
|---|---|---|
| 触发方式 | 轮询扫描 | 精准触发 |
| 数据库压力 | 每次扫描全表 | 无额外查询 |
| 实时性 | 取决于扫描间隔 | 延迟时间到达即触发 |
| 扩展性 | 数据量大时性能差 | 不受数据量影响 |
记住三句话:
- 发消息时设延迟级别
- 消费时做幂等校验
- 监控内部Topic堆积