TTL+死信队列+Topic 交换机实现不同时延的消息发送

一、业务背景

用户在平台上可以新建一个任务,新建任务时,可以选择执行该任务的时间。新建任务后,将该任务包括执行时间存入数据库中。那么如何实现定时执行呢?

在最初的实现方案中,是通过定时任务频繁扫表,判断执行时间和当前时间进行的。在新一期的开发中使用了 RabbitMQ 的延时功能,通过程序计算执行时间和当前时间的时间偏差作为延时时间,然后投 RabbitMQ 。由于 RabbitMQ 的延时插件必须是硬盘节点,硬盘节点的效率稍微低一些,在此给出通过多个 Topic Exchange 和死信队列结合的方式来实现延时消息发送。

在说之前,还有必要了解一下为什么需要多个交换机和队列。队列都是先进先出的,假设使用一个队列,A 消息需要延时10秒发送,B 消息需要3秒发送。假设 A 消息先入队,B消息后入队。那么就造成了消息被延时时间长的消息阻塞的情况。而如果仅仅是增加队列的数量,这个问题依然存在,因为用户创建任务时间是随机的。

二、基础理论

RabbitMQ 的死信队列(dead-letter-exchange),即 DLX。当一个消息被拒绝或者消息 TTL(Time To Live,存活时间) 到期或者队列达到最大长度,这些情况都会进入死信队列。

还记得 TopicExchange 吗?它支持以部分匹配的 Routing Key 进行入队。例如,* 代表仅仅一个单词,# 代表多个单词。例如,一个交换机 E1 和队列 Q1 绑定,路由键为:#.destination,那么在发送消息时,无论你是 a.destination 还是 a.a.destination 都会投到这个队列中。

通过 Topic Exchange 的这种机制,可以将消息的 TTL 值转换为不同的 Routing Key,然后交给对应的交换机,交换机会将这条消息投入相应的队列。这种实现方法,将过期时间划分为若干等级,换言之,这种实现方式,你用的路由键是这个样子:

N.N.N.N.destination

每一个 N 代表的是0或1,合起来是延迟时间的二进制数,例如,延迟10秒的路由键则为:

1.0.1.0.destination

假设有消息M,延时10秒,那么它的路由键为:1.0.1.0.destination,与之对应的有以下交换机:

  • N3 交换机, Routing Key 为:1.#,该交换机是顶层交换机,所有的消息都投入这个交换机中,该交换机绑定一个队列 Q3,满足 1. 开头的路由键都将进入该队列,该队列的延时时间为 2 ^ 3,即8秒。显然,消息 M 的路由键满足该交换机路由键,于是该交换机将消息投递到 Q3 中,8秒钟后过期,过期后将投递下一级 N2 交换机。
  • N2 交换机,Routing Key 为:*.1.#,该交换机绑定的队列 Q2 的过期时间为4秒。消息 M 的路由键不满足该交换机路由键,于是 N2 继续转投下一级交换机 N1 。
  • N1 交换机,Routing Key 为:*.*.1.#,该交换机绑定队列 Q1 的过期时间为2秒。消息 M 的路由键满足该交换机,于是 N1 交换机转投到 Q1 上,2秒钟后,转投下一级交换机 N0 。
  • N0 交换机,Routing Key 为: *.*.*.1.#,该 交换机绑定队列 Q0 的过期时间为1秒。消息 M 的路由键不满足该交换机,于是 N0 转投下一级交换机。需要注意的是 N0 已经对应二进制的最低一位,从 N0 转投出来的消息 M ,先后经历了 N3 的8秒和 N1 的2秒,延时10秒已经达到。所以,从 N0 交换机转投出来的消息或者从 Q0 中到期的消息,都应该投向真正的业务交换机 destination 中。
  • 业务交换机(根交换机),路由键为:#.destination ,显然,消息 M 满足,于是该消息就走完了延时并真正投递到了业务队列中。

下图为10秒延时消息的投递过程,实线部分为消息 M 被投递的路线,首先投递到 Level3 交换机中,路由键符合,投递到 Level 3交换机对应的队列中,该队列延时 2^3=8 秒。Level2 为 Q1 的死信交换机,消息 M 到期后从 Q1 中转投出,到死信交换机 Level2 上,不满足入队条件,于是 Level 2转投 Level1 交换机。这里需要注意的是,Level 1 不仅是 Level2 的不满足的转投点,同时也是 Q2 队列的死信交换机。于是,经过层层转发,逐步判断入队,最终投递到业务交换机上。
在这里插入图片描述
从以上的例子中可以看出,若要支持更长的延时时间,只需要提高 N 的层数即可。例如以下路由键:

0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.1.0.destination

一共有28个 N ,那么这个路由键就支持 2^0 --- 2^27秒之间的所有数量级的消息。28个 N,也就是28个级别,最大支持268,435,455秒,大约8.5年。

接下来,看一下下面这张图,或许你能更好的理解。这里详细说明了在不满足交换机入队的情况下,如何转投到下一级交换机上,以及转投时的路由键。
在这里插入图片描述在这里插入图片描述
该算法详情见参考资料,加深理解。

三、Java实现

我假定你已经真的理解了,接下来,以代码实现它。本人所用环境:springboot-2.1.9,JDK 8。为了容易演示以及测试,规定最大路由键为:N.N.N.N.destination,即最大交换机等级为 N3,最大支持延时时间为15秒。

本人将直接通过一个微服务来动态创建这些队列和交换机。当 springboot 项目启动完成后,便创建这些队列,包括业务交换机。我认为,这个服务无需创建业务交换机绑定的业务队列,业务队列由具体实现业务的服务创建,它只需要绑定到我这个服务的业务交换机上即可。

首先是自定义配置:

base:
  exchangename: EXCHANGE.TOPIC.SENDTASK.N
  queuename: QUEUE.DELAY.SENDTASK.N
  level: 3

send:
  queue:
    exchange: EXCHANGE.DELAY.DIRECT.SENDTASK
  • base.exchangename 指的是这一系列交换机的基名,交换机的全名为基名+级别。
  • 上面的例子,我将来创建好的若干交换机名字为:EXCHANGE.TOPIC.SENDTASK.N3EXCHANGE.TOPIC.SENDTASK.N2EXCHANGE.TOPIC.SENDTASK.N1EXCHANGE.TOPIC.SENDTASK.N0
  • base.queuename 的作用也是如此。
  • base.level代表我创建的交换机最大等级为3。
  • send.queue.exchange代表的是业务交换机的名字。

Rabbitmq 配置:

spring:
  rabbitmq:
    addresses: 127.0.0.1:5672
    username: guest
    password: guest

接下来是一些工具类代码。例如,根据等级和消息的延时时间换算消息路由键的代码,这里末尾为 .d 代替原来的 .destination

// 调用该方法getMessageRoutingKey(10, 3)。延迟10秒,等级为3
// 返回 1.0.1.0.d
public static String getMessageRoutingKey(int delay, int set) {

    String binString = Integer.toBinaryString(delay);

    StringBuffer sb = new StringBuffer();

    for (int i = 0; i < set + 1 - binString.length(); i++) {
        sb.append("0");
    }

    binString = sb.toString() + binString;

    StringBuilder sb1 = new StringBuilder();
    for (int j = 0; j < binString.length(); j++) {
        sb1.append(binString.charAt(j));
        sb1.append(".");
    }
    sb1.append("d");

    return sb1.toString();
}

下面的方法是根据队列延时,等级以及置位符获取绑定路由键的方法:

// 调用该方法getBindingRoutingKey(1, 3, 1)。延迟1秒的队列,入队时的路由键为*.*.*.1.#
// 调用该方法getBindingRoutingKey(1, 3, 1)。延时1秒的队列,不满足的路由键为*.*.*.0.#
public static String getBindingRoutingKey(int delay, int set, int val) {

    String binString = Integer.toBinaryString(delay);

    StringBuffer sb = new StringBuffer();

    for (int i = 0; i < set - binString.length() + 1; i++) {
        sb.append("*.");
    }

    binString = sb.toString() + val + ".#";

    return binString.toString();
}

以下类是进行递归动态创建队列和交换机并完成绑定的类,该类实现了 CommandLineRunner 接口并复写了 run() 方法,springboot 项目启动完成后,该方法会执行。

@Configuration
public class RabbitMqConfig implements CommandLineRunner {

    @Value("${base.exchangename}")
    String exchangeName;

    @Value("${base.queuename}")
    String queueName;

    @Value("${base.level}")
    Integer baseLevel;

    @Value("${send.queue.exchange}")
    String exchange;

    @Autowired
    RabbitAdmin rabbitAdmin;

    @Autowired
    TopicExchange directExchange;

    @Bean
    RabbitAdmin initRabbitAdmin(ConnectionFactory connectionFactory) {
        return new RabbitAdmin(connectionFactory);
    }

    /**
     * Springboot加载完成后, 调用该方法创建延时队列和交换机
     * 以 baseLevel = 3为例, 即Level 0 - Level 3, 最大支持 2^4=16秒延时
     */
    @Override
    public void run(String... args){
        createDelayQueue(baseLevel);
    }

    /**
     * 首先创建根交换机, 即到期后转投的业务交换机, 
     * 如: SEND-TASK-Exchange, 该交换机类型必须为TopicExchange
     */
    @Bean
    TopicExchange initDirectExchange() {
        return new TopicExchange(exchange, true, false);
    }

    /**
     * 递归方法, 从根交换机上进行逐层创建队列和交换机并绑定关系
     */
    public void createDelayQueue(Integer currentLevel) {

        if (currentLevel == 0) {
            // 递归退出条件, 创建Level-0队列, 该队列TTL值为1秒, 过期后将投递到根交换机中
            Queue queue = QueueBuilder.durable(queueName + currentLevel)
                    .withArgument("x-message-ttl", 1000)
                    .withArgument("x-dead-letter-exchange", exchange)
                    .build();

            // 创建Level-0交换机
            TopicExchange topicExchange = new TopicExchange(exchangeName + currentLevel, true, false);

            // 将Level-0交换机与Level-0队列绑定, 以下方法返回:*.*.*.1.#, 即延迟1秒的消息入Level-0队列
            String key = MqUtil.getBindingRoutingKey(1, baseLevel, 1);
            Binding binding = BindingBuilder.bind(queue).to(topicExchange).with(key);

            // Level-0交换机与根交换机绑定, 以下方法返回:*.*.*.0.#, 即不满足Level-0交换机的说明延时时间为0, 直接入根交换机
            String key1 = MqUtil.getBindingRoutingKey(1, baseLevel, 0);
            Binding binding1 = BindingBuilder.bind(directExchange).to(topicExchange).with(key1);

            rabbitAdmin.declareQueue(queue);
            rabbitAdmin.declareExchange(topicExchange);
            rabbitAdmin.declareBinding(binding);
            rabbitAdmin.declareBinding(binding1);
            return;
        }

        // 递归开始, 若想创建第Level层交换机和队列, 必须先创建其下一层的交换机, 因为Level层的队列和交换机要与下层的交换机绑定
        createDelayQueue(currentLevel - 1);

        // 创建第一层队列, 请注意:当N>21时, 此处会越界成为负值从而报错
        Queue queue = QueueBuilder.durable(queueName + currentLevel)
                .withArgument("x-message-ttl", (1 << currentLevel) * 1000)
                .withArgument("x-dead-letter-exchange", exchangeName + (currentLevel - 1))
                .build();

        // 创建第一层交换机
        TopicExchange topicExchange = new TopicExchange(exchangeName + currentLevel, true, false);

        // 将同层的交换机与队列绑定
        String key = MqUtil.getBindingRoutingKey(1 << currentLevel, baseLevel, 1);
        Binding binding = BindingBuilder.bind(queue).to(topicExchange).with(key);

        // 将当前层交换机与下层交换机绑定
        String key1 = MqUtil.getBindingRoutingKey(1 << currentLevel, baseLevel, 0);
        TopicExchange nextTopicExchange = new TopicExchange(exchangeName + (currentLevel - 1), true, false);
        Binding binding1 = BindingBuilder.bind(nextTopicExchange).to(topicExchange).with(key1);

        rabbitAdmin.declareQueue(queue);
        rabbitAdmin.declareExchange(topicExchange);
        rabbitAdmin.declareBinding(binding);
        rabbitAdmin.declareBinding(binding1);
    }
}

四、运行的结果

首先来看一下创建的结果:
在这里插入图片描述在这里插入图片描述可以看到,创建了四个级别的交换机和业务交换机,以及4个级别的队列。接下来看一下交换机和交换机、交换机和队列的绑定关系。

N3 交换机以及 N3 队列

N3 为最高级别的交换机,他绑定了两个路由键,如果满足 0.# 的,就转投 N2 交换机,如果满足 1.# 的,就进入 N3 队列进行8秒的延时操作。
在这里插入图片描述下图为 N3 队列的信息,看到死信交换机为 N2 ,TTL 为8秒。入队条件为路由键满足 1.# ,入队交换机为 N3 。
在这里插入图片描述

N2 交换机和 N2 队列

下图为 N2 交换机详情,可以看到,从 N3 转投的路由键为:0.#,满足入队的路由键为 *.1.#,不满足入队、投递下层交换机的路由键为 *.0.#
在这里插入图片描述
下图为 N2 队列详情,他的死信交换机为 N1,TTL 时间为4秒。
在这里插入图片描述中间的过程与这部分类似,就不再一一上图。

N0 交换机 和 N0 队列

下图为 N0交换机,不满足入队条件的路由键直接投递业务交换机。
在这里插入图片描述
下图为 N0 队列,他的死信交换机为业务交换机,TTL 值为1秒。
在这里插入图片描述

五、进行测试

以下代码是业务服务中,如何使用业务交换机并进行绑定的代码:

@Configuration
public class RabbitmqConfig {

    @Bean
    public Queue createQueue() {
        return new Queue("SEND-TASK");
    }

	// 使用之前创建好的业务交换机
    @Bean
    TopicExchange topicExchange() {
        return new TopicExchange("EXCHANGE.DELAY.DIRECT.SENDTASK");
    }

	// 将业务队列与业务交换机进行绑定,路由键为 #.d
	// 这样在从 N0 交换机出来的时候能够被业务交换机投递到该队列中
    @Bean
    Binding bindingExchange(Queue queue, TopicExchange topicExchange) {
        return BindingBuilder.bind(queue).to(topicExchange).with("#.d");
    }
}

参考资料

[1] RabbitMQ Delayed Delivery

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值