RabbitMQ 常用知識點總結

萌新J發表於2021-07-12

基礎

為什麼使用 MQ?

1、削峰:在某個模組接收到超過最大承受的併發量時,可以通過 MQ 排隊來使這些削減同一時刻處理的訊息量。減小併發量。

2、解耦:在傳送 MQ 處理業務時,可以使業務程式碼與當前的程式碼解耦,便於維護和擴充。

3、非同步:非同步使得在呼叫 MQ 後可以去處理其他操作,在 MQ 執行完後會自動反饋結果。

 

MQ缺點

1、複雜性提高,引入了其他問題。如訊息丟失、重複消費、訊息順序執行等。這些解決方案下面會說到。

2、當機後不可用。可以建立叢集來解決。

 

幾種 MQ 實現總結

ActiveMQ:老牌的 MQ,可靠性高,但支援的併發量低,目前維護也比較少。適用於併發量低的專案。

Kafka:支援超高併發場景,但是訊息可靠性較差(消費失敗後不支援重試)。適用於產生大量資料的資料收集服務。

RocketMQ:支援超高併發場景,可靠性高。但支援的客戶端語言不多。適用於高併發的網際網路專案。

RabbitMQ:支援一般的高併發場景(萬級),可靠性高。支援客戶端語言較多。但是其實現是通過 Erlang 語言,不方便學習。適用於中小型專案。

 

完整架構圖

Publisher:生產者,生產訊息的元件。

Exchange:交換機,對生產者傳來的訊息進行解析並傳給佇列,交換機型別分為 fanout、direct、Topic、headers,其中headers交換機是根據訊息物件的 headers 屬性值進行匹配的,效能較差,一般不使用。

Queue:佇列,因為其是 FIFO 結構,所以訊息會按先進先出的順序被髮送給消費者消費。

Binding:交換機與佇列的繫結關係,生產者在傳送訊息時會攜帶一個 RoutingKey ,在訊息到達交換機後,交換機會根據 RoutingKey 匹配對應 BindingKey 的佇列,然後把訊息傳送給該佇列。

Virtual Host:又稱為 vhost,相當於一個資料夾,包含一個或多個 Exchange 與 Queue 的組合。

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

Consumer:消費者,專門消費訊息的元件。

Connection:佇列與消費者之間的元件,在由佇列向消費者傳送訊息時,需要先建立連線,建立連線物件。

Channel:通道。訊息由佇列傳送直消費者時,用於傳輸的通道物件。

四大核心概念指的是生產者、交換機、佇列、消費者。

 

RabbitMQ 六種工作模式

1、Simple 簡單模式

不配置交換機,生產者直接傳送給佇列(實際使用了預設的交換機),消費者監聽佇列,佇列與消費者是1對1的關係。

 

2、work 工作模式

和簡單模式差不多,同樣是不配置交換機,不同的是工作模式多個消費者監聽一個佇列。

公平分發:在工作模式中,預設情況下多個消費者會依次接收並消費佇列中的訊息。

不公平分發:在工作模式中,可以在消費者端獲取訊息時將 channel 的引數 basicQos 設為1(預設0),那麼就會在訊息分發時優先選擇空閒的消費者分發。如果不存在空閒佇列,那麼還是按公平分發。

 

預取值:可以看作是規定的消費者等待消費佇列內部期望的佇列長度。比如消費 C1 是 2,C2 是 3,那麼開始的訊息會先分配給 C1,直到 C1 中等待訊息的訊息佇列長度為2時,下一個訊息才會分配給 C2,然後C2也積累了3個訊息後,繼續C1、C2輪流分配。預期值預設為0,所以預設情況就是消費者輪流被分配訊息。

配置方式也是設定消費者端的 channel 物件的 basicQos 引數。

 

3、publish/subscribe 釋出訂閱模式

交換機是 fanout 型別。交換機會將接收的訊息傳送給所有與其繫結的佇列。

 

4、routing 路由模式

交換機是 direct 型別。交換機會根據接收訊息的 RoutingKey 尋找匹配的 BindingKey,然後傳送給對應的佇列。BindingKey 是和 RoutingKey 完全匹配的,一對一關係。

 

5、topic 主題模式

交換機是 topic 型別。交換機會根據接收訊息的 RoutingKey 尋找匹配的 BindingKey,與 routing 模式不同的是,topic 模式訊息攜帶的 BindingKey 可以是一個萬用字元。交換機會匹配與萬用字元匹配的 BindingKey 對應的佇列。* 表示任意一個單次,# 表示0個或多個單次。如果 RoutingKey 不包括萬用字元,那麼就相當於路由模式,如果 RoutingKey 是 #,那麼就相當於釋出訂閱模式。

 

6、RPC模式

RPC,也就是遠端呼叫, RabbitMQ 的 RPC 模式可以實現 RPC 的非同步呼叫。客戶端既是傳送者也是消費者,在請求傳送給佇列 rpc_queue 後,伺服器會監聽這個佇列,獲取後處理,處理完成將返回資料訊息發給佇列 reply_to,而客戶端也會監聽這個佇列,最終實現得到結果資料。

 

高階

死信佇列

死信佇列,是指訊息在變成死信訊息後會被髮給與其繫結好的死信交換機,然後重新被死信交換機傳送至新的佇列,最後被消費者消費。而訊息在變成死信訊息的過程消耗的時間就成為了延期時間,所以常常用於實現延時佇列。

死信來源

1、訊息TTL過期。

2、佇列內等待消費的訊息達到最大長度(預設佇列無長度限制)

3、訊息在消費者被拒絕(Nack 或 reject),且不重新加入佇列

 

延時佇列實現

1、為訊息設定過期時間

這種方式就是在生產者傳送訊息時指定訊息的過期時間,等到訊息在死信佇列中過期後會被髮送給死信交換機。

配置佇列:

傳送方:

2、直接為佇列設定訊息的過期時間,進入佇列的訊息則會到達設定的過期時間後自動變成死信訊息。

兩種方式的比較:

1、為訊息設定過期時間會有一個缺陷,因為佇列是先進先出結構,所以如果為訊息設定過期時間,那麼先進的訊息一定會先被執行,後面的一定會先等到前面的訊息執行完成後才被執行,如果前面的訊息過期時間長於後面的,那麼後面的訊息即使到達過期時間後也不會被執行,必須等到前面的訊息傳送完才能執行。所以只適用於傳送的延時訊息按過期時間遞增順序的場景。

2、直接為佇列設定過期時間,因為是進入佇列的訊息都會被分配相同的過期時間,所以不會產生上面的問題,所以也存在弊端。如果需要配置多個過期時間,那麼每次都需要重新宣告一個死信交換機、死信佇列以及繫結關係。這樣會造成配置臃腫。所以只適用於配置過期時間種類數較少的場景。

3、可以看出這兩種方式都存在不足之處,有沒有一種完美的方案呢?在 1 中,可以將訊息按過期時間傳送放在交換機裡執行。因為交換機並不存在順序執行,所以就避免了 1 的問題。

實現:

配置案例程式碼:

@Configuration
public class DelayedQueueConfig {
    public static final String DELAYED_QUEUE_NAME = "delayed.queue";
    public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";
    public static final String DELAYED_ROUTING_KEY = "delayed.routingkey";
    @Bean
    public Queue delayedQueue() {
        return new Queue(DELAYED_QUEUE_NAME);
    }
    //自定義交換機 我們在這裡定義的是一個延遲交換機
    @Bean
    public CustomExchange delayedExchange() { 
        Map<String, Object> args = new HashMap<>();
        //自定義交換機的型別
        args.put("x-delayed-type", "direct");
        return new CustomExchange(DELAYED_EXCHANGE_NAME, "x-delayed-message", true, false,args);
    }
    @Bean
    public Binding bindingDelayedQueue(@Qualifier("delayedQueue") Queue queue,
                                       @Qualifier("delayedExchange") CustomExchange
                                               delayedExchange) {
        return BindingBuilder.bind(queue).to(delayedExchange).with(DELAYED_ROUTING_KEY).noargs();
    } 
}

生產者案例程式碼:

public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";
    public static final String DELAYED_ROUTING_KEY = "delayed.routingkey";
    public RabbitTemplate rabbitTemplate;
    @GetMapping("sendDelayMsg/{message}/{delayTime}")
    public void sendMsg(@PathVariable String message,@PathVariable Integer delayTime) {
        rabbitTemplate.convertAndSend(DELAYED_EXCHANGE_NAME, DELAYED_ROUTING_KEY, message,
                correlationData ->{
                    correlationData.getMessageProperties().setDelay(delayTime);
                    return correlationData;
                });
        log.info(" 當 前 時 間 : {}, 發 送 一 條 延 遲 {} 毫秒的資訊給佇列 delayed.queue:{}", new Date(),delayTime, message);
    }

消費者案例程式碼:

public static final String DELAYED_QUEUE_NAME = "delayed.queue";
    @RabbitListener(queues = DELAYED_QUEUE_NAME)
    public void receiveDelayedQueue(Message message) {
        String msg = new String(message.getBody());
        log.info("當前時間:{},收到延時佇列的訊息:{}", new Date().toString(), msg);
    }

 

訊息可靠性

使用 RabbitMQ 來進行部分業務的執行,尤其是一些重要的業務,如果訊息在 MQ 中丟失,就會對整個系統造成比較嚴重的影響。保證訊息可靠性主要分為保證各元件的持久化以及避免訊息的丟失。

1、元件持久化

保證交換機、佇列、訊息的持久化。針對於交換機、佇列,在宣告時就可以將交換機、佇列宣告為持久化的。

而訊息的持久化,需要在已開啟交換機、佇列的持久化後,在傳送訊息時將訊息的 BasicProperties 引數的 deliveryMode 設為 2,就可以實現持久化

AMQP.BasicProperties.Builder builder = new AMQP.BasicProperties.Builder();
builder.deliveryMode(2);
AMQP.BasicProperties properties = builder.build();
channel.basicPublish("", QUEUE_NAME, properties, msg.getBytes());

而在 SpringBoot 封裝好的 RabbitTemplate 的 convertAndSend 中,預設就已經將 deliveryMode 設為了2。

 

2、生產者到交換機(訊息是否到達交換機都會觸發,回撥方法引數會返回是否成功的 boolean值)

配置:

1)配置檔案開啟配置 spring.rabbitmq.publisher-confirm-type=correlated(老版本是spring.rabbitmq.publisher-confirms=true)。

⚫ NONE

禁用釋出確認模式,是預設值

⚫ CORRELATED

釋出訊息成功到交換器後會觸發回撥方法

⚫ SIMPLE

經測試有兩種效果,其一效果和 CORRELATED 值一樣會觸發回撥方法,其二在釋出訊息成功後使用 rabbitTemplate 呼叫 waitForConfirms 或 waitForConfirmsOrDie 方法等待 broker 節點返回傳送結果,根據返回結果來判定下一步的邏輯,要注意的點是waitForConfirmsOrDie 方法如果返回 false 則會關閉 channel,則接下來無法傳送訊息到 broker

2)配置回撥介面並加入到容器

@Component
@Slf4j
public class MyCallBack implements RabbitTemplate.ConfirmCallback {
    /**
     * 交換機不管是否收到訊息的一個回撥方法
     * CorrelationData
     * 訊息相關資料
     * ack
     * 交換機是否收到訊息
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String id=correlationData!=null?correlationData.getId():"";
        if(ack){
            log.info("交換機已經收到 id 為:{}的訊息",id);
        }else{
            log.info("交換機還未收到 id 為:{}訊息,由於原因:{}",id,cause);
        } 
    } 
}

3)配置生產者(在生產者傳送時定義的 CorrelationData 物件可以在回撥介面中獲取到,如果沒有定義回撥介面接收的就是空物件)。以及將回撥介面註冊到 rabbitTemplate 物件中

@RestController
@RequestMapping("/confirm")
@Slf4j
public class Producer {
    public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
    @Autowired
    private RabbitTemplate rabbitTemplate;
    @Autowired
    private MyCallBack myCallBack;
    //依賴注入 rabbitTemplate 之後再設定它的回撥物件
    @PostConstruct
    public void init(){
        rabbitTemplate.setConfirmCallback(myCallBack);
    }
    
    @GetMapping("sendMessage/{message}")
    public void sendMessage(@PathVariable String message){
        //指定訊息 id 為 1
        CorrelationData correlationData1=new CorrelationData("1");
        String routingKey="key1";
        rabbitTemplate.convertAndSend(CONFIRM_EXCHANGE_NAME,routingKey,message+routingKey,correlationData1);
        CorrelationData correlationData2=new CorrelationData("2");
        routingKey="key2";
        rabbitTemplate.convertAndSend(CONFIRM_EXCHANGE_NAME,routingKey,message+routingKey,correlationData2);
        log.info("傳送訊息內容:{}",message);
    }
}

 

3、交換機到佇列(訊息未找到匹配佇列觸發)

如果訊息傳到交換機後,沒有找到對應的佇列,那麼這個訊息預設會丟失,而如果配置了 Mandatory 引數可以在訊息在交換機丟失時觸發回撥方法。

配置

1)開啟配置

#開啟回撥函式
spring.rabbitmq.publisher-returns=true
#是否在交換機沒有匹配合適的佇列後返回給生產者,false表示丟棄
spring.rabbitmq.template.mandatory=true

2)實現 ReturnCallback 介面,定義回撥介面並加入容器,這裡就在上一個中增加的元件類上實現

@Component
@Slf4j
public class MyCallBack implements RabbitTemplate.ConfirmCallback,RabbitTemplate.ReturnCallback {
    /**
     * 交換機不管是否收到訊息的一個回撥方法
     * CorrelationData
     * 訊息相關資料
     * ack
     * 交換機是否收到訊息
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String id=correlationData!=null?correlationData.getId():"";
        if(ack){
            log.info("交換機已經收到 id 為:{}的訊息",id);
        }else{
            log.info("交換機還未收到 id 為:{}訊息,由於原因:{}",id,cause);
        } 
    }
    //當訊息無法路由的時候的回撥方法
    @Override
    public void returnedMessage(Message message, int replyCode, String replyText, String
            exchange, String routingKey) {
        log.error(" 消 息 {}, 被 交 換 機 {} 退 回 , 退 回 原 因 :{}, 路 由 key:{}",new String(message.getBody()),exchange,replyText,routingKey);
    } 
}

3)將這個元件配置到 rabbitTemplate 物件中。

 

4、佇列到消費者(手動確認)

預設情況下,訊息傳送到消費者後會立刻返回給佇列一個確認標識,顯示簽收。而如果消費者在確認標識返回成功後,執行業務到一半時發生異常,那麼這條訊息就沒有執行完,所以需要關閉自動確認,等到業務執行完畢後才進行手動的確認。在SpringBoot 對 RabbitMQ 封裝的依賴中,提供了佇列的補償機制,如果佇列在一段時間沒有收到消費者的確認訊息,那麼就會重新傳送訊息。

手動確認又分為三種方式,單個確認、批量確認和非同步確認。

單個確認:

批量確認:方式和單個確認一樣,因為 waitForConfirms 方法作用的就是當前訊息以及之前的所有未確認訊息。

非同步確認:通過新增回撥介面來在執行完畢失敗後自動返回結果。

  

而在 SpringBoot 的繼承中,單個確認與批量確認都是使用 channel的 basicAck 方法。

使用配置:

1)在配置檔案中將手動確認開啟

2)在業務最後新增手動確認的程式碼。

multiple表示簽收是否批量,也就是是否包括前面未簽收的訊息。deleveryTag 是一個自增的訊息唯一標識

此外,如果發生異常,可以取消這次確認,並選擇是否重新加入佇列。拒絕確認有兩種方式。一種Nack,一種是 Reject。區別是 Nack 會將當前訊息之前的所有未確認的訊息也取消確認,而 Reject 只針對於當前訊息。(未確認/取消確認的訊息會被標記為 unacked 狀態,即使當機也不會丟失,發出的訊息如果沒有接收到返回資訊每隔一段時間會重新傳送一次)。

 

5、開啟RabbitMQ 的事務(生產者端到MQ,不推薦)

channel.txSelect()宣告啟動事務模式;

channel.txComment()提交事務;

channel.txRollback()回滾事務。

try {
  channel.txSelect();
  channel.basicPublish(exchange,routingKey,MessageProperties.PERSISTENT_TEXT_PLAIN,msg.getBytes());
  channel.txCommit();
} catch (Exception e) {
  e.printStackTrace();
  channel.txRollback();
}

使用事務可以有效生產者端傳送訊息的可靠性,但是其不適用於多執行緒執行的場景,多個執行緒執行效率會很低。所以一般不推薦。

 

訊息補償機制

1、SpringBoot 封裝的補償機制

在 SpringBoot 為 RabbitMQ 封裝的依賴中,提供一種補償機制,如果發出的訊息在一段時間內沒有響應(簽收或者拒絕),那麼該訊息就會進行重發。預設情況下會隔5秒一直進行重發,直到消費者響應。

我們可以通過自定義配置引數來修改預設的補償機制

spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true       # 自動觸發補償機制
          max-attempts: 5     # 補償機制嘗試次數
          max-interval: 10000   # 重試最大間隔時間
          initial-interval: 2000  # 重試初始間隔時間
          multiplier: 2 # 間隔時間乘子,間隔時間*乘子=下一次的間隔時間,最大不能超過設定的最大間隔時間

 

2、自定義補償機制。對於封裝的補償機制存在一些不足,因為其是無差別補償,也就是隻要消費者沒有響應就會重發,但是對於一些異常導致沒有響應即使發幾次都會導致沒有響應(如資料計算異常,資料型別轉換異常),這樣的補償機制就會消耗 CPU 資源。所以對於這些異常可以捕獲然後直接處理。對於其他異常(如呼叫第三方介面失敗)則可以進行補償重試。

 

對於MQ 整個模組的補償機制,可以參考下面的架構圖

各步驟:

1、發生業務操作,業務資料寫入資料庫

2、生產者將訊息傳送給MQ的佇列Q1

3、傳送了一條與step2中一摸一樣的延遲訊息到對了Q3

4、消費者監聽Q1,獲取到了step2中傳送的業務訊息

5、消費者在收到生產者的業務訊息後,傳送了一條確認訊息(記錄收到的訊息資訊)到Q2

6、回撥檢查服務監聽了Q2,獲取到了消費者傳送的確認訊息

7、回撥檢查服務將這條確認訊息寫入資料庫等待之後的比對

8、Q3中的延遲訊息延遲時間已到,被回撥檢查服務接收到,之後就拿著這條延遲訊息在資料庫中比對,如果比對成功,證明消費者接收到了生產者的業務訊息並處理成功(如果不處理成功誰會傻了吧唧傳送確認訊息呢);如果比對失敗,證明消費者沒有接收到生產者的業務訊息,或者說消費者接收到了業務訊息之後始終沒有處理成功相關的業務併傳送確認訊息。這時回撥檢查服務就會呼叫生產者的相關業務介面,讓生產者再次傳送這條失敗的訊息

9、有一種最極端的情況,step2和step3的訊息都傳送失敗了或者說在訊息傳遞過程中發生意外丟失了!定時檢查服務會一直輪詢儲存確認訊息的資料庫中的訊息資料,並於生產者的業務資料庫中的業務資料進行比對,如果兩者比對數量一致,則代表業務執行沒有問題;如果比對不一致,確認訊息資料庫的資料量小於生產者業務資料量的話,就證明消費者沒有接收到生產者傳送的訊息。這時定時檢查服務會通知生產者再次傳送訊息到MQ的佇列Q1

 

訊息冪等性

由於訊息補償機制的存在,可以更加有效保證訊息可以被消費,但是帶來的問題是可能某個訊息執行的比較久,導致同一條訊息再次被髮送給了消費者,而前一條訊息順利執行完,這樣一條訊息就會被多次執行,所以消費者端的方法需要涉及成冪等性,也就是對於一條訊息,無論被消費者消費幾次,效果都是一樣的。實現方案主要有兩種。

1、唯一ID+指紋碼。

唯一ID指的是使用 UUID、或者運算元據的主鍵,而指紋碼是與業務相關的ID,比如雪花演算法就是根據當前時間戳生成的,生成的ID就屬於指紋碼。

在生產者傳送時建立 Messgae 物件,將業務資料以及唯一ID+指紋碼儲存到Meaasge物件中進行傳送

Message message = MessageBuilder.withBody(msg.getBytes()).setContentType(MessageProperties.CONTENT_TYPE_JSON)
                .setContentEncoding("utf-8").setMessageId(UUID.randomUUID() + "").build();
amqpTemplate.convertAndSend(queueName, message);

然後再消費者端接收,獲取ID,在消費者消費最後以其為主鍵新增到mysql中,在業務開始時檢查是否存在,不存在繼續執行。

缺點是高併發場景下會受到效能瓶頸限制。可以通過分庫分表解決。

2、redis 操作。

在消費者方法開始使用 redis 的 setnx 方法來處理判斷資料可以一步到位,是實現冪等性的最佳方案。

 

訊息順序執行

如果多個消費者監聽同一個佇列,那麼預設下訊息會依次順序分配給消費者。

上面提到預取值概念,通過配置消費者端的 channel 的 basicQos 引數來修改,但是這會收到消費者執行快慢、生產者傳送訊息到佇列的順序等因素影響,所以並不可靠。

所以實現訊息順序執行的方式就是增加佇列,拆分消費者,使每個消費者只監聽一個佇列。

 

訊息積壓

如果發現佇列中積壓了很多訊息沒有處理,那麼該如何解決。

1、對於積壓的訊息,首先需要先檢查對應的消費者端,解決其執行慢導致阻塞的問題後,增加臨時佇列和消費者來處理積壓的訊息,等到恢復後再將 MQ 改成原來架構。

2、對於設定了 TTL 的訊息,在其因訊息積壓過期丟失後,在 MQ 空閒時將過期丟失的訊息進行重發。

 

備用交換機

在上面說到過,在訊息發給交換機後,如果交換機沒有找到匹配的佇列,那麼這個訊息預設會丟失,可以配置訊息在交換機上沒有匹配到佇列後的回撥訊息,以及將此條訊息重新發回生產者。但是也可以配置一個備用交換機,在沒有匹配到佇列後發給備用交換機。

配置案例:

在同時配置備用交換機、returnCallBack 回撥介面下,如果訊息沒有匹配到對應的訊息,那麼會優先採用備用交換機。

 

 

 

參考部落格:

RabbitMQ應用問題——訊息補償機制以及冪等性的保證簡單介紹

RabbitMQ重試機制

相關文章