基于时间轮实现百万级定时任务

 先说一下背景:系统主要功能是从MQ中读取消息经过内部处理转换后将数据推给另一个消息队列,现在需要做一个延迟推送,每条数据延迟3分钟推送,对时效性要求不是特别高。主要有以下参考方案。

方案1:本地延迟队列 + 定时任务(纯内存)​

适用场景​​:单机部署、延迟时间固定、轻量级需求

1.使用DelayQueue存储消息,元素需实现Delayed接口,记录消息到期时间戳。
2.消费者线程轮询队列,自动过滤未到期消息。

public class DelayedMQMessage implements Delayed {
    private String message;
    private long expireTime; // 当前时间 + 180秒

    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }
}

// 消费者线程
DelayQueue<DelayedMQMessage> delayQueue = new DelayQueue<>();
new Thread(() -> {
    while (true) {
        DelayedMQMessage msg = delayQueue.take(); // 阻塞直到消息到期
        processMessage(msg.getMessage());
    }
}).start();

// 接收IBM MQ消息后放入延迟队列
public void onMQMessage(String message) {
    delayQueue.put(new DelayedMQMessage(message, System.currentTimeMillis() + 180_000));
}

方案2:Redis Sorted Set(分布式延迟队列)​

​​适用场景​​:分布式环境、需持久化

// 使用Spring Data Redis
public void addToDelayQueue(String message) {
    String key = "mq_delay_queue";
    long expireTime = System.currentTimeMillis() + 180_000;
    redisTemplate.opsForZSet().add(key, message, expireTime);
}

// 定时任务(每5秒检查一次)
@Scheduled(fixedRate = 5000)
public void processExpiredMessages() {
    Set<String> messages = redisTemplate.opsForZSet().rangeByScore("mq_delay_queue", 0, System.currentTimeMillis());
    messages.forEach(msg -> {
        processMessage(msg);
        redisTemplate.opsForZSet().remove("mq_delay_queue", msg);
    });
}

​​方案3:MQ死信队列+ TTL​​

1.指定死信队列(DLQ)。
2.设置消息的TTL为180秒,未处理消息自动转入DLQ,再由消费者监听DLQ处理。

方案4:数据库 + 定时扫描

1.消息存入数据库表,记录状态(PENDING)和计划处理时间(当前时间 + 180秒)。
2.定时任务扫描到期消息并处理

方案5:时间轮算法(高性能延迟调度)​

适用场景​​:高并发、低延迟精度要求

使用Netty的HashedWheelTimer调度延迟任务:

HashedWheelTimer timer = new HashedWheelTimer(1, TimeUnit.SECONDS);

public void onMQMessage(String message) {
    timer.newTimeout(timeout -> {
        processMessage(message);
    }, 180, TimeUnit.SECONDS);
}

优点​​:高性能,O(1)时间复杂度。
​​缺点​​:内存中运行,宕机后任务丢失,需要手动实现持久化,例如通过接口触发停止接收新的定时任务,等内存中的任务执行完成后再关闭进程。

最终选择

2、3、4方案需要结合组件来实现,适合分布式场景,而我们这次主打一个简单便捷,经过对比方案1、5,最后选择方案5,从性能上来说时间轮更好一点。

维度DelayQueueNetty时间轮​
插入/删除复杂度O(log n)(基于优先级堆)O(1)(哈希轮寻址)
任务触发延迟依赖堆调整,高负载时可能波动固定时间槽,延迟更稳定
内存占用每个任务需封装为对象,堆内存压力大时间轮预分配槽位,内存更可控
吞吐量单线程消费,可能成为瓶颈支持多槽位并行处理

 时间轮算法的设计思想来源于钟表。如下图所示,时间轮可以理解为一种环形结构,像钟表一样被分为多个 slot 槽位。每个 slot 代表一个时间段,每个 slot 中可以存放多个任务,使用的是链表结构保存该时间段到期的所有任务。时间轮通过一个时针随着时间一个个 slot 转动,并执行 slot 中的所有到期任务。

在这里插入图片描述

推荐阅读

Netty默认配置​:

  • 槽数 = 512,通常建议设为2的幂(如512/1024)以优化哈希计算。
  • 每槽时间 = 100毫秒

总时间跨度​​:512 × 100ms = ​​51.2秒​
槽数越多,任务哈希到不同槽位的冲突越少,避免单个槽位链表过长,提升遍历效率。
​​时间精度​​:tickDuration决定时间轮的最小调度粒度,若设为1秒,任务触发时间可能有±1秒误差,若设为100毫秒,误差更小,但时间轮推进频率更高,CPU开销增加。

​​当任务的延迟时间超过单级时间轮的总时间跨度时,确实需要通过“多级时间轮”或“任务重新哈希”机制来处理​​。若任务延迟超过总跨度(如60秒任务放入10槽时间轮),需依赖“轮数”机制(remainingRounds)或分层时间轮,这会增加复杂度。

Netty时间轮的优化空间​

1、合理设置槽位(ticksPerWheel)和槽位间隔(tickDuration),这两个决定了精度与内存。
2、结合线程池异步提交任务,避免阻塞时间轮推进。

注意:当线程池队列容量和最大线程数满了之后,时间轮中提交任务的线程还是会发送阻塞,这时就要合理配置线程数和队列容量,避免时间轮提交任务发送阻塞。

<dependency>
   <groupId>io.netty</groupId>
   <artifactId>netty-all</artifactId>
   <version>5.0.0.Alpha2</version>
</dependency>
    @Primary
    @Bean
    public TaskExecutor taskExecutorLf() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // 设置核心线程数
        executor.setCorePoolSize(ConfigProperties.THREAD_NUM);
        // 设置最大线程数
        executor.setMaxPoolSize(12);
        // 设置队列容量
        executor.setQueueCapacity(5000);
        // 设置线程活跃时间(秒)
        executor.setKeepAliveSeconds(60);
        // 设置默认线程名称
        executor.setThreadNamePrefix("zzs-");
        // 设置拒绝策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        return executor;
    }
    @Resource
    private TaskExecutor taskExecutorLf;
    private static HashedWheelTimer wheelTimer = new HashedWheelTimer(1, TimeUnit.SECONDS, 512);

    @JmsListener(id = "#{T(com.zzs.properties.ConfigProperties).MONITOR_QUEUE}", destination = "#{T(com.zzs.properties.ConfigProperties).MONITOR_QUEUE}", concurrency = "2")
    public void onMessage(Message message) {
                wheelTimer.newTimeout(timeout -> {
                      CompletableFuture.runAsync(() -> {
                          dataService.processMessage(message);
                      }, taskExecutorLf);
                }, ConfigProperties.DELAY_TIME, TimeUnit.SECONDS);
    }

多线程下:Netty 的 HashedWheelTimer.newTimeout方法​​支持多线程调用​​,其内部通过无锁化设计(如 MPSC 队列)保证线程安全,最终任务执行仍由时间轮的单个工作线程(WorkerThread)串行处理。

触发停止接收消息:

@Slf4j
@RestController
@RequestMapping(value = "/api")
public class ManagerController {
    @Autowired
    private JmsListenerEndpointRegistry registry;


    @PostMapping(value = "stop")
    public String stopListener(){
        registry.getListenerContainer(ConfigProperties.MONITOR_QUEUE).stop();
        return ConfigProperties.MONITOR_QUEUE + " Stopping now.";
    }
}

槽位和槽位时间有什么关系?

槽(Slot)​​和​​每一槽的时间(tickDuration)​​共同决定了时间轮的精度、时间跨度及任务调度能力。

​​ - 槽数量(ticksPerWheel)​​:时间轮的环形队列中槽的总数,决定了时间轮的“容量”。
​​ - 每槽时间(tickDuration)​​:指针从一个槽移动到下一个槽的时间间隔,决定了时间轮的精度。
​​ - 总时间跨度​​:时间轮完整转动一圈的时间 = ticksPerWheel × tickDuration。

也就是:总时间跨度=ticksPerWheel×tickDuration

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

鱼找水需要时间

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值