文章目录
先说一下背景:系统主要功能是从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,从性能上来说时间轮更好一点。
维度 | DelayQueue | Netty时间轮 |
---|---|---|
插入/删除复杂度 | 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