RocketMQ之順序消費:Demo及實現原理分析

目錄

全局順序消費

局部順序消費

Demo

源碼分析

順序消息重試機制


場景分析

順序消費:是指消息的產生順序和消費順序相同,按照FIFO先進先出的原則嚴格保持一致。

假設有個下單場景,每個階段需要發郵件通知用戶訂單狀態變化。用戶付款完成時系統給用戶發送訂單已付款郵件,訂單已發貨時給用戶發送訂單已發貨郵件,訂單完成時給用戶發送訂單已完成郵件。

發送郵件的操作爲了不阻塞訂單主流程,可以通過mq消息來解耦,下游郵件服務器收到mq消息後發送具體郵件,已付款郵件、已發貨郵件、訂單已完成郵件這三個消息,下游的郵件服務器需要順序消費這3個消息並且順序發送郵件纔有意義。否則就會出現已發貨郵件先發出,已付款郵件後發出的情況。

但是mq消費者往往是集羣部署,一個消費組內存在多個消費者,同一個消費者內部,也可能存在多個消費線程並行消費,如何在消費者集羣環境中,如何保證郵件mq消息發送與消費的順序性呢?

順序消費又分兩種,全局順序消費和局部順序消費

 

全局順序消費

什麼是全局順序消費?所有發到mq的消息都被順序消費,類似數據庫中的binlog,需要嚴格保證全局操作的順序性

那麼RocketMQ中如何做才能保證全局順序消費呢?

這就需要設置topic下讀寫隊列數量爲1,即quene隊列只能爲同一個;

爲什麼要設置讀寫隊列數量爲1呢?
假設讀寫隊列有多個,消息就會存儲在多個隊列中,消費者負載時可能會分配到多個消費隊列同時進行消費,多隊列併發消費時,無法保證消息消費順序性

那麼全局順序消費有必要麼?
A、B都下了單,B用戶訂單的郵件先發送,A的後發送,不行麼?其實,大多數場景下,mq下只需要保證局部消息順序即可,即A的付款消息先於A的發貨消息即可,A的消息和B的消息可以打亂,這樣系統的吞吐量會更好,將隊列數量置爲1,極大的降低了系統的吞吐量,不符合mq的設計初衷

舉個例子來說明局部順序消費。

假設訂單A的消息爲A1,A2,A3,發送順序也如此。訂單B的消息爲B1,B2,B3,A訂單消息先發送,B訂單消息後發送

消費順序如下
A1,A2,A3,B1,B2,B3是全局順序消息,嚴重降低了系統的併發度
A1,B1,A2,A3,B2,B3是局部順序消息,可以被接受
A2,B1,A1,B2,A3,B3不可接收,因爲A2出現在了A1的前面

 

局部順序消費

那麼在RocketMQ裏局部順序消息又是如何怎麼實現的呢?

要保證消息的順序消費,有三個關鍵點

  • 消息順序發送
  • 消息順序存儲
  • 消息順序消費

第一點,消息順序發送,多線程發送的消息無法保證有序性,因此,需要業務方在發送時,針對同一個業務編號(如同一筆訂單)的消息需要保證在一個線程內順序發送,在上一個消息發送成功後,在進行下一個消息的發送。對應到mq中,消息發送方法就得使用同步發送,異步發送無法保證順序性

第二點,消息順序存儲,mq的topic下會存在多個queue,要保證消息的順序存儲,同一個業務編號的消息需要被髮送到一個queue中。對應到mq中,需要使用MessageQueueSelector來選擇要發送的queue,即對業務編號進行hash,然後根據隊列數量對hash值取餘,將消息發送到一個queue中

第三點,消息順序消費,要保證消息順序消費,同一個queue就只能被一個消費者所消費,因此對broker中消費隊列加鎖是無法避免的。同一時刻,一個消費隊列只能被一個消費者消費,消費者內部,也只能有一個消費線程來消費該隊列。即,同一時刻,一個消費隊列只能被一個消費者中的一個線程消費

上面第一、第二點中提到,要保證消息順序發送和消息順序存儲需要使用mq的同步發送和MessageQueueSelector來保證,具體Demo會有體現

至於第三點中的加鎖操作會結合源碼來具體分析

 

Demo

producer中模擬了兩個線程,併發順序發送100個消息的情況,發送的消息中,key爲消息發送編號i,消息body爲orderId,大家注意下MessageQueueSelector的使用;

consumer的demo有兩個,第一個爲正常集羣消費的consumer,另外一個是順序消費的consumer,從結果中觀察消息消費順序;

理想情況下消息順序消費的結果應該是,同一個orderId下的消息的編號i值應該順序遞增,但是不同orderId之間的消費可以並行,即局部有序即可;

 

Producer Demo

public class Producer {
    public static void main(String[] args)  {
        try {
            MQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
            ((DefaultMQProducer) producer).setNamesrvAddr("111.231.110.149:9876");
            producer.start();
            
            //順序發送100條編號爲0到99的,orderId爲1 的消息
            new Thread(() -> {
                Integer orderId = 1;
                sendMessage(producer, orderId);
            }).start();
            //順序發送100條編號爲0到99的,orderId爲2 的消息
            new Thread(() -> {
                Integer orderId = 2;
                sendMessage(producer, orderId);
            }).start();
            //sleep 30秒讓消息都發送成功再關閉
            Thread.sleep(1000*30);

            producer.shutdown();
        } catch (MQClientException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private static void sendMessage(MQProducer producer, Integer orderId) {
        for (int i = 0; i < 100; i++) {
            try {
                Message msg =
                        new Message("TopicTestjjj", "TagA", i + "",
                                (orderId + "").getBytes(RemotingHelper.DEFAULT_CHARSET));
                SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
                    @Override
                    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                        Integer id = (Integer) arg;
                        int index = id % mqs.size();
                        return mqs.get(index);
                    }
                }, orderId);
                System.out.println("message send,orderId:"+orderId);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}


Normal Consumer Demo
模擬了一個消費者中多線程並行消費消息的情況,使用的消費監聽器爲MessageListenerConcurrently

public class Consumer {

    public static void main(String[] args) throws InterruptedException, MQClientException {

        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4");

        consumer.setNamesrvAddr("111.231.110.149:9876");

        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

        consumer.subscribe("TopicTestjjj", "*");
        //單個消費者中多線程並行消費
        consumer.setConsumeThreadMin(3);
        consumer.setConsumeThreadMin(6);

        consumer.registerMessageListener(new MessageListenerConcurrently() {

            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
                ConsumeConcurrentlyContext context) {
                for (MessageExt msg : msgs) {
//                    System.out.println("收到消息," + new String(msg.getBody()));
                    System.out.println("queueId:"+msg.getQueueId()+",orderId:"+new String(msg.getBody())+",i:"+msg.getKeys());
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });

        consumer.start();

        System.out.printf("Consumer Started.%n");
    }
}


看下結果輸出,如圖,同一個orderId下,編號爲10的消息先於編號爲9的消息被消費,不是正確的順序消費,即普通的並行消息消費,無法保證消息消費的順序性

Order Consumer Demo
順序消費的消費者例子如下,使用的監聽器是MessageListenerOrderly

public class Consumer {

    public static void main(String[] args) throws MQClientException {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
        consumer.setNamesrvAddr("111.231.110.149:9876");

        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

        consumer.subscribe("TopicTestjjj", "TagA");

        //消費者並行消費
        consumer.setConsumeThreadMin(3);
        consumer.setConsumeThreadMin(6);

        consumer.registerMessageListener(new MessageListenerOrderly() {
            @Override
            public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
//                context.setAutoCommit(false);
                for (MessageExt msg : msgs) {
                    System.out.println("queueId:"+msg.getQueueId()+",orderId:"+new String(msg.getBody())+",i:"+msg.getKeys());
                }
                return ConsumeOrderlyStatus.SUCCESS;
            }
        });

        consumer.start();
        System.out.printf("Consumer Started.%n");
    }

}


結果如下,同一個orderId下,消息順序消費,不同orderId並行消費,符合預期


源碼分析

在源碼分析之前,先來思考下幾個問題

前面已經提到實現消息順序消費的關鍵點有三個,其中前兩點已經明確瞭解決思路

  • 第一點,消息順序順序發送,可以由業務方在單線程使用同步發送消息的方式來保證
  • 第二點,消息順序存儲,可以由業務方將同一個業務編號的消息發送到一個隊列中來實現
  • 還剩下第三點,消息順序消費,實現消息順序消費的關鍵點又是什麼呢?

舉個例子,假設業務方針對某個訂單發送了N個順序消息,這N個消息都發送到了mq服務端的一個隊列中,假設消費者集羣中有3個消費者,每個消費者中又是開了N個線程多線程消費

第一種情形,假設3個消費者同時拉取一個隊列的消息進行消費,結果會怎麼樣?N個消息可能會分配在3個消費者中進行消費,多機並行的情況下,消費能力的不同,無法保證這N個消息被順序消費,所以得保證一個消費隊列同一個時刻只能被一個消費者消費

假設又已經保證了一個隊列同一個時刻只能被一個消費者消費,那就能保證順序消費了?同一個消費者多線程進行消費,同樣會使得的N個消費被分配到N個線程中,一樣無法保證消息順序消費,所以還得保證一個隊列同一個時刻只能被一個消費者中一個線程消費

下面順序消息的源碼分析中就針對這兩點來進行分析,即

  1. 如何保證一個隊列只被一個消費者消費
  2. 如何保證一個消費者中只有一個線程能進行消費

()一)鎖定MessageQueue(添加隊列鎖,鎖定隊列)

先看第一個問題,如何保證一個隊列只被一個消費者消費。

消費隊列存在於broker端,如果想保證一個隊列被一個消費者消費,那麼消費者在進行消息拉取消費時就必須想mq服務器申請隊列鎖,消費者申請隊列鎖的代碼存在於RebalanceService消息隊列負載的實現代碼中

先明確一點,同一個消費組中的消費者共同承擔topic下所有消費者隊列的消費,因此每個消費者需要定時重新負載並分配其對應的消費隊列,具體爲消費者分配消費隊列的代碼實現在RebalanceImpl#rebalanceByTopic中,本文不多講

客戶端實現
消費者重新負載,並且分配完消費隊列後,需要向mq服務器發起消息拉取請求,代碼實現在RebalanceImpl#updateProcessQueueTableInRebalance中,針對順序消息的消息拉取,mq做了如下判斷


核心思想就是,消費客戶端先向broker端發起對messageQueue的加鎖請求,只有加鎖成功時才創建pullRequest進行消息拉取,下面看下lock加鎖請求方法

代碼實現邏輯比較清晰,就是調用lockBatchMQ方法發送了一個加鎖請求,那麼broker端收到加鎖請求後的處理邏輯又是怎麼樣?

broker端實現
broker端收到加鎖請求的處理邏輯在RebalanceLockManager#tryLockBatch方法中,RebalanceLockManager中關鍵屬性如下

//默認鎖過期時間 60秒
    private final static long REBALANCE_LOCK_MAX_LIVE_TIME = Long.parseLong(System.getProperty(
        "rocketmq.broker.rebalance.lockMaxLiveTime", "60000"));
 //重入鎖
    private final Lock lock = new ReentrantLock();
 //key爲消費者組名稱,value是一個key爲MessageQueue,value爲LockEntry的map
    private final ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable =
        new ConcurrentHashMap<String, ConcurrentHashMap<MessageQueue, LockEntry>>(1024);


LockEntry對象中關鍵屬性如下

//消費者id
private String clientId;
//最後加鎖時間
private volatile long lastUpdateTimestamp = System.currentTimeMillis();


isLocked方法如下

public boolean isLocked(final String clientId) {
            boolean eq = this.clientId.equals(clientId);
            return eq && !this.isExpired();
        }

        public boolean isExpired() {
            boolean expired =
                (System.currentTimeMillis() - this.lastUpdateTimestamp) > REBALANCE_LOCK_MAX_LIVE_TIME;

            return expired;
        }

對messageQueue進行加鎖的關鍵邏輯如下:

如果messageQueue對應的lockEntry爲空,標誌隊列未加鎖,返回加鎖成功;

如果lockEntry對應clientId爲自己並且沒過期,標誌同一個客戶端重複加鎖,返回加鎖成功(可重入);

如果鎖已經過期,返回加鎖成功;

總而言之,broker端通過對ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable的維護來達到messageQueue加鎖的目的,使得同一時刻,一個messageQueue只能被一個消費者消費

(二)synchronized申請線程獨佔鎖(添加線程鎖)

假設消費者對messageQueue的加鎖已經成功,那麼就進入到了第二個步驟,創建pullRequest進行消息拉取,消息拉取部分的代碼實現在PullMessageService中,消息拉取完後,需要提交到ConsumeMessageService中進行消費,順序消費的實現爲ConsumeMessageOrderlyService,提交消息進行消費的方法爲ConsumeMessageOrderlyService#submitConsumeRequest,具體實現如下

可以看到,構建了一個ConsumeRequest對象,並提交給了ThreadPoolExecutor來並行消費,看下順序消費的ConsumeRequest的run方法實現


裏面先從messageQueueLock中獲取了messageQueue對應的一個鎖對象,看下messageQueueLock的實現


其中維護了一個ConcurrentMap<MessageQueue, Object> mqLockTable,使得一個messageQueue對應一個鎖對象object

獲取到鎖對象後,使用synchronized嘗試申請線程級獨佔鎖

  1. 如果加鎖成功,同一時刻只有一個線程進行消息消費;
  2. 如果加鎖失敗,會延遲100ms重新嘗試向broker端申請鎖定messageQueue,鎖定成功後重新提交消費請求;

至此,第三個關鍵點的解決思路也清晰了,基本上就兩個步驟

  1. 創建消息拉取任務時,消息客戶端向broker端申請鎖定MessageQueue,使得一個MessageQueue同一個時刻只能被一個消費客戶端消費
  2. 消息消費時,多線程針對同一個消息隊列的消費先嚐試使用synchronized申請獨佔鎖,加鎖成功才能進行消費,使得一個MessageQueue同一個時刻只能被一個消費客戶端中一個線程消費

 

順序消息重試機制

在使用順序消息時,一定要注意其異常情況的出現,對於順序消息,當消費者消費消息失敗後,消息隊列 RocketMQ 版會自動不斷地進行消息重試(每次間隔時間爲 1 秒),重試最大值是Integer.MAX_VALUE.這時,應用會出現消息消費被阻塞的情況。因此,建議您使用順序消息時,務必保證應用能夠及時監控並處理消費失敗的情況,避免阻塞現象的發生

重要的事再強調一次:在使用順序消息時,一定要注意其異常情況的出現!

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