一个看起来很小的需求:用户下单 15 分钟没付款,订单要自动关闭、库存要释放。实现方式却五花八门:定时轮询、Redis 过期通知、RabbitMQ 死信队列、延迟插件、时间轮……最常见的起点是定时任务每分钟扫一次表,但数据量上来之后,扫一次要好几秒,还经常把数据库拖慢。
这篇把"延迟任务"的四种主流实现从简单到可靠排一遍:定时轮询 → Redis 过期通知 → RabbitMQ 死信/延迟队列 → 时间轮。每种讲清原理、代码和坑,最后给选型表。代码按 Spring Boot 落地。
一、先看需求本质:这是一个"延迟任务"问题
"15 分钟后关单"翻译成技术语言:在指定时间点执行一个任务(关单 + 释放库存 + 发通知)。所有方案都是在回答同一个问题:怎么在"到了时间"的时候,准确、可靠地触发任务。
评价一个延迟方案看三个维度:
| 维度 | 含义 |
|---|---|
| 精度 | 触发时间和设定时间差多少(秒级?分钟级?) |
| 可靠性 | 服务重启/宕机后,任务会不会丢 |
| 实时性 | 任务量大时会不会延迟堆积 |
二、方案 v1:定时任务轮询(最简单,够用就行)
// 每分钟扫一次"已超时未支付"的订单
@Scheduled(cron = "0 * * * * ?")
public void closeTimeoutOrders() {
// 查 15 分钟前创建、仍为待支付的订单
List<Order> expired = orderMapper.selectExpired(
LocalDateTime.now().minusMinutes(15), OrderStatus.PENDING_PAY);
expired.forEach(order -> {
orderMapper.updateStatus(order.getId(), OrderStatus.CLOSED);
stockService.release(order.getSkuId(), order.getQuantity()); // 释放库存
});
}特点:
- ✅ 实现最简单,零额外组件
- ✅ 可靠(数据库兜底,重启不丢)
- ❌ 精度差(最多延迟一个扫描周期,这里是 1 分钟)
- ❌ 量大时扫表压力大(每次全表扫过期订单)
适用:单机、订单量不大、分钟级精度可接受,是最常见的起步方案。
坑:别用 @Scheduled 处理"到点执行"的精确业务(比如红包准时开抢),它的本质是"周期扫描",不是"延迟触发"。
三、方案 v2:Redis 过期通知(keyspace notifications)
思路:把订单号写进 Redis 并设置 TTL,订单过期 = key 过期,监听过期事件来关单。
// 下单时:订单号写入 Redis,15 分钟过期
stringRedisTemplate.opsForValue().set(
"order:timeout:" + orderNo, "1", Duration.ofMinutes(15));// 监听 key 过期事件(需要 Redis 开启 notify-keyspace-events Ex)
@EventListener
public void onKeyExpired(KeyExpiredEvent event) {
String key = event.getKey();
if (key != null && key.startsWith("order:timeout:")) {
String orderNo = key.substring("order:timeout:".length());
closeOrder(orderNo);
}
}// 配置:Redis 开启过期事件通知
// redis.conf: notify-keyspace-events Ex特点:
- ✅ 实现轻(不引额外中间件,Redis 现成)
- ⚠️ 精度:过期事件是惰性删除触发的——Redis 只在"有人访问这个 key"或"后台定期抽样"时才真正删除并发布事件,可能延迟数分钟甚至更久
- ❌ 不可靠:key 过期事件不保证必达,Redis 重启/主从切换可能丢事件
- ❌ 消息量大时会刷屏(所有 key 的过期事件都往同一个 channel 发)
适用:对精度、可靠性要求不高的场景。订单关单不推荐(丢了就永远不关了),可以配合兜底扫描。
四、方案 v3:RabbitMQ 死信队列 / 延迟插件(推荐)
RabbitMQ 做延迟有两种姿势,先讲死信队列(DLX),原理通用(任何 MQ 都能类比):
// 1. 声明:延迟队列 A(TTL 15 分钟,无消费者)+ 死信绑定
@Bean
public Queue delayQueue() {
return QueueBuilder.durable("order.delay")
.ttl(15 * 60 * 1000) // 消息 15 分钟过期
.deadLetterExchange("order.dlx") // 死信交换机
.deadLetterRoutingKey("close") // 死信路由键
.build();
}
// 2. 声明:关单队列 B + 消费者
@RabbitListener(queues = "order.close")
public void onClose(OrderMsg msg) {
closeOrder(msg.getOrderNo()); // 到点的订单在这里被处理
}// 下单时:把订单发进延迟队列,15 分钟后自动变死信进关单队列
rabbitTemplate.convertAndSend("order.exchange", "delay", orderMsg);特点:
- ✅ 精度高(TTL 到期即投递,秒级)
- ✅ 可靠(消息持久化,MQ 重启不丢)
- ❌ 一个队列一个 TTL:不同超时时间要建不同队列(15 分钟关单、30 分钟自动确认收货,得两个队列)
第二个姿势:RabbitMQ 延迟插件(rabbitmq_delayed_message_exchange),消息带 x-delay 属性,一个交换机通吃所有延迟时间:
rabbitTemplate.convertAndSend("order.delay.exchange", "delay", msg, m -> {
m.getMessageProperties().setDelay(15 * 60 * 1000); // 每条消息指定延迟
return m;
});延迟插件本质是把"延迟消息"存在插件内置的存储里,到期再投递,原理和死信队列一致,只是封装得更方便。需要 Docker 镜像额外装插件:
rabbitmq:3-management不带,要用带插件的镜像或手动 enable。
选型:超时时间固定(关单、收货)→ 死信队列够用;延迟时间多变 → 上延迟插件。
五、方案 v4:时间轮(Netty HashedWheelTimer / Redisson 延迟队列)
场景再进阶:订单量巨大(每秒上万单),RabbitMQ 的 TTL 队列是先进先出的,队头的消息没到期,后面到期的也只能排队等——延迟不准且可能堆积。
时间轮把时间切成一个个"槽"(tick),任务按到期时间挂到对应槽位,指针转一圈,扫到哪个槽就执行该槽的任务:
// Redisson 延迟队列:基于 Redis 的 zset 实现,到期元素才出队
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(
redissonClient.getQueue("order:close"));
// 下单时:把订单号放进去,15 分钟后到期
delayedQueue.offer(orderNo, 15, TimeUnit.MINUTES);
// 消费者:阻塞等待到期订单
while (true) {
String orderNo = delayedQueue.poll(); // 到期的订单才会被取出
closeOrder(orderNo);
}特点:
- ✅ 高吞吐、精度高(毫秒级)
- ✅ 不停扫描数据库
- ❌ 实现复杂度高;Netty 时间轮是内存态(重启丢任务),Redisson 延迟队列基于 Redis 持久(重启不丢但依赖 Redis 高可用)
适用:超高并发、毫秒级精度的延迟任务(限时抢购开抢、优惠券到期)。
六、选型表 + 我的结论
| 方案 | 精度 | 可靠性 | 复杂度 | 适用 |
|---|---|---|---|---|
| 定时轮询 | 分钟级 | ✅ 高(DB 兜底) | 低 | 单机、量小 |
| Redis 过期通知 | 分钟级(可能更久) | ⚠️ 低(可能丢) | 低 | 非关键业务 |
| RabbitMQ 死信/延迟插件 | 秒级 | ✅ 高(持久化) | 中 | 电商关单、任务调度 |
| 时间轮/Redisson 延迟队列 | 毫秒级 | ⚠️ 内存态 or 依赖 Redis | 高 | 超大规模 |
我的落地:订单关单用 RabbitMQ 死信队列(固定 15 分钟 + 持久化不丢),同时保留一个低频定时扫描兜底(防 MQ 消息丢失导致漏关单)——双保险是延迟任务最稳的姿势:MQ 保证精度,DB 扫描保证不丢。
消息基础设施见《RabbitMQ 快速入门》和《AMQP 协议详解》;关单后要释放的库存一致性,见《缓存与数据库一致性》;关单通知的下游消费幂等,见《接口幂等与防重提交》。
验证记录:RabbitMQ 死信队列方案已实跑验证——起临时 RabbitMQ 容器(rabbitmq:3-management),声明 TTL 队列 + 死信交换机 + 死信队列,投递消息后观察到期自动进入死信队列,确认 TTL 到期触发行为符合预期。Redis 过期通知的惰性删除特性为 Redis 标准行为,静态核对。
