RabbitMQ--延迟队列(六)
延迟队列也是死信队列的一种,也就是当消息TTL过期,对应的一种情况。
概念
延时队列,队列内部是有序的,最重要的特性就体现在它的延时属性上,延时队列中的元素是希望在指定时间到了以后或之前取出和处理,简单来说,延时队列就是用来存放需要在指定时间被处理的元素的队列。
应用场景
-
订单在十分钟之内未支付则自动取消
-
新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。
-
用户注册成功后,如果三天内没有登陆则进行短信提醒。
-
用户发起退款,如果三天内没有得到处理则通知相关运营人员。
-
预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议
这些场景都有一个特点,需要在某个事件发生之后或者之前的指定时间点完成某一项任务,如:发生订单生成事件,在十分钟之后检查该订单支付状态,然后将未支付的订单进行关闭;看起来似乎使用定时任务,一直轮询数据,每秒查一次,取出需要被处理的数据,然后处理不就完事了吗?如果数据量比较少,确实可以这样做,比如:对于“如果账单一周内未支付则进行自动结算”这样的需求,如果对于时间不是严格限制,而是宽松意义上的一周,那么每天晚上跑个定时任务检查一下所有未支付的账单,确实也是一个可行的方案。
但对于数据量比较大,并且时效性较强的场景,如:“订单十分钟内未支付则关闭“,短期内未支付的订单数据可能会有很多,活动期间甚至会达到百万甚至千万级别,对这么庞大的数据量仍旧使用轮询的方式显然是不可取的,很可能在一秒内无法完成所有订单的检查,同时会给数据库带来很大压力,无法满足业务要求而且性能低下。
实战
项目准备
与SpringBoot整合
创建两个队列 QA 和 QB,两者队列 TTL 分别设置为 10S 和 40S,然后在创建一个交换机 X 和死信交换机 Y,它们的类型都是direct,创建一个死信队列 QD,它们的绑定关系如下:
在和Springboot整合之后,只需要写个配置类:
- 配置交换机和队列和路由key。
pom依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.9</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.uin</groupId>
<artifactId>rabbitmq-springboot</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rabbitmq-springboot</name>
<description>rabbitmq-springboot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--RabbitMQ依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!--web组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--json转化-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.74</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--swagger依赖-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
<!--自带的测试组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--rabbitmq测试依赖-->
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
配置文件
spring:
rabbitmq:
host: localhost
username: guest
password: guest
# 15672 是客户端的端口号
port: 5672
virtual-host: /
config层
package com.uin.rabbitmqspringboot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
/**
* @author wanglufei
* @description: Swagger的配置类
* @date 2022/2/5/4:48 PM
*/
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket webApplication() {
return new Docket(DocumentationType.SWAGGER_2)
.groupName("webApi")
.apiInfo(webApiInfo())
.select()
.build();
}
public ApiInfo webApiInfo() {
return new ApiInfoBuilder()
.title("rabbitmq 接口文档")
.description("本文档描述了 rabbitmq 微服务接口定义")
.version("1.0")
.contact(new Contact("enjoy","http://www.baidu.com","1634060836@qq.com"))
.build();
}
}
package com.uin.rabbitmqspringboot.config;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* @author wanglufei
* @description: 延迟队列配置类
* @date 2022/2/5/5:11 PM
*/
@Configuration
public class TTLQueueConfig {
//普通队列
public static final String QUEUE_A_NAME = "Qa";
public static final String QUEUE_B_NAME = "Qb";
//死信队列
public static final String QUEUE_D_NAME = "Qd";
//普通交换机
public static final String EXCHANGE_NAME_X = "X";
//死信交换机
public static final String EXCHANGE_NAME_Y_DEAD_LETTER = "Y";
//声明交换机
@Bean("exchange_x")
public DirectExchange exchange_x() {
return new DirectExchange(EXCHANGE_NAME_X);
}
//声明交换机
@Bean("exchange_y")
public DirectExchange exchange_y() {
return new DirectExchange(EXCHANGE_NAME_Y_DEAD_LETTER);
}
//声明队列
@Bean("queue_a")
public Queue queue_a() {
//普通队列和死信的交换机绑定
//参数的设置
Map<String, Object> arguments = new HashMap<>();
//设置死信交换机
arguments.put("x-dead-letter-exchange", EXCHANGE_NAME_Y_DEAD_LETTER);
//路由
arguments.put("x-dead-letter-routing-key", "YD");
//设置消息的过期时间 ms
arguments.put("x-message-ttl", 10000);
return QueueBuilder.durable(QUEUE_A_NAME).withArguments(arguments).build();
}
@Bean("queue_b")
public Queue queue_b() {
//普通队列和死信的交换机绑定
//参数的设置
Map<String, Object> arguments = new HashMap<>();
//设置死信交换机
arguments.put("x-dead-letter-exchange", EXCHANGE_NAME_Y_DEAD_LETTER);
//路由
arguments.put("x-dead-letter-routing-key", "YD");
//设置消息的过期时间 ms
arguments.put("x-message-ttl", 40000);
return QueueBuilder.durable(QUEUE_B_NAME).withArguments(arguments).build();
}
@Bean("queue_d")
public Queue queue_d() {
return QueueBuilder.durable(QUEUE_D_NAME).build();
}
//绑定 普通队列和普通交换机的绑定
@Bean
public Binding Binding_qA_x(@Qualifier("queue_a") Queue queue_a,
@Qualifier("exchange_x") DirectExchange exchange_x) {
return BindingBuilder.bind(queue_a).to(exchange_x).with("XA");
}
@Bean
public Binding Binding_qB_x(@Qualifier("queue_b") Queue queue_b,
@Qualifier("exchange_x") DirectExchange exchange_x) {
return BindingBuilder.bind(queue_b).to(exchange_x).with("XB");
}
//死信队列和死信交换机的绑定
@Bean
public Binding Binding_qD_y(@Qualifier("queue_d") Queue queue_d,
@Qualifier("exchange_y") DirectExchange exchange_y) {
return BindingBuilder.bind(queue_d).to(exchange_y).with("YD");
}
}
controller层
package com.uin.rabbitmqspringboot.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
/**
* @author wanglufei
* @description: 生产者--发送延迟消息
* @date 2022/2/5/5:59 PM
*/
@RestController
@RequestMapping("/ttl")
@Slf4j
public class Producer {
@Autowired
private RabbitTemplate rabbitTemplate;
// @RequestMapping(value = "/sendMsg/{message}", method = RequestMethod.GET)
@GetMapping("/sendMsg/{message}")
public void sendMsg(@PathVariable String message) {
//{} 相当于占位符 会被后面的两个参数替换掉
log.info("当前时间:{},发送消息给两个延时队列:{}", new Date().toString(), message);
rabbitTemplate.convertAndSend("X", "XA", "消息来自ttl队列为10s的队列" + message);
rabbitTemplate.convertAndSend("X", "XB", "消息来自ttl队列为40s的队列" + message);
}
}
消费者(监听层)
package com.uin.rabbitmqspringboot.listener;
import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* @author wanglufei
* @description: 队列TTl的消费值 --监听器
* @date 2022/2/5/6:16 PM
*/
@Component
@Slf4j
public class DeadLetterQueueConsumer {
@RabbitListener(queues = "Qd")
public void receiveD(Message message, Channel channel) throws Exception {
String msg = new String(message.getBody());
log.info("当前时间:{},接受到的死信消息:{}", new Date().toString(), msg);
}
}
测试
延时队列的优化
项目准备
为了能够满足新的时间需求,而不用增加无数个队列的要求,做次优化。
优化点:增加一个没有时间的ttl队列。Qc
测试
//声明优化队列
@Bean("queue_c")
public Queue queue_c() {
//普通队列和死信的交换机绑定
//参数的设置
Map<String, Object> arguments = new HashMap<>();
//设置死信交换机
arguments.put("x-dead-letter-exchange", EXCHANGE_NAME_Y_DEAD_LETTER);
//路由
arguments.put("x-dead-letter-routing-key", "YD");
// 设置消息的过期时间 ms
// arguments.put("x-message-ttl", 40000);
return QueueBuilder.durable(QUEUE_C_NAME).withArguments(arguments).build();
}
//绑定 普通队列和普通交换机的绑定
@Bean
public Binding Binding_qC_x(@Qualifier("queue_c") Queue queue_c,
@Qualifier("exchange_x") DirectExchange exchange_x) {
return BindingBuilder.bind(queue_c).to(exchange_x).with("XC");
}
//开始发送 消息 TTL
//注解的value、method、params及headers分别指定“请求的URL、请求方法、请求参数及请求头”。
@RequestMapping(value = "/sendExpirationMsg/{message}/{ttlTime}", method = RequestMethod.GET)
public void sendMsg(@PathVariable("message") String message,
@PathVariable("ttlTime") String ttlTime) {
log.info("当前时间:{},发送时长:{}毫秒,消息给时间没有限制的TTL队列:{}", new Date().toString(), ttlTime, message);
//MessagePostProcessor 函数式接口
rabbitTemplate.convertAndSend("X", "XC", "消息来自ttl队列为10s的队列" + message,
msg -> {
//设置发送消息的时间延迟
msg.getMessageProperties().setExpiration(ttlTime);
return msg;
});
}
根据测试结果,发现消息是排队的,并不是按照时间长短发送的。会存在时序问题。
看起来似乎没什么问题,但是在最开始的时候,就介绍过如果使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“,因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列,如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。
Reference
RabbitMQ插件实现延时队列
上文中提到的问题,确实是一个问题,如果不能实现在消息粒度上的 TTL,并使其在设置的TTL 时间及时死亡,就无法设计成一个通用的延时队列。那如何解决呢,接下来我们就去解决该问题。
安装插件
[插件下载位置](Release 3.9.0 · rabbitmq/rabbitmq-delayed-message-exchange (github.com))
rabbitmq-delayed-message-exchange --插件名称
https://github.com/rabbitmq/rabbitmq-delayed-message-exchange/releases/tag/v3.8.0 --下载地址,选择3.8
安装插件
重启RabbitMQ服务。
x-delayed-message交换机
概念
这里将使用的是一个 RabbitMQ 延迟消息插件 rabbitmq-delayed-message-exchange,目前维护在 RabbitMQ 插件社区,我们可以声明 x-delayed-message 类型的 Exchange,消息发送时指定消息头 x-delay 以毫秒为单位将消息进行延迟投递。
实现的原理
上面使用 DLX (死信队列)+ TTL(消息过期时间) 的模式,消息首先会路由到一个正常的队列,根据设置的 TTL 进入死信队列,与之不同的是通过 x-delayed-message 声明的交换机,它的消息在发布之后不会立即进入队列,先将消息保存至 Mnesia(一个分布式数据库管理系统,适合于电信和其它需要持续运行和具备软实时特性的 Erlang 应用。目前资料介绍的不是很多)。
这个插件将会尝试确认消息是否过期,首先要确保消息的延迟范围是 Delay > 0, Delay =< ?ERL_MAX_T(在 Erlang 中可以被设置的范围为 (2^32)-1 毫秒),如果消息过期通过 x-delayed-type 类型标记的交换机投递至目标队列,整个消息的投递过程也就完成了。
目前该插件的当前设计并不真正适合包含大量延迟消息(例如数十万或数百万)的场景,详情参见 #/issues/72 另外该插件的一个可变性来源是依赖于 Erlang 计时器,在系统中使用了一定数量的长时间计时器之后,它们开始争用调度程序资源,并且时间漂移不断累积。
如果你采用了 Delayed Message 插件这种方式来实现,对于消息可用性要求较高的,在发现消息之前可以先落入 DB 打标记,消费之后将消息标记为已消费,中间可以加入定时任务做检测,这可以进一步保证你的消息的可靠性。
实战
在这里新增了一个队列delayed.queue,一个自定义交换机 delayed.exchange,绑定关系如下:
在我们自定义的交换机中,这是一种新的交换类型,该类型消息支持延迟投递机制 消息传递后并不会立即投递到目标队列中,而是存储在 mnesia(一个分布式数据系统)表中,当达到投递时间时,才投递到目标队列中。
交换机和队列的配置类
package com.uin.rabbitmqspringboot.config;
import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* @author wanglufei
* @description: 延迟队列交换机
* @date 2022/2/5/10:31 PM
*/
@Configuration
public class DelayExchange {
public static final String DELAY_QUEUE_NAME = "delayed.queue";
public static final String DELAY_EXCHANGE_NAME = "delayed.exchange";
public static final String DELAY_EXCHANGE_ROUTING_KEY = "delayed.routingkey";
//自定义延迟交换机
@Bean
public CustomExchange delayedExchange() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-delayed-type", "direct");
return new CustomExchange(DELAY_EXCHANGE_NAME, "x-delayed-message", true, false, arguments);
}
//声明延迟队列
@Bean
public Queue delayedQueue() {
return new Queue(DELAY_QUEUE_NAME);
}
//绑定队列和自定义的交换机
@Bean
public Binding delayedExchangeQueue(@Qualifier("delayedQueue") Queue queue,
@Qualifier("delayedExchange") CustomExchange delayedExchange) {
return BindingBuilder.bind(queue).to(delayedExchange).with(DELAY_EXCHANGE_ROUTING_KEY).noargs();
}
}
发送消息
//基于插件的TTL消息的发送 消息 及 延迟的时间
@RequestMapping("/sendDelayMsg/{message}/{delayTime}")
public void sendMsg(@PathVariable("message") String message,
@PathVariable("delayTime") Integer delayTime) {
log.info("当前时间:{},发送时长:{}毫秒,信息给延迟队列delayed.queue:{}", new Date().toString(), delayTime,
message);
rabbitTemplate.convertAndSend(DelayExchange.DELAY_EXCHANGE_NAME,
DelayExchange.DELAY_EXCHANGE_ROUTING_KEY,
"消息来自于delayed.queue:" + message + ",时长:" + delayTime,
msg -> {
//设置延迟时长
msg.getMessageProperties().setDelay(delayTime);
return msg;
});
}
消费消息的监听器
package com.uin.rabbitmqspringboot.listener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* @author wanglufei
* @description: TODO
* @date 2022/2/6/8:26 AM
*/
@Component
@Slf4j
public class DelayMessageConsumer {
@RabbitListener(queues = "delayed.queue")
public void receiveDelay(Message message) {
String msg = new String(message.getBody());
log.info("当前时间:{},接受到延迟队列的消息:{}", new Date().toString(), msg);
}
}
测试
发送请求:
http://localhost:8080/ttl/sendDelayMsg/喵喵/20000
http://localhost:8080/ttl/sendDelayMsg/喵喵/2000
发现根据插件的延时队列,并不会存在时序问题。符合预期。
延迟队列的总结
延迟队列一种是DLX(死信队列)+TTL(过期时间),另一种是基于插件的延迟队列。
延时队列在需要延时处理的场景下非常有用,使用 RabbitMQ 来实现延时队列可以很好的利用RabbitMQ 的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃。
另外,通过 RabbitMQ 集群的特性,可以很好的解决单点故障问题,不会因为单个节点挂掉导致延时队列不可用或者消息丢失。
当然,延时队列还有很多其它选择,比如利用 Java 的 DelayQueue,利用 Redis 的 zset,利用 Quartz(定时器)或者利用 kafka 的时间轮,这些方式各有特点,看需要适用的场景。
本文来自博客园,作者:{BearBrick0},转载请注明原文链接:{https://www.cnblogs.com/bearbrick0}