分佈式專題(二十):消息隊列因何而生


消息隊列已經逐漸成爲企業IT系統內部通信的核心手段。它具有低耦合、可靠投遞、廣播、流量控制、最終一致性等一系列功能,成爲異步RPC的主要手段之一。
當今市面上有很多主流的消息中間件,如老牌的ActiveMQ、RabbitMQ,炙手可熱的Kafka,阿里巴巴自主開發的Notify、MetaQ、RocketMQ等。
本文不會一一介紹這些消息隊列的所有特性,而是探討一下自主開發設計一個消息隊列時,你需要思考和設計的重要方面。過程中我們會參考這些成熟消息隊列的很多重要思想。
本文首先會闡述什麼時候你需要一個消息隊列,然後以Push模型爲主,從零開始分析設計一個消息隊列時需要考慮到的問題,如RPC、高可用、順序和重複消息、可靠投遞、消費關係解析等。
也會分析以Kafka爲代表的pull模型所具備的優點。最後是一些高級主題,如用批量/異步提高性能、pull模型的系統設計理念、存儲子系統的設計、流量控制的設計、公平調度的實現等。其中最後四個方面會放在下篇講解。

何時需要消息隊列

當你需要使用消息隊列時,首先需要考慮它的必要性。可以使用mq的場景有很多,最常用的幾種,是做業務解耦/最終一致性/廣播/錯峯流控等。反之,如果需要強一致性,關注業務邏輯的處理結果,則RPC顯得更爲合適。

解耦

解耦是消息隊列要解決的最本質問題。所謂解耦,簡單點講就是一個事務,只關心核心的流程。而需要依賴其他系統但不那麼重要的事情,有通知即可,無需等待結果。換句話說,基於消息的模型,關心的是“通知”,而非“處理”。
比如在美團旅遊,我們有一個產品中心,產品中心上游對接的是主站、移動後臺、旅遊供應鏈等各個數據源;下游對接的是篩選系統、API系統等展示系統。當上遊的數據發生變更的時候,如果不使用消息系統,勢必要調用我們的接口來更新數據,就特別依賴產品中心接口的穩定性和處理能力。但其實,作爲旅遊的產品中心,也許只有對於旅遊自建供應鏈,產品中心更新成功纔是他們關心的事情。而對於團購等外部系統,產品中心更新成功也好、失敗也罷,並不是他們的職責所在。他們只需要保證在信息變更的時候通知到我們就好了。
而我們的下游,可能有更新索引、刷新緩存等一系列需求。對於產品中心來說,這也不是我們的職責所在。說白了,如果他們定時來拉取數據,也能保證數據的更新,只是實時性沒有那麼強。但使用接口方式去更新他們的數據,顯然對於產品中心來說太過於“重量級”了,只需要發佈一個產品ID變更的通知,由下游系統來處理,可能更爲合理。
再舉一個例子,對於我們的訂單系統,訂單最終支付成功之後可能需要給用戶發送短信積分什麼的,但其實這已經不是我們系統的核心流程了。如果外部系統速度偏慢(比如短信網關速度不好),那麼主流程的時間會加長很多,用戶肯定不希望點擊支付過好幾分鐘纔看到結果。那麼我們只需要通知短信系統“我們支付成功了”,不一定非要等待它處理完成。

最終一致性

最終一致性指的是兩個系統的狀態保持一致,要麼都成功,要麼都失敗。當然有個時間限制,理論上越快越好,但實際上在各種異常的情況下,可能會有一定延遲達到最終一致狀態,但最後兩個系統的狀態是一樣的。
業界有一些爲“最終一致性”而生的消息隊列,如Notify(阿里)、QMQ(去哪兒)等,其設計初衷,就是爲了交易系統中的高可靠通知。
以一個銀行的轉賬過程來理解最終一致性,轉賬的需求很簡單,如果A系統扣錢成功,則B系統加錢一定成功。反之則一起回滾,像什麼都沒發生一樣。
然而,這個過程中存在很多可能的意外:

  1. A扣錢成功,調用B加錢接口失敗。
  2. A扣錢成功,調用B加錢接口雖然成功,但獲取最終結果時網絡異常引起超時。
  3. A扣錢成功,B加錢失敗,A想回滾扣的錢,但A機器down機。

可見,想把這件看似簡單的事真正做成,真的不那麼容易。所有跨VM的一致性問題,從技術的角度講通用的解決方案是:

  1. 強一致性,分佈式事務,但落地太難且成本太高,後文會具體提到。
  2. 最終一致性,主要是用“記錄”和“補償”的方式。在做所有的不確定的事情之前,先把事情記錄下來,然後去做不確定的事情,結果可能是:成功、失敗或是不確定,“不確定”(例如超時等)可以等價爲失敗。成功就可以把記錄的東西清理掉了,對於失敗和不確定,可以依靠定時任務等方式把所有失敗的事情重新搞一遍,直到成功爲止。
    回到剛纔的例子,系統在A扣錢成功的情況下,把要給B“通知”這件事記錄在庫裏(爲了保證最高的可靠性可以把通知B系統加錢和扣錢成功這兩件事維護在一個本地事務裏),通知成功則刪除這條記錄,通知失敗或不確定則依靠定時任務補償性地通知我們,直到我們把狀態更新成正確的爲止。
    整個這個模型依然可以基於RPC來做,但可以抽象成一個統一的模型,基於消息隊列來做一個“企業總線”。
    具體來說,本地事務維護業務變化和通知消息,一起落地(失敗則一起回滾),然後RPC到達broker,在broker成功落地後,RPC返回成功,本地消息可以刪除。否則本地消息一直靠定時任務輪詢不斷重發,這樣就保證了消息可靠落地broker。
    broker往consumer發送消息的過程類似,一直髮送消息,直到consumer發送消費成功確認。
    我們先不理會重複消息的問題,通過兩次消息落地加補償,下游是一定可以收到消息的。然後依賴狀態機版本號等方式做判重,更新自己的業務,就實現了最終一致性。

最終一致性不是消息隊列的必備特性,但確實可以依靠消息隊列來做最終一致性的事情。另外,所有不保證100%不丟消息的消息隊列,理論上無法實現最終一致性。好吧,應該說理論上的100%,排除系統嚴重故障和bug。
像Kafka一類的設計,在設計層面上就有丟消息的可能(比如定時刷盤,如果掉電就會丟消息)。哪怕只丟千分之一的消息,業務也必須用其他的手段來保證結果正確。

廣播

消息隊列的基本功能之一是進行廣播。如果沒有消息隊列,每當一個新的業務方接入,我們都要聯調一次新接口。有了消息隊列,我們只需要關心消息是否送達了隊列,至於誰希望訂閱,是下游的事情,無疑極大地減少了開發和聯調的工作量。
比如本文開始提到的產品中心發佈產品變更的消息,以及景點庫很多去重更新的消息,可能“關心”方有很多個,但產品中心和景點庫只需要發佈變更消息即可,誰關心誰接入。

錯峯與流控

試想上下游對於事情的處理能力是不同的。比如,Web前端每秒承受上千萬的請求,並不是什麼神奇的事情,只需要加多一點機器,再搭建一些LVS負載均衡設備和Nginx等即可。但數據庫的處理能力卻十分有限,即使使用SSD加分庫分表,單機的處理能力仍然在萬級。由於成本的考慮,我們不能奢求數據庫的機器數量追上前端。
這種問題同樣存在於系統和系統之間,如短信系統可能由於短板效應,速度卡在網關上(每秒幾百次請求),跟前端的併發量不是一個數量級。但用戶晚上個半分鐘左右收到短信,一般是不會有太大問題的。如果沒有消息隊列,兩個系統之間通過協商、滑動窗口等複雜的方案也不是說不能實現。但系統複雜性指數級增長,勢必在上游或者下游做存儲,並且要處理定時、擁塞等一系列問題。而且每當有處理能力有差距的時候,都需要單獨開發一套邏輯來維護這套邏輯。所以,利用中間系統轉儲兩個系統的通信內容,並在下游系統有能力處理這些消息的時候,再處理這些消息,是一套相對較通用的方式。

總而言之,消息隊列不是萬能的。對於需要強事務保證而且延遲敏感的,RPC是優於消息隊列的。
對於一些無關痛癢,或者對於別人非常重要但是對於自己不是那麼關心的事情,可以利用消息隊列去做。
支持最終一致性的消息隊列,能夠用來處理延遲不那麼敏感的“分佈式事務”場景,而且相對於笨重的分佈式事務,可能是更優的處理方式。
當上下游系統處理能力存在差距的時候,利用消息隊列做一個通用的“漏斗”。在下游有能力處理的時候,再進行分發。

如果下游有很多系統關心你的系統發出的通知的時候,果斷地使用消息隊列吧。


消息隊列的流派之爭

這篇文章的標題很難起,網上一翻全是各種MQ的性能比較,很容易讓人以爲我也是這麼“粗俗”的人(o(╯□╰)o)。我這篇文章想要表達的是——它們根本不是一個東西,有毛的性能好比較?

MQ是什麼

Message Queue(MQ),消息隊列中間件。很多人都說:MQ通過將消息的發送和接收分離來實現應用程序的異步和解偶,這個給人的直覺是——MQ是異步的,用來解耦的,但是這個只是MQ的效果而不是目的。MQ真正的目的是爲了通訊,屏蔽底層複雜的通訊協議,定義了一套應用層的、更加簡單的通訊協議。一個分佈式系統中兩個模塊之間通訊要麼是HTTP,要麼是自己開發的TCP,但是這兩種協議其實都是原始的協議。HTTP協議很難實現兩端通訊——模塊A可以調用B,B也可以主動調用A,如果要做到這個兩端都要背上WebServer,而且還不支持長連接(HTTP 2.0的庫根本找不到)。TCP就更加原始了,粘包、心跳、私有的協議,想一想頭皮就發麻。MQ所要做的就是在這些協議之上構建一個簡單的“協議”——生產者/消費者模型。MQ帶給我的“協議”不是具體的通訊協議,而是更高層次通訊模型。它定義了兩個對象——發送數據的叫生產者;消費數據的叫消費者, 提供一個SDK讓我們可以定義自己的生產者消費者實現消息通訊而無視底層通訊協議。

MQ的流派

列出功能表來比較MQ差異或者來一場“MQ性能大比武”的做法都是比較扯的,首先要做的事情應該是分類。我理解的MQ分爲兩個流派

有broker

這個流派通常有一臺服務器作爲Broker,所有的消息都通過它中轉。生產者把消息發送給它就結束自己的任務了,Broker則把消息主動推送給消費者(或者消費者主動輪詢)。

  • 重Topic流

    kafka、JMS就屬於這個流派,生產者會發送key數據到Broker,由Broker比較key之後決定給那個消費者。這種模式是我們最常見的模式,是我們對MQ最多的印象。在這種模式下一個topic往往是一個比較大的概念,甚至一個系統中就可能只有一個topic,topic某種意義上就是queue,生產者發送key相當於說:“hi,把數據放到key的隊列中”。


如上圖所示,Broker定義了三個隊列,key1,key2,key3,生產者發送數據的時候會發送key1和data,Broker在推送數據的時候則推送data(也可能把key帶上)。雖然架構一樣但是kafka的性能要比jms的性能不知道高到多少倍,所以基本這種類型的MQ只有kafka一種備選方案。如果你需要一條暴力的數據流(在乎性能而非靈活性)那麼kafka是最好的選擇。

  • 輕Topic流

這種的代表是RabbitMQ(或者說是AMQP)。生產者發送key數據,消費者定義訂閱的隊列,Broker收到數據之後會通過一定的邏輯計算出key對應的隊列,然後把數據交給隊列


注意到了嗎?這種模式下解耦了key和queue,在這種架構中queue是非常輕量級的(在RabbitMQ中它的上限取決於你的內存),消費者關心的只是自己的queue;生產者不必關心數據最終給誰只要指定key就行了,中間的那層映射在AMQP中叫exchange(交換機)。AMQP中有四種種exchange——Direct exchange:key就等於queue;Fanout exchange:無視key,給所有的queue都來一份;Topic exchange:key可以用“寬字符”模糊匹配queue;最後一個厲害了Headers exchange:無視key,通過查看消息的頭部元數據來決定發給那個queue(AMQP頭部元數據非常豐富而且可以自定義)。這種結構的架構給通訊帶來了很大的靈活性,我們能想到的通訊方式都可以用這四種exchange表達出來。如果你需要一個企業數據總線(在乎靈活性)那麼RabbitMQ絕對的值得一用。

無broker

此門派是AMQP的“叛徒”,某位道友嫌棄AMQP太“重”(那是他沒看到用Erlang實現的時候是多麼的行雲流水) 所以設計了zeromq。這位道友非常睿智,他非常敏銳的意識到——MQ是更高級的Socket,它是解決通訊問題的。所以ZeroMQ被設計成了一個“庫”而不是一箇中間件,這種實現也可以達到——沒有broker的目的。


節點之間通訊的消息都是發送到彼此的隊列中,每個節點都既是生產者又是消費者。ZeroMQ做的事情就是封裝出一套類似於scoket的API可以完成發送數據,讀取數據。如果你仔細想一下其實ZeroMQ是這樣的


頓悟了嗎?Actor模型,ZeroMQ其實就是一個跨語言的、重量級的Actor模型郵箱庫。你可以把自己的程序想象成一個actor,zeromq就是提供郵箱功能的庫;zeromq可以實現同一臺機器的IPC通訊也可以實現不同機器的TCP、UDP通訊。如果你需要一個強大的、靈活、野蠻的通訊能力,別猶豫zeromq。

MQ只能異步嗎

答案是否定了,首先ZeroMQ支持請求->應答模式;其次RabbitMQ提供了RPC是地地道道的同步通訊,只有JMS、kafka這種架構才只能做異步。我們很多人第一次接觸MQ都是JMS之類的這種所以纔會產生這種錯覺

總結

kafka,zeromq,rabbitmq代表了三種完全不同風格的MQ架構;關注點完全不同:

  • kafka在乎的是性能,速度

  • rabbitmq追求的是靈活

  • zeromq追求的是輕量級、分佈式

如果你拿zeromq來做大數據量的傳輸功能,不是生產者的內存“爆掉”就是消費者被“壓死”;如果你用kafka做通訊總線那絕對的不會快只能更慢;你想要rabbitmq實現分佈式,那真的是難爲它。


如何設計一個消息隊列

綜述

我們現在明確了消息隊列的使用場景,下一步就是如何設計實現一個消息隊列了。

基於消息的系統模型,不一定需要broker(消息隊列服務端)。市面上的的Akka(actor模型)、ZeroMQ等,其實都是基於消息的系統設計範式,但是沒有broker。
我們之所以要設計一個消息隊列,並且配備broker,無外乎要做兩件事情:

  1. 消息的轉儲,在更合適的時間點投遞,或者通過一系列手段輔助消息最終能送達消費機。
  2. 規範一種範式和通用的模式,以滿足解耦、最終一致性、錯峯等需求。
    掰開了揉碎了看,最簡單的消息隊列可以做成一個消息轉發器,把一次RPC做成兩次RPC。發送者把消息投遞到服務端(以下簡稱broker),服務端再將消息轉發一手到接收端,就是這麼簡單。

一般來講,設計消息隊列的整體思路是先build一個整體的數據流,例如producer發送給broker,broker發送給consumer,consumer回覆消費確認,broker刪除/備份消息等。
利用RPC將數據流串起來。然後考慮RPC的高可用性,儘量做到無狀態,方便水平擴展。
之後考慮如何承載消息堆積,然後在合適的時機投遞消息,而處理堆積的最佳方式,就是存儲,存儲的選型需要綜合考慮性能/可靠性和開發維護成本等諸多因素。
爲了實現廣播功能,我們必須要維護消費關係,可以利用zk/config server等保存消費關係。
在完成了上述幾個功能後,消息隊列基本就實現了。然後我們可以考慮一些高級特性,如可靠投遞,事務特性,性能優化等。
下面我們會以設計消息隊列時重點考慮的模塊爲主線,穿插灌輸一些消息隊列的特性實現方法,來具體分析設計實現一個消息隊列時的方方面面。

實現隊列基本功能

RPC通信協議

剛纔講到,所謂消息隊列,無外乎兩次RPC加一次轉儲,當然需要消費端最終做消費確認的情況是三次RPC。既然是RPC,就必然牽扯出一系列話題,什麼負載均衡啊、服務發現啊、通信協議啊、序列化協議啊,等等。在這一塊,我的強烈建議是不要重複造輪子。利用公司現有的RPC框架:Thrift也好,Dubbo也好,或者是其他自定義的框架也好。因爲消息隊列的RPC,和普通的RPC沒有本質區別。當然了,自主利用Memchached或者Redis協議重新寫一套RPC框架並非不可(如MetaQ使用了自己封裝的Gecko NIO框架,卡夫卡也用了類似的協議)。但實現成本和難度無疑倍增。排除對效率的極端要求,都可以使用現成的RPC框架。
簡單來講,服務端提供兩個RPC服務,一個用來接收消息,一個用來確認消息收到。並且做到不管哪個server收到消息和確認消息,結果一致即可。當然這中間可能還涉及跨IDC的服務的問題。這裏和RPC的原則是一致的,儘量優先選擇本機房投遞。你可能會問,如果producer和consumer本身就在兩個機房了,怎麼辦?首先,broker必須保證感知的到所有consumer的存在。其次,producer儘量選擇就近的機房就好了。

高可用

其實所有的高可用,是依賴於RPC和存儲的高可用來做的。先來看RPC的高可用,美團的基於MTThrift的RPC框架,阿里的Dubbo等,其本身就具有服務自動發現,負載均衡等功能。而消息隊列的高可用,只要保證broker接受消息和確認消息的接口是冪等的,並且consumer的幾臺機器處理消息是冪等的,這樣就把消息隊列的可用性,轉交給RPC框架來處理了。
那麼怎麼保證冪等呢?最簡單的方式莫過於共享存儲。broker多機器共享一個DB或者一個分佈式文件/kv系統,則處理消息自然是冪等的。就算有單點故障,其他節點可以立刻頂上。另外failover可以依賴定時任務的補償,這是消息隊列本身天然就可以支持的功能。存儲系統本身的可用性我們不需要操太多心,放心大膽的交給DBA們吧!
對於不共享存儲的隊列,如Kafka使用分區加主備模式,就略微麻煩一些。需要保證每一個分區內的高可用性,也就是每一個分區至少要有一個主備且需要做數據的同步,關於這塊HA的細節,可以參考下篇pull模型消息系統設計。

服務端承載消息堆積的能力

消息到達服務端如果不經過任何處理就到接收者了,broker就失去了它的意義。爲了滿足我們錯峯/流控/最終可達等一系列需求,把消息存儲下來,然後選擇時機投遞就顯得是順理成章的了。
只是這個存儲可以做成很多方式。比如存儲在內存裏,存儲在分佈式KV裏,存儲在磁盤裏,存儲在數據庫裏等等。但歸結起來,主要有持久化和非持久化兩種。
持久化的形式能更大程度地保證消息的可靠性(如斷電等不可抗外力),並且理論上能承載更大限度的消息堆積(外存的空間遠大於內存)。
但並不是每種消息都需要持久化存儲。很多消息對於投遞性能的要求大於可靠性的要求,且數量極大(如日誌)。這時候,消息不落地直接暫存內存,嘗試幾次failover,最終投遞出去也未嘗不可。
市面上的消息隊列普遍兩種形式都支持。當然具體的場景還要具體結合公司的業務來看。

存儲子系統的選擇

我們來看看如果需要數據落地的情況下各種存儲子系統的選擇。理論上,從速度來看,文件系統>分佈式KV(持久化)>分佈式文件系統>數據庫,而可靠性卻截然相反。還是要從支持的業務場景出發作出最合理的選擇,如果你們的消息隊列是用來支持支付/交易等對可靠性要求非常高,但對性能和量的要求沒有這麼高,而且沒有時間精力專門做文件存儲系統的研究,DB是最好的選擇。
但是DB受制於IOPS,如果要求單broker 5位數以上的QPS性能,基於文件的存儲是比較好的解決方案。整體上可以採用數據文件+索引文件的方式處理,具體這塊的設計比較複雜,可以參考下篇的存儲子系統設計。
分佈式KV(如MongoDB,HBase)等,或者持久化的Redis,由於其編程接口較友好,性能也比較可觀,如果在可靠性要求不是那麼高的場景,也不失爲一個不錯的選擇。

消費關係解析

現在我們的消息隊列初步具備了轉儲消息的能力。下面一個重要的事情就是解析發送接收關係,進行正確的消息投遞了。
市面上的消息隊列定義了一堆讓人暈頭轉向的名詞,如JMS 規範中的Topic/Queue,Kafka裏面的Topic/Partition/ConsumerGroup,RabbitMQ裏面的Exchange等等。拋開現象看本質,無外乎是單播與廣播的區別。所謂單播,就是點到點;而廣播,是一點對多點。當然,對於互聯網的大部分應用來說,組間廣播、組內單播是最常見的情形。
消息需要通知到多個業務集羣,而一個業務集羣內有很多臺機器,只要一臺機器消費這個消息就可以了。
當然這不是絕對的,很多時候組內的廣播也是有適用場景的,如本地緩存的更新等等。另外,消費關係除了組內組間,可能會有多級樹狀關係。這種情況太過於複雜,一般不列入考慮範圍。所以,一般比較通用的設計是支持組間廣播,不同的組註冊不同的訂閱。組內的不同機器,如果註冊一個相同的ID,則單播;如果註冊不同的ID(如IP地址+端口),則廣播。
至於廣播關係的維護,一般由於消息隊列本身都是集羣,所以都維護在公共存儲上,如config server、zookeeper等。維護廣播關係所要做的事情基本是一致的:

  1. 發送關係的維護。
  2. 發送關係變更時的通知。

隊列高級特性設計

上面都是些消息隊列基本功能的實現,下面來看一些關於消息隊列特性相關的內容,不管可靠投遞/消息丟失與重複以及事務乃至於性能,不是每個消息隊列都會照顧到,所以要依照業務的需求,來仔細衡量各種特性實現的成本,利弊,最終做出最爲合理的設計。

可靠投遞(最終一致性)

這是個激動人心的話題,完全不丟消息,究竟可不可能?答案是,完全可能,前提是消息可能會重複,並且,在異常情況下,要接受消息的延遲。
方案說簡單也簡單,就是每當要發生不可靠的事情(RPC等)之前,先將消息落地,然後發送。當失敗或者不知道成功失敗(比如超時)時,消息狀態是待發送,定時任務不停輪詢所有待發送消息,最終一定可以送達。
具體來說:

  1. producer往broker發送消息之前,需要做一次落地。
  2. 請求到server後,server確保數據落地後再告訴客戶端發送成功。
  3. 支持廣播的消息隊列需要對每個待發送的endpoint,持久化一個發送狀態,直到所有endpoint狀態都OK纔可刪除消息。

對於各種不確定(超時、down機、消息沒有送達、送達後數據沒落地、數據落地了回覆沒收到),其實對於發送方來說,都是一件事情,就是消息沒有送達。
重推消息所面臨的問題就是消息重複。重複和丟失就像兩個噩夢,你必須要面對一個。好在消息重複還有處理的機會,消息丟失再想找回就難了。
Anyway,作爲一個成熟的消息隊列,應該儘量在各個環節減少重複投遞的可能性,不能因爲重複有解決方案就放縱的亂投遞。
最後說一句,不是所有的系統都要求最終一致性或者可靠投遞,比如一個論壇系統、一個招聘系統。一個重複的簡歷或話題被髮布,可能比丟失了一個發佈顯得更讓用戶無法接受。不斷重複一句話,任何基礎組件要服務於業務場景。

消費確認

當broker把消息投遞給消費者後,消費者可以立即響應我收到了這個消息。但收到了這個消息只是第一步,我能不能處理這個消息卻不一定。或許因爲消費能力的問題,系統的負荷已經不能處理這個消息;或者是剛纔狀態機裏面提到的消息不是我想要接收的消息,主動要求重發。
把消息的送達和消息的處理分開,這樣才真正的實現了消息隊列的本質-解耦。所以,允許消費者主動進行消費確認是必要的。當然,對於沒有特殊邏輯的消息,默認Auto Ack也是可以的,但一定要允許消費方主動ack。
對於正確消費ack的,沒什麼特殊的。但是對於reject和error,需要特別說明。reject這件事情,往往業務方是無法感知到的,系統的流量和健康狀況的評估,以及處理能力的評估是一件非常複雜的事情。舉個極端的例子,收到一個消息開始build索引,可能這個消息要處理半個小時,但消息量卻是非常的小。所以reject這塊建議做成滑動窗口/線程池類似的模型來控制,
消費能力不匹配的時候,直接拒絕,過一段時間重發,減少業務的負擔。
但業務出錯這件事情是隻有業務方自己知道的,就像上文提到的狀態機等等。這時應該允許業務方主動ack error,並可以與broker約定下次投遞的時間。

重複消息和順序消息

上文談到重複消息是不可能100%避免的,除非可以允許丟失,那麼,順序消息能否100%滿足呢? 答案是可以,但條件更爲苛刻:

  1. 允許消息丟失。
  2. 從發送方到服務方到接受者都是單點單線程。

所以絕對的順序消息基本上是不能實現的,當然在METAQ/Kafka等pull模型的消息隊列中,單線程生產/消費,排除消息丟失,也是一種順序消息的解決方案。
一般來講,一個主流消息隊列的設計範式裏,應該是不丟消息的前提下,儘量減少重複消息,不保證消息的投遞順序。
談到重複消息,主要是兩個話題:

  1. 如何鑑別消息重複,並冪等的處理重複消息。
  2. 一個消息隊列如何儘量減少重複消息的投遞。

先來看看第一個話題,每一個消息應該有它的唯一身份。不管是業務方自定義的,還是根據IP/PID/時間戳生成的MessageId,如果有地方記錄這個MessageId,消息到來是能夠進行比對就
能完成重複的鑑定。數據庫的唯一鍵/bloom filter/分佈式KV中的key,都是不錯的選擇。由於消息不能被永久存儲,所以理論上都存在消息從持久化存儲移除的瞬間上游還在投遞的可能(上游因種種原因投遞失敗,不停重試,都到了下游清理消息的時間)。這種事情都是異常情況下才會發生的,畢竟是小衆情況。兩分鐘消息都還沒送達,多送一次又能怎樣呢?冪等的處理消息是一門藝術,因爲種種原因重複消息或者錯亂的消息還是來到了,說兩種通用的解決方案:

  1. 版本號。

  1. 狀態機。

事務

持久性是事務的一個特性,然而只滿足持久性卻不一定能滿足事務的特性。還是拿扣錢/加錢的例子講。滿足事務的一致性特徵,則必須要麼都不進行,要麼都能成功。
解決方案從大方向上有兩種:

  1. 兩階段提交,分佈式事務。
  2. 本地事務,本地落地,補償發送。

分佈式事務存在的最大問題是成本太高,兩階段提交協議,對於仲裁down機或者單點故障,幾乎是一個無解的黑洞。對於交易密集型或者I/O密集型的應用,沒有辦法承受這麼高的網絡延遲,系統複雜性。
並且成熟的分佈式事務一定構建與比較靠譜的商用DB和商用中間件上,成本也太高。
那如何使用本地事務解決分佈式事務的問題呢?以本地和業務在一個數據庫實例中建表爲例子,與扣錢的業務操作同一個事務裏,將消息插入本地數據庫。如果消息入庫失敗,則業務回滾;如果消息入庫成功,事務提交。
然後發送消息(注意這裏可以實時發送,不需要等定時任務檢出,以提高消息實時性)。以後的問題就是前文的最終一致性問題所提到的了,只要消息沒有發送成功,就一直靠定時任務重試。
這裏有一個關鍵的點,本地事務做的,是業務落地和消息落地的事務,而不是業務落地和RPC成功的事務。這裏很多人容易混淆,如果是後者,無疑是事務嵌套RPC,是大忌,會有長事務死鎖等各種風險。
而消息只要成功落地,很大程度上就沒有丟失的風險(磁盤物理損壞除外)。而消息只要投遞到服務端確認後本地才做刪除,就完成了producer->broker的可靠投遞,並且當消息存儲異常時,業務也是可以回滾的。
本地事務存在兩個最大的使用障礙:

  1. 配置較爲複雜,“綁架”業務方,必須本地數據庫實例提供一個庫表。
  2. 對於消息延遲高敏感的業務不適用。

話說回來,不是每個業務都需要強事務的。扣錢和加錢需要事務保證,但下單和生成短信卻不需要事務,不能因爲要求發短信的消息存儲投遞失敗而要求下單業務回滾。所以,一個完整的消息隊列應該定義清楚自己可以投遞的消息類型,如事務型消息,本地非持久型消息,以及服務端不落地的非可靠消息等。對不同的業務場景做不同的選擇。另外事務的使用應該儘量低成本、透明化,可以依託於現有的成熟框架,如Spring的聲明式事務做擴展。業務方只需要使用@Transactional標籤即可。

性能相關

異步/同步

首先澄清一個概念,異步,同步和oneway是三件事。異步,歸根結底你還是需要關心結果的,但可能不是當時的時間點關心,可以用輪詢或者回調等方式處理結果;同步是需要當時關心
的結果的;而oneway是發出去就不管死活的方式,這種對於某些完全對可靠性沒有要求的場景還是適用的,但不是我們重點討論的範疇。
迴歸來看,任何的RPC都是存在客戶端異步與服務端異步的,而且是可以任意組合的:客戶端同步對服務端異步,客戶端異步對服務端異步,客戶端同步對服務端同步,客戶端異步對服務端同步。
對於客戶端來說,同步與異步主要是拿到一個Result,還是Future(Listenable)的區別。實現方式可以是線程池,NIO或者其他事件機制,這裏先不展開講。
服務端異步可能稍微難理解一點,這個是需要RPC協議支持的。參考servlet 3.0規範,服務端可以吐一個future給客戶端,並且在future done的時候通知客戶端。
整個過程可以參考下面的代碼:

客戶端同步服務端異步。

  1. Future<Result> future = request(server);//server立刻返回future
  2. synchronized(future){
  3. while(!future.isDone()){
  4. future.wait();//server處理結束後會notify這個future,並修改isdone標誌
  5. }
  6. }
  7. return future.get();

客戶端同步服務端同步。

Result result = request(server);

客戶端異步服務端同步(這裏用線程池的方式)。

  1. Future<Result> future = executor.submit(new Callable(){public void call<Result>(){
  2. result = request(server);
  3. }})
  4. return future;

客戶端異步服務端異步。

  1. Future<Result> future = request(server);//server立刻返回future
  2. return future

上面說了這麼多,其實是想讓大家脫離兩個誤區:

  1. RPC只有客戶端能做異步,服務端不能。
  2. 異步只能通過線程池。

那麼,服務端使用異步最大的好處是什麼呢?說到底,是解放了線程和I/O。試想服務端有一堆I/O等待處理,如果每個請求都需要同步響應,每條消息都需要結果立刻返回,那麼就幾乎沒法做I/O合併
(當然接口可以設計成batch的,但可能batch發過來的仍然數量較少)。而如果用異步的方式返回給客戶端future,就可以有機會進行I/O的合併,把幾個批次發過來的消息一起落地(這種合併對於MySQL等允許batch insert的數據庫效果尤其明顯),並且徹底釋放了線程。不至於說來多少請求開多少線程,能夠支持的併發量直線提高。
來看第二個誤區,返回future的方式不一定只有線程池。換句話說,可以在線程池裏面進行同步操作,也可以進行異步操作,也可以不使用線程池使用異步操作(NIO、事件)。
回到消息隊列的議題上,我們當然不希望消息的發送阻塞主流程(前面提到了,server端如果使用異步模型,則可能因消息合併帶來一定程度上的消息延遲),所以可以先使用線程池提交一個發送請求,主流程繼續往下走。
但是線程池中的請求關心結果嗎?Of course,必須等待服務端消息成功落地,纔算是消息發送成功。所以這裏的模型,準確地說事客戶端半同步半異步(使用線程池不阻塞主流程,但線程池中的任務需要等待server端的返回),server端是純異步。客戶端的線程池wait在server端吐回的future上,直到server端處理完畢,才解除阻塞繼續進行。

總結一句,同步能夠保證結果,異步能夠保證效率,要合理的結合才能做到最好的效率。

push還是pull

上文提到的消息隊列,大多是針對push模型的設計。現在市面上有很多經典的也比較成熟的pull模型的消息隊列,如Kafka、MetaQ等。這跟JMS中傳統的push方式有很大的區別,可謂另闢蹊徑。
我們簡要分析下push和pull模型各自存在的利弊。

慢消費

慢消費無疑是push模型最大的致命傷,穿成流水線來看,如果消費者的速度比發送者的速度慢很多,勢必造成消息在broker的堆積。假設這些消息都是有用的無法丟棄的,消息就要一直在broker端保存。當然這還不是最致命的,最致命的是broker給consumer推送一堆consumer無法處理的消息,consumer不是reject就是error,然後來回踢皮球。
反觀pull模式,consumer可以按需消費,不用擔心自己處理不了的消息來騷擾自己,而broker堆積消息也會相對簡單,無需記錄每一個要發送消息的狀態,只需要維護所有消息的隊列和偏移量就可以了。所以對於建立索引等慢消費,消息量有限且到來的速度不均勻的情況,pull模式比較合適。

消息延遲與忙等

這是pull模式最大的短板。由於主動權在消費方,消費方無法準確地決定何時去拉取最新的消息。如果一次pull取到消息了還可以繼續去pull,如果沒有pull取到則需要等待一段時間重新pull。
但等待多久就很難判定了。你可能會說,我可以有xx動態pull取時間調整算法,但問題的本質在於,有沒有消息到來這件事情決定權不在消費方。也許1分鐘內連續來了1000條消息,然後半個小時沒有新消息產生,
可能你的算法算出下次最有可能到來的時間點是31分鐘之後,或者60分鐘之後,結果下條消息10分鐘後到了,是不是很讓人沮喪?
當然也不是說延遲就沒有解決方案了,業界較成熟的做法是從短時間開始(不會對broker有太大負擔),然後指數級增長等待。比如開始等5ms,然後10ms,然後20ms,然後40ms……直到有消息到來,然後再回到5ms。
即使這樣,依然存在延遲問題:假設40ms到80ms之間的50ms消息到來,消息就延遲了30ms,而且對於半個小時來一次的消息,這些開銷就是白白浪費的。
在阿里的RocketMq裏,有一種優化的做法-長輪詢,來平衡推拉模型各自的缺點。基本思路是:消費者如果嘗試拉取失敗,不是直接return,而是把連接掛在那裏wait,服務端如果有新的消息到來,把連接notify起來,這也是不錯的思路。但海量的長連接block對系統的開銷還是不容小覷的,還是要合理的評估時間間隔,給wait加一個時間上限比較好~

總結

本文從爲何使用消息隊列開始講起,然後主要介紹瞭如何從零開始設計一個消息隊列,包括RPC、事務、最終一致性、廣播、消息確認等關鍵問題。並對消息隊列的push、pull模型做了簡要分析,最後從批量和異步角度,分析了消息隊列性能優化的思路。下篇會着重介紹一些高級話題,如存儲系統的設計、流控和錯峯的設計、公平調度等。希望通過這些,讓大家對消息隊列有個提綱挈領的整體認識,並給自主開發消息隊列提供思路。另外,本文主要是源自自己在開發消息隊列中的思考和讀源碼時的體會,比較不"官方",也難免會存在一些漏洞,歡迎大家多多交流。


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