三天吃透RabbitMQ面試八股文

程式設計師大彬發表於2023-03-16

本文已經收錄到Github倉庫,該倉庫包含計算機基礎、Java基礎、多執行緒、JVM、資料庫、Redis、Spring、Mybatis、SpringMVC、SpringBoot、分散式、微服務、設計模式、架構、校招社招分享等核心知識點,歡迎star~

Github地址:https://github.com/Tyson0314/Java-learning


什麼是RabbitMQ?

RabbitMQ是一個由erlang開發的訊息佇列。訊息佇列用於應用間的非同步協作。

RabbitMQ的元件

Message:由訊息頭和訊息體組成。訊息體是不透明的,而訊息頭則由一系列的可選屬性組成,這些屬性包括routing-key、priority、delivery-mode(是否永續性儲存)等。

Publisher:訊息的生產者。

Exchange:接收訊息並將訊息路由到一個或多個Queue。default exchange 是預設的直連交換機,名字為空字串,每個新建佇列都會自動繫結到預設交換機上,繫結的路由鍵名稱與佇列名稱相同。

Binding:透過Binding將Exchange和Queue關聯,這樣Exchange就知道將訊息路由到哪個Queue中。

Queue:儲存訊息,佇列的特性是先進先出。一個訊息可分發到一個或多個佇列。

Virtual host:每個 vhost 本質上就是一個 mini 版的 RabbitMQ 伺服器,擁有自己的佇列、交換器、繫結和許可權機制。vhost 是 AMQP 概念的基礎,必須在連線時指定,RabbitMQ 預設的 vhost 是 / 。當多個不同的使用者使用同一個RabbitMQ server提供的服務時,可以劃分出多個vhost,每個使用者在自己的vhost建立exchange和queue。

Broker:訊息佇列伺服器實體。

什麼時候使用MQ

對於一些不需要立即生效的操作,可以拆分出來,非同步執行,使用訊息佇列實現。

以常見的訂單系統為例,使用者點選下單按鈕之後的業務邏輯可能包括:扣減庫存、生成相應單據、發簡訊通知。這種場景下就可以用 MQ 。將簡訊通知放到 MQ 非同步執行,在下單的主流程(比如扣減庫存、生成相應單據)完成之後傳送一條訊息到 MQ, 讓主流程快速完結,而由另外的執行緒消費MQ的訊息。

RabbitMQ的優缺點

缺點:使用erlang實現,不利於二次開發和維護;效能較kafka差,持久化訊息和ACK確認的情況下生產和消費訊息單機吞吐量大約在1-2萬左右,kafka單機吞吐量在十萬級別。

優點:有管理介面,方便使用;可靠性高;功能豐富,支援訊息持久化、訊息確認機制、多種訊息分發機制。

RabbitMQ 有哪些重要的角色?

RabbitMQ 中重要的角色有:生產者、消費者和代理。

  1. 生產者:訊息的建立者,負責建立和推送資料到訊息伺服器;
  2. 消費者:訊息的接收方,用於處理資料和確認訊息;
  3. 代理:就是 RabbitMQ 本身,用於扮演“快遞”的角色,本身不生產訊息,只是扮演“快遞”的角色。

Exchange 型別

Exchange分發訊息時根據型別的不同分發策略不同,目前共四種型別:direct、fanout、topic、headers 。headers 模式根據訊息的headers進行路由,此外 headers 交換器和 direct 交換器完全一致,但效能差很多。

Exchange規則。

型別名稱型別描述
fanout把所有傳送到該Exchange的訊息路由到所有與它繫結的Queue中
directRouting Key==Binding Key
topic模糊匹配
headersExchange不依賴於routing key與binding key的匹配規則來路由訊息,而是根據傳送的訊息內容中的header屬性進行匹配。

direct

direct交換機會將訊息路由到binding key 和 routing key完全匹配的佇列中。它是完全匹配、單播的模式。

fanout

所有發到 fanout 型別交換機的訊息都會路由到所有與該交換機繫結的佇列上去。fanout 型別轉發訊息是最快的。

topic

topic交換機使用routing key和binding key進行模糊匹配,匹配成功則將訊息傳送到相應的佇列。routing key和binding key都是句點號“. ”分隔的字串,binding key中可以存在兩種特殊字元“*”與“##”,用於做模糊匹配,其中“*”用於匹配一個單詞,“##”用於匹配多個單詞。

headers

headers交換機是根據傳送的訊息內容中的headers屬性進行路由的。在繫結Queue與Exchange時指定一組鍵值對;當訊息傳送到Exchange時,RabbitMQ會取到該訊息的headers(也是一個鍵值對的形式),對比其中的鍵值對是否完全匹配Queue與Exchange繫結時指定的鍵值對;如果完全匹配則訊息會路由到該Queue,否則不會路由到該Queue。

訊息丟失

訊息丟失場景:生產者生產訊息到RabbitMQ Server訊息丟失、RabbitMQ Server儲存的訊息丟失和RabbitMQ Server到消費者訊息丟失。

訊息丟失從三個方面來解決:生產者確認機制、消費者手動確認訊息和持久化。

生產者確認機制

生產者傳送訊息到佇列,無法確保傳送的訊息成功的到達server。

解決方法:

  1. 事務機制。在一條訊息傳送之後會使傳送端阻塞,等待RabbitMQ的回應,之後才能繼續傳送下一條訊息。效能差。
  2. 開啟生產者確認機制,只要訊息成功傳送到交換機之後,RabbitMQ就會傳送一個ack給生產者(即使訊息沒有Queue接收,也會傳送ack)。如果訊息沒有成功傳送到交換機,就會傳送一條nack訊息,提示傳送失敗。

在 Springboot 是透過 publisher-confirms 引數來設定 confirm 模式:

spring:
    rabbitmq:   
        ##開啟 confirm 確認機制
        publisher-confirms: true

在生產端提供一個回撥方法,當服務端確認了一條或者多條訊息後,生產者會回撥這個方法,根據具體的結果對訊息進行後續處理,比如重新傳送、記錄日誌等。

// 訊息是否成功傳送到Exchange
final RabbitTemplate.ConfirmCallback confirmCallback = (CorrelationData correlationData, boolean ack, String cause) -> {
            log.info("correlationData: " + correlationData);
            log.info("ack: " + ack);
            if(!ack) {
                log.info("異常處理....");
            }
    };

rabbitTemplate.setConfirmCallback(confirmCallback);

路由不可達訊息

生產者確認機制只確保訊息正確到達交換機,對於從交換機路由到Queue失敗的訊息,會被丟棄掉,導致訊息丟失。

對於不可路由的訊息,有兩種處理方式:Return訊息機制和備份交換機。

Return訊息機制

Return訊息機制提供了回撥函式 ReturnCallback,當訊息從交換機路由到Queue失敗才會回撥這個方法。需要將mandatory 設定為 true ,才能監聽到路由不可達的訊息。

spring:
    rabbitmq:
        ##觸發ReturnCallback必須設定mandatory=true, 否則Exchange沒有找到Queue就會丟棄掉訊息, 而不會觸發ReturnCallback
        template.mandatory: true

透過 ReturnCallback 監聽路由不可達訊息。

    final RabbitTemplate.ReturnCallback returnCallback = (Message message, int replyCode, String replyText, String exchange, String routingKey) ->
            log.info("return exchange: " + exchange + ", routingKey: "
                    + routingKey + ", replyCode: " + replyCode + ", replyText: " + replyText);
rabbitTemplate.setReturnCallback(returnCallback);

當訊息從交換機路由到Queue失敗時,會返回 return exchange: , routingKey: MAIL, replyCode: 312, replyText: NO_ROUTE

備份交換機

備份交換機alternate-exchange 是一個普通的exchange,當你傳送訊息到對應的exchange時,沒有匹配到queue,就會自動轉移到備份交換機對應的queue,這樣訊息就不會丟失。

消費者手動訊息確認

有可能消費者收到訊息還沒來得及處理MQ服務就當機了,導致訊息丟失。因為訊息者預設採用自動ack,一旦消費者收到訊息後會通知MQ Server這條訊息已經處理好了,MQ 就會移除這條訊息。

解決方法:消費者設定為手動確認訊息。消費者處理完邏輯之後再給broker回覆ack,表示訊息已經成功消費,可以從broker中刪除。當訊息者消費失敗的時候,給broker回覆nack,根據配置決定重新入隊還是從broker移除,或者進入死信佇列。只要沒收到消費者的 acknowledgment,broker 就會一直儲存著這條訊息,但不會 requeue,也不會分配給其他 消費者。

消費者設定手動ack:

##設定消費端手動 ack
spring.rabbitmq.listener.simple.acknowledge-mode=manual

訊息處理完,手動確認:

    @RabbitListener(queues = RabbitMqConfig.MAIL_QUEUE)
    public void onMessage(Message message, Channel channel) throws IOException {

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        //手工ack;第二個引數是multiple,設定為true,表示deliveryTag序列號之前(包括自身)的訊息都已經收到,設為false則表示收到一條訊息
        channel.basicAck(deliveryTag, true);
        System.out.println("mail listener receive: " + new String(message.getBody()));
    }

當訊息消費失敗時,消費端給broker回覆nack,如果consumer設定了requeue為false,則nack後broker會刪除訊息或者進入死信佇列,否則訊息會重新入隊。

持久化

如果RabbitMQ服務異常導致重啟,將會導致訊息丟失。RabbitMQ提供了持久化的機制,將記憶體中的訊息持久化到硬碟上,即使重啟RabbitMQ,訊息也不會丟失。

訊息持久化需要滿足以下條件:

  1. 訊息設定持久化。釋出訊息前,設定投遞模式delivery mode為2,表示訊息需要持久化。
  2. Queue設定持久化。
  3. 交換機設定持久化。

當釋出一條訊息到交換機上時,Rabbit會先把訊息寫入持久化日誌,然後才向生產者傳送響應。一旦從佇列中消費了一條訊息的話並且做了確認,RabbitMQ會在持久化日誌中移除這條訊息。在消費訊息前,如果RabbitMQ重啟的話,伺服器會自動重建交換機和佇列,載入持久化日誌中的訊息到相應的佇列或者交換機上,保證訊息不會丟失。

映象佇列

當MQ發生故障時,會導致服務不可用。引入RabbitMQ的映象佇列機制,將queue映象到叢集中其他的節點之上。如果叢集中的一個節點失效了,能自動地切換到映象中的另一個節點以保證服務的可用性。

通常每一個映象佇列都包含一個master和多個slave,分別對應於不同的節點。傳送到映象佇列的所有訊息總是被直接傳送到master和所有的slave之上。除了publish外所有動作都只會向master傳送,然後由master將命令執行的結果廣播給slave,從映象佇列中的消費操作實際上是在master上執行的。

訊息重複消費怎麼處理?

訊息重複的原因有兩個:1.生產時訊息重複,2.消費時訊息重複。

生產者傳送訊息給MQ,在MQ確認的時候出現了網路波動,生產者沒有收到確認,這時候生產者就會重新傳送這條訊息,導致MQ會接收到重複訊息。

消費者消費成功後,給MQ確認的時候出現了網路波動,MQ沒有接收到確認,為了保證訊息不丟失,MQ就會繼續給消費者投遞之前的訊息。這時候消費者就接收到了兩條一樣的訊息。由於重複訊息是由於網路原因造成的,無法避免。

解決方法:傳送訊息時讓每個訊息攜帶一個全域性的唯一ID,在消費訊息時先判斷訊息是否已經被消費過,保證訊息消費邏輯的冪等性。具體消費過程為:

  1. 消費者獲取到訊息後先根據id去查詢redis/db是否存在該訊息
  2. 如果不存在,則正常消費,消費完畢後寫入redis/db
  3. 如果存在,則證明訊息被消費過,直接丟棄

消費端怎麼進行限流?

當 RabbitMQ 伺服器積壓大量訊息時,佇列裡的訊息會大量湧入消費端,可能導致消費端伺服器奔潰。這種情況下需要對消費端限流。

Spring RabbitMQ 提供引數 prefetch 可以設定單個請求處理的訊息個數。如果消費者同時處理的訊息到達最大值的時候,則該消費者會阻塞,不會消費新的訊息,直到有訊息 ack 才會消費新的訊息。

開啟消費端限流:

##在單個請求中處理的訊息個數,unack的最大數量
spring.rabbitmq.listener.simple.prefetch=2

原生 RabbitMQ 還提供 prefetchSize 和 global 兩個引數。Spring RabbitMQ沒有這兩個引數。

//單條訊息大小限制,0代表不限制
//global:限制限流功能是channel級別的還是consumer級別。當設定為false,consumer級別,限流功能生效,設定為true沒有了限流功能,因為channel級別尚未實現。
void basicQos(int prefetchSize, int prefetchCount, boolean global) throws IOException;

什麼是死信佇列?

消費失敗的訊息存放的佇列。

訊息消費失敗的原因:

  • 訊息被拒絕並且訊息沒有重新入隊(requeue=false)
  • 訊息超時未消費
  • 達到最大佇列長度

設定死信佇列的 exchange 和 queue,然後進行繫結:

    @Bean
    public DirectExchange dlxExchange() {
        return new DirectExchange(RabbitMqConfig.DLX_EXCHANGE);
    }

    @Bean
    public Queue dlxQueue() {
        return new Queue(RabbitMqConfig.DLX_QUEUE, true);
    }

    @Bean
    public Binding bindingDeadExchange(Queue dlxQueue, DirectExchange deadExchange) {
        return BindingBuilder.bind(dlxQueue).to(deadExchange).with(RabbitMqConfig.DLX_QUEUE);
    }

在普通佇列加上兩個引數,繫結普通佇列到死信佇列。當訊息消費失敗時,訊息會被路由到死信佇列。

    @Bean
    public Queue sendSmsQueue() {
        Map<String,Object> arguments = new HashMap<>(2);
        // 繫結該佇列到私信交換機
        arguments.put("x-dead-letter-exchange", RabbitMqConfig.DLX_EXCHANGE);
        arguments.put("x-dead-letter-routing-key", RabbitMqConfig.DLX_QUEUE);
        return new Queue(RabbitMqConfig.MAIL_QUEUE, true, false, false, arguments);
    }

生產者完整程式碼:

@Component
@Slf4j
public class MQProducer {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @Autowired
    RandomUtil randomUtil;

    @Autowired
    UserService userService;

    final RabbitTemplate.ConfirmCallback confirmCallback = (CorrelationData correlationData, boolean ack, String cause) -> {
            log.info("correlationData: " + correlationData);
            log.info("ack: " + ack);
            if(!ack) {
                log.info("異常處理....");
            }
    };


    final RabbitTemplate.ReturnCallback returnCallback = (Message message, int replyCode, String replyText, String exchange, String routingKey) ->
            log.info("return exchange: " + exchange + ", routingKey: "
                    + routingKey + ", replyCode: " + replyCode + ", replyText: " + replyText);

    public void sendMail(String mail) {
        //貌似執行緒不安全 範圍100000 - 999999
        Integer random = randomUtil.nextInt(100000, 999999);
        Map<String, String> map = new HashMap<>(2);
        String code = random.toString();
        map.put("mail", mail);
        map.put("code", code);

        MessageProperties mp = new MessageProperties();
        //在生產環境中這裡不用Message,而是使用 fastJson 等工具將物件轉換為 json 格式傳送
        Message msg = new Message("tyson".getBytes(), mp);
        msg.getMessageProperties().setExpiration("3000");
        //如果消費端要設定為手工 ACK ,那麼生產端傳送訊息的時候一定傳送 correlationData ,並且全域性唯一,用以唯一標識訊息。
        CorrelationData correlationData = new CorrelationData("1234567890"+new Date());

        rabbitTemplate.setMandatory(true);
        rabbitTemplate.setConfirmCallback(confirmCallback);
        rabbitTemplate.setReturnCallback(returnCallback);
        rabbitTemplate.convertAndSend(RabbitMqConfig.MAIL_QUEUE, msg, correlationData);

        //存入redis
        userService.updateMailSendState(mail, code, MailConfig.MAIL_STATE_WAIT);
    }
}

消費者完整程式碼:

@Slf4j
@Component
public class DeadListener {

    @RabbitListener(queues = RabbitMqConfig.DLX_QUEUE)
    public void onMessage(Message message, Channel channel) throws IOException {

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        //手工ack
        channel.basicAck(deliveryTag,false);
        System.out.println("receive--1: " + new String(message.getBody()));
    }
}

當普通佇列中有死信時,RabbitMQ 就會自動的將這個訊息重新發布到設定的死信交換機去,然後被路由到死信佇列。可以監聽死信佇列中的訊息做相應的處理。

說說pull模式

pull模式主要是透過channel.basicGet方法來獲取訊息,示例程式碼如下:

GetResponse response = channel.basicGet(QUEUE_NAME, false);
System.out.println(new String(response.getBody()));
channel.basicAck(response.getEnvelope().getDeliveryTag(),false);

怎麼設定訊息的過期時間?

在生產端傳送訊息的時候可以給訊息設定過期時間,單位為毫秒(ms)

Message msg = new Message("tyson".getBytes(), mp);
msg.getMessageProperties().setExpiration("3000");

也可以在建立佇列的時候指定佇列的ttl,從訊息入佇列開始計算,超過該時間的訊息將會被移除。

參考連結

RabbitMQ基礎

Springboot整合RabbitMQ

RabbitMQ之訊息持久化

RabbitMQ傳送郵件程式碼

線上rabbitmq問題


最後給大家分享一個Github倉庫,上面有大彬整理的300多本經典的計算機書籍PDF,包括C語言、C++、Java、Python、前端、資料庫、作業系統、計算機網路、資料結構和演算法、機器學習、程式設計人生等,可以star一下,下次找書直接在上面搜尋,倉庫持續更新中~

Github地址https://github.com/Tyson0314/java-books

相關文章