RabbitMQ教程

119 阅读34分钟

1.git地址

gitee.com/andy_yeung/…

2.springboot整合rabbitmq

2.1.引入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

2.2.配置文件

spring:
  rabbitmq:
    host: 106.14.42.235
    port: 5672
    virtual-host: /
    username: andy
    password: 123456

2.3.配置消息转换器

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
@Configuration
public class SysConfiguration {
    @Bean
    public MessageConverter jacksonMessageConvertor(){
        Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
        jackson2JsonMessageConverter.setCreateMessageIds(true);
        return jackson2JsonMessageConverter;
    }
}

3.rabbitmq工作模型

broker 相当于mysql服务器

virtual host相当于数据库(可以有多个数据库)

queue相当于表

消息相当于记录

消息队列有三个核心要素:消息生产者消息****队列消息消费者

生产者(Producer):发送消息的应用;(java程序,也可能是别的语言写的程序)

消费者(Consumer):接收消息的应用;(java程序,也可能是别的语言写的程序)

代理(Broker):就是消息服务器,RabbitMQ Server就是Message Broker;

连接(Connection):连接RabbitMQ服务器的TCP长连接;

信道(Channel):连接中的一个虚拟通道,消息队列发送或者接收消息时,都是通过信道进行的;

虚拟主机(Virtual host):一个虚拟分组,在代码中就是一个字符串,当多个不同的用户使用同一个RabbitMQ服务时,可以划分出多个Virtual host,每个用户在自己的Virtual host创建exchange/queue等;(分类比较清晰、相互隔离)

交换机(Exchange):交换机负责从生产者接收消息,并根据交换机类型分发到对应的消息队列中,起到一个路由的作用;

路由键(Routing Key):交换机根据路由键来决定消息分发到哪个队列,路由键是消息的目的地址;

绑定(Binding):绑定是队列和交换机的一个关联连接(关联关系);

队列(Queue):存储消息的缓存;

消息(Message):由生产者通过RabbitMQ发送给消费者的信息;(消息可以任何数据,字符串、user对象,json串等等)

4.交换机类型

Exchange:代表交换机

交换机分四种,但是队列不分种类

4.1.四种交换机

1、Fanout Exchange(扇形,分发,广播)

2、Direct Exchange(直连、定向)

3、Topic Exchange(主题)

4、Headers Exchange(头部)

4.2.Fanout Exchange

Fanout Exchange (广播交换机)投递到所有绑定的队列,不需要路由键,不需要进行路由键的匹配,相当于广播、群发;(只要绑定了这个交换机的所有队列都可以收到消息,无需任何条件)

4.3.Direct Exchange

Direct Exchange(直连交换机)根据路由键精确匹配(一模一样)进行转发消息队列;

在上面这张图中,我们可以看到 交换机X (Direct Exchange)绑定了两个队列。队列Q1 绑定的routing key为 orange, 队列 Q2 绑定的routing key有两个:一个绑定键为 black,另一个绑定键为 green,在这种绑定情况下,生产者发布消息到 exchange 上,绑定键为 orange 的消息会被发布到队列 Q1。绑定键为 black和green 的消息会被发布到队列 Q2,其他消息类型的消息将被丢弃(不会被转发到任何一个队列中)。

如上图所示,多个队列绑定的routing key也可以相同,此时作用相当于Fanout Exchange。当消息发布到X后,绑定键为 black 的消息会被发布到队列 Q1和Q2。

4.4.Topic Exchange

Topic Exchange(主题交换机)和 Direct Exchange的区别是,Direct Exchange的routing key是精确匹配的,而Topic Exchange的routing key是模糊匹配的。

#匹配多个单词,用来表示任意数量(零个或多个)单词

*匹配一个单词(必须有一个,而且只有一个),用.隔开的为一个单词

routing key要想模糊匹配要用 . 分隔,不可以写成beijing# 或 beijing*

举例:

beijing.# == beijing.queue.abc, beijing.queue.xyz.xxx

beijing.* == beijing.queue, beijing.xyz

发送时指定的路由键:lazy.orange.rabbit,可以转发到Q1和Q2,路由键也可以精确成 ..rabbit,这样只会转发给Q2

4.5.Headers Exchange

Headers exchange与 direct、topic、fanout不同,它是通过匹配 AMQP 协议消息的 header 而非路由键,有点像HTTP的Headers;headers exchange 与 direct Exchange类似,性能方面比后者查很多,所以在实际项目中用的很少。

5.交换机的详细属性(参考)

1、Name:交换机名称;就是一个字符串

2、Type:交换机类型,direct, topic, fanout, headers四种

3、Durability:持久化,声明交换机是否持久化,代表交换机在服务器重启后是否还存在;

4、Auto delete:是否自动删除,曾经有队列绑定到该交换机,后来解绑了,那就会自动删除该交换机;

5、Internal:内部使用的,如果是yes,客户端无法直接发消息到此交换机,它只能用于交换机与交换机的绑定。

6、Arguments:只有一个取值alternate-exchange,表示备用交换机;

6.队列的详细属性(参考)

Type:队列类型

Name:队列名称,就是一个字符串,随便一个字符串就可以;

Durability:声明队列是否持久化,代表队列在服务器重启后是否还存在;

Auto delete: 是否自动删除,如果为true,当没有消费者连接到这个队列的时候,队列会自动删除;

Exclusive:exclusive属性的队列只对首次声明它的连接可见,并且在连接断开时自动删除;

基本上不设置它,设置成false

Arguments:队列的其他属性,例如指定DLX(死信交换机等);

1、x-expires:Number

当Queue(队列)在指定的时间未被访问,则队列将被自动删除;

2、x-message-ttl:Number

发布的消息在队列中存在多长时间后被取消(单位毫秒);

3、x-overflow:String

设置队列溢出行为,当达到队列的最大长度时,消息会发生什么,有效值为Drop Head或Reject Publish;

4、x-max-length:Number

队列所能容下消息的最大长度,当超出长度后,新消息将会覆盖最前面的消息,类似于Redis的LRU算法;

5、 x-single-active-consumer:默认为false

激活单一的消费者,也就是该队列只能有一个消息者消费消息;

6、x-max-length-bytes:Number

限定队列的最大占用空间,当超出后也使用类似于Redis的LRU算法;

7、x-dead-letter-exchange:String

指定队列关联的死信交换机,有时候我们希望当队列的消息达到上限后溢出的消息不会被删除掉,而是走到另一个队列中保存起来;

8.x-dead-letter-routing-key:String

指定死信交换机的路由键,一般和7一起定义;

9.x-max-priority:Number

如果将一个队列加上优先级参数,那么该队列为优先级队列;

(1)、给队列加上优先级参数使其成为优先级队列

x-max-priority=10【0-255取值范围】

(2)、给消息加上优先级属性

通过优先级特性,将一个队列实现插队消费;

MessageProperties messageProperties=new MessageProperties(); messageProperties.setPriority(8);

10、x-queue-mode:String(理解下即可)

队列类型x-queue-mode=lazy懒队列,在磁盘上尽可能多地保留消息以减少RAM使用,如果未设置,则队列将保留内存缓存以尽可能快地传递消息;

11、x-queue-master-locator:String(用的较少,不讲)

在集群模式下设置队列分配到的主节点位置信息;

每个queue都有一个master节点,所有对于queue的操作都是事先在master上完成,之后再slave上进行相同的操作;

每个不同的queue可以坐落在不同的集群节点上,这些queue如果配置了镜像队列,那么会有1个master和多个slave。

基本上所有的操作都落在master上,那么如果这些queues的master都落在个别的服务节点上,而其他的节点又很空闲,这样就无法做到负载均衡,那么势必会影响性能;

关于master queue host 的分配有几种策略,可以在queue声明的时候使用x-queue-master-locator参数,或者在policy上设置queue-master-locator,或者直接在rabbitmq的配置文件中定义queue_master_locator,有三种可供选择的策略:

(1)min-masters:选择master queue数最少的那个服务节点host;

(2)client-local:选择与client相连接的那个服务节点host;

(3)random:随机分配;

7.声明队列和交换机的方式

7.1.配置bean的方式

在消费者方声明一个交换机和队列,并进行绑定

package com.yc.consumer.config;

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @Description: 创建队列和交换机并绑定
 * @Author Andy
 * @Date 2023/10/17 18:53
 */
@Configuration
public class MqConfig {
    /** 创建广播交换机和队列并进行绑定 */
    /** 创建交换机 */
    @Bean
    public FanoutExchange fanoutExchange(){
        // FanoutExchange:广播交换机 fec1:交换机名称
        //        return ExchangeBuilder.fanoutExchange("fec1").build();
        return new FanoutExchange("fec.2");
    }
    /** 创建队列 */
    @Bean
    public Queue fecQueue1(){
        // durable 表示持久化队列 fecQueue1:队列名
        //        return QueueBuilder.durable("fecQueue1").build();
        return new Queue("fecQueue.1");
    }
    /** 创建队列 */
    @Bean
    public Queue fecQueue2(){
        // durable 表示持久化队列 fecQueue2:队列名
        return QueueBuilder.durable("fecQueue.2").build();
        // 等价于 return new Queue("fecQueue2");
    }
    /** 建立绑定关系 通过参数列表 */
    @Bean
    public Binding bindFecQueue1WithFanoutExchange(Queue fecQueue1, FanoutExchange fanoutExchange){
        // 将队列fecQueue1和交换机fanoutExchange绑定
        return BindingBuilder.bind(fecQueue1).to(fanoutExchange);
    }
    /** 建立绑定关系 通过调用方法 */
    @Bean
    public Binding bindFecQueue2WithFanoutExchange(){
        // 将队列fecQueue2和交换机fanoutExchange绑定
        return BindingBuilder.bind(fecQueue2()).to(fanoutExchange());
    }


    /** 创建主题交换机和队列并进行绑定 */
    @Bean
    public TopicExchange topicExchange(){
        // topicExchange:主题交换机 tec1:交换机名称
        return ExchangeBuilder.topicExchange("tec2").build();
    }
    @Bean
    public Queue tecQueue1(){
        // durable 表示持久化队列 tecQueue-blue:队列名
        return QueueBuilder.durable("tecQueue-blue").build();
    }
    @Bean
    public Queue tecQueue2(){
        // durable 表示持久化队列 tecQueue-green:队列名
        return QueueBuilder.durable("tecQueue-green").build();
    }
    @Bean
    public Binding bindTecQueue1WithTopicExchange(Queue tecQueue1, TopicExchange topicExchange){
        // with(blue): 表示routingKey是blue
        // 将队列tecQueue1和交换机topicExchange绑定
        return BindingBuilder.bind(tecQueue1).to(topicExchange).with("blue");
    }
    @Bean
    public Binding bindTecQueue2WithTopicExchange(){
        // with(green): 表示routingKey是green
        // 将队列tecQueue1和交换机topicExchange绑定
        return BindingBuilder.bind(tecQueue2()).to(topicExchange()).with("green");
    }
}

坑人的地方:

要想用以上的方式创建交换机和队列,必须至少有一个@RabbitListener,否则该工程压根不是消费者,不会连接mq,自然也不会创建任何东西,这时也不会检查mq的连接信息是否正确

7.2.注解的方式@RabbitListener

@Component
public class MsgListener {

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "fanoutQ1", durable = "true"),
            exchange = @Exchange(name = "fanoutExc1", type = "fanout")
    ))
    public void listenerFanoutQueue1(String msg) throws InterruptedException {
        System.out.println("消费者1号收到了fanoutQ1的消息:【"+msg+"】");
    }

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "fanoutQ2", durable = "true"),
            exchange = @Exchange(name = "fanoutExc1", type = "fanout")
    ))
    public void listenerFanoutQueue2(String msg) throws InterruptedException {
        System.out.println("消费者2号收到了fanoutQ2的消息:【"+msg+"】");
    }


    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name="blue.queue1",durable = "true"),
            exchange = @Exchange(name = "direct.adny", type = ExchangeTypes.DIRECT),
            key = {"blue","red"}
    ))
    public void listenerDirectQueue1(String msg) throws InterruptedException {
        System.out.println("消费者1号收到了blue.queue1的消息:【"+msg+"】");
    }

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name="yellow.queue2",durable = "true"),
            exchange = @Exchange(name = "direct.adny", type = ExchangeTypes.DIRECT),
            key = {"yellow","red"}
    ))
    public void listenerDirectQueue2(String msg) throws InterruptedException {
        System.err.println("消费者2号收到了yellow.queue2的消息:【"+msg+"】");
    }
    

    @RabbitListener(queues={"object.queue"})
    public void listenerQueueAcceptObject(Map<String,Object> msg) throws InterruptedException {
        System.err.println("消费者2号收到了object.queue的消息:【"+msg+"】");
    }
}

8.不公平分发

在最开始的时候我们学习到 RabbitMQ 分发消息采用的轮训分发,但是在某种场景下这种策略并不是很好,比方说有两个消费者在处理任务,其中有个消费者 1 处理任务的速度非常快,而另外一个消费者 2处理速度却很慢,这个时候我们还是采用轮训分发的化就会到这处理速度快的这个消费者很大一部分时间处于空闲状态,而处理慢的那个消费者一直在干活,这种分配方式在这种情况下其实就不太好,但是RabbitMQ 并不知道这种情况它依然很公平的进行分发。

为了避免这种情况,我们可以设置参数

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1 

意思就是如果这个任务我还没有处理完或者我还没有应答你,你先别分配给我,我目前只能处理一个任务,然后 rabbitmq 就会把该任务分配给没有那么忙的那个空闲消费者,当然如果所有的消费者都没有完成手上任务,队列还在不停的添加新任务,队列有可能就会遇到队列被撑满的情况,这个时候就只能添加新的 worker 或者改变其他存储任务的策略。

9.生产者重连

上述的配置描述不完全准确,经本人测试验证的出的结论:

  1. spring.rabbitmq.template.retry.enabled=false时,只会尝试进行初始化连接,失败后抛异常。
  2. 如果开启重连机制后,spring.rabbitmq.template.retry.max-attempts=3表示最多尝试进行三次连接(默认值也是3),包括初始化连接的那一次,一共连接3次,都失败了就抛异常,日志上会显示3次Attempting to connect to:[ip:port](不是最初的猜想:初始化连接失败后再尝试连接3次,一共连接4次)
  3. pring.rabbitmq.template.retry.initial-interval=1000ms时,表示第一次尝试连接和第二次尝试连接的间隔是1000ms
  4. spring.rabbitmq.template.retry.multiplier=2时,表示第2次连接失败后,后面每次间隔时间都是前一次的2倍,第3次和第2次尝试之间间隔2s,第4次和第3次尝试之间间隔4秒,第5次和第4次尝试之间间隔8秒,第6次和第5次尝试之间间隔10秒(因为默认最大间隔时长是10秒)
  5. spring.rabbitmq.template.retry.max-interval=10000ms时,表示最大间隔时长是10秒

9.1.测试案例

spring:
  rabbitmq:
    host: 106.14.42.235
    port: 5672
    virtual-host: /yc-vhost
    username: yangcheng
    password: yc123456
#    连接超时时间
    connection-timeout: 1s
    template:
      retry:
        enabled: true # 开启超时后重连
@SpringBootTest
class PublisherMsgApplicationTests {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @Test
    void sendMsg() {
        String msg = "哈哈哈";
        String exchange = "fanoutExc1";
        String routingKey = "";
        rabbitTemplate.convertAndSend(exchange, routingKey, msg);
    }

}

停掉rabbitmq服务后运行,控制台输出

10.生产者确认

10.1.spring amqp中该怎么配置生产者确认

spring:
  rabbitmq:    
    publisher-confirm-type: correlated # 开启生产者发送消息的确认机制 correlated表示异步回调的方式返回回执结果
    publisher-returns: true # 启用消息路由结果通知(消息已经成功发送到交换机了,交换机是否转发给队列了呢?不知道,需要给我个结果告诉我是否转发给队列,如果路由失败一般情况下是开发人员的代码写的有问题)

10.2.编写ReturnsCallback方法

此方法只有路由失败才会返回结果

@Slf4j
@Configuration
public class RabbitTemplateConfig implements ApplicationContextAware {
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returned) {
                log.debug("消息路由失败,应答码:{},原因:{},交换机:{},路由键:{},消息:{}",
                          returned.getReplyCode(), returned.getReplyText(), returned.getExchange(), returned.getRoutingKey(), returned.getMessage());
                // 其他逻辑处理...
            }
        });
    }
}

10.3.发送时编写ConfirmCallback

@Slf4j
@SpringBootTest
class PublisherMsgApplicationTests {

    @Autowired
    RabbitTemplate rabbitTemplate;
    
    /**
     * 正常发送路由 由Confirm返回 ack 告知发送成功 (临时消息入队成功就告知成功,持久消息落盘后告知成功)
     */
    @Test
    void sendMsg1() throws InterruptedException {
        CorrelationData correlationData = new CorrelationData();
        correlationData.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            @Override
            public void onFailure(Throwable ex) {
                log.error("消息回调失败:{}",ex);
            }
            @Override
            public void onSuccess(CorrelationData.Confirm result) {
                log.debug("收到confirm callback回执");
                if (result.isAck()){
                    // 只要消息发送到交换机就算成功
                    log.debug("消息发送成功,收到ack");
                }else {
                    // 消息未发送到交换机就算失败
                    log.debug("消息发失败,收到nack,原因:{}", result.getReason());
                }
            }
        });
        String msg = "测试发布消息确认机制-blue";
        String exchange = "direct.adny";
        String routingKey = "blue";
        rabbitTemplate.convertAndSend(exchange, routingKey, msg, correlationData);
        // 由于这里是单元测试,方法执行完就结束了,此时结果还没返回,因此需要等待一会让结果返回后再结束单元测试
        Thread.sleep(1000);
    }

    /**
     * 正常发送 路由失败(没有routingKey xxx),通过ReturnsCallback返回 ack 告知发送成功,并告知路由失败原因
     */
    @Test
    void sendMsg2() throws InterruptedException {
        CorrelationData correlationData = new CorrelationData();
        correlationData.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            @Override
            public void onFailure(Throwable ex) {
                log.error("消息回调失败:{}",ex);
            }
            @Override
            public void onSuccess(CorrelationData.Confirm result) {
                log.debug("收到confirm callback回执");
                if (result.isAck()){
                    // 只要消息发送到交换机就算成功
                    log.debug("消息发送成功,收到ack");
                }else {
                    // 消息未发送到交换机就算失败
                    log.debug("消息发失败,收到nack,原因:{}", result.getReason());
                }
            }
        });
        String msg = "测试发布消息确认机制-xxx";
        String exchange = "direct.adny";
        String routingKey = "xxx";
        rabbitTemplate.convertAndSend(exchange, routingKey, msg, correlationData);
        // 由于这里是单元测试,方法执行完就结束了,此时结果还没返回,因此需要等待一会让结果返回后再结束单元测试
        Thread.sleep(1000);
    }

    /**
     * 发送失败 (没有aaa交换机) 由Confirm返回 nack 告知发送失败
     */
    @Test
    void sendMsg3() throws InterruptedException {
        CorrelationData correlationData = new CorrelationData();
        correlationData.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            @Override
            public void onFailure(Throwable ex) {
                log.error("消息回调失败:{}",ex);
            }
            @Override
            public void onSuccess(CorrelationData.Confirm result) {
                log.debug("收到confirm callback回执");
                if (result.isAck()){
                    // 只要消息发送到交换机就算成功
                    log.debug("消息发送成功,收到ack");
                }else {
                    // 消息未发送到交换机就算失败
                    log.debug("消息发失败,收到nack,原因:{}", result.getReason());
                }
            }
        });
        String msg = "测试发布消息确认机制-xxx";
        String exchange = "aaa";
        String routingKey = "xxx";
        rabbitTemplate.convertAndSend(exchange, routingKey, msg, correlationData);
        // 由于这里是单元测试,方法执行完就结束了,此时结果还没返回,因此需要等待一会让结果返回后再结束单元测试
        Thread.sleep(1000);
    }

}

10.4.总结生产者确认的几种返回情况

  1. 消息发送成功但是路由失败了,ConfirmCallback 收到 ack,ReturnsCallback 会返回路由失败原因
  2. 临时类型的消息发送成功了也路由到队列了,ConfirmCallback 收到 ack
  3. 持久化类型的消息发送成功了也路由到队列并持久化了,ConfirmCallback 收到 ack
  4. 其余情况 ConfirmCallback统统返回 nack:比如:消息发送失败了,没有发送到交换机;持久化类型的消息在队列中持久化失败了;......

其实我们压根不需要去管ack的情况,只需要处理nack的情况,而情况1路由失败只可能是程序代码写的有问题,改代码就好。

10.5.如何处理生产者的确认消息

  1. 生产者确认需要额外的网络和系统资源开销,尽量不要使用
  2. 如果一定要使用,也不要开启Publisher-Returns机制,因为路由失败是自己的程序有问题,改程序就好了
  3. 对于nack消息可以有限次数的重试,依然失败则记录异常消息

11.面试:如何保证生产者消息发送可靠?

  1. 开启生产者重连机制
  2. 开启生产者确认机制,发送消息会返回回执,发送成功返回ack回执,发送失败返回nack的回执,这样可以基于回执的情况来判断,失败了可以重发消息。

这样基本可以保证生产者消息发送的可靠性,但是以上手段都会增加额外的网络和系统资源的负担。除非对消息发送的可靠性有较高的要求,否则尽量不要开启生产者确认机制。

12.回退消息

12.1.什么是消息回退

在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息ACK,如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。那么如何让无法被路由的消息帮我想办法处理一下?最起码通知我一声,我好自己处理啊。通过设置 mandatory 参数可以在当消息传递过程中不可达目的地时将消息返回给生产者。

12.2.怎么实现

上一节生产者确认时通过开启publisher-returns可以拿到路由失败的消息,但前提是开启publisher-returns。但是假如不开启publisher-returns可以通过设置Mandatory的方式拿到路由失败的消息。作用是一样的,只是开启方式不一样。

rabbitTemplate.setMandatory(true);

13.备用交换机

虽然有了回退消息的机制,我们获得了对无法投递的消息的感知能力,但有时候,我们并不知道该如何处理这些无法路由的消息,最多打个日志,然后触发报警,再来手动处理。这种方式不够优雅和简单。

在 RabbitMQ 中,有一种备份交换机的机制存在,可以很好的应对这个问题。什么是备份交换机呢?备份交换机可以理解为 RabbitMQ 中交换机的“备胎”,当我们为某一个交换机声明一个对应的备份交换机时,就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由备份交换机来进行转发和处理,通常备份交换机的类型为 Fanout ,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进入这个队列了。当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。

13.1.代码架构图

13.2.实现备份交换机

13.3.备用交换机使用场景

当消息经过交换器准备路由给队列的时候,发现没有对应的队列可以投递信息,在rabbitmq中会默认丢弃消息,如果我们想要监测哪些消息被投递到没有对应的队列,我们可以用备用交换机来实现,可以接收备用交换机的消息,然后记录日志或发送报警信息。

13.4.注意事项

mandatory 参数(或publisher-returns)与备份交换机可以一起使用的时候,如果两者同时开启,消息究竟何去何从?谁优先级高?经过验证结果显示是:备份交换机优先级高。无法被路由的消息不会回退而是直接投递到备份交换机上。

14.数据持久化

14.1.数据持久化分三种

spring amqp中创建的交换机,队列,消息默认都是持久化的。

14.1.1.交换机持久化

14.1.2.队列持久化

14.1.3.消息持久化

14.2.持久化消息持相比临时消息有什么优势?

当发送临时消息时,消息都存在内存中,消息过多就容易产生消息堆积,如果内存达到预警值,则mq会阻塞消息,此时消息不能进来,会把内存中的一部分落到磁盘上,这个动作叫page out。从而导致mq性能不稳定,忽高忽低,因为每次page out都会阻塞一段时间。

怎么解决呢?早期可以用持久化消息来代替临时消息,这样每次消息进来都会持久化到磁盘,不会发生page out,内存中会清除一部分消息(此时性能会有所下降),留一部分消息,但不会发生阻塞消息进来。相比于直接page out阻塞消息来说好多了,但是不够完美。于是后面有了惰性队列来解决消息积压的问题。

15.惰性队列

v

15.1.创建惰性队列

15.1.1.控制台手动创建

15.1.2.代码创建

@Bean
public Queue fecQueue2(){
    // durable 表示持久化队列 fecQueue2:队列名 lazy:惰性队列
    return QueueBuilder.durable("fecQueue.2").lazy().build();
}

或者

@RabbitListener(queuesToDeclare = @Queue(
    name = "lazy.queue1", 
    durable = "true", 
    arguments = @Argument(name = "x-queue-mode",value = "lazy")
))
public void listenerLazyQueue1(String msg) {
	System.out.println("消费者1号收到了lazy.queue1的消息:【"+msg+"】");
}

15.2.测试惰性队列消息积压

通过给惰性队列发送100万条数据观察,消息的投递情况一直处于高水位,并没有普通队列的消息阻塞情况或消息忽高忽低的情况。所以惰性队列可以很好的解决消息积压的问题。

16.如何保证消息可靠性

17.消费者确认

17.1.无限制的重新入队怎么办,失败重试机制

spring:
  rabbitmq:
    host: 106.14.42.235
    port: 5672
    virtual-host: /yc-vhost
    username: yangcheng
    password: yc123456
    listener:
      simple:
        acknowledge-mode: auto 
        prefetch: 1
        retry:
          enabled: true 

/**
 * @Description: 定义消费失败消息处理(开启失败重试机制后才生效)
 * @Author Andy
 * @Date 2023/10/24 11:49
 */
@Configuration
@ConditionalOnProperty(prefix = "spring.rabbitmq.listener.simple.retry",name = "enabled",havingValue = "true")
public class ErrorConfiguration {

    @Bean
    public DirectExchange errorExchange(){
        return new DirectExchange("error.direct");
    }

    @Bean
    public Queue errorQueue(){
        return new Queue("error.queue");
    }

    @Bean
    public Binding errorBinding(){
        return BindingBuilder.bind(errorQueue()).to(errorExchange()).with("error");
    }

    @Bean
    public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate){
        return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
    }
    
}

@RabbitListener(queuesToDeclare = @Queue(
            name = "lazy.queue1",
            durable = "true",
            arguments = @Argument(name = "x-queue-mode",value = "lazy")
    ))
    public void listenerLazyQueue1(String msg) throws Exception {
        System.out.println("消费者1号收到了lazy.queue1的消息:【"+msg+"】");
        throw new RuntimeException("gu yi bao cuo");
    }

18.消费者如何保证消息可靠性

19.幂等性(解决重复消费)

消息消费时的幂等性(消息不被重复消费)

同一个消息,第一次接收,正常处理业务,如果该消息第二次再接收,那就不能再处理业务,否则就处理重复了;

19.1.唯一消息id

19.2.设置消息id的方式一

/**
     * 配置json消息对象转换 开启消息id自动创建
     * @return
     */
@Bean
public MessageConverter jacksonMessageConvertor(){
    Jackson2JsonMessageConverter jsonMessageConverter = new Jackson2JsonMessageConverter();
    // 开启自动创建消息唯一id
    jsonMessageConverter.setCreateMessageIds(true);
    return jsonMessageConverter;
}

19.3.设置消息id的方式二

Message message = MessageBuilder.withBody("asfasa".getBytes())
.setMessageId(UUID.randomUUID().toString()).build();
rabbitTemplate.convertAndSend("test.simple.queue",message);

19.4.唯一消息id解决幂等性的缺点

会侵入消费者的业务代码,还需要操作数据库,可以采用redis的setnx操作

20.过期消息(TTL消息)

:::info TTL 是什么呢?TTL(Time To Live ) 是 RabbitMQ 中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,单位是毫秒。换句话说,如果一条消息设置了 TTL 属性或者进入了设置TTL 属性的队列,那么这条消息如果在TTL 设置的时间内没有被消费,则会成为"死信",如果配置了死信交换机则进入死信交换机,没有配置的话则会自动删除。

:::

:::color4 如果同时配置了队列的TTL 和消息的TTL,那么较小的那个值将会被使用,有两种方式设置 TTL。

:::

过期时间决定了该消息在没有任何消费者消费时,消息可以存活多久

消息的过期时间有两种设置方式:(过期消息)

20.1.在队列上设置过期消息

此队列里的所有消息在过期时间内没有被消费则会自动删除

队列中所有消息都有相同的过期时间

20.1.1.控制台设置

20.1.2.代码设置

// 指定x-message-ttl值的类型是integer,值是10000 单位ms
@RabbitListener(queuesToDeclare = @Queue(
            name = "TTL.queue1",
            durable = "true",
            arguments = {@Argument(name = "x-queue-mode",value = "lazy"),
                    @Argument(name = "x-message-ttl",type = "java.lang.Integer",value = "1000")}
    ))
    public void listenerTtlQueue1(String msg) throws Exception {
        System.out.println("消费者收到了TTL.queue1的消息:【"+msg+"】");
    }

或者

@Bean
    public Queue TTLQueue4(){
        return QueueBuilder.durable("TTLQueue4").withArgument("x-message-ttl",40000).build();
    }


// @Bean
// public Queue TTLQueue2(){
//     // 注意 .expires(20000)表示队列在20000描ms后该队列就没有了(队列只能存在20000ms),这不是设置过期消息的方式
//     return QueueBuilder.durable("TTLQueue2").expires(20000).build();
// }

20.2.发送消息时设置过期时间

对消息进行单独设置:每条消息TTL可以不同

MessageProperties messageProperties = new MessageProperties();
messageProperties.setContentEncoding("UTF-8");// 设置消息的编码格式
messageProperties.setExpiration("10000"); // 消息的过期时间,单位ms
messageProperties.setMessageId(UUID.randomUUID().toString());// 设置消息id
messageProperties.setDeliveryMode(MessageDeliveryMode.PERSISTENT);// 设置消息持久化
Message message = new Message("这条消息10秒后过期".getBytes(), messageProperties);
rabbitTemplate.convertAndSend("fanoutExc1", "", message);

20.3.不设置TTL会怎样

如果不设置 TTL,表示消息永远不会过期,如果将 TTL 设置为 0,则表示除非此时可以直接投递该消息到消费者,否则该消息将会被丢弃。

20.4.两种方式的删除时机

:::color1 对于第一种方式通过队列属性控制TTL的消息来说,一旦消息过期,则会立马删除,这是因为过期的消息肯定是在队头,删除代价很低。

:::

:::warning 对于第二种方式来说,消息过期之后是不会立马删除的,因为每条消息的过期时间不同,如果要删除过期消息,那必须扫描整个队列,代价太高,所以消息是否过期是在消息消费的时候判断的。总的来说消息删除是有一定延时的!并不能确保准时删除。(能否准时删除需要看mq当前的负载情况)

:::

21.延迟队列

延时队列中的元素是希望在指定时间到了以后或之前取出和处理,简单来说,延时队列就是用来存放需要在指定时间被处理的元素的队列。

21.1.使用场景

1.订单在十分钟之内未支付则自动取消

2.新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。

3.用户注册成功后,如果三天内没有登陆则进行短信提醒。

4.用户发起退款,如果三天内没有得到处理则通知相关运营人员。

5.预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议

这些场景都有一个特点,需要在某个事件发生之后或者之前的指定时间点完成某一项任务,如:发生订单生成事件,在十分钟之后检查该订单支付状态,然后将未支付的订单进行关闭;看起来似乎使用定时任务,一直轮询数据,每秒查一次,取出需要被处理的数据,然后处理不就完事了吗?如果数据量比较少,确实可以这样做,比如:对于“如果账单一周内未支付则进行自动结算”这样的需求,如果对于时间不是严格限制,而是宽松意义上的一周,那么每天晚上跑个定时任务检查一下所有未支付的账单,确实也是一个可行的方案。但对于数据量比较大,并且时效性较强的场景,如:“订单十分钟内未支付则关闭“,短期内未支付的订单数据可能会有很多,活动期间甚至会达到百万甚至千万级别,对这么庞大的数据量仍旧使用轮询的方式显然是不可取的,很可能在一秒内无法完成所有订单的检查,同时会给数据库带来很大压力,无法满足业务要求而且性能低下。

使用mq的延迟队列后的流程图:

21.2.rabbitmq插件实现延迟队列

21.3.安装插件

地址: github.com/rabbitmq/ra…

插件名称:rabbitmq_delayed_message_exchange-3.8.17.8f537ac.ez 版本号和rabbitmq适配即可

  1. docker容器版rabbitmq安装

当初创建容器时已经将mq的plugins目录挂载到宿主机上去了,因此只需将插件放在挂载目录下即可

执行一条命令使其生效

rabbitmq是容器的名称

docker exec -it rabbitmq rabbitmq-plugins enable rabbitmq_delayed_message_exchange

最后需要重启rabbitmq

  1. linux版rabbitmq也同理,这里忽略

21.4.验证安装结果

当创建延迟交换机时type选择x-delayed-message,另外还必须指定交换机的x-delayed-type参数,可选值有fanout,direct,topic,因为延迟交换机也要指定是哪种转发类型的

21.5.创建延迟交换机

@RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "delay.queue.001", durable = "true"),
            exchange = @Exchange(name = "delay.exc.001", type = "direct",delayed = "true"),
            key = "delay001"
    ))
    public void listenerDelayQueue(String msg) {
        log.info("消费者收到了delay.exc.001 - delay.queue.001的消息:【{}】", msg);
    }

@Configuration
public class DelayExchangeQueueConfig {
    @Bean
    public DirectExchange delayExchange002(){
        return ExchangeBuilder.directExchange("delay.exc.002")
                .delayed() // 设置交换机为延迟交换机
                .durable(true)
                .build();
    }
    @Bean
    public Queue delayQueue002(){
        return QueueBuilder.durable("delay.queue.002").build();
    }
    @Bean
    public Binding binding(){
        return BindingBuilder.bind(delayQueue002()).to(delayExchange002()).with("delay002");
    }
}

21.6.发送延迟消息

@Test
    void testDelay1() {
        MessageProperties messageProperties = new MessageProperties();
        messageProperties.setContentEncoding("UTF-8");// 设置消息的编码格式
        messageProperties.setDelay(10000); // 消息的延迟时间,单位ms
        messageProperties.setMessageId(new Date()+"");// 设置消息id
        Message message = new Message("这是一条延迟10s的消息".getBytes(), messageProperties);
        rabbitTemplate.send("delay.exc.001","delay001", message);
        log.info("消息发送完毕!");
    }

    @Test
    void testDelay2(){
        rabbitTemplate.convertAndSend("delay.exc.001", "delay001", "我是一条延迟消息,NO.", new MessagePostProcessor() {
            @Override
            public Message postProcessMessage(Message message) throws AmqpException {
                message.getMessageProperties().setDelay(20000);// 消息的延迟时间,单位ms
                return message;
            }
        });
        log.info("消息发送完毕");
    }

21.7.延迟消息的缺点

延迟消息非常消耗cpu资源,而且延迟时间越长,延迟消息越多,则消耗越明显。

21.8.可以删除掉在延迟交换机中还未转发到队列中的某些消息吗?

TODO

22.死信队列(死信交换机)

22.1.死信概念

先从概念解释上搞清楚这个定义,死信,顾名思义就是无法被消费的消息,字面意思可以这样理解,一般来说,producer 将消息投递到 exchange 或者直接到queue 里了,consumer 从 queue 取出消息进行消费,但某些时候由于特定的原因导致 queue 中的某些消息无法被消费,这样的消息如果没有后续的处理,就变成了死信,有死信自然就有了死信队列。

22.2.应用场景

  1. 为了保证订单业务的消息数据不丢失,需要使用到 RabbitMQ 的死信队列机制,当消息消费发生异常时,将消息投入死信队列中。
  2. 实现延迟消息,通过发送有过期时间的消息到无任何消费者消费的队列中,等过期时间一到就会被发送到死信队列中,订阅了死信队列的消费者就可以在过期时间之后消费消息了,间接的通过死信队列来实现延迟消费的目的。

总的来说死信队列两个作用:1. 延迟消息(不够完美的延迟消息,忘掉这个,发明死信队列的初衷不是为了用来做延迟队列的) 2.确保消息不丢失(最主要的作用)

22.3.死信的来源

  1. 消息 TTL 过期成为死信进入死信交换机
  2. 队列达到最大长度,队列满了,消息还会继续进入队列,只是队列中最早的消息会成为死信进入死信交换机(队列长度是6,陆续进去了10条消息到队列,前4条会成为死信,后面的6条继续在队列中)
  3. 消息被拒绝(basic.reject 或 basic.nack)并且 requeue=false.(开启消费者确认模式,当消费者消费失败了,返回nack或reject时,正常情况下会重新入队,但是如果设置了requeue=false无法重新入队就会成为死信)

22.4.死信交换机

22.5.代码架构

22.6.设置死信交换机

22.7.死信队列实现延迟消息的缺点

当通过设置队列TTL+死信交换机来实现延迟消息时,每当增加一个新的时间需求,就要新增一个队列,那岂不是要增加无数个队列?

那通过设置单条消息的TTL+死信交换机不就行了吗?在之前过期消息删除机时提到了,使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“,因为 RabbitMQ 只会检查第一个消息(轮到消费者消费该消息了)是否过期,如果过期则丢到死信队列,如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。

RabbitMQ 3.6.x 之前我们一般采用死信队列+TTL过期时间来实现延迟队列。在 RabbitMQ 3.6.x 开始,RabbitMQ 官方提供了延迟队列的插件,可以下载放置到 RabbitMQ 根目录下的 plugins 下。

因此,用死信队列来确保消息可靠性还是不错的,但要实现延迟队列的话不是最好的选择。

23.优先级队列

23.1.介绍

正常队列的消费顺序是先进先出,先到达队列的消息先被消费,但是实际业务中有时需要让后来的消息先消费,让消息有了优先级,这就需要用到优先级队列。

:::info 要让队列实现优先级需要做的事情有如下事情:队列需要设置为优先级队列,消息需要设置消息的优先级,消费者需要等待消息已经发送到队列中才去消费因为,这样才有机会对消息进行排序

:::

:::color1 如果消息消费的很快,大于消息生产的速度,每进到队列一条消息就立马被消费,那么设置优先级没有意义;只有当消费者消费的速度远低于生产者生产消息的速度,队列中有消息积压的情况下(此时队列中的消息会根据优先级排序,优先级大的消息放在队头,最先被消费),对消息设置优先级才有意义。

:::

:::warning 优先级的值设置的越大越先被消费,但带来的资源消耗越高

:::

23.2.创建优先级队列

23.2.1.控制台创建

x-max-priority的值可以是0-255,官网推荐 1-10 如果设置太高比较吃内存和 CPU

23.3.代码创建

@RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "priority.queue.001",
                            durable = "true",
                            arguments = {@Argument(name = "x-max-priority",value = "10",type = "java.lang.Integer")}
                        ),
            exchange = @Exchange(name = "priority.exc.001", type = "direct"),
            key = "priority"
    ))
    public void listenerPriorityQueue(String msg) {
        log.info("消费者收到了priority.exc.001 - priority.queue.001的消息:【{}】", msg);
    }

或者

@Bean
    public Queue delayQueue002(){
        return QueueBuilder.durable("priority.queue.002").maxPriority(10).build();
    }

23.4.发送优先级消息

rabbitTemplate.convertAndSend("priority.exc.001", "priority", "我是一条优先消息", message -> {
    message.getMessageProperties().setPriority(5);// 消息的优先级
    return message;
});

24.集群

rabbitmq-03.docx

生产中一定用的是镜像集群模式

25.疑问

25.1.rabbitmq支持广播消费吗?

广播消费:多个消费者订阅同一个消息队列,队列中的每条消息都会被多个消费者消费

答:不支持。rabbitmq只支持交换机层面上的广播,因为提供了fanout交换机,每个绑定到这个交换机上的队列都会收到同样的消息。但是每个队列中的每条消息只能被一个消费者消费,如果有多个消费者订阅同一个队列,则会轮询消费,你消费一条我消费一条,不会出现你我都消费同一条消息的情况。但是假如每个消费者消费的速度不同的情况下,可以通过设置prefetch使得能者多劳,消费快的多消费几条消息,消费慢的少消费几条消息。

25.2.延迟交换机支持消息撤回或删除吗?

应该是不支持,但没找到确切答案

25.3.面试常问:怎么保证消息可靠性?(怎么解决消息丢失?)

一条消息从生产到消费需要经过生产者->交换机->队列->消费者,每个步骤都有可能丢失消息,保证消息可靠不丢失需要考虑的方面有很多,需要从生产者生产可靠性,消费者消费可靠性,交换机,队列,消息可靠性入手

25.3.1.持久化

首先要保证消息可靠性,必须确保交换机,队列,消息都是持久化的,队列最好是惰性队列,这样才能保证服务宕机后数据不丢失

25.3.2.生产者可靠性保证

开启生产者重连机制,保证生产者连接mq服务时的可靠性,开启生产者确认机制,保证生产者可以感知到消息是否成功投递到交换机,若失败则进行相应的异常处理。开启消息回退或备用交换机,确保路由失败的消息不丢失。

25.3.3.消费者可靠性保证

开启消费者确认和失败重试机制,当消费者成功消费后会返回ack回执,当消费者消费失败后会返回nack,此时消息会重新入队再次投递,重试次数耗尽后会进入指定的交换机做专门的处理。或者开启死信交换机,当消息成为死信后进入到死信交换机和队列,让专门的消费者监听死信队列。

25.3.4.弊端

开启以上机制后,确实可以保证消息可靠性,但是也会增加额外的网络和系统资源的负担,甚至降低mq的性能和吞吐量。