消息Broker,目前常见二点实现方案就是消息队列,简称MQ,目前比较常见的MQ实现:ActiveMQ,RabbitMQ,RocketMQ,Kakfa
集中常见MQ的对比
追求可用性:Kafka,RocketMQ,RabbitMQ
追求可靠性:RabbitMQ,RocketMQ
追求吞吐能力:RockerMQ,Kafka
追求消息低延迟:RabbitMQ,Kafka
其中RabbitMQ是基于Erlang语言开发的开源消息通信中间件,官网地址:https://www.rabbitmq.com
docker安装启动rabbitmq
使用已有的镜像tar包,使用docker load命令加载镜像
docker load -i mq.tar //也可以直接拉取镜像docker pull rabbitmq
云服务器部署rabbitmq需要开启以下几个端口:15672、5672、25672、61613、1883。
15672(UI页面通信口)、5672(client端通信口)、25672(server间内部通信口)、61613(stomp 消息传输)、1883(MQTT消息队列遥测传输)。
docker启动rabbitmq
docker run \
-e RABBITMQ_DEFAULT_USER=itheima \
-e RABBITMQ_DEFAULT_PASS=123321 \
-v mq-plugins:/plugins \
--name mq \
--hostname mq \
-p 15672:15672 \
-p 5672:5672 \
--network hmall \
-d \
rabbitmq:3.8-management
安装完成后,访问 http://123.207.5.157:15672可看到管理控制台。首次访问需要登录,默认的用户名和密码已经在配置环境中指定了。
RabbitMQ架构图
publisher:生产者,发送消息的一方.
consumer:消费者,消费消息的一方
exchange:交换机,负责消息路由,生产者发送的消息由交换机决定转发到哪个队列.不存储消息,路由失败的话,消息会丢失.
queue:队列,存储消息,生产者发送的消息会暂时存放在队列中,等待消费者消费.
virtual host:虚拟主机,起到数据隔离的作用,每个虚拟主机互相独立,拥有各自的交换机和队列.
WorkQueue模型
workqueue模型就是让多个消费者绑定到一个队列,共同消费队列中的消息.
当生产者生产消息的速度远远大于消息的消费速度时,消息会在堆积越来越多,无法及时处理.此时就可以用work模型:多个消费者共同对消息进行处理,这样消息处理的速度就大大提高了.
模拟大量消息堆积案例:
消息发送
@Test
public void testWorkQueue() throws InterruptedException {
String queueName = "work.queue";
String msg = "hello,messgae";
for (int i = 0; i < 50; i++) {
rabbitTemplate.convertAndSend(queueName,msg+i);
Thread.sleep(20);
}
}
消息接收:两个消费者绑定同一个队列
@RabbitListener(queues = "work.queue")
public void listenWorkQueue(String msg) throws InterruptedException {
System.out.println("消费者1收到的消息"+msg+LocalTime.now());
Thread.sleep(20);
}
@RabbitListener(queues = "work.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
System.err.println("消费者2收到的消息"+msg+ LocalTime.now());
Thread.sleep(200);
}
消费者1sleep了20毫秒,相当于每秒钟处理50个消息;
消费者2sleep了200毫秒,相当于每秒钟处理5个消息
测试
消费者1和消费者2分别消费了25条消息,区别在于消费者1很快消费完了,而消费者2却在缓慢的消费.
在application.yml文件中将prefetch设置为1
spring:
rabbitmq:
listener:
simple:
prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
开启prefetch=1后的测试结果:
消费者1收到的消息hello,messgae222:20:57.730229900
消费者1收到的消息hello,messgae322:20:57.760150900
消费者1收到的消息hello,messgae422:20:57.790068800
消费者1收到的消息hello,messgae522:20:57.819991200
消费者1收到的消息hello,messgae622:20:57.849911700
消费者1收到的消息hello,messgae722:20:57.879831300
消费者1收到的消息hello,messgae822:20:57.910923600
消费者2收到的消息hello,messgae922:20:57.910923600
消费者1收到的消息hello,messgae1022:20:57.941088800
消费者1收到的消息hello,messgae1122:20:57.970420700
消费者1收到的消息hello,messgae1222:20:58.000493600
消费者1收到的消息hello,messgae1322:20:58.029420800
消费者1收到的消息hello,messgae1422:20:58.059338400
消费者1收到的消息hello,messgae1522:20:58.091000700
消费者1收到的消息hello,messgae1622:20:58.120348700
消费者2收到的消息hello,messgae1722:20:58.152450400
消费者1收到的消息hello,messgae1822:20:58.188132500
消费者1收到的消息hello,messgae1922:20:58.227359
消费者1收到的消息hello,messgae2022:20:58.256548100
消费者1收到的消息hello,messgae2122:20:58.288860400
消费者1收到的消息hello,messgae2222:20:58.317795800
消费者1收到的消息hello,messgae2322:20:58.347659
消费者2收到的消息hello,messgae2422:20:58.362579100
消费者1收到的消息hello,messgae2522:20:58.394763900
消费者1收到的消息hello,messgae2622:20:58.424609300
消费者1收到的消息hello,messgae2722:20:58.456453400
消费者1收到的消息hello,messgae2822:20:58.486433800
消费者1收到的消息hello,messgae2922:20:58.515837800
消费者1收到的消息hello,messgae3022:20:58.547375200
消费者1收到的消息hello,messgae3122:20:58.576823900
消费者2收到的消息hello,messgae3222:20:58.607905300
消费者1收到的消息hello,messgae3322:20:58.639474700
消费者1收到的消息hello,messgae3422:20:58.668871500
消费者1收到的消息hello,messgae3522:20:58.699093700
消费者1收到的消息hello,messgae3622:20:58.730322200
消费者1收到的消息hello,messgae3722:20:58.762059300
消费者1收到的消息hello,messgae3822:20:58.792380300
消费者1收到的消息hello,messgae3922:20:58.825667800
消费者2收到的消息hello,messgae4022:20:58.853559
消费者1收到的消息hello,messgae4122:20:58.888268900
消费者1收到的消息hello,messgae4222:20:58.915374300
消费者1收到的消息hello,messgae4322:20:58.946131800
消费者1收到的消息hello,messgae4422:20:58.976632600
消费者1收到的消息hello,messgae4522:20:59.007536300
消费者1收到的消息hello,messgae4622:20:59.036990200
消费者1收到的消息hello,messgae4722:20:59.068823800
消费者2收到的消息hello,messgae4822:20:59.098863200
消费者1收到的消息hello,messgae4922:20:59.128784700
可以发现消费者1睡眠时间短,处理消息速度很快,而消费者2只消费了4条消息.这样能够充分利用每一个消费者的消费能力,有效避免了消息积压问题.
总结:work模型把多个消费者绑定到同一个队列中,同一条消息只能被一个消费者消费.通过设置prefetch=1来充分利用每一个消费者的消费能力.
交换机类型
在订阅模型中,多了一个exchange角色,主要包含以下几个部分
Publisher:生产者,将消息发送给交换机
Exchange;交换机,一方面接收生产者发送过来的消息,另一方面根据路由规则转发到指定的队列中(只负责转发消息,不储备消息,若没有队列与其绑定,或者没有符合路由规则的队列,则消息可能会丢失).
Queue:消息队列,首先需要绑定交换机,然后接收来自交换机转发过来的消息进行存储,供消费者使用.
Consumer:消费者,订阅队列并消费消息.
Exchange有以下4种:
Fanout:广播,将消息转发到与其绑定的所有队列.
Direct:订阅,基于RoutingKey(路由key)发送给订阅了该消息的队列.
Topic:通配符匹配,可以理解为高级的Direct,可以使用通配符
Headers:头匹配,基于MQ的消息头匹配,用的较少.
Fanout交换机
fanout,英文翻译是扇出,中文可以理解为广播,广播模式的消息发送流程如图
注:可以由多个队列绑定到交换机,生产者只能将消息发送到交换机,交换机再将消息转发到与其绑定的所有队列,之后订阅队列的消费者都能拿到消息.
练习
(1)创建Fanout类型的交换机hello.fanout
(2)创建两个队列fanout.queue1和fanout.queue2,都绑定到同一个交换hello.fanout.
创建hello.fanout交换机
分别创建两个队列fanout.queue1和fanout.queue2
将两个队列绑定到交换机
发送消息
@Test
public void testFanoutExchange() {
// 交换机名称
String exchangeName = "hello.fanout";
// 消息
String message = "hello, everyone!";
rabbitTemplate.convertAndSend(exchangeName, "", message);
}
消息接收
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
System.out.println("消费者1接收到Fanout消息:【" + msg + "】");
}
@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
System.out.println("消费者2接收到Fanout消息:【" + msg + "】");
}
总结:fanout交换机会接收publisher发送的消息,将消息发送到与之绑定的所有队列;交换机不能缓存消息,路由失败的话会造成消息丢失.
Direct交换机
在Fanout模式中,消息会被所有的绑定的队列消费,而在Direct模式中,消息会被发送到指定的交换机,交换机再转发到指定的队列中消费.Direct模式的消息发送流程如图
注:Direct模式的队列需要先通过RoutingKey与Exchange进行绑定,发送方在发送消息到交换机时也要指定RoutingKey,交换机收到消息后根据路由key转发到相应的队列(要求路由Key必须一致).
练习
(1)创建一个hmall.direct的交换机
(2)创建direct.queue1和direct.queue2两个队列,都绑定到hmall.direct,前一个队列的routingkey为blue和red,后者的routingkey为red和yellow.
(3)编写两个消费者方法,分别监听direct.queue1和direct.queue2,再编写发送方向hmall.direct发送消息的方法.
创建hmall.direct交换机
分别创建两个队列direct.queue1和direct.queue2
将两个队列绑定到交换机
消息接收
@RabbitListener(queues = "direct.queue1")
public void listenDirectQueue1(String msg) {
System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}
@RabbitListener(queues = "direct.queue2")
public void listenDirectQueue2(String msg) {
System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}
发送消息
@Test
public void testSendDirectExchange() {
// 交换机名称
String exchangeName = "hmall.direct";
// 消息
String message = "Hello,EveryOne红色警报!日本乱排核废水,导致海洋生物变异,惊现哥斯拉!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "red", message);
}
**总结:**Fanout和Direct交换机的区别:Faout交换机会将消息转发到所有与之的队列,而Direct交换机根据routingkey转发到指定的队列;如果有多个队列的routingkey相同,此时功能与Fanout相同.
Topic交换机
Topic交换机与Direct类似,都是根据RoutingKey将消息转发到指定的队列.区别在于Topic交换机可以使用通配符进行绑定,通配符规则:#:匹配一个或者多个词;*:只能匹配一个词
如:item.#:能够匹配item.hello.world或者item.world
item.*:只能匹配item.hello.
Topic交换机消息发送流程如图
练习
publisher发送的消息使用的RoutingKey有4种:
china.news代表中国的新闻消息
china.weather代表中国的天气消息
japan.news代表日本的新闻消息
japan.weather代表日本的天气消息
topic.queue1绑定的RoutingKey:china.#可以匹配所有以china开头的消息,包括china.news和china.weather.
topic.queue2绑定的RoutingKey:#.news可以匹配所有以new结尾的消息,包括china.news和japan.news.
创建Topic交换机与两个队列,再使用通配符绑定到交换机上
消息发送
/**
* topicExchange
*/
@Test
public void testSendTopicExchange() {
// 交换机名称
String exchangeName = "hmall.topic";
// 消息
String message = "喜报!孙悟空大战哥斯拉,胜!";
// 发送消息
rabbitTemplate.convertAndSend(exchangeName, "china.news", message);
}
接收消息
@RabbitListener(queues = "topic.queue1")
public void listenTopicQueue1(String msg){
System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】");
}
@RabbitListener(queues = "topic.queue2")
public void listenTopicQueue2(String msg){
System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
}
总结:Direct交换机与Topic的区别:Direct只能绑定一个词的RoutingKey,而Topic交换机可以绑定多个词的路由key,每个词之间以.分割,并且可以使用通配符匹配,#表示0个或者多个词,*只能表示一个词.
声明队列和交换机
基本API
spintAMQP提供了一个Queue类,用来创建队列
SpringAMQP还提供了一个Exchange接口,其实现类包含所有的交换机
可以使用以上的方法来创建队列和交换机,不过SpingAMQP还提供了ExchangeBuilder来简化这个流程,
而在绑定队列和交换机时,需要使用BindingBuilder来创建Binding对象.
Fanout示例
@Configuration
public class FanoutConfig {
/**
* 声明交换机
* @return Fanout类型交换机
*/
@Bean
public FanoutExchange fanoutExchange(){
return new FanoutExchange("hmall.fanout");
}
/**
* 第1个队列
*/
@Bean
public Queue fanoutQueue1(){
return new Queue("fanout.queue1");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange){
return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
}
/**
* 第2个队列
*/
@Bean
public Queue fanoutQueue2(){
return new Queue("fanout.queue2");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange){
return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
}
}
Direct示例
@Configuration
public class DirectConfig {
/**
* 声明交换机
* @return Direct类型交换机
*/
@Bean
public DirectExchange directExchange(){
return ExchangeBuilder.directExchange("hmall.direct").build();
}
/**
* 第1个队列
*/
@Bean
public Queue directQueue1(){
return new Queue("direct.queue1");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue1WithRed(Queue directQueue1, DirectExchange directExchange){
return BindingBuilder.bind(directQueue1).to(directExchange).with("red");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue1WithBlue(Queue directQueue1, DirectExchange directExchange){
return BindingBuilder.bind(directQueue1).to(directExchange).with("blue");
}
/**
* 第2个队列
*/
@Bean
public Queue directQueue2(){
return new Queue("direct.queue2");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue2WithRed(Queue directQueue2, DirectExchange directExchange){
return BindingBuilder.bind(directQueue2).to(directExchange).with("red");
}
/**
* 绑定队列和交换机
*/
@Bean
public Binding bindingQueue2WithYellow(Queue directQueue2, DirectExchange directExchange){
return BindingBuilder.bind(directQueue2).to(directExchange).with("yellow");
}
}
基于注解声明
注解声明DIrect模式的交换机和队列
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),
exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
key = {"red", "blue"}
))
public void listenDirectQueue1(String msg){
System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue2"),
exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
key = {"red", "yellow"}
))
public void listenDirectQueue2(String msg){
System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}
注解声明Topic模式的交换机和队列
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue1"),
exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC),
key = "china.#"
))
public void listenTopicQueue1(String msg){
System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】");
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.queue2"),
exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC),
key = "#.news"
))
public void listenTopicQueue2(String msg){
System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
}
消息转换器
通过Sping发送的消息体是一个Object对象
在数据传输时,发送的消息会被序列化成字节发送给MQ,接收消息的时候,消息会被反序列化为Java对象,但是,Spring默认采用的是JDK的序列化,JDK序列存在以下问题:数据体积过大;有安全漏洞;可读性差.
测试默认转换器
(1)通过@Bean的方式创建一个队列
@Configuration
public class MessageConfig {
@Bean
public Queue objectQueue() {
return new Queue("object.queue");
}
}
(2往队列中发送一个Map对象
@Test
public void testObjectQueue(){
Map<String,Object> m = new HashMap<>();
m.put("name","张三");
m.put("age",21);
rabbitTemplate.convertAndSend("object.queue",m);
}
查看控制台消息
配置JSON转换器
在publisher和consumer两个服务中都引入依赖
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
如果在项目中引入spring-boot-starter-web依赖,则无需再次引入Jackson依赖.
配置消息转换器,在publisher和consumer两个服务的启动类中添加Bean
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jackson2JsonMessageConverter.setCreateMessageIds(true);
return jackson2JsonMessageConverter;
}
消息转换器中添加的messageId可以做幂等性判断.
再次发送消息,可以查看正常的消息结构.
消费者接收Object
由于publisher发送的是Map,所以消费者一定要用Map接收
@RabbitListener(queues = "object.queue")
public void listenSimpleQueueMessage(Map<String, Object> msg) throws InterruptedException {
System.out.println("消费者接收到object.queue消息:【" + msg + "】");
}
消息可靠性
生产者发送消息到消费者的流程如图
消息在传输的每一步都可能导致消息丢失,分别从生产者,MQ以及消费者三个角度考虑,比如
发送消息时丢失的场景:(1)生产者发送消息时连接MQ失败;(2)生产者发送消息到MQ后未能找到Exchange;(3)生产者发送消息到达MQ的Exchange后,未能够找到对应的Queue.
MQ导致消息丢失的场景:消息到达MQ保存至队列后,还没发给消费者MQ就宕机;
消费者处理消息时:(1)消费者收到消息还没消费就宕机了;(2)消费者在处理消息的过程中抛出异常
综上所述,要保证MQ的可靠性,必须从3个方面解决:(1)确保生产者一定能将消息发送到MQ;(2)确保消息在MQ中不会丢失;(3)保证消费者一定能消费到消息.
生产者重试机制
当出现网络故障时,此时生产者发送消息时会连接MQ失败,为了解决这个问题,可以使用SpringAMQP的重试机制,即RabbitTemplate与MQ连接失败时,进行多次的连接重试.
开启重试机制需要在application.yaml文件添加以下内容:
spring:
rabbitmq:
connection-timeout: 1s # 设置MQ的连接超时时间
template:
retry:
enabled: true # 开启超时重试机制
initial-interval: 1000ms # 失败后的初始等待时间
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
max-attempts: 3 # 最大重试次数
测试重试机制,关闭RabbitMQ服务
docker stop mq
然后往MQ中发送一条消息,可以看到每隔一秒重试1次,总共重试了3次.
注:当网络不稳定的时候,使用重试机制虽然可以一定程度上提高消息发送的成功率,但是SpingAMQP提供的重试是阻塞式的,也就是说在重试等待的过程中,当前线程会被阻塞;因此对于有性能要求的业务,应该关闭重试机制.或者是使用异步线程来执行发送消息的代码.
生产者确认机制
RabbitMQ提供了Publisher Confirm和Publisher Return两种消息确认机制.如图
总共有4种情况:
(1)当消息发送到MQ,但是路由失败,此时通过Publisher Return返回异常信息,同时返回ack的确认信息给生产者表示投递成功.
(2)临时消息发送到MQ并且入队成功,此时返回ack给生产者表示发送成功.
(3)持久消息发送到MQ并且入队成功完成持久化,此时返回ack表示消息发送成功.
(4)其他情况都会返回nack给生产者表示消息发送失败.
其中ack表示投递成功,nack表示投递失败,ack和nack都属于Publisher Confirm机制,return属于Publisher Return机制.默认两种机制都是关闭状态,需要通过配置文件开启.
实现生产者确认
(1)开启生产者确认
在生产者端的application.yaml文件添加如下配置
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启publisher confirm机制种的correlated模式
publisher-returns: true # 开启publisher return机制
publisher-confirm-type有三种模式:
none:关闭confirm机制
simple:同步阻塞等待MQ的回执
correlated(推荐):MQ异步回调返回回执
(2)定义ReturnCallback
由于每个RabbitTemplate只能配置一个ReturnCallback,因此可以在配置类中统一配置.
@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
private final RabbitTemplate rabbitTemplate;
@PostConstruct
public void init(){
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returned) {
log.error("触发return callback,");
log.debug("exchange: {}", returned.getExchange());
log.debug("routingKey: {}", returned.getRoutingKey());
log.debug("message: {}", returned.getMessage());
log.debug("replyCode: {}", returned.getReplyCode());
log.debug("replyText: {}", returned.getReplyText());
}
});
}
}
(3)定义ConfirmCallback
由于每个消息发送时的处理逻辑不一定相同,因此ConfirmCallback需要在每次发送消息时定义.只需要在convertAndSend方法时多传递一个参数CorrelationData对象:
该对象包含两个成员,分别是
id:消息的唯一标识
SettableListenableFuture:回执结果的Future对象
MQ的回执会通过这个Future对象返回,所以可以给Future添加回调函数来处理消息回执.
@Test
void testPublisherConfirm() {
// 1.创建CorrelationData
CorrelationData cd = new CorrelationData();
// 2.给Future添加ConfirmCallback
cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
@Override
public void onFailure(Throwable ex) {
// 2.1.Future发生异常时的处理逻辑,基本不会触发
log.error("send message fail", ex);
}
@Override
public void onSuccess(CorrelationData.Confirm result) {
// 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
log.debug("发送消息成功,收到 ack!");
}else{ // result.getReason(),String类型,返回nack时的异常描述
log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
}
}
});
// 3.发送消息
rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}
注:开启生产者确认会消耗MQ性能,一般不建议开启.
MQ可靠性
当消息到达MQ以后,如果MQ不能及时保存也会造成消息丢失.以下几种方式能够保证消息及时保存.
(1)数据持久化
为了提升性能,MQ的数据默存在于内存,重启后数据就会丢失,为了保证数据不丢失,需要进行数据持久化,包括交换机持久化,队列持久化和消息持久化.
交换机持久化
Durable表示持久化模式,Transient表示临时模式.
队列持久化
消息持久化
注:如果同时开启持久化机制和生产者确认机制,那么MQ会在消息持久化以后才发送ACK回执.为了减少IO次数,发送MQ的小溪不是逐条持久化到磁盘的,而是每隔一段时间批量持久化,一般间隔是100毫秒左右,这会导致ACK有一定的延迟,所以生产者的确认一般采用异步方式.
LazyQueue
RabbitMQ在默认情况下会将消息保存在内存中减降低消息收发的延迟,但是在某些情况下会导致消息堆积,比如消费者宕机或者出现网络故障;消息发送量突然增大且超过了消费者处理速度;消费者处理业务发生阻塞。当出现消息堆积现象时,RabbitMQ的内存占用会越来越高,当到达内存预警上限时,会触发PageOut机制,此时会将内存消息刷到磁盘上,并且需要耗费一定的时间,在PageOut过程中,会阻塞队列进程,此时无法处理新的消息,生产者的所有请求都会别阻塞。
为了解决这个问题,RabbitMQ从3.6.0版本开始,增加了Lazy Queues模式(惰性队列);惰性队列接收到消息后会将消息存入磁盘而不是内存,当消费者需要消费消息时就会从磁盘读取并加载到内存(懒加载),并且支持数百万条的消息存储。
注:3.12版本之后,LazyQueue成为所有队列的默认格式。
控制台配置Lazy模式
代码配置Lazy模式
@Bean
public Queue lazyQueue(){
return QueueBuilder
.durable("lazy.queue")
.lazy() // 开启Lazy模式
.build();
}
QueueBuilder的lazy()函数配置lazy模式,底层源码如下
因此,也可以通过注解方式声明Lazy模式
@RabbitListener(queuesToDeclare = @Queue(
name = "lazy.queue",
durable = "true",
arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void listenLazyQueue(String msg){
log.info("接收到 lazy.queue的消息:{}", msg);
}
消费者可靠性
当RabbitMQ向消费者发送消息后,可能出现以下几种故障,消息在发送过程中出现了网络故障;消费者接收到消息还没处理就宕机;在处理的过程中处理不当出现异常。
有以下几种机制确保消费者处理消息的可靠性。
消费者确认机制
消费者确认机制(Consumer Acknowledgement)指的是消费者处理消息后,会向RabbitMQ发送一个回执,通知MQ自己对消息的处理状态,总共有三种:
ack:消费者成功消息,之后MQ会从队列中删除该消息;
nack:消息处理失败,MQ会再一次发送消息给消费者;
reject:消息处理失败并且消费者拒绝该消息,MQ会从队列中删除该消息;
SpringAMQP已经实现了消息确认机制,可以通过配置文件设置ACK处理方式;有三种模式
none:不处理;当消息发送给消费者后立刻返回ack,然后MQ从队列中删除该消息,这种方式不安全,一般不用;
manual:手动模式;需要自己在业务代码中发送ack或者reject,存在业务入侵,但更灵活
auto:自动模式;springAMQP通过AOP对消息的处理逻辑做了环绕增强,当业务正常执行时自动返回ack,当业务出现异常时,根据异常返回不同结果:
如果是业务异常,自动返回nack;
如果是消息处理异常或者校验异常吗,自动返回reject;
通过以下配置可以修改SpringAMQP的ACK处理模式
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: none # 不做处理
模拟异常消息的处理
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
log.info("spring 消费者接收到消息:【" + msg + "】");
if (true) {
throw new MessageConversionException("故意的");
}
log.info("消息处理完成");
}
可以发现当消息发生异常时,消息会被RabbitMQ删除。
将模式改为auto:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto # 自动ack
此时spring自动返回reject,因为抛出的是消息转换异常,并且消息会被删除。
把异常修改为RuntimeException类型
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
log.info("spring 消费者接收到消息:【" + msg + "】");
if (true) {
throw new RuntimeException("故意的");
}
log.info("消息处理完成");
}
此时消息属于业务异常,返回MQ会返回nack,然后再次向消费者发送消息。
失败重试机制
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者,如果消费者再次执行出错,消息会再次requeue到队列,再次发送给消费者,直到消息处理成功,消息要是一直requeue会增大MQ处理消息的压力,为了解决这种场景,spring提供了消费者失败重试机制,当消费者出现异常时通过本地重试消费,而不是无限的requeue到队列。
开启消费者的失败重试机制
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
此时消费者在处理失败后,消息不会requeue到队列中,而是在本地重试了3次,本地重试3次以后,抛出了AmqpRejectAndDontRequeueException异常,MQ返回reject,然后消息被删除。
总结:开启本地重试后,当消息处理过程中抛出异常时,不会requeue到队列,而是在消费者本地重试;当重试达到最大次数后,spring会返回reject,然后丢弃消息。
失败处理策略
当本地测试达到最大重试次数后,消息会被丢弃,当业务要求消息可靠的时候,这种机制就不合适了。因此Spring允许自定义达到最大重试次数后的消息处理策略,这个策略通过MessageRecovery接口定义。该接口有三个实现:
RejectAndDontRequeueRecoverer(默认):重试次数用完后,MQ返回reject,丢弃消息。
ImmediateRequeueMessageRecoverer:重试次数用完后,返回nack,消息重新入队。
RepublishMessageRecoverer:重试次数用完后,将处理失败的消息发送到指定的交换机。
RepublishMessageRecoverer实现:
(1)在消费者服务中定义处理失败消息的交换机和队列
@Bean
public DirectExchange errorMessageExchange(){
return new DirectExchange("error.direct");
}
@Bean
public Queue errorQueue(){
return new Queue("error.queue", true);
}
@Bean
public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
}
(2)定义RepublishMessageRecoverer,同时关联交换机
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
业务幂等性
幂等性就是多次执行造成的结果是一致的,可以理解为多次调用某个接口执行的结果与调用一次的结果是一致的,比如多次查询数据。但是在数据的更新通常不是幂等的,比如取消订单和退款业务。为此需要保证消息的幂等性,有两种方案:唯一消息ID与业务状态判断。
唯一消息ID
思路:给每个消息都生成一个唯一id,然后和消息一起发送给消费者,消费者收到消息后进行处理,处理成功后将消息id保存至数据库,等下次再收到相同消息时,就去数据库查询消息id,若存在则为重复消息,放弃处理该消息。
springAMQP的MessageConverter自带了MeaageID的功能,以jackson的消息转换器为例开启这个功能
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jjmc.setCreateMessageIds(true);
return jjmc;
}
业务判断
以支付修改订单的业务为例:
@Override
public void markOrderPaySuccess(Long orderId) {
// 1.查询订单
Order old = getById(orderId);
// 2.判断订单状态
if (old == null || old.getStatus() != 1) {
// 订单不存在或者订单状态不是1,放弃处理
return;
}
// 3.尝试更新订单
Order order = new Order();
order.setId(orderId);
order.setStatus(2);
order.setPayTime(LocalDateTime.now());
updateById(order);
}
由于上述代码判断和更新是两步操作,可能存在线程安全问题,可以将上述操作进行合并。
@Override
public void markOrderPaySuccess(Long orderId) {
//下面代码等同于 UPDATE `order` SET status = ? , pay_time = ? WHERE id = ? AND status = 1
lambdaUpdate()
.set(Order::getStatus, 2)
.set(Order::getPayTime, LocalDateTime.now())
.eq(Order::getId, orderId)
.eq(Order::getStatus, 1)
.update();
}
在where条件中不仅判断了id,还判断了status必须为1的条件,若条件不符合,说明订单已支付,那么SQL匹配不到数据,也就不会执行。
延迟消息
在电商支付业务中,如果用户下单后一直不付款,就会一直占有库存资源,导致其他用户无法正常下单。为了解决这个问题,可以使用延迟消息,对于超过一定时间未支付的订单,取消该订单并释放占用的库存。
在RabbitMQ中实现延迟消息有两种方案:死信交换机+TTL,延迟消息插件。
死信交换机
当队列中的消息满足下列条件之一时,该消息便成为死信:
(1)消费者使用basic.reject或者basic.nack声明消费失败,并且消息的requeue参数设置为false;
(2) 消息是一个过期消息,超时无人消费;
(3) 要发送的队列满了,无法投递。
如果一个队列的消息已经成为死信,并且这个队列通过dead-letter-exchange属性绑定了交换机,那么这个交换机就是死信交换价,队列中的死信会发送到这个交换机中。死信交换机的作用:
1.收集那些处理失败而被拒绝的消息。
2.收集因为队列满了而被拒绝的消息。
3.收集TTL(有效期)到期的消息。
死信交换机实现延迟消息
交换机ttl.fanout和队列ttl.queue绑定,但是ttl.queue没有消费者监听,而是通过dead-letter-exchange绑定了死信交换机hmall.direct,该交换机与direct.queue1队列绑定。
发送一条消息到ttl,fanout,routingkey为blue,同时设置消息的有效期为5000毫秒
注:虽然ttl.fanout没有指定routingkey,但是消息变成死信发送到死信交换机时,会使用之前的routingkey。当消息到达ttl.queue后,由于没有消费者监听消费,5秒之后消息成为死信,发往死信交换机。
死信交换机hmall.direct通过routingkey将死信路由到direct.queue1中,之后消费者便成功消费到消息了。
总结:RabbitMQ的消息过期是基于追溯方式实现的,也就是说当消息的TTL到期后不一定会被移除或者发送到死信交换机中,而是消息在队首时才会被处理。当队列中堆积了很多消息的时候,过期消息可能不会被按时处理。
延迟消息插件
虽然死信交换机可以实现延迟消息,但是太麻烦,因此RabbitMQ提供了一个延迟消息插件来实现延迟消息。
插件下载地址:插件下载地址:
GitHub - rabbitmq/rabbitmq-delayed-message-exchange: Delayed Messaging for RabbitMQ
(1)插件安装
先查看RabbitMQ的插件目录对应的数据卷
docker volume inspect mq-plugins
结果
[
{
"CreatedAt": "2024-06-19T09:22:59+08:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/mq-plugins/_data", //插件目录
"Name": "mq-plugins",
"Options": null,
"Scope": "local"
}
]
将插件上传至插件目录下
docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
(2)声明延迟交换机
基于注解方式
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "delay.queue", durable = "true"),
exchange = @Exchange(name = "delay.direct", delayed = "true"),
key = "delay"
))
public void listenDelayMessage(String msg){
log.info("接收到delay.queue的延迟消息:{}", msg);
}
(3)发送延迟消息
@Test
void testPublisherDelayMessage() {
// 1.创建消息
String message = "hello, delayed message";
// 2.发送消息,利用消息后置处理器添加消息头
rabbitTemplate.convertAndSend("delay.direct", "delay", message, new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
// 添加延迟消息属性
message.getMessageProperties().setDelay(5000);
return message;
}
});
}
注:延迟消息插件内部会维护一个本地数据库表,同时使用Elang Timers功能实现计时,如果消息的延迟时间设置过长,可能会堆积大量的延迟消息,消耗较大的CPU开销,因此,不建议设置延迟时间过长的消息。