一、业务背景
用户在平台上可以新建一个任务,新建任务时,可以选择执行该任务的时间。新建任务后,将该任务包括执行时间存入数据库中。那么如何实现定时执行呢?
在最初的实现方案中,是通过定时任务频繁扫表,判断执行时间和当前时间进行的。在新一期的开发中使用了 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.N3
,EXCHANGE.TOPIC.SENDTASK.N2
,EXCHANGE.TOPIC.SENDTASK.N1
,EXCHANGE.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");
}
}