Loading

RabbitMQ高级特性

内容:如何保证消息可靠性,如何发送延迟消息(基于死信队列和DelayExchange插件),使用惰性队列解决消息堆积问题,MQ高可用集群

消息可靠性

img

RabbitMQ的消息模型中,可能有以下部分发生消息丢失

  1. 消息在传输路径上丢失,比如发送者到exchangerexchangerqueuequeueconsumer
  2. 由于MQ宕机,导致queue上的消息丢失
  3. consumer尚未消费消息时宕机

Step1. 生产者确认

类似网络中可靠数据传输协议所采用的方法来解决消息在传输路径上丢失的问题,采用ack和nack来代表消息的成功或失败确认。

当生产者发送一条消息时,根据该消息的实际情况,它会接到RabbitMQ发回的结果:

  1. publisher-confirm:
    1. 消息已经投送给交换机,返回ack
    2. 消息未投送到给交换机,返回nack
  2. publisher-return:
    1. 消息已经投递给交换机,但路由到队列的过程中失败了,返回ack和路由失败原因

考虑当一个消息发送给交换机成功了,但发送给队列失败了,此时消息生产者接收到两条消息,一个是publisher-confirmack,一个是publisher-returnack

类似TCP用seq number来确认ACK是在回复哪个报文段,RabbitMQ允许为每个消息设置一个唯一的全局ID来确认这是哪一个消息的回执

使用Spring-AMQP实现生产者确认

接收并处理publisher-confirm

publisher-confirm代表着消息是否被成功发送到交换机上,首先要配置以下publisher-confirm-type,这里我们使用correlated

spring:
  rabbitmq:
    # ... other configurations ...
    publisher-confirm-type: correlated

然后,我们要给RabbitTemplate设置ConfirmCallback,由于RabbitTemplate是全局唯一的,所以ConfirmCallback也是全局唯一的,不过你也可以为每一条消息设置自己的Callback。

这里,我们通过实现ApplicationContextAware,在ApplicationContext初始化完成后获取RabbitTemplate,并调用其setConfirmCallback方法为它设置了一个确认回调,来监测消息是否到达了交换机。

@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        RabbitTemplate template = applicationContext.getBean(RabbitTemplate.class);
        template.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            @Override
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                String correlationId = correlationData == null ? "null" : correlationData.getId();
                Message correlationReturnMessage = correlationData == null ? null : correlationData.getReturnedMessage();
                if (ack) {
                    log.info("消息到达交换机,消息ID => {}", correlationId);
                } else {
                    log.info("消息未到达交换机, 消息ID => {},原因:{},消息内容:{}", correlationData.getId(), cause, correlationReturnMessage);
                }
            }
        });
    }
}

在上面的代码中,我首先对correlationData进行了一系列的判空操作,这是因为correlationData可能为null,只要外界发送消息时没有传递一个CorrelationData对象时,我们的回调中correlationData参数就为空。因为这时代表用户并没有绑定一个CorrelationData到发送的消息上。

发送时可以这样绑定该对象:

// 生成唯一ID
String id = UUID.randomUUID().toString();
CorrelationData correlationData = new CorrelationData(id);
rabbitTemplate.convertAndSend("amq.topic", "a.simple.test", message, correlationData);

当消息发送正常时,可以看到如下日志:

img

当你尝试向一个不存在的交换机发送消息时,可以看到如下日志:

消息未到达交换机, 消息ID => c7623b95-1174-45f4-b494-b95031e0c631,原因:channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'bamq.topic' in vhost '/', class-id=60, method-id=40),消息内容:null

除此之外,你还可以在每条消息发送时通过CorrelationData对象设置独立的publisher-confirm回调,在这里你可以编写重试的逻辑:

CorrelationData correlationData = new CorrelationData(id);
correlationData.getFuture().addCallback(
         (CorrelationData.Confirm result) -> {
            if (!result.isAck())
               // 重试
               rabbitTemplate.convertAndSend("amq.topic", "a.simple.test", message, correlationData);
         },
         (Throwable ex) -> {
            log.warn("发送时抛出异常...");
         }
);

接收并处理publisher-return

publisher-return会在消息成功发送到交换机但发送到队列失败时收到。

首先是配置:

spring:
  rabbitmq:
    # ...
    # publisher-returns: true
    template:
      mandatory: true

publisher-returns代表启用接收publisher-returntemplate.mandatory代表开启强制消息投递,如果为false则当失败时直接丢弃消息,为true则调用ReturnCallbacktemplate.mandatory的优先级要高,所以设置它后你可以不设置publisher-returns

然后,我们要给RabbitTemplate设置Return,由于RabbitTemplate是全局唯一的,所以ReturnCallback也是全局唯一的。

@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        RabbitTemplate template = applicationContext.getBean(RabbitTemplate.class);

        template.setReturnCallback(new RabbitTemplate.ReturnCallback() {
            @Override
            public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
                log.warn("Send message faild, msg => [{}]. Retrying..." , replyText);
                template.convertAndSend(exchange, routingKey, message.getBody());
            }
        });
    }
}

这时,当你尝试使用错误的routingKey发送消息时,你会接到publisher-return消息

img

使用生产者确认,可以保证消息不会莫名其妙的丢失,丢失一定会通知我们,我们可以进行重发或其它逻辑。

Step2. 消息持久化

在Spring-AMQP中,默认情况下,交换机、队列和消息都是持久的,不用做任何设置

为了避免宕机后消息丢失,需要将消息持久化到磁盘中。

注意,下面的图片中,Feature一栏中,我们自己创建的my.exchange交换机没有D这个标识,这个标识代表“持久的”,即该Exchange在关机后不会消失。

img

你在创建交换机和队列时是能够选择是否持久化的,Durable代表持久的,Transient代表瞬时的。

img

在Java代码中,所有交换机的父类AbstractExchange有一个构造器:

img

这代表我们在创建交换机时可以通过第二个参数为true来控制它是否是持久的。

在创建队列时,我们也可以用QueueBuilder来完成这点(Queue的构造函数里也可以设置)

QueueBuilder.durable("simple.queue").build();

上面只是将交换机和队列持久化了,并不是将里面的数据持久化,重启后,虽然交换机和队列还在,但里面的数据不在了。所以,如果要将消息持久,还要在发送消息时设置持久化

Message message = MessageBuilder.withBody("hello, spring".getBytes(StandardCharsets.UTF_8))
               // 设置消息传送模式,持久化
               .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
               .build();

Step3. 消费者消息确认

为了确保消费者正常消费后MQ再删除消息,RabbitMQ提供消费者确认机制

  1. 消费者成功消费,向MQ发送ACK,MQ删除消息
  2. 消费者消费失败,向MQ发送NACK,MQ重发消息
  3. 消费者宕机,MQ没有接到任何确认,等待消费者重启后重发消息

Spring-AMQP提供了三种消息确认模式:

  1. Manual:手动发送确认消息
  2. Auto:Spring监测Listener方法,自动发送确认消息
  3. None:关闭此功能,MQ不管消费者是否接到消息,发送消息后立即删除消息

配置:

spring: yaml
  rabbitmq:
    listener:
      simple:
        prefetch: 1
        acknowledge-mode: auto

Step4. 失败重试机制

Step3中有一个循环调用,即如果消费者消费失败,MQ会重新发送消息,而一般情况下如果是代码逻辑引起的失败,则即使重发也不会好,就会造成消费者一直报告NACK,MQ一直重发的局面。

img

为了避免MQ的压力过大,我们可以采用本地重试机制,即在本地重新计算

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1
        acknowledge-mode: auto
        retry:
          # 开启本地重试
          enabled: true
          # 重试重试间隔 ms
          initial-interval: 1000
          # 重试间隔因子,new_interval = last_interval * multipiler
          multiplier: 1
          # 最大尝试次数
          max-attempts: 3
          # 是否无状态,无状态不保持事务,有状态保持事务
          stateless: true

配置如上代码后,Spring-AMQP会在消息处理失败后尝试在本地重试3次,每次间隔1s,等三次后,Spring-AMQP会给MQ服务器发送reject,表示该消息被放弃,服务器可以删除该消息了。

下图是重试三次后的一条日志,显示重试次数耗尽

img

你也可以设置重试次数耗尽后的策略:

  1. RejectAndDontRequeueRecoverer:直接reject
  2. ImmediateRequeueMessageRecoverer:返回nack,重新入队
  3. RepublishMessageRecoverer:将消息投递到指定交换机

配置MessageRecoverer,覆盖原始的reject策略,在重试次数耗尽时将消息发送给error.direct这个交换机,并使用error这个Routingkey。

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

然后,当dl.queue发送消息的重试次数耗尽后,消息被重发到了error.queue

img

消息可靠性总结

  1. 可以使用生产者确认机制定制在消息在发送路径上失败时的策略
    1. publisher-confirm可以确认消息是否发送到交换机
    2. publisher-return可以确认消息是否发送到queue
  2. 可以使用持久化技术来将队列、交换机和消息持久化,当MQ宕机时,消息也不会丢失。Spring-AMQP默认开启了所有的持久化
  3. 可以使用消费者确认机制来定制当消费者处理消息失败时,MQ的策略
    1. none,MQ直接删除消息
    2. auto,自动监测listener,执行完成发送ack,执行失败发送nack
    3. manual,手动监测listener,发送ack和nack
  4. 如果代码逻辑有误,很可能陷入消费者不断nack,MQ不断重发的状态,可以使用本地重试机制来解决或缓解这一问题
    1. RejectAndDontRequeueRecoverer,重试次数耗尽后直接reject
    2. ImmediateRequeueMessageRecoverer,重试次数耗尽后发送nack,消息重新入队
    3. RepublishMessageRecoverer,重试次数耗尽后重新将消息发送到指定交换机

发送延迟消息

基于死信交换机

当一个队列中的消息满足下列情况之一时,可以称为死信

  1. 被消费者reject或nack,并且消息的requeue参数设置为false
  2. 消息超时无人消费
  3. 消息队列满了,当有新消息,最早的消息被丢弃

如果一个队列配置了dead-letter-exchange后,它其中所有的死信都会被投递到这个交换机中,该交换机称为死信交换机

与Spring-AMQP中的RepublishMessageRecoverer不同,虽然结果都是将消息投递到另一个交换机中,但死信交换机是由产生死信的队列投递,而前者是由消费者重新投递

TTL到期产生的死信

由于第一种死信产生方式我们已经在前面看到过了,所以这里直接从消息超时开始。TTL就是Time-To-Live,也就是消息的存活时间。RabbitMQ中,可以给队列设置统一的TTL时间,也可以给消息单独设置TTL时间。如果消息和队列都有TTL,则以更小的为准。

死信交换机实现延迟消息

当你为一个消息设置ttl,并且不监听它所在的消息队列,而是监听死信交换机路由到的队列,这样就实现了消息的延迟发送。消息经历ttl后才被消费者消费。

img

在创建Queue时,可以使用QueueBuilder来添加死信交换机和死信RoutingKey

public Queue ttlQueue() {
   return QueueBuilder.durable("ttl.queue")
            .ttl(10000)
            .deadLetterExchange("dl.direct")
            .deadLetterRoutingKey("dl")
            .build();
}

这样,我们创建了一个ttl为10000ms,死信交换机为dl.direct,私信routingkey为dl的队列。

定义死信交换机、Routingkey以及它所绑定的队列,并进行监听:

@RabbitListener(bindings = @QueueBinding(
      value = @Queue(name = "dl.queue", durable = "true"),
      exchange = @Exchange(name = "dl.direct"),
      key = "dl"
))
public void listenDlQueue(String msg) {
   log.info("消费者接收到了dl.queue的延迟消息");
}

接下来,向ttl.queue发送消息,10秒后,dl.queue会接收到消息。

img

在创建消息时,也可以通过MessageBuilder为消息设置超时时间

Message message = MessageBuilder
         .withBody("hello, ttl messsage".getBytes(StandardCharsets.UTF_8))
         .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
         // 设置超时时间
         .setExpiration("5000")
         .build();

DelayExchange插件

安装

Release页面下载这个插件,上传到你的虚拟机

修改DockerCompose文件,将插件挂载到容器的/plugins目录下:

services:
  rabbitmq1:
    image: rabbitmq:3-management
    ports:
      - 5672:5672
      - 15672:15672
    volumes:
      # 启动脚本
      - ./startup.sh:/startup.sh
      # 插件
      - ./rabbitmq_delayed_message_exchange-3.10.2.ez:/plugins/rabbitmq_delayed_message_exchange-3.10.2.ez
    command:
      # 运行启动脚本
      /bin/bash -c /startup.sh

启动脚本里包含启用RabbitMQ插件的命令和启动RabbitMQ服务器的命令,因为自定义command时原先的command会被替换,所以你必须手动启动服务器。你也可以选择使用&而不是启动脚本。启动脚本的内容如下:

rabbitmq-plugins enable rabbitmq_delayed_message_exchange
rabbitmq-server

记得给脚本执行权限:

chmod a+x startup.sh

其实在rabbitmq的官方docker镜像文档中有一个更优雅的启动插件的方法,这里就不研究了。

重启,插件已经安装成功:

img

使用

DelayExchange插件的原理就是提供一个新的交换机类型,这个交换机可以暂存消息,等到时间后将消息转发给队列。

可以看到安装插件后,我们已经可以创建x-delay-message类型的exchange了

img

DelayExchange实际上是对官方交换机的装饰器,它只提供消息的暂存,所以你还要为它指定一个底层使用的官方交换机。使用x-delayed-type参数指定

img

然后我们装模做样的将它绑定到之前的dl.queue

img

现在就要对它发消息了,它是通过给消息添加一个x-delay头来指定延迟的时间的

Message message = MessageBuilder
         .withBody("hello, ttl messsage".getBytes(StandardCharsets.UTF_8))
         .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
         .setHeader("x-delay", 5000)
         .build();

img

使用Spring-AMQP创建延迟队列,注解方式:

img

只需要添加delayed=true属性即可

Bean方式:

img

使用ExchangeBuilder的delayed方法

记得之前的publisher-return吗,如果你向x-delayed-message类型的exchange发送消息,它会默认认为消息发送到了交换机但没到队列,所以ReturnCallback会被回调,如果你在里面配置了重发,那么该消息可能会被重发多次,你要处理好这种情况。

@Override
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
      // 判断,如果消息具有接收延迟,放行它
      if (message.getMessageProperties().getReceivedDelay() > 0) return;

      log.warn("Send message faild, msg => [{}]. Retrying..." , replyText);
      template.convertAndSend(exchange, routingKey, message.getBody());
}

惰性队列

消息堆积问题

队列容量有限,当消费者消费速度跟不上生产者生产速度时,队列迟早会满,之后,再来新的消息时,最早到达的消息会成为死信,被发到死信交换机或者被丢弃。

解决消息堆积的几种思路

  1. 提高消费速度(增加消费者个数)
  2. 提高单个消费者处理速度(开启线程池)
  3. 扩大队列容量,提高堆积上限

惰性队列

惰性队列从第三点上解决这一问题,由于内存容量有限,所以惰性队列有如下特性:

  1. 接收消息后直接写入磁盘(非内存存储)
  2. 消费者消费消息时才从磁盘写入内存
  3. 支持百万消息的存储

相当于牺牲一部分性能换得存储更多的消息,在需要处理大量消息时可以使用

在运行中修改队列为惰性队列:

img

在Spring-AMQP中声明队列为惰性队列:

// Bean方式
@Bean
public Queue lazyQueue() {
   return QueueBuilder.durable("lazy.queue")
            .lazy()
            .build();
}
// 注解方式
@RabbitListener(queuesToDeclare = @Queue(
      name = "delay.queue",
      durable = "true",
      // 通过添加队列参数设置
      arguments = @Argument(name = "x-queue-mode", value = "lazy")
))

当向惰性队列中发送大量消息时,会看到它们都被pageout到磁盘中了,内存中实际上什么都没有

img

MQ集群

RabbitMQ的集群类型:

  1. 普通集群:将队列分散到集群中的各个节点上,节点中保存不同的数据,提高整个集群并发能力(当节点故障,其中的消息丢失)
  2. 镜像集群:是一种主从集群,将主节点的数据备份到从节点上,提高可用性(主从同步非强一致)
  3. 仲裁队列:用来代替镜像集群,保证强一致性

普通集群

  1. 集群节点间共享交换机、队列元信息,但不共享队列以及其中的消息
  2. 当访问集群中某节点时,如果访问的队列不在该节点,请求会被传递到队列所在节点并返回
  3. 队列所在节点宕机,队列数据丢失

首先,创建RabbitMQ的配置文件rabbitmq.conf,在里面指定集群中的节点地址,节点地址统一用rabbit@hostname的格式。后面我们会用docker compose创建这些节点,并给它们的hostname命名为mq1~3

loopback_users.guest = false
listeners.tcp.default = 5672
cluster_formation.peer_discovery_backend = rabbit_peer_discovery_classic_config
cluster_formation.classic_config.nodes.1 = rabbit@mq1
cluster_formation.classic_config.nodes.2 = rabbit@mq2
cluster_formation.classic_config.nodes.3 = rabbit@mq3

然后,RabbitMQ集群通信需要一个共同的cookie,我们创建.erlang.cookie文件并随便写入一个Cookie

PFXHSGXZINBKKBNIDRJM

然后,我们修改它的权限,让它只能被root读写:

sudo chown root:root .erlang.cookie
sudo chmod 600 .erlang.cookie

然后编辑docker compose文件

# normal-cluster.docker-compose.yaml
services:
  mq1:
    image: rabbitmq:3-management
    hostname: mq1
    ports:
      - 5672:5672
      - 15672:15672
    volumes:
      - ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
      - ./.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie

  mq2:
    image: rabbitmq:3-management
    hostname: mq2
    ports:
      - 5673:5672
      - 15673:15672
    volumes:
      - ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
      - ./.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie

  mq3:
    image: rabbitmq:3-management
    hostname: mq3
    ports:
      - 5674:5672
      - 15674:15672
    volumes:
      - ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
      - ./.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie

up服务

docker compose -f normal-cluster.docker-compose.yaml up

连接任意节点的控制台,已经可以看到集群的状态

img

现在我们在mq1创建队列并写入数据,发现在其它节点也能看到,停掉mq1后,其它节点的这个队列显示为stopped状态

img

这也印证了,队列的元信息在集群间共享,但队列本身不共享

镜像集群

镜像集群通过主从模式来将节点上的数据在集群间备份,实现高可用

不过它和传统的主从模式还有些区别,有点像ElasticSearch的分片集群。其主要区别是主节点可以作为其它节点的镜像节点,就当作从节点就是主节点的一个备份,然后多个节点之间建立主从关系相互备份。下图中,节点1做了节点3的镜像节点,节点2做了节点1的镜像节点,节点3做了节点2的镜像节点。

img

  1. 主从关系是基于队列讨论的,你可以说,节点1是队列1的主节点,是队列3的从节点
  2. 主节点操作完成同步给从节点
  3. 一个队列所在的主节点宕机后,它的丛节点会做新的主节点

镜像模式

  1. exactly:你可以指定一个count,它代表队列的副本数。当它为1时,队列在集群中有一个副本,即它所在的主节点;当它为2时,队列在集群中有两个副本,那就意味着它必须选择一个集群中的其它节点做该队列的镜像节点。
  2. all:队列在集群中的所有节点间进行镜像(不推荐,会带来高昂的磁盘和网络压力)
  3. nodes:手动指定队列镜像到哪些节点
# 设置policy,名字为`ha-two`,匹配所有以`two.`开头的队列,将它们设置为`exactly`模式,指定count为2,同步模式为自动
rabbitmqctl set_policy ha-two "^two\." '{"ha-mode": "exactly", "ha-params": 2, "ha-sync-mode": "automatic"}'

# 匹配所有以`all.`开头的队列,将它们设置为`all`模式
rabbitmqctl set_policy ha-all "^all\." '{"ha-mode": "all"}'

# 匹配所有以`nodes.`开头的队列,将它们设置为`nodes`模式,并且指定镜像节点
rabbitmqctl set_policy ha-nodes "^nodes\." '{"ha-mode": "nodes", "ha-params": ["rabbit@nodeA", "rabbit@nodeB"]}'

我们想要新建一个excatly模式的policy,那就直接进入到集群中任意一个节点中,执行命令就行:

img

在控制台上也能看见策略被创建了

img

此时我们在mq1上创建一个two.queue,就能看到它同步到了集群中的一个其它节点上,现在集群中有两个该队列的副本

img

当我们写入数据后,把mq1停掉,访问mq2,发现mq2变成了这个队列的主节点,mq3变成了镜像节点

img

所以,exactly镜像模式的目标就是保证集群中永远有count份队列的副本,当count小于集群中节点数量,它的效果和all类似。

仲裁队列

  1. 和镜像模式相同,支持主从数据同步
  2. 使用简单,没有复杂配置
  3. 基于Raft协议,保证强一致性

使用仲裁队列,只需要创建队列时指定类型为Quorum,并且选择Node即可,选中的Node作为该队列的主节点,其它Node作为该队列的从节点。(这不是镜像模式的all吗)

img

img

关于使用Spring-AMQP创建仲裁队列,我都不想说了,只要你了解了Spring-AMQP的API风格,就知道该怎么做了

new QueueBuilder()
   .durable("quorum.queue")
   .quorum()
   .build();

不过集群模式需要修改Spring的配置了,需要在连接时指定要连接的集群中的所有节点:

spring:
  rabbitmq:
#    host: localhost
#    port: 5672 # 端口
    addresses: localhost:5672,localhost:5673,localhost:5674

最后的总结

本篇文章基于黑马程序员的网课SpringCloud+RabbitMQ+Docker+Redis+搜索+分布式,史上最全面的springcloud微服务...中的高级篇day05,主要介绍了以下内容

  1. 对于MQ消息的可靠性如何保证
    1. 发布者确认
    2. 消息持久化
    3. 接收者确认
    4. 失败重试
  2. 如何发送延迟消息
    1. 通过死信交换机
    2. 通过DelayExchange插件
  3. 如何使用惰性队列解决消息堆积问题
  4. MQ的高可用集群搭建
    1. 普通集群
    2. 镜像集群
    3. 仲裁队列
posted @ 2022-08-29 11:30  yudoge  阅读(688)  评论(1编辑  收藏  举报