延时队列的几种实现方式(RabbitMQ、Redisson)

863 阅读4分钟

前言

在我们的业务中,经常会使用到延迟队列,今天主要来讲解下目前主流使用的几种实现延迟效果的实现方式。目前只写了下面4种,后期补充RocketMQ,kafka

主要方式

1.通过redisson的set存储原理实现延迟队列

2.通过RabbitMQ的ttl队列+死信路由策略

3.使用RabbitMQ的延迟插件

4.使用redis的key的过期通知策略,(由于key的数量过多后,就会出现延迟,无法在正确的key过期时间通知到服务端,不考虑使用)

Redisson实现方式

依赖

<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId> 
<version>3.16.7</version> 
</dependency>

实现


package com.gery.redis.common.configuration;

import com.gery.redis.listener.RedissonListener;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RedissonClient;

import org.springframework.boot.CommandLineRunner;

import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.List;


/**
 * @Description: RedissonQueueInit
 * @Author: gery
 * @Date: 2022/11/10 18:40
 */

@Slf4j
@Component
public class RedissonQueueListeners implements CommandLineRunner {

    @Resource
    private RedissonClient client;

    @Resource
    private List<RedissonListener> redissonListenerList;

    /***
     * @Description: startThread
     *
     * @Param: [queueName, redissonListener]
     * @return: void
     * @Author: gery
     * @Date: 2022/12/30
     */
    private <T> void startThread(String queueName, RedissonListener redissonListener) {
        RBlockingQueue<T> blockingFairQueue = client.getBlockingQueue(queueName);
        //处理 可能由于服务器宕机导致消息未被消费
        client.getDelayedQueue(blockingFairQueue);
        //新起线程监听队列
        Thread thread = new Thread(() -> {
            while (true) {
                String message = "";
                try {
                    message = (String) blockingFairQueue.take();
                    log.info("监听队列线程,监听名称:{},内容:{}", queueName, message);
                    redissonListener.doService(message);
                } catch (Exception e) {
                    log.info("监听线程错误,", e);
                }

            }
        });
        thread.setName(queueName);
        thread.start();
    }


    /***
     * @Description: run
     *
     * @Param: [args]
     * @return: void
     * @Author: gery
     * @Date: 2022/12/30
     */
    @Override
    public void run(String... args) throws Exception {
        redissonListenerList.forEach(redissionListener -> startThread(redissionListener.getClass().getName(), redissionListener));
    }
}

原理

Redisson延迟队列使用三个队列:【消息延时队列】、【消息顺序队列】、【消息目标队列】。

image.png

  1. 将元素及延时信息入队,之后定时任务将到期的元素转移到阻塞队列
  2. 使用HashedWheelTimer做定时,定时到期之后从zset中取头部100个到期元素,所以定时和转移到阻塞队列是解耦的,无论是哪个task触发的pushTask,最终都是先取zset的头部先到期的元素
  3. 元素数据都是存在redis服务端的,客户端只是执行HashedWheelTimer任务,所以单个客户端挂了不影响服务端数据,做到分布式的高可用。

ttl队列+死信

条件

  1. 当消息被消费者拒绝接收,并且不会被其他消费者消费,消息就会到达死信队列
  2. 消息被设置了ttl时间,并且这个时间已经到期了,消息就会到达死信队列
  3. 队列已经达到了最大限度,排在前面的消息会被丢弃或到私信队列

原理

image.png

坑点

  1. 绑定私信交换机时,一定要将x-dead-letter的参数值设置正确,不然过期消息无法正确路由
  2. 时序问题,由于RabbitMQ是一个FIFO有序队列,遵循先进先出原则,且队列只会判断第一个消息是否过期,不会对队列上的每一个消息都做过期判断,所以会出现长时间过期的消息阻塞短时间过期的情况,这就导致不能在一个队列中设置不同过期时间

使用RabbitMQ插件实现

说明

当前办法需要安装延时消息插件(rabbitmq-delayed-message-exchange)我们可以声明 x-delayed-message 类型的 Exchange,消息发送时指定消息头 x-delay 以毫秒为单位将消息进行延迟投递。

原理

  1. 当消息到达声明x-delayed-message的交换机时,他的消息在发布之后不会立即进入队列,而是将消息保存至Mnesia种
  2. 这个插件将会尝试确认消息是否过期,首先要确保消息的延迟范围是 Delay > 0, Delay =< ?ERL_MAX_T
  3. 如果消息过期通过 x-delayed-type 类型标记的交换机投递至目标队列,整个消息的投递过程也就完成了。

坑点

  1. 不要下载过高版本的RabbitMQ和插件,下载一个稳定的版本即可
  2. 开启插件后一定要重启RabbitMQ,否则不生效,如果重启也没生效,你可以尝试下kill掉MQ的进程,然后再启动
  3. 开启消息确认机制后,生产者生产的消息都会进入returncallback回调中。⭐⭐⭐
    • 一旦开启了消息确认机制,延迟消息每次都会先进入returncallback回调,然后才会投递成功,这里涉及到延迟插件的原理,首先是交换机设置了延迟投递delayed:true,延迟消息实际上是挂载到交换机上,不会马上就通过路由投递出去,而returncallback回调打印出来的返回信息:replyText=NO_ROUTE,很明显了吧,说是没有路由,所以消息确认失败了,因为延迟插件没有马上通过路由投递。
    • 有个mandatory参数,mandatory为true表示开启强制投递,为false表示不强制,而且这个值可以为null, 如果我们的yml文件中开启了消息确认机制:publisher-returns: true,则每次一定会走returncallback回调,所以我们要么不开启消息确认机制,要么就把mandatory设置为false,这样延迟插件的消息就不会每次走一遍回调了

总结

第一次写文章,上述方法都在代码中测试过, 文章凡是有不足的地方麻烦指下