metaq雜記

_吹雪_發表於2018-10-09

Name Server:維護broker的地址列表,以及topic和topic對應的佇列的地址列表。每個broker與每個Name Server之間使用長連線來保持心跳,並向其定時註冊topic資訊。可以從兩個維度來理解Name Server的能力: 1)Name Server可以提供一個特定的topic對應的broker地址列表;2)Name Server可以提供一臺broker上包含的所有topic列表。輕量級的名稱服務。幾乎無狀態的節點,相互之間不會有資料同步。 主要提供topic路由資訊的註冊及查詢。(MetaQ 1.x和MetaQ 2.x是依賴ZooKeeper的,由於ZooKeeper功能過重,RMetaQ 3.x去掉了對ZooKeeper依賴,採用自己的NameServer)

Producer和叢集中某一個Name Server間採用長連線,Producer定期從Name Server中獲取到Topic對應的broker地址列表,即Topic路由資訊,並快取在本地,然後選擇一臺合適的master broker釋出訊息。 注意producer釋出訊息是將訊息釋出到與對應的master broker上,再由master broker同步到slave broker上。

Consumer和叢集中某一個Name Server間採用長連線,並定期從Name Server中獲取到Topic路由資訊,然後選擇合適的broker拉取訊息進行消費。

在 Metaq2.x 之前版本,佇列也稱為“分割槽”,兩者描述的是一個概念。 但是按照 2.x 的實現,使用佇列描 述更合適。

資料儲存分為兩級,物理佇列+邏輯佇列。
物理佇列:一個broker只有一個物理佇列,所有發到broker的資料都會順序寫入該佇列,當一個檔案被寫滿時(預設為1G),會新建檔案繼續寫入。
邏輯佇列:當consumer消費資料時,consumer先根據nameServer提供的路由資訊定位到broker,再從broker的消費佇列讀取index,從而定位到物理佇列的位置。一個topic有多個分割槽,每個分割槽對應一個消費佇列,而消費佇列由index組成。

commitlog訊息檔案:broker在接收到生產者傳送來的訊息後,是如何對其進行儲存的呢?在MetaQ中,真正的訊息本身實際上是存放在broker本地一個名為commitlog的檔案中的,並且這個commitlog的寫入是不區分Topic的,即不論什麼Topic的訊息,都會在接收到之後順序寫入commitlog檔案中,commitlog的檔名就是起始位元組位置 寫滿後,產生一個新的檔案。

索引檔案:讀取的時候又是怎麼從commitlog中找到訊息的呢?的確,僅僅只儲存訊息本身是無法做到這個的(因為在僅有commitlog檔案的前提下,訊息的長度、型別等資訊都是無法確定的),所以MetaQ還有索引檔案(在一些文件中也稱為Message Queue)。broker將訊息儲存到commitlog檔案後,會將該訊息在檔案的物理位置(offset),訊息大小,訊息型別等資訊封裝成一個固定大小的資料結構,稱為索引單元。其中,offset是java long型,有64位,從理論上講,offset在100年內都不會發生溢位,所以可以認為message queue長度無限。從而簡單地,可以把message queue理解為是一個長度無限的陣列,offset就是下標。多個索引單元組成一個索引檔案,和commitlog檔案一樣,檔名是起始位元組位置,寫滿後,產生一個新的檔案。

broker 將訊息儲存到檔案後,會將該訊息在檔案的物理位置,訊息大小,訊息型別封裝成一個固定大 小的資料結構,暫且稱這個資料結構為索引單元吧,大小固定為 16k,訊息在物理檔案的位置稱為 offset。
多個索引單元組成了一個索引檔案,索引檔案預設固定大小為 20M,和訊息檔案一樣,檔名是 起始位元組位置,寫滿後,產生一個新的檔案。

metaq的訊息儲存由commit log和邏輯佇列consume queue配合完成。 首先會將所有的訊息分topic存在commit log檔案中,commit log檔案最大為1G,超過1G會生成新檔案,檔案以起始位元組大小命名。consume queue邏輯佇列相當於是commit log檔案的索引,記錄offset偏移量,size長度,訊息的hashcode等資訊。物理佇列只有一個(也就是commit log檔案),採用固定大小的檔案順序儲存訊息。邏輯佇列有多個(每個對應一個topic),每個邏輯佇列有多個分割槽(topicA_1分割槽,topicA_2分割槽,topicA_3分割槽),每個分割槽有多個索引單元。MetaQ中將每一個Topic分為了幾個區,每個區對應了一個消費佇列,這些消費佇列就由各個索引檔案組成。消費端在拉取訊息時,只要知道自己是訂閱的Topic從nameserver獲取broker地址建立連線後,就能根據消費佇列中的索引檔案,去物理佇列中獲取訂閱的訊息。如下圖所示,topicA在broker1 和 broker2分別有topicA_1分割槽, topicA_2分割槽;topicA_3 分割槽,topicA_4 分割槽。每個分割槽裡存是的commit log檔案的索引資訊。消費資訊時,需要通過索引資訊,到commit log檔案中獲取真正的資料資訊進行消費。仔細觀察圖metaq_arch_1/2/3.png

offset:這個概念其實放在這裡講略微有些早了,可以在瞭解完MetaQ 的訊息生產模型之後再來了解。兩個“offset”,剛開始還是有點迷惑的,梳理之後才理出了頭緒,於是這裡把這兩種offset拿出來專門加以區分。

  1. 訊息儲存過程中的offset: 這個offset就是指索引單元中的offset,它標誌著訊息在commitlog檔案中的物理位置。這個offset的存在也是MetaQ能正確在commitlog檔案中定位訊息的前提和保障。
  2. 訊息消費時的offset: 這個offset是指當前訊息被消費到的位置(接下來從哪個位置開始消費),這個offset是消費者順序消費訊息的依據和保障。這個offset被儲存在消費者本地、資料庫,還會定時更新到broker中。消費者每次拉取請求就需要offset這個引數,如果沒有這個offset,MetaQ就不能實現順序讀了。MetaQ檔案目錄下有兩個檔案用於持久化消費進度,每次將offset寫入consumerOffset.json檔案,然後備份到 consumerOffset.json.bak檔案中。在程式碼實現上,offset的持久化實際上是儲存進一個以Topic和groupId的組合字串為key,以ConcurrentMap為value的ConcurrentMap,而這個value上的ConcurrentMap又是以queueId為key,以offset為value的。

對於某一特定Topic而言,brokerId和分割槽號組合起來就是一個有序的分割槽列表, 如Topic “hello”對應的有序分割槽列表為{A-0,A-1,B-0,B-1,B-2},生產者生產訊息實際上可以理解為是以topic下的分割槽為單位進行的,即生產者按照一定規則向“brokerId-分割槽號”組成的有序分割槽列表對應的分割槽傳送訊息。傳送的規則可以定製,一般採用輪詢的方式。
訊息的儲存就是藉助之前介紹過的commitlog檔案和索引檔案來實現的。

消費者消費訊息也是以topic下的分割槽為單位,分組內一個消費者對應消耗一個分割槽的訊息。這樣一來就出現以下兩種情況:

  1. 一個Group中的消費者個數大於總的分割槽數目:在這種情況下,多出來的消費者空閒,不參與消費;
  2. 一個Group中的消費者個數小於總的分割槽數目:在這種情況下,有部分消費者需要承擔額外的消費任務。

當Topic下的分割槽數足夠大的時候,可以認為消費者負載是平均分配的。
於是,訊息的拉取過程描述如下:
(1) 根據Topic和分割槽號找到對應的邏輯消費佇列,記為A;
(2) 根據A和offset找到對應的待消費的索引位置,記為B;
(3) 從B開始,讀取B所對應的commitlog檔案中的訊息放入消費者佇列中,直到讀取到的訊息長度等於給定的maxSize。在這個過程中,offset同時後移更新。
(4) 返回結果,結果中包含更新後的offset值,並將offset儲存下來作為下一次消費開始的位置標誌。

首先,從以上截圖可以驗證:
(1) 一個broker上可以釋出多個Topic(圖中有TopicA、TopicB)
(2) 一個Topic下可以對應多個分割槽(圖中TopicA下有0和1兩個分割槽,TopicB下有0,1,2三個分割槽)
(3) commitlog檔案是不區分Topic的訊息儲存檔案,所有Topic下的訊息都會順序寫到同一個commitlog檔案中
(3) 不同的ConsumerGroup之間獨立進行訊息的消費,消費過程組間互不影響。不同的消費者分組有自己獨有的消費佇列。
可以看到,針對每一個ConsumerGroup,都維護有一個重試佇列(RETRY Queue)和一個死信佇列(DLQ Queue)。 其中,重試佇列用於消費失敗後的重試,並且設定有最大重試次數(預設是16次),死信佇列存放多次重試後依舊無法消費的訊息。消費者在消費訊息的過程中,如果消費失敗,則將這條訊息加入到重試佇列中,由重試執行緒繼續進行重試消費,如果重試最大次數後還是消費失敗,則將訊息加入到死信佇列中,加入到死信佇列中的訊息需要進行人工干預。在這個過程中,主執行緒繼續往後推進,從而實現訊息的亂序消費和相對順序消費。

當前例子是PushConsumer用法,使用方式給使用者感覺是訊息從MetaQ伺服器推到了應用客戶端。 但是實際PushConsumer內部是使用長輪詢Pull方式從MetaQ伺服器拉訊息,然後再回撥使用者Listener方法

RocketMQ訊息有有序保證例項:

@Test
public void testQueueProducer() {
    try {
        MetaProducer producer = new MetaProducer(GROUP_NAME);
        //佇列的並行度是15個
        producer.setDefaultTopicQueueNums(15);
        producer.start();
        // 設定tag是為了讓消費端過濾不想要的tag
        String[] tags = new String[]{"TagA", "TagB", "TagC", "TagD", "TagE"};
        //do {
            for (int i = 0; i < 50000; i++) {
                // 保證訂單ID相同的訊息放在同一個佇列中
                int orderId = i % 10;
                Message msg = new Message(TOPIC, tags[i % tags.length], "KEY" + i, ("Hello MetaQ " + i).getBytes());
                SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
                    @Override
                    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                        //arg就是orderId
                        Integer id = (Integer) arg;
                        System.out.println(String.format("mq size=%d,arg=%d", mqs.size(), id));
                        // id即orderId取佇列大小的模 目的就是設定訂單相同放在一個佇列中
                        int index = id % mqs.size();
                        System.out.println("index=" + index);
                        return mqs.get(index);
                    }
                }, orderId);
                System.out.println(sendResult);
            }
            Thread.sleep(10000l);
        //} while (true);

         producer.shutdown();
    } catch (Exception e) {
        e.printStackTrace();
    }
    // SendResult [sendStatus=SEND_OK, msgId=0A6549250000277400000AC0059D66F9, messageQueue=MessageQueue [topic=orderTestTopic, brokerName=taobaodaily-f, queueId=3], queueOffset=653]

}

@Test
public void testOrderConsumerQueue() throws MQClientException, InterruptedException {
    MetaPushConsumer consumer = new MetaPushConsumer(GROUP_NAME);

    consumer.subscribe(TOPIC, "TagB");

    consumer.registerMessageListener(new MessageListenerOrderly() {

        @Override
        public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
            context.setAutoCommit(true);
            try {
                System.out.println(Thread.currentThread().getName() + " Receive New Messages: " + new String(msgs.get(0).getBody(), "UTF-8"));
            } catch (Exception e) {
                e.printStackTrace();
            }

            return ConsumeOrderlyStatus.SUCCESS;
        }
    });

    consumer.start();

    System.out.println("Consumer Started.");

    Thread.sleep(10000l);
    // ConsumeMessageThread_7 Receive New Messages: Hello MetaQ 67

}

從輸出結果可以看出mq size=8, 分佈在兩臺機器上,brokerName=taobaodaily-05-tx, queueId=0/1/2/3, brokerName=taobaodaily-04-timer, queueId=0/1/2/3
總結
傳送順序訊息無法利用叢集FailOver特性
消費順序訊息的並行度依賴於佇列數量
佇列熱點問題,個別佇列由於雜湊不均導致訊息過多,消費速度跟不上,產生訊息堆積問題
遇到訊息失敗的訊息,無法跳過,當前佇列消費暫停

傳送訊息負載均衡:傳送訊息通過輪詢佇列的方式 傳送,每個佇列接收平均的訊息量。通過增加機器,可以水平擴充套件佇列容量。 另外也可以自定義方式選擇發往哪個佇列。

訂閱訊息負載均衡:如果有 5 個佇列,2 個 consumer,那麼第一個 Consumer 消費 3 個佇列,第二 consumer 消費 2 個佇列。 這樣即可達到平均消費的目的,可以水平擴充套件 Consumer 來提高消費能力。但是 Consumer 數量要小於等於佇列數 量,如果 Consumer 超過佇列數量,那麼多餘的 Consumer 將不能消費訊息。

事務訊息:
圖transaction

  1. 傳送方向 MQ 服務端傳送訊息;
  2. MQ Server 將訊息持久化成功之後,向傳送方 ACK 確認訊息已經傳送成功,此時訊息為半訊息。
  3. 傳送方開始執行本地事務邏輯。
  4. 傳送方根據本地事務執行結果向 MQ Server 提交二次確認(Commit 或是 Rollback),MQ Server 收到 Commit 狀態則將半訊息標記為可投遞,訂閱方最終將收到該訊息;MQ Server 收到 Rollback 狀態則刪除半訊息,訂閱方將不會接受該訊息。
  5. 在斷網或者是應用重啟的特殊情況下,上述步驟4提交的二次確認最終未到達 MQ Server,經過固定時間後 MQ Server 將對該訊息發起訊息回查。
  6. 傳送方收到訊息回查後,需要檢查對應訊息的本地事務執行的最終結果。
  7. 傳送方根據檢查得到的本地事務的最終狀態再次提交二次確認,MQ Server 仍按照步驟4對半訊息進行操作。
    事務訊息傳送對應步驟1、2、3、4,事務訊息回查對應步驟5、6、7。

訊息過濾:
(1). 在 Broker 端進行 Message Tag 比對,先遍歷 Consume Queue,如果儲存的 Message Tag 與訂閱的 Message Tag 不符合,則跳過,繼續比對下一個,符合則傳輸給 Consumer。注意:Message Tag 是字串形式,ConsumeQueue 中儲存的是其對應的 hashcode,比對時也是比對 hashcode。
(2). Consumer 收到過濾後的訊息後,同樣也要執行在 Broker 端的操作,但是比對的是真實的 Message Tag 字串,而不是 Hashcode。

零拷貝原理:
圖zero_copy_1,zero_copy_2
在broker向消費端傳送訊息時,若採用傳統的IO方式b會從磁碟拷貝資料到頁快取->使用者空間從頁快取讀資料->使用者空間再將資料寫入socket快取中->通過網路傳送資料,顯然這樣做使得使用者空間和核心空間產生了多餘的讀寫。MetaQ採用零拷貝的方式,通過mmap的方式使得頁快取與使用者空間快取共享資料,之後直接將資料從頁快取中將資料傳入socket快取中加快效率。

metaq如何解決事務問題:一般分散式事務採用的kv儲存方式,通過key尋找message,第二階段的回滾或者提交需要修改訊息狀態;然而metaq第一階段傳送prepared訊息,拿到offset,第二階段通過offset訪問訊息,修改資料狀態。通過offset訪問更改資料的缺點是系統髒也過多。

訊息無序性,如何保證其順序性: 訊息的有序消費是指按照訊息傳送的先後順序消費。正常情況下,單執行緒下produder同一個topic(一個topic邏輯上是一個佇列,物理上分為多個佇列)下,metaq支援區域性順序消費。分兩種順序消費方式普通順序消費和嚴格順序消費。普通順序消費,當一個broker當機或者重啟時,允許該部分訊息延遲消費,其他broker照常消費;嚴格消費,當某broker當機或者重啟時,犧牲分散式的failover特性,整個叢集都不能使用,大大降低服務可用性,目前使用場景中資料庫binlog同步強依賴嚴格順序消費,其他應用場景用普通順序消費可滿足。然而在平時的應用中,單執行緒producer幾乎不可能,我們可以通過設計規避這個缺點。例如同一個訂單需要傳送的建立訂單訊息、訂單付款訊息、訂單完成按消費順序消費才有意義,按優先順序傳送訊息(metaq按優先順序發訊息開銷比較大,沒有特意支援這個特性)同一個佇列,優先順序高的訊息先傳送,可以在消費端做異常情況的處理邏輯。

非順序訊息如何實現佇列內並行:PullService根據rebalance結果從對應的佇列中獲取資料,並將其快取到客戶端本地記憶體,然後根據客戶端設定規則(一次批量消費訊息的數目)將資料分成多段派發給下游的消費執行緒。消費端一個佇列只維護一個offset,消費成功後只提交最小的offset。將拉回來的資料分成了三段(0-10,10-20,20-30)分別派發給消費執行緒。 0-10,20-30這兩部分資料消費完成,但10-20這段資料還未消費完成。提交服務端消費位點則為10(消費成功的是10和30),知道這段資料消費完成提交位點才會為30。
PS:所以併發消費有可能出現重複消費的問題。如中間有部分資料一直沒有被成功消費,此時重新負載,別的消費端拿到當前佇列就會導致重複消費。

最佳實踐
Producer最佳實踐
1、每個訊息在業務層面的唯一標識碼,要設定到 keys 欄位,方便將來定位訊息丟失問題。由於是雜湊索引,請務必保證 key 儘可能唯一,這樣可以避免潛在的雜湊衝突。
2、訊息傳送成功或者失敗,要列印訊息日誌,務必要列印 sendresult 和 key 欄位。
3、對於訊息不可丟失應用,務必要有訊息重發機制。例如:訊息傳送失敗,儲存到資料庫,能有定時程式嘗試重發或者人工觸發重發。
4、某些應用如果不關注訊息是否傳送成功,請直接使用sendOneWay方法傳送訊息。

Consumer最佳實踐
1、消費過程要做到冪等(即消費端去重)
2、儘量使用批量方式消費方式,可以很大程度上提高消費吞吐量。
3、優化每條訊息消費過程