RabbitMQ 高可用之如何確保消息成功消費

@[toc] 前面一篇文章松哥和大家聊了 MQ 高可用之如何確保消息成功發送,各種配置齊上陣,最終確保了消息的成功發送,甚至在一些極端情況下還可能發生同一條消息重複發送的情況,不管怎麼樣,消息總算髮送出去了,如果小夥伴們還沒看過上篇文章,建議先看看,再來學習本文:

今天我們就來聊一聊消息消費的問題,看看如何確保消息消費成功,並且確保冪等性。

1. 兩種消費思路

RabbitMQ 的消息消費,整體上來說有兩種不同的思路:

  • 推(push):MQ 主動將消息推送給消費者,這種方式需要消費者設置一個緩衝區去緩存消息,對於消費者而言,內存中總是有一堆需要處理的消息,所以這種方式的效率比較高,這也是目前大多數應用採用的消費方式。
  • 拉(pull):消費者主動從 MQ 拉取消息,這種方式效率並不是很高,不過有的時候如果服務端需要批量拉取消息,倒是可以採用這種方式。

兩種方式我都舉個例子看下。

先來看推(push):

這種方式大家比較常見,就是通過 @RabbitListener 註解去標記消費者,如下:

@Component
public class ConsumerDemo {
    @RabbitListener(queues = RabbitConfig.JAVABOY_QUEUE_NAME)
    public void handle(String msg) {
        System.out.println("msg = " + msg);
    }
}

當監聽的隊列中有消息時,就會觸發該方法。

再來看拉(pull):

@Test
public void test01() throws UnsupportedEncodingException {
    Object o = rabbitTemplate.receiveAndConvert(RabbitConfig.JAVABOY_QUEUE_NAME);
    System.out.println("o = " + new String(((byte[]) o),"UTF-8"));
}

調用 receiveAndConvert 方法,方法參數爲隊列名稱,方法執行完成後,會從 MQ 上拉取一條消息下來,如果該方法返回值爲 null,表示該隊列上沒有消息了。receiveAndConvert 方法有一個重載方法,可以在重載方法中傳入一個等待超時時間,例如 3 秒。此時,假設隊列中沒有消息了,則 receiveAndConvert 方法會阻塞 3 秒,3 秒內如果隊列中有了新消息就返回,3 秒後如果隊列中還是沒有新消息,就返回 null,這個等待超時時間要是不設置的話,默認爲 0。

這是消息兩種不同的消費模式。

如果需要從消息隊列中持續獲得消息,就可以使用推模式;如果只是單純的消費一條消息,則使用拉模式即可。切忌將拉模式放到一個死循環中,變相的訂閱消息,這會嚴重影響 RabbitMQ 的性能。

2. 確保消費成功兩種思路

上篇文章中,我們想盡辦法確保消息能夠發送成功,對於消息消費成功,其實官方提供了相關的機制,我們一起來看下。

爲了保證消息能夠可靠的到達消息消費者,RabbitMQ 中提供了消息消費確認機制。當消費者去消費消息的時候,可以通過指定 autoAck 參數來表示消息消費的確認方式。

  • 當 autoAck 爲 false 的時候,此時即使消費者已經收到消息了,RabbitMQ 也不會立馬將消息移除,而是等待消費者顯式的回覆確認信號後,纔會將消息打上刪除標記,然後再刪除。
  • 當 autoAck 爲 true 的時候,此時消息消費者就會自動把發送出去的消息設置爲確認,然後將消息移除(從內存或者磁盤中),即使這些消息並沒有到達消費者。

我們來看一張圖:

如上圖所示,在 RabbitMQ 的 web 管理頁面:

  • Ready 表示待消費的消息數量。
  • Unacked 表示已經發送給消費者但是還沒收到消費者 ack 的消息數量。

這是我們可以從 UI 層面觀察消息的消費情況確認情況。

當我們將 autoAck 設置爲 false 的時候,對於 RabbitMQ 而言,消費分成了兩個部分:

  • 待消費的消息
  • 已經投遞給消費者,但是還沒有被消費者確認的消息

換句話說,當設置 autoAck 爲 false 的時候,消費者就變得非常從容了,它將有足夠的時間去處理這條消息,當消息正常處理完成後,再手動 ack,此時 RabbitMQ 纔會認爲這條消息消費成功了。如果 RabbitMQ 一直沒有收到客戶端的反饋,並且此時客戶端也已經斷開連接了,那麼 RabbitMQ 就會將剛剛的消息重新放回隊列中,等待下一次被消費。

綜上所述,確保消息被成功消費,無非就是手動 Ack 或者自動 Ack,無他。當然,無論這兩種中的哪一種,最終都有可能導致消息被重複消費,所以一般來說我們還需要在處理消息時,解決冪等性問題。

3. 消息拒絕

當客戶端收到消息時,可以選擇消費這條消息,也可以選擇拒絕這條消息。我們來看下拒絕的方式:

@Component
public class ConsumerDemo {
    @RabbitListener(queues = RabbitConfig.JAVABOY_QUEUE_NAME)
    public void handle(Channel channel, Message message) {
        //獲取消息編號
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        try {
            //拒絕消息
            channel.basicReject(deliveryTag, true);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

消費者收到消息之後,可以選擇拒絕消費該條消息,拒絕的步驟分兩步:

  1. 獲取消息編號 deliveryTag。
  2. 調用 basicReject 方法拒絕消息。

調用 basicReject 方法時,第二個參數是 requeue,即是否重新入隊。如果第二個參數爲 true,則這條被拒絕的消息會重新進入到消息隊列中,等待下一次被消費;如果第二個參數爲 false,則這條被拒絕的消息就會被丟掉,不會有新的消費者去消費它了。

需要注意的是,basicReject 方法一次只能拒絕一條消息。

4. 消息確認

消息確認分爲自動確認和手動確認,我們分別來看。

4.1 自動確認

先來看看自動確認,在 Spring Boot 中,默認情況下,消息消費就是自動確認的。

我們來看如下一個消息消費方法:

@Component
public class ConsumerDemo {
    @RabbitListener(queues = RabbitConfig.JAVABOY_QUEUE_NAME)
    public void handle2(String msg) {
        System.out.println("msg = " + msg);
        int i = 1 / 0;
    }
}

通過 @Componet 註解將當前類注入到 Spring 容器中,然後通過 @RabbitListener 註解來標記一個消息消費方法,默認情況下,消息消費方法自帶事務,即如果該方法在執行過程中拋出異常,那麼被消費的消息會重新回到隊列中等待下一次被消費,如果該方法正常執行完沒有拋出異常,則這條消息就算是被消費了。

4.2 手動確認

手動確認我又把它分爲兩種:推模式手動確認與拉模式手動確認。

4.2.1 推模式手動確認

要開啓手動確認,需要我們首先關閉自動確認,關閉方式如下:

spring.rabbitmq.listener.simple.acknowledge-mode=manual

這個配置表示將消息的確認模式改爲手動確認。

接下來我們來看下消費者中的代碼:

@RabbitListener(queues = RabbitConfig.JAVABOY_QUEUE_NAME)
public void handle3(Message message,Channel channel) {
    long deliveryTag = message.getMessageProperties().getDeliveryTag();
    try {
        //消息消費的代碼寫到這裏
        String s = new String(message.getBody());
        System.out.println("s = " + s);
        //消費完成後,手動 ack
        channel.basicAck(deliveryTag, false);
    } catch (Exception e) {
        //手動 nack
        try {
            channel.basicNack(deliveryTag, false, true);
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

將消費者要做的事情放到一個 try..catch 代碼塊中。

如果消息正常消費成功,則執行 basicAck 完成確認。

如果消息消費失敗,則執行 basicNack 方法,告訴 RabbitMQ 消息消費失敗。

這裏涉及到兩個方法:

  • basicAck:這個是手動確認消息已經成功消費,該方法有兩個參數:第一個參數表示消息的 id;第二個參數 multiple 如果爲 false,表示僅確認當前消息消費成功,如果爲 true,則表示當前消息之前所有未被當前消費者確認的消息都消費成功。
  • basicNack:這個是告訴 RabbitMQ 當前消息未被成功消費,該方法有三個參數:第一個參數表示消息的 id;第二個參數 multiple 如果爲 false,表示僅拒絕當前消息的消費,如果爲 true,則表示拒絕當前消息之前所有未被當前消費者確認的消息;第三個參數 requeue 含義和前面所說的一樣,被拒絕的消息是否重新入隊。

當 basicNack 中最後一個參數設置爲 false 的時候,還涉及到一個死信隊列的問題,這個松哥以後再專門寫文章和大家細聊。

4.2.2 拉模式手動確認

拉模式手動 ack 比較麻煩一些,在 Spring 中封裝的 RabbitTemplate 中並未找到對應的方法,所以我們得用原生的辦法,如下:

public void receive2() {
    Channel channel = rabbitTemplate.getConnectionFactory().createConnection().createChannel(true);
    long deliveryTag = 0L;
    try {
        GetResponse getResponse = channel.basicGet(RabbitConfig.JAVABOY_QUEUE_NAME, false);
        deliveryTag = getResponse.getEnvelope().getDeliveryTag();
        System.out.println("o = " + new String((getResponse.getBody()), "UTF-8"));
        channel.basicAck(deliveryTag, false);
    } catch (IOException e) {
        try {
            channel.basicNack(deliveryTag, false, true);
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

這裏涉及到的 basicAck 和 basicNack 方法跟前面的一樣,我就不再贅述。

5. 冪等性問題

最後我們再來說說消息的冪等性問題。

大家設想下面一個場景:

> 消費者在消費完一條消息後,向 RabbitMQ 發送一個 ack 確認,此時由於網絡斷開或者其他原因導致 RabbitMQ 並沒有收到這個 ack,那麼此時 RabbitMQ 並不會將該條消息刪除,當重新建立起連接後,消費者還是會再次收到該條消息,這就造成了消息的重複消費。同時,由於類似的原因,消息在發送的時候,同一條消息也可能會發送兩次(參見四種策略確保 RabbitMQ 消息發送可靠性!你用哪種?)。種種原因導致我們在消費消息時,一定要處理好冪等性問題。

冪等性問題的處理倒也不難,基本上都是從業務上來處理,我來大概說說思路。

採用 Redis,在消費者消費消息之前,現將消息的 id 放到 Redis 中,存儲方式如下:

  • id-0(正在執行業務)
  • id-1(執行業務成功)

如果 ack 失敗,在 RabbitMQ 將消息交給其他的消費者時,先執行 setnx,如果 key 已經存在(說明之前有人消費過該消息),獲取他的值,如果是 0,當前消費者就什麼都不做,如果是 1,直接 ack。

極端情況:第一個消費者在執行業務時,出現了死鎖,在 setnx 的基礎上,再給 key 設置一個生存時間。生產者,發送消息時,指定 messageId。

當然這只是一個簡單思路供大家參考。

松哥在 vhr 項目中也處理了消息冪等性問題,感興趣的小夥伴可以查看 vhr 源碼(https://github.com/lenve/vhr),代碼在 mailserver 中。

6. 小結

好啦,今天就和小夥伴們聊了下 RabbitMQ 中和消息消費相關的幾個話題,感興趣的小夥伴可以實踐下哦~

複製文章標題並在公衆號後臺回覆,可以下載本文案例~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章