使用消息隊列需要注意的幾個關鍵問題

工作的項目中使用了消息隊列,需要注意幾個關鍵問題:

  • 消息的順序問題
  • 消息的重複問題
  • 事務消息


看了一篇不錯的文章以下是那篇文章部分內容:


一、順序消息

消息有序指的是可以按照消息的發送順序來消費。例如:一筆訂單產生了 3 條消息,分別是訂單創建、訂單付款、訂單完成。消費時,要按照順序依次消費纔有意義。與此同時多筆訂單之間又是可以並行消費的。首先來看如下示例:

假如生產者產生了2條消息:M1、M2,要保證這兩條消息的順序,應該怎樣做?你腦中想到的可能是這樣:


你可能會採用這種方式保證消息順序


假定M1發送到S1,M2發送到S2,如果要保證M1先於M2被消費,那麼需要M1到達消費端被消費後,通知S2,然後S2再將M2發送到消費端。

這個模型存在的問題是,如果M1和M2分別發送到兩臺Server上,就不能保證M1先達到MQ集羣,也不能保證M1被先消費。換個角度看,如果M2先於M1達到MQ集羣,甚至M2被消費後,M1才達到消費端,這時消息也就亂序了,說明以上模型是不能保證消息的順序的。如何才能在MQ集羣保證消息的順序?一種簡單的方式就是將M1、M2發送到同一個Server上:


保證消息順序,你改進後的方法


這樣可以保證M1先於M2到達MQServer(生產者等待M1發送成功後再發送M2),根據先達到先被消費的原則,M1會先於M2被消費,這樣就保證了消息的順序。

這個模型也僅僅是理論上可以保證消息的順序,在實際場景中可能會遇到下面的問題:


網絡延遲問題

只要將消息從一臺服務器發往另一臺服務器,就會存在網絡延遲問題。如上圖所示,如果發送M1耗時大於發送M2的耗時,那麼M2就仍將被先消費,仍然不能保證消息的順序。即使M1和M2同時到達消費端,由於不清楚消費端1和消費端2的負載情況,仍然有可能出現M2先於M1被消費的情況。

那如何解決這個問題?將M1和M2發往同一個消費者,且發送M1後,需要消費端響應成功後才能發送M2。

聰明的你可能已經想到另外的問題:如果M1被髮送到消費端後,消費端1沒有響應,那是繼續發送M2呢,還是重新發送M1?一般爲了保證消息一定被消費,肯定會選擇重發M1到另外一個消費端2,就如下圖所示。


保證消息順序的正確姿勢

這樣的模型就嚴格保證消息的順序,細心的你仍然會發現問題,消費端1沒有響應Server時有兩種情況,一種是M1確實沒有到達(數據在網絡傳送中丟失),另外一種消費端已經消費M1且已經發送響應消息,只是MQ Server端沒有收到。如果是第二種情況,重發M1,就會造成M1被重複消費。也就引入了我們要說的第二個問題,消息重複問題,這個後文會詳細講解。

回過頭來看消息順序問題,嚴格的順序消息非常容易理解,也可以通過文中所描述的方式來簡單處理。總結起來,要實現嚴格的順序消息,簡單且可行的辦法就是:

保證生產者 - MQServer - 消費者是一對一對一的關係

這樣的設計雖然簡單易行,但也會存在一些很嚴重的問題,比如:

  1. 並行度就會成爲消息系統的瓶頸(吞吐量不夠)
  2. 更多的異常處理,比如:只要消費端出現問題,就會導致整個處理流程阻塞,我們不得不花費更多的精力來解決阻塞的問題。

但我們的最終目標是要集羣的高容錯性和高吞吐量。這似乎是一對不可調和的矛盾,那麼阿里是如何解決的?

世界上解決一個計算機問題最簡單的方法:“恰好”不需要解決它!—— 沈詢

有些問題,看起來很重要,但實際上我們可以通過合理的設計或者將問題分解來規避。如果硬要把時間花在解決問題本身,實際上不僅效率低下,而且也是一種浪費。從這個角度來看消息的順序問題,我們可以得出兩個結論:

  1. 不關注亂序的應用實際大量存在
  2. 隊列無序並不意味着消息無序

所以從業務層面來保證消息的順序而不僅僅是依賴於消息系統,是不是我們應該尋求的一種更合理的方式?

最後我們從源碼角度分析RocketMQ怎麼實現發送順序消息。

RocketMQ通過輪詢所有隊列的方式來確定消息被髮送到哪一個隊列(負載均衡策略)。比如下面的示例中,訂單號相同的消息會被先後發送到同一個隊列中:

// RocketMQ通過MessageQueueSelector中實現的算法來確定消息發送到哪一個隊列上
// RocketMQ默認提供了兩種MessageQueueSelector實現:隨機/Hash
// 當然你可以根據業務實現自己的MessageQueueSelector來決定消息按照何種策略發送到消息隊列中
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);

在獲取到路由信息以後,會根據MessageQueueSelector實現的算法來選擇一個隊列,同一個OrderId獲取到的肯定是同一個隊列。

private SendResult send()  {
    // 獲取topic路由信息
    TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
    if (topicPublishInfo != null && topicPublishInfo.ok()) {
        MessageQueue mq = null;
        // 根據我們的算法,選擇一個發送隊列
        // 這裏的arg = orderId
        mq = selector.select(topicPublishInfo.getMessageQueueList(), msg, arg);
        if (mq != null) {
            return this.sendKernelImpl(msg, mq, communicationMode, sendCallback, timeout);
        }
    }
}
二、消息重複

上面在解決消息順序問題時,引入了一個新的問題,就是消息重複。那麼RocketMQ是怎樣解決消息重複的問題呢?還是“恰好”不解決。

造成消息重複的根本原因是:網絡不可達。只要通過網絡交換數據,就無法避免這個問題。所以解決這個問題的辦法就是繞過這個問題。那麼問題就變成了:如果消費端收到兩條一樣的消息,應該怎樣處理?

  1. 消費端處理消息的業務邏輯保持冪等性
  2. 保證每條消息都有唯一編號且保證消息處理成功與去重表的日誌同時出現

第1條很好理解,只要保持冪等性,不管來多少條重複消息,最後處理的結果都一樣。第2條原理就是利用一張日誌表來記錄已經處理成功的消息的ID,如果新到的消息ID已經在日誌表中,那麼就不再處理這條消息。

第1條解決方案,很明顯應該在消費端實現,不屬於消息系統要實現的功能。第2條可以消息系統實現,也可以業務端實現。正常情況下出現重複消息的概率其實很小,如果由消息系統來實現的話,肯定會對消息系統的吞吐量和高可用有影響,所以最好還是由業務端自己處理消息重複的問題,這也是RocketMQ不解決消息重複的問題的原因。

RocketMQ不保證消息不重複,如果你的業務需要保證嚴格的不重複消息,需要你自己在業務端去重。

三、事務消息

RocketMQ除了支持普通消息,順序消息,另外還支持事務消息。首先討論一下什麼是事務消息以及支持事務消息的必要性。我們以一個轉帳的場景爲例來說明這個問題:Bob向Smith轉賬100塊。

在單機環境下,執行事務的情況,大概是下面這個樣子:


單機環境下轉賬事務示意圖

當用戶增長到一定程度,Bob和Smith的賬戶及餘額信息已經不在同一臺服務器上了,那麼上面的流程就變成了這樣:


集羣環境下轉賬事務示意圖

這時候你會發現,同樣是一個轉賬的業務,在集羣環境下,耗時居然成倍的增長,這顯然是不能夠接受的。那如何來規避這個問題?

大事務 = 小事務 + 異步

將大事務拆分成多個小事務異步執行。這樣基本上能夠將跨機事務的執行效率優化到與單機一致。轉賬的事務就可以分解成如下兩個小事務:


小事務+異步消息


圖中執行本地事務(Bob賬戶扣款)和發送異步消息應該保證同時成功或者同時失敗,也就是扣款成功了,發送消息一定要成功,如果扣款失敗了,就不能再發送消息。那問題是:我們是先扣款還是先發送消息呢?

首先看下先發送消息的情況,大致的示意圖如下:


事務消息:先發送消息

存在的問題是:如果消息發送成功,但是扣款失敗,消費端就會消費此消息,進而向Smith賬戶加錢。

先發消息不行,那就先扣款吧,大致的示意圖如下:


事務消息-先扣款

存在的問題跟上面類似:如果扣款成功,發送消息失敗,就會出現Bob扣錢了,但是Smith賬戶未加錢。

可能大家會有很多的方法來解決這個問題,比如:直接將發消息放到Bob扣款的事務中去,如果發送失敗,拋出異常,事務回滾。這樣的處理方式也符合“恰好”不需要解決的原則。

這裏需要說明一下:如果使用Spring來管理事物的話,大可以將發送消息的邏輯放到本地事物中去,發送消息失敗拋出異常,Spring捕捉到異常後就會回滾此事物,以此來保證本地事物與發送消息的原子性。

RocketMQ支持事務消息,下面來看看RocketMQ是怎樣來實現的。


RocketMQ實現發送事務消息

RocketMQ第一階段發送Prepared消息時,會拿到消息的地址,第二階段執行本地事物,第三階段通過第一階段拿到的地址去訪問消息,並修改消息的狀態。

細心的你可能又發現問題了,如果確認消息發送失敗了怎麼辦?RocketMQ會定期掃描消息集羣中的事物消息,如果發現了Prepared消息,它會向消息發送端(生產者)確認,Bob的錢到底是減了還是沒減呢?如果減了是回滾還是繼續發送確認消息呢?RocketMQ會根據發送端設置的策略來決定是回滾還是繼續發送確認消息。這樣就保證了消息發送與本地事務同時成功或同時失敗。

那我們來看下RocketMQ源碼,是如何處理事務消息的。客戶端發送事務消息的部分(完整代碼請查看:rocketmq-example工程下的com.alibaba.rocketmq.example.transaction.TransactionProducer):

// =============================發送事務消息的一系列準備工作========================================
// 未決事務,MQ服務器回查客戶端
// 也就是上文所說的,當RocketMQ發現`Prepared消息`時,會根據這個Listener實現的策略來決斷事務
TransactionCheckListener transactionCheckListener = new TransactionCheckListenerImpl();
// 構造事務消息的生產者
TransactionMQProducer producer = new TransactionMQProducer("groupName");
// 設置事務決斷處理類
producer.setTransactionCheckListener(transactionCheckListener);
// 本地事務的處理邏輯,相當於示例中檢查Bob賬戶並扣錢的邏輯
TransactionExecuterImpl tranExecuter = new TransactionExecuterImpl();
producer.start()
// 構造MSG,省略構造參數
Message msg = new Message(......);
// 發送消息
SendResult sendResult = producer.sendMessageInTransaction(msg, tranExecuter, null);
producer.shutdown();

接着查看sendMessageInTransaction方法的源碼,總共分爲3個階段:發送Prepared消息、執行本地事務、發送確認消息。

//  ================================事務消息的發送過程=============================================
public TransactionSendResult sendMessageInTransaction(.....)  {
    // 邏輯代碼,非實際代碼
    // 1.發送消息
    sendResult = this.send(msg);
    // sendResult.getSendStatus() == SEND_OK
    // 2.如果消息發送成功,處理與消息關聯的本地事務單元
    LocalTransactionState localTransactionState = tranExecuter.executeLocalTransactionBranch(msg, arg);
    // 3.結束事務
    this.endTransaction(sendResult, localTransactionState, localException);
}

endTransaction方法會將請求發往broker(mq server)去更新事務消息的最終狀態:

  1. 根據sendResult找到Prepared消息 ,sendResult包含事務消息的ID
  2. 根據localTransaction更新消息的最終狀態

如果endTransaction方法執行失敗,數據沒有發送到broker,導致事務消息的 狀態更新失敗,broker會有回查線程定時(默認1分鐘)掃描每個存儲事務狀態的表格文件,如果是已經提交或者回滾的消息直接跳過,如果是prepared狀態則會向Producer發起CheckTransaction請求,Producer會調用DefaultMQProducerImpl.checkTransactionState()方法來處理broker的定時回調請求,而checkTransactionState會調用我們的事務設置的決斷方法來決定是回滾事務還是繼續執行,最後調用endTransactionOnewaybroker來更新消息的最終狀態。

再回到轉賬的例子,如果Bob的賬戶的餘額已經減少,且消息已經發送成功,Smith端開始消費這條消息,這個時候就會出現消費失敗和消費超時兩個問題,解決超時問題的思路就是一直重試,直到消費端消費消息成功,整個過程中有可能會出現消息重複的問題,按照前面的思路解決即可。


消費事務消息

這樣基本上可以解決消費端超時問題,但是如果消費失敗怎麼辦?阿里提供給我們的解決方法是:人工解決。大家可以考慮一下,按照事務的流程,因爲某種原因Smith加款失敗,那麼需要回滾整個流程。如果消息系統要實現這個回滾流程的話,系統複雜度將大大提升,且很容易出現Bug,估計出現Bug的概率會比消費失敗的概率大很多。這也是RocketMQ目前暫時沒有解決這個問題的原因,在設計實現消息系統時,我們需要衡量是否值得花這麼大的代價來解決這樣一個出現概率非常小的問題,這也是大家在解決疑難問題時需要多多思考的地方。

20160321補充:在3.2.6版本中移除了事務消息的實現,所以此版本不支持事務消息,具體情況請參考rocketmq的issues:
https://github.com/alibaba/RocketMQ/issues/65
https://github.com/alibaba/RocketMQ/issues/138
https://github.com/alibaba/RocketMQ/issues/156

===============

關於事務消息,還有別的解決方案, 轉載另一篇文章

說到分佈式事務,就會談到那個經典的”賬號轉賬”問題:2個賬號,分佈處於2個不同的DB,或者說2個不同的子系統裏面,A要扣錢,B要加錢,如何保證原子性?

一般的思路都是通過消息中間件來實現“最終一致性”:A系統扣錢,然後發條消息給中間件,B系統接收此消息,進行加錢。

但這裏面有個問題:A是先update DB,後發送消息呢? 還是先發送消息,後update DB?

假設先update DB成功,發送消息網絡失敗,重發又失敗,怎麼辦? 
假設先發送消息成功,update DB失敗。消息已經發出去了,又不能撤回,怎麼辦?

所以,這裏下個結論: 只要發送消息和update DB這2個操作不是原子的,無論誰先誰後,都是有問題的。

那這個問題怎麼解決呢??

錯誤的方案0

有人可能想到了,我可以把“發送消息”這個網絡調用和update DB放在同1個事務裏面,如果發送消息失敗,update DB自動回滾。這樣不就保證2個操作的原子性了嗎?

這個方案看似正確,其實是錯誤的,原因有2:

(1)網絡的2將軍問題:發送消息失敗,發送方並不知道是消息中間件真的沒有收到消息呢?還是消息已經收到了,只是返回response的時候失敗了?

如果是已經收到消息了,而發送端認爲沒有收到,執行update db的回滾操作。則會導致A賬號的錢沒有扣,B賬號的錢卻加了。

(2)把網絡調用放在DB事務裏面,可能會因爲網絡的延時,導致DB長事務。嚴重的,會block整個DB。這個風險很大。

基於以上分析,我們知道,這個方案其實是錯誤的!

方案1–業務方自己實現

假設消息中間件沒有提供“事務消息”功能,比如你用的是Kafka。那如何解決這個問題呢?

解決方案如下: 
(1)Producer端準備1張消息表,把update DB和insert message這2個操作,放在一個DB事務裏面。

(2)準備一個後臺程序,源源不斷的把消息表中的message傳送給消息中間件。失敗了,不斷重試重傳。允許消息重複,但消息不會丟,順序也不會打亂。

(3)Consumer端準備一個判重表。處理過的消息,記在判重表裏面。實現業務的冪等。但這裏又涉及一個原子性問題:如果保證消息消費 + insert message到判重表這2個操作的原子性?

消費成功,但insert判重表失敗,怎麼辦?關於這個,在Kafka的源碼分析系列,第1篇, exactly once問題的時候,有過討論。

通過上面3步,我們基本就解決了這裏update db和發送網絡消息這2個操作的原子性問題。

但這個方案的一個缺點就是:需要設計DB消息表,同時還需要一個後臺任務,不斷掃描本地消息。導致消息的處理和業務邏輯耦合額外增加業務方的負擔。

方案2 – RocketMQ 事務消息

爲了能解決該問題,同時又不和業務耦合,RocketMQ提出了“事務消息”的概念。

具體來說,就是把消息的發送分成了2個階段:Prepare階段和確認階段。

具體來說,上面的2個步驟,被分解成3個步驟: 
(1) 發送Prepared消息 
(2) update DB 
(3) 根據update DB結果成功或失敗,Confirm或者取消Prepared消息。

可能有人會問了,前2步執行成功了,最後1步失敗了怎麼辦?這裏就涉及到了RocketMQ的關鍵點:RocketMQ會定期(默認是1分鐘)掃描所有的Prepared消息,詢問發送方,到底是要確認這條消息發出去?還是取消此條消息?

具體代碼實現如下:

也就是定義了一個checkListener,RocketMQ會回調此Listener,從而實現上面所說的方案。

// 也就是上文所說的,當RocketMQ發現`Prepared消息`時,會根據這個Listener實現的策略來決斷事務
TransactionCheckListener transactionCheckListener = new TransactionCheckListenerImpl();
// 構造事務消息的生產者
TransactionMQProducer producer = new TransactionMQProducer("groupName");
// 設置事務決斷處理類
producer.setTransactionCheckListener(transactionCheckListener);
// 本地事務的處理邏輯,相當於示例中檢查Bob賬戶並扣錢的邏輯
TransactionExecuterImpl tranExecuter = new TransactionExecuterImpl();
producer.start()
// 構造MSG,省略構造參數
Message msg = new Message(......);
// 發送消息
SendResult sendResult = producer.sendMessageInTransaction(msg, tranExecuter, null);
producer.shutdown();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
public TransactionSendResult sendMessageInTransaction(.....)  {
    // 邏輯代碼,非實際代碼
    // 1.發送消息
    sendResult = this.send(msg);
    // sendResult.getSendStatus() == SEND_OK
    // 2.如果消息發送成功,處理與消息關聯的本地事務單元
    LocalTransactionState localTransactionState = tranExecuter.executeLocalTransactionBranch(msg, arg);
    // 3.結束事務
    this.endTransaction(sendResult, localTransactionState, localException);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

總結:對比方案2和方案1,RocketMQ最大的改變,其實就是把“掃描消息表”這個事情,不讓業務方做,而是消息中間件幫着做了。

至於消息表,其實還是沒有省掉。因爲消息中間件要詢問發送方,事物是否執行成功,還是需要一個“變相的本地消息表”,記錄事物執行狀態。

人工介入

可能有人又要說了,無論方案1,還是方案2,發送端把消息成功放入了隊列,但消費端消費失敗怎麼辦?

消費失敗了,重試,還一直失敗怎麼辦?是不是要自動回滾整個流程?

答案是人工介入。從工程實踐角度講,這種整個流程自動回滾的代價是非常巨大的,不但實現複雜,還會引入新的問題。比如自動回滾失敗,又怎麼處理?

對應這種極低概率的case,採取人工處理,會比實現一個高複雜的自動化回滾系統,更加可靠,也更加簡單。


發佈了123 篇原創文章 · 獲贊 334 · 訪問量 52萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章