深入理解RabbitMQ中的prefetch_count引數

throwable發表於2020-10-18

前提

在某一次使用者標籤服務中大量用到非同步流程,使用了RabbitMQ進行解耦。其中,為了提高消費者的處理效率針對了不同節點任務的消費者執行緒數和prefetch_count引數都做了調整和測試,得到一個相對合理的組合。這裡深入分析一下prefetch_count引數在RabbitMQ中的作用。

prefetch_count引數的含義

先從AMQPAdvanced Message Queuing Protocol,即高階訊息佇列協議,RabbitMQ實現了此協議的0-9-1版本的大部分內容)和RabbitMQ的具體實現去理解prefetch_count引數的含義,可以查閱對應的文件(見文末參考資料)。AMQP 0-9-1定義了basic.qos方法去限制消費者基於某一個Channel或者Connection上未進行ack的最大訊息數量上限。basic.qos方法支援兩個引數:

  • global:布林值。
  • prefetch_count:整數。

這兩個引數在AMQP 0-9-1定義中的含義和RabbitMQ具體實現時有所不同,見下表:

global引數值 AMQP 0-9-1prefetch_count引數的含義 RabbitMQprefetch_count引數的含義
false prefetch_count值在當前Channel的所有消費者共享 prefetch_count對於基於當前Channel建立的消費者生效
true prefetch_count值在當前Connection的所有消費者共享 prefetch_count值在當前Channel的所有消費者共享

或者用簡潔的英文表格理解:

global prefetch_count in AMQP 0-9-1 prefetch_count in RabbitMQ
false Per channel limit Per customer limit
true Per connection limit Per channel limit

這裡畫一個圖理解一下:

上圖僅僅為了區分協議本身和RabbitMQ中實現的不同,接著說說prefetch_count對於消費者(執行緒)和待消費訊息的作用。假定一個前提:RabbitMQ客戶端從RabbitMQ服務端獲取到佇列訊息的速度比消費者執行緒消費速度快,目前有兩個消費者執行緒共用一個Channel例項。當global引數為false時候,效果如下:

而當global引數為true時候,效果如下:

在消費者執行緒處理速度遠低於RabbitMQ客戶端從RabbitMQ服務端獲取到佇列訊息的速度的場景下,prefetch_count條未進行ack的訊息會暫時存放在一個佇列(準確來說是阻塞佇列,然後阻塞佇列中的訊息任務會流轉到一個列表中遍歷回撥消費者控制程式碼,見下一節的原始碼分析)中等待被消費者處理。這部分訊息會佔據JVM的堆記憶體,所以在效能調優或者設定應用程式的初始化和最大堆記憶體的時候,如果剛好用到RabbitMQ的消費者,必須要考慮這些"預取訊息"的記憶體佔用量。不過值得注意的是:prefetch_countRabbitMQ服務端的引數,它的設定值或者快照都不會存放在RabbitMQ客戶端。同時需要注意prefetch_count生效的條件和特性(從引數設定的一些demo和原始碼上感知):

  • prefetch_count引數僅僅在basic.consumeautoAck引數設定為false的前提下才生效,也就是不能使用自動確認,自動確認的訊息沒有辦法限流。
  • basic.consume如果在非自動確認模式下忘記了手動呼叫basic.ack,那麼prefetch_count正是未ack訊息數量的最大上限。
  • prefetch_count是由RabbitMQ服務端控制,一般情況下能保證各個消費者執行緒中的未ack訊息分發是均衡的,這點筆者猜測是consumerTag起到了關鍵作用。

RabbitMQ客戶端中prefetch_count原始碼跟蹤

編寫本文的時候引入的RabbitMQ客戶端版本為:com.rabbitmq:amqp-client:5.9.0

上面說了這麼多都只是根據官方的文件或者部落格中的理論依據進行分析,其實更加根本的分析方法是直接閱讀RabbitMQJava客戶端原始碼,主要是針對basic.qosbasic.consume兩個方法,對應的是com.rabbitmq.client.impl.ChannelN#basicQos()com.rabbitmq.client.impl.ChannelN#basicConsume()兩個方法。先看ChannelN#basicQos()

這裡的basicQos()方法多了一個prefetchSize引數,用於限制分發內容的大小上限,預設值0代表無限制,而prefetchCount的取值範圍是[0,65535],取值為0也是代表無限制。這裡的ChannelN#basicQos()實現中直接封裝basic.qos方法引數進行一次RPC呼叫,意味著直接更變RabbitMQ服務端的配置,即時生效,同時引數值完全沒有儲存在客戶端程式碼中,印證了前面一節的結論。接著看ChannelN#basicConsume()方法:

上圖已經把關鍵部分用紅圈圈出,因為整個訊息消費過程是非同步的,涉及太多的類和方法,這裡不全量貼出,整理了一個流程圖:

整個訊息消費過程,prefetch_count引數並未出現在客戶端程式碼中,又再次印證了前面一節的結論,即prefetch_count引數的行為和作用完全由RabbitMQ服務端控制。而最終Customer或者常用的DefaultCustomer控制程式碼是在WorkPoolRunnable中回撥的,這類任務的執行執行緒來自於ConsumerWorkService內部的執行緒池,而這個執行緒池又使用了Executors.newFixedThreadPool()去構建,使用了預設的執行緒工廠類,因此在Customer#handleDelivery()方法內部列印的執行緒名稱的樣子是pool-1-thread-*

這裡VariableLinkedBlockingQueue就是前一節中的message queue的原型

prefetch_count引數使用

設定prefetch_count引數比較簡單,就是呼叫Channel#basicQos()方法:

public class RabbitQos {

    static String QUEUE = "qos.test";

    public static void main(String[] args) throws Exception {
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setHost("localhost");
        connectionFactory.setPort(5672);
        connectionFactory.setUsername("guest");
        connectionFactory.setPassword("guest");
        Connection connection = connectionFactory.newConnection();
        Channel channel = connection.createChannel();
        channel.queueDeclare(QUEUE, true, false, false, null);
        channel.basicQos(2);
        channel.basicConsume("qos.test", false, new DefaultConsumer(channel) {

            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                System.out.println("1------" + Thread.currentThread().getName());
                sleep();
            }
        });
        channel.basicConsume("qos.test", false, new DefaultConsumer(channel) {

            @Override
            public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                System.out.println("2------" + Thread.currentThread().getName());
                sleep();
            }
        });
        for (int i = 0; i < 20; i++) {
            channel.basicPublish("", QUEUE, MessageProperties.TEXT_PLAIN, String.valueOf(i).getBytes());
        }
        sleep();
    }

    private static void sleep() {
        try {
            Thread.sleep(Long.MAX_VALUE);
        } catch (Exception ignore) {

        }
    }
}

上面是原生的amqp-client的寫法,如果使用了spring-amqpspring-boot-starter-amqp),可以通過配置檔案中的spring.rabbitmq.listener.direct.prefetch屬性指定所有消費者執行緒的prefetch_count,如果要針對部分消費者執行緒進行該屬性的設定,則需要針對RabbitListenerContainerFactory進行改造。

prefetch_count引數最佳實踐

關於prefetch_count引數的設定,RabbitMQ官方有一篇文章進行了分析:《Finding bottlenecks with RabbitMQ 3.3》。該文章分析了訊息流控的整個流程,其中提到了prefetch_count引數的一些指標:

這裡指出了,如果prefetch_count的值超過了30,那麼網路頻寬限制開始占主導地位,此時進一步增加prefetch_count的值就會變得收效甚微。也就是說,官方是建議把prefetch_count設定為30。這裡再參看一下spring-boot-starter-amqp中對此引數定義的預設值,具體是AbstractMessageListenerContainer中的DEFAULT_PREFETCH_COUNT

如果沒有通過spring.rabbitmq.listener.direct.prefetch進行覆蓋,那麼使用spring-boot-starter-amqp中的註解定義的消費者執行緒中設定的prefetch_count就是250

筆者認為,應該綜合頻寬、每條訊息的資料包大小、消費者執行緒處理的速率等等角度去考慮prefetch_count的設定。總結如下(個人經驗僅供參考):

  • 當消費者執行緒的處理速度十分慢,而佇列的訊息量十分少的場景下,可以考慮把prefetch_count設定為1
  • 當佇列中的每條訊息的資料包十分大的時候,要計算好客戶端可以容納的未ack總訊息量的記憶體極限,從而設計一個合理的prefetch_count值。
  • 當消費者執行緒的處理速度十分快,遠遠大於RabbitMQ服務端的訊息分發,在網路頻寬充足的前提下,設定可以把prefetch_count值設定為0,不做任何的訊息流控。
  • 一般場景下,建議使用RabbitMQ官方的建議值30或者spring-boot-starter-amqp中的預設值250

小結

小結一下:

  • prefetch_countRabbitMQ服務端的引數,設定後即時生效。
  • prefetch_count對於AMQP-0-9-1中的定義與RabbitMQ中的實現不完全相同。
  • prefetch_count值設定建議使用框架提供的預設值或者通過分組實驗結合資料包大小進行計算和評估出一個合理值。

彩蛋

筆者把文章釋出到公眾號和朋友圈後,筆者的師傅作了點評,指出其中的一點不足:

確實如此,prefetch_count的本質作用就是消費者的流控,官方的那篇文章也提到了網路和頻寬的重要性,所以要考慮RTTRound-Trip Time,往返時延),這裡的RTT概念來源於《計算機網路原理》:

The RTT includes packet-propagation delays, packet-queuing delays and packet -processing delay.

也就是說RTT = 資料包傳播時延(往返)+ 資料包排隊時延(路由器和交換機的)+ 資料處理時延(應用程式處理耗時,用在本文的場景就是消費者處理訊息的耗時)。假設RTT中只計算網路的時延,不包含資料處理的時延,那麼資料包往返需要2RTT,也就是一條消費訊息處理的資料包的往返,RTT越大,那麼資料傳輸成本越高,應該允許客戶端"預取"更多的未ack訊息避免消費者執行緒等待。這樣就可以計算出單個消費者執行緒處理達到最飽和狀態下的"預取"訊息量:prefetch_count = 2RTT / 消費者執行緒處理單條訊息的耗時。依照此概念舉例:

  • RTT30ms,而消費者執行緒處理單條訊息的耗時為10ms,此時,消費速率佔優勢,可以考慮把prefetch_count設定為6或者更大的值(考慮堆記憶體極限的限制)。
  • RTT30ms,而消費者執行緒處理單條訊息的耗時為200msRTT佔優勢,消費速率滯後,此時考慮把prefetch_count設定為1即可。

思考:為什麼spring-boot-starter-amqp把prefetch_count預設值設定為250這麼高的值,很少開發者改動它卻沒有出現明顯問題?

(本文完 c-4-d e-a-20201017)

相關文章