RabbitMQ 深入浅出

759 阅读13分钟

RabbitMQ 基本使用

RabbitMQ 基本使用:消息队列、AMQP 介绍,以及 RabbitMQ 支持的 7 中模式介绍及代码实现。

过期时间

过期时间 TTL(Time To Live)表示可以对消息设置预期的时间,在这个时间内都可以被消费者消费;超过时间则消息将被自动删除。

RabbitMQ 可以对消息和队列设置 TTL:

  1. 通过队列属性设置,队列中所有消息都有相同的过期时间

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

  3. 如果上述两种方法同时使用,则消息的过期时间以两者之间较小的那个为准

消息在队列的生存时间一旦超过设置的 TTL 值,就称为 Dead Message,被投递到死信队列, 消费者将无法再收到该消息。

队列 TTL

消费者与生产者工程代码详见:RabbitMQ 与 SpringBoot 整合

在消费者工程 RabbitConfig 中增加 testTTl 队列,测试 TTL 功能

  @Bean
  public Queue topicTTL() {
    return QueueBuilder.durable("testTTL").ttl(6000).build();
  }

  @Bean
  public Binding topicBindingTTL(Exchange topicExchange, Queue topicTTL) {
    return BindingBuilder.bind(topicTTL).to(topicExchange).with("test.#").noargs();
  }

ttl 方法传入毫秒值,底层就是给队列增加 x-message-ttl 属性。如果不设置 TTL,则表示此消息不会过期。如果将 TTL 设置为 0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃。

发起请求:

http://127.0.0.1:8081/sendMessage?exchange=testTopic&routingKey=test.halo&message=HelloWorld!&times=100

等候 6 秒,如下图,testTTL 队列已经清空消息,而 testQueue 仍保留。

消息 TTL

在生产者工程 ProducerController 中增加测试 TTL 功能接口

  @GetMapping("/sendMessageTTL")
  public String sendMessage(String exchange, String routingKey, String message, Integer times, Long ttl) {
    if (times == null) {
      times = 1;
    }
    if (ttl == null) {
      ttl = 10000L;
    }
    int realTimes = 0;
    MessageProperties properties = new MessageProperties();
    properties.setExpiration(String.valueOf(ttl));
    for (int i = 0; i < times; i++) {
      Message msg = new Message((message + i).getBytes(), properties);
      rabbitTemplate.convertAndSend(exchange, routingKey, msg);
      realTimes++;
    }
    return "总共发送 " + realTimes + " 条消息。";
  }

发起请求:

http://127.0.0.1:8081/sendMessageTTL?exchange=testTopic&routingKey=test.halo&message=HelloWorld!&times=100&ttl=1000

因为请求中 ttl 是 1 秒,而页面 5 秒刷新一次,所以 total 一直显示为 0。

可以发现,testQueue 和 testTTL 消息都没有了。说明:当同时指定了 queue 和 message 的 TTL 值,两者中较小的那个才会起作用。

死信队列

DLX,全称为 Dead-Letter-Exchange,可以称之为死信交换机,也有人称之为死信邮箱。当消息在一个队列中变成死信(dead message)之后,它能被重新发送到另一个交换机中,这个交换机就是 DLX,绑定 DLX 的队列就称之为死信队列。

消息变成死信,可能是由于以下的原因:

  1. 消息被拒绝
  2. 消息过期
  3. 队列达到最大长度

DLX 也是一个正常的交换机,和一般的交换机没有区别,它能在任何的队列上被指定,实际上就是设置某一个队列的属性。当这个队列中存在死信时,RabbitMQ 就会自动地将这个消息重新发布到设置的 DLX 上去,进而被路由到死信队列。

使用死信队列,只需要在定义队列的时候设置队列参数 x-dead-letter-exchange 指定交换机即可。

消费者 RabbitConfig 中增加

  @Bean
  public Exchange dlxExchange() {
    //死信交换机
    return ExchangeBuilder.fanoutExchange("testDLX").durable(true).build();
  }

  @Bean
  public Queue dlxQueue() {
    //与死信交换机绑定的队列
    return QueueBuilder.durable("testDlx").build();
  }

  @Bean
  public Binding dlxBinding(Exchange dlxExchange, Queue dlxQueue) {
    return BindingBuilder.bind(dlxQueue).to(dlxExchange).with("").noargs();
  }

  @Bean
  public Queue topicTtlDlx1() {
    //过期队列
    return QueueBuilder.durable("testTtlDlx1").ttl(6000).deadLetterExchange("testDLX").build();
  }

  @Bean
  public Queue topicTtlDlx2() {
    //消息个数限制队列
    return QueueBuilder.durable("testTtlDlx2").maxLength(10).deadLetterExchange("testDLX").build();
  }

  @Bean
  public Binding dlxBinding1(Exchange topicExchange, Queue topicTtlDlx1) {
    return BindingBuilder.bind(topicTtlDlx1).to(topicExchange).with("test.#").noargs();
  }

  @Bean
  public Binding dlxBinding2(Exchange topicExchange, Queue topicTtlDlx2) {
    return BindingBuilder.bind(topicTtlDlx2).to(topicExchange).with("test.#").noargs();
  }
  • 新建一个 Fanout 类型的 testDLX 交换机,作为死信交换机;

  • 新建一个 testDlx 队列,与 testDLX 交换机绑定;

  • 新建一个过期队列 testTtlDlx1,过期时间 6 秒,与 Topic 类型的 testTopic 交换机绑定,路由为 test.#,死信交换机为 testDLX;

  • 新建一个消息个数限制队列 testTtlDlx2,限制 10 个消息,与 Topic 类型的 testTopic 交换机绑定,路由为 test.#,死信交换机为 testDLX。

发起请求:

http://127.0.0.1:8081/sendMessage?exchange=testTopic&routingKey=test.halo&message=HelloWorld!&times=100

可以发现,testTtlDlx2 中只能存放 10 条消息,多余的 90 条消息,立马转发给 testDlx 死信队列(testQueue 队列是因为消费者开启,并且只消费了该队列,所以为 0)。

过一段时间后,testTtlDlx1 中消息全部过期,也转发给了 testDlx 死信队列,此时死信队列中刚好为 190 条消息(testTTL 队列没有配置死信队列,所以消息过期后不会发给死信队列)。

延迟队列

延迟队列存储的对象是对应的延迟消息,所谓 “延迟消息” 是指当消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。

在 RabbitMQ 中延迟队列可以通过 过期时间 + 死信队列 来实现。

比如:设置过期队列 A,TTL 为 5 秒,其死信队列为 B。那么往 A 发送消息,没有消费者消费 A 的消息,5 秒后,消息发送给 B,此时死信队列 B 的消费者即可获取消息。从而实现了延迟 5 秒消费消息。

应用场景:

  1. 系统中如有需要在指定的某个时间之后执行的任务都可以通过延迟队列处理

  2. 支付场景:如果在用户下单之后的 15 分钟内没有支付成功,则该订单取消,此时要进行取消订单的一些业务处理(比如更新订单状态、将库存加回去)

消息确认机制

确保消息被送达,提供了两种方式:发布确认和事务。两者不可同时使用,在 channel 为事务时,不可引入确认模式;同样 channel 为确认模式下,不可使用事务。

发布确认

有两种方式:消息发送成功确认和消息发送失败回调。

生产者 ProducerController 中增加接口

  @GetMapping("/sendMessagePC")
  public String sendMessagePC(String exchange, String routingKey, String message, Integer times) throws InterruptedException, ExecutionException, TimeoutException {
    if (times == null) {
      times = 1;
    }
    //消息发送成功确认
    rabbitTemplate.setConfirmCallback((correlationData, b, s) -> {
      System.out.println("correlationData: " + correlationData);
      System.out.println("ack: " + b);
      System.out.println("cause: " + s);
    });
    //消息发送失败回调
    rabbitTemplate.setReturnsCallback(returned -> {
      System.out.println("returnedMessage: " + returned);
    });
    int realTimes = 0;
    for (int i = 0; i < times; i++) {
      CorrelationData correlationData = new CorrelationData("Message " + i);
      rabbitTemplate.convertAndSend(exchange, routingKey, message + i, correlationData);
      CorrelationData.Confirm confirm = correlationData.getFuture().get(10, TimeUnit.SECONDS);
      System.out.println("Confirm received, ack = " + confirm.isAck());
      realTimes++;
    }
    return "总共发送 " + realTimes + " 条消息。";
  }

生产者 application.yml 配置文件中增加配置后如下

server:
  port: 8081
spring:
  rabbitmq:
    host: 192.168.2.100
    port: 5672
    username: dev
    password: 123456
    virtual-host: /dev
    # 设置发布确认类型
    publisher-confirm-type: correlated
    # 开启发送失败回调
    publisher-returns: true
    template:
      # 需配置为 true,否则消息丢失
      mandatory: true

发送请求:

http://127.0.0.1:8081/sendMessagePC?exchange=testTopic&routingKey=test.halo&message=HelloWorld!&times=1

正常发送时,打印日志如下,可发现一切正常(调用成功确认,不调用失败回调)。

Confirm received, ack = true
correlationData: CorrelationData [id=Message 0]
ack: true
cause: null

http://127.0.0.1:8081/sendMessagePC?exchange=testTopic1&routingKey=test.halo&message=HelloWorld!&times=1

发送消息到一个不存在的交换机时,日志如下,发送失败,并且原因是 no exchange 'testTopic1' in vhost '/dev'(调用成功确认,不调用失败回调)。

Confirm received, ack = false
correlationData: CorrelationData [id=Message 0]
ack: false
cause: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange 'testTopic1' in vhost '/dev', class-id=60, method-id=40)


http://127.0.0.1:8081/sendMessagePC?exchange=testTopic&routingKey=test2.halo&message=HelloWorld!&times=1

发送消息到一个不存在的路由中时,日志如下,发送成功(调用成功确认,并且调用失败回调)

Confirm received, ack = true
returnedMessage: ReturnedMessage [message=(Body:'HelloWorld!0' MessageProperties [headers={spring_returned_message_correlation=Message 0}, contentType=text/plain, contentEncoding=UTF-8, contentLength=0, receivedDeliveryMode=PERSISTENT, priority=0, deliveryTag=0]), replyCode=312, replyText=NO_ROUTE, exchange=testTopic, routingKey=test2.halo]
correlationData: CorrelationData [id=Message 0]
ack: true
cause: null

总结:

  1. 不管消息是否成功发送,成功确认回调函数都会调用;
  2. 成功发送的定义是消息是否正确发送给 RabbitMQ,如果没有队列的路由能和消息匹配上而丢失该消息,这种情况也称为发送成功;
  3. 只有发送的消息因没有对应的队列接收而丢弃时,才会调用失败回调。

事务支持

场景:业务处理伴随消息的发送,业务处理失败(事务回滚)后要求消息不发送。RabbitMQ 使用调用者的外部事务,通常是首选,因为它是非侵入性的(低耦合)。

在生产者 ProducerController 中增加接口

  @GetMapping("/sendMessageTransacted")
  @Transactional
  public String sendMessageTransacted(String exchange, String routingKey, String message) {
    rabbitTemplate.setChannelTransacted(true);
    //路由键与队列同名
    rabbitTemplate.convertAndSend(exchange, routingKey, message + "--01");
    //模拟业务处理失败
    System.out.println(1 / 0);
    rabbitTemplate.convertAndSend(exchange, routingKey, message + "--02");
    return "success";
  }

生产者 application.yml 配置文件中取消发布确认配置后如下

server:
  port: 8081
spring:
  rabbitmq:
    host: 192.168.2.100
    port: 5672
    username: dev
    password: 123456
    virtual-host: /dev
    # 设置发布确认类型
    # publisher-confirm-type: correlated
    # 开启发送失败回调
    # publisher-returns: true
    # template:
      # 需配置为 true,否则消息丢失
      # mandatory: true

注入 RabbitMQ 事务管理器

  @Bean
  public RabbitTransactionManager rabbitTransactionManager(ConnectionFactory connectionFactory) {
    return new RabbitTransactionManager(connectionFactory);
  }

发起请求:

http://127.0.0.1:8081/sendMessageTransacted?exchange=testTopic&routingKey=test.halo&message=HelloWorld!

可以发现,一条消息也没有发送出去(也可以将 1 / 0 行注释掉,看看是不是发送出去两条消息;或者将 @Transactional 注解行注释,看看是不是发送出去一条消息)。

消息追踪

消息中心的消息追踪需要使用 Trace 实现,Trace 是 RabbitMQ 用于记录每一次发送的消息,方便使用 RabbitMQ 的开发者调试、排错。可通过插件形式提供可视化界面。Trace 启动后会自动创建系统 Exchange:amq.rabbitmq.trace,每个队列会自动绑定该 Exchange,绑定后发送到队列的消息都会记录到 Trace 日志。

消息追踪启用与查看命令

先启用插件,才能使用。

相关命令:

  • rabbitmq-plugins list:查看插件列表

  • rabbitmq-plugins enable rabbitmq_tracing:启用 trace 插件

  • rabbitmqctl trace_on:打开 trace

  • rabbitmqctl trace_on -p /dev:打开 trace 的开关(/dev为需要日志追踪的vhost)

  • rabbitmqctl trace_off:关闭 trace

  • rabbitmq-plugins disable rabbitmq_tracing:关闭 trace 插件

  • rabbitmqctl set_user_tags dev administrator:配置 dev 用户为 administrator 角色(只有 administrator 的角色才能查看日志界面)

日志追踪启用插件并打开 trace_on 之后,会发现多了一个 Topic 交换机:amq.rabbitmq.trace。

创建新的 Trace,步骤如上图。

发起请求:

http://127.0.0.1:8081/sendMessage?exchange=testTopic&routingKey=test.halo&message=HelloWorld!&times=1

点击 dev-tracing.log 弹出页面,内容经格式化后各截取一条记录,内容如下:

  • 生产日志

    { "channel": 1, "connection": "192.168.2.1:10738 -> 192.168.2.100:5672", "exchange": "testTopic", "node": "rabbit@docker100", "payload": "SGVsbG9Xb3JsZCEw", "properties": { "content_encoding": "UTF-8", "content_type": "text/plain", "delivery_mode": 2, "headers": {

    },
    "priority": 0
    

    }, "queue": "none", "routed_queues": [ "testQueue", "testTTL", "testTtlDlx1", "testTtlDlx2" ], "routing_keys": [ "test.halo" ], "timestamp": "2020-12-31 6:23:49:122", "type": "published", "user": "dev", "vhost": "/dev" }

  • 消费日志

    { "channel": 1, "connection": "192.168.2.1:10728 -> 192.168.2.100:5672", "exchange": "testTopic", "node": "rabbit@docker100", "payload": "SGVsbG9Xb3JsZCEw", "properties": { "content_encoding": "UTF-8", "content_type": "text/plain", "delivery_mode": 2, "headers": {

    },
    "priority": 0
    

    }, "queue": "testQueue", "routed_queues": "none", "routing_keys": [ "test.halo" ], "timestamp": "2020-12-31 6:23:49:123", "type": "received", "user": "dev", "vhost": "/dev" }

其中 payload 就是经过 Base64 加密后的消息内容

消息堆积

当生产速度大于消费速度时,会产生消息堆积。

堆积产生的影响:

  1. 新消息无法入队列
  2. 旧消息无法丢弃
  3. 消息等待消费时间过长,超出了业务容忍范围

堆积产生场景:

  1. 生产者突然发布大量消息
  2. 消费者挂了
  3. 消费者性能不足
  4. 消费者消费失败

堆积对应解决办法:

  1. 消息队列有削峰作用,只要不超过业务容忍范围,就没有大问题,只需要排查生产者是否正常。如果超出了,那么采用下面的办法

  2. 部署多个消费者

  3. 排查消费者的消费性能瓶颈;消费者多线程处理,配置 concurrency 和 prefetch

  4. 排查消费失败原因

消息丢失


消息在生产者丢失

生产者发送成功,但是 MQ 没有收到该消息,消息在从生产者传输到 MQ 的过程中丢失,一般是由于网络不稳定的原因。

解决方案:采用 RabbitMQ 发布确认机制,当消息成功被 MQ 接收到时,会给生产者发送一个确认消息,表示接收成功。RabbitMQ 发送方消息确认模式有以下三种:普通确认模式,批量确认模式,异步监听确认模式。Spring 整合 RabbitMQ 后只使用了异步监听确认模式,但可以通过阻塞的方式实现同步。

CorrelationData.Confirm confirm = correlationData.getFuture().get(10, TimeUnit.SECONDS);

getFuture() 会使线程阻塞,get(10, TimeUnit.SECONDS) 表示 10 秒内返回,否则抛出异常。

消息在 RabbitMQ 丢失

消息成功发送到 MQ,消息还没被消费却在 MQ 中丢失,比如 MQ 服务器宕机或者重启会出现这种情况。

解决方案:持久化交换机,队列,消息,确保 MQ 服务器重启时依然能从磁盘恢复对应的交换机,队列和消息。 Spring 整合后默认开启了交换机,队列,消息的持久化,所以不修改任何设置就可以保证消息不在 RabbitMQ 丢失。

消息在消费者丢失

消息费者消费消息时,如果设置为自动回复 MQ,消息者收到消息后会自动回复 MQ 服务器,MQ 则会删除该条消息,如果消息已经在 MQ 被删除但是消费者的业务处理出现异常或者消费者服务宕机,那么就会导致该消息没有处理成功从而导致该条消息丢失。

解决方案:设置为手动回复 MQ 服务器,当消费者出现异常或者服务宕机时,MQ 服务器不会删除该消息,而是会把消息重发给绑定该队列的消费者,如果该队列只绑定了一个消费者,那么该消息会一直保存在 MQ 服务器,直到消息者能正常消费为止。

有序消费消息


多个消费者消费一个队列

当 RabbitMQ 采用 Work Queue 模式,此时只会有一个 Queue 但是会有多个 Consumer,同时多个 Consumer 直接是竞争关系,此时就会出现 MQ 消息乱序的问题。

解决方案:使用主题模式,绑定多个队列,生产者根据某个字段计算哈希值并与队列个数取余,然后将消息发送至对应队列。即可使某一类消息进入到同一个队列,进而被同一个消费者消费。

消费者采用多线程

当 RabbitMQ 采用简单队列模式的时候,如果消费者采用多线程的方式来加速消息的处理,此时也会出现消息乱序的问题。

解决方案:消费者获取消息后,根据某个字段计算哈希值并与内存队列个数取余,然后将消息发送至对应内存队列,让同一个线程去处理。即可保证有序性。

重复消费


为了防止消息在消费者端丢失,会采用手动回复 MQ 的方式来解决,同时也引出了一个问题,消费者处理消息成功,手动回复 MQ 时由于网络不稳定,连接断开,导致 MQ 没有收到消费者回复的消息,那么该条消息还会保存在 MQ 的消息队列,由于 MQ 的消息重发机制,会重新把该条消息发给和该队列绑定的消息者处理,这样就会导致消息重复消费。而有些操作是不允许重复消费的,比如下单,减库存,扣款等操作。

解决方案:如果消费消息的业务是幂等性操作,就算重复消费也没问题,可以不做处理。如果不支持幂等性操作,如:下单,减库存,扣款等,那么可以在消费者端每次消费成功后将该条消息 id 保存到数据库,每次消费前查询该消息 id,如果该条消息 id 已经存在那么表示已经消费过就不再消费否则就消费。可以采用 Redis 存储消息 id,使用 setnx 命令存储消息 id。

setnx(key,value):如果 key 不存在则插入成功且返回 1;如果 key 存在,则不进行任何操作,返回 0