訊息佇列已經逐漸成為企業IT系統內部通訊的核心手段。它具有低耦合、可靠投遞、廣播、流量控制、最終一致性等一系列功能,成為非同步RPC的主要手段之一。
當今市面上有很多主流的訊息中介軟體,如老牌的ActiveMQ、RabbitMQ,炙手可熱的Kafka,阿里巴巴自主開發的Notify、MetaQ、RocketMQ等。
本文不會一一介紹這些訊息佇列的所有特性,而是探討一下自主開發設計一個訊息佇列時,你需要思考和設計的重要方面。過程中我們會參考這些成熟訊息佇列的很多重要思想。
本文首先會闡述什麼時候你需要一個訊息佇列,然後以Push模型為主,從零開始分析設計一個訊息佇列時需要考慮到的問題,如RPC、高可用、順序和重複訊息、可靠投遞、消費關係解析等。
也會分析以Kafka為代表的pull模型所具備的優點。最後是一些高階主題,如用批量/非同步提高效能、pull模型的系統設計理念、儲存子系統的設計、流量控制的設計、公平排程的實現等。其中最後四個方面會放在下篇講解。
1 何時需要訊息佇列
當你需要使用訊息佇列時,首先需要考慮它的必要性。可以使用mq的場景有很多,最常用的幾種,是做業務解耦/最終一致性/廣播/錯峰流控等。反之,如果需要強一致性,關注業務邏輯的處理結果,則RPC顯得更為合適。
1.1 解耦
解耦是訊息佇列要解決的最本質問題。所謂解耦,簡單點講就是一個事務,只關心核心的流程。而需要依賴其他系統但不那麼重要的事情,有通知即可,無需等待結果。換句話說,基於訊息的模型,關心的是“通知”,而非“處理”。
比如在美團旅遊,我們有一個產品中心,產品中心上游對接的是主站、移動後臺、旅遊供應鏈等各個資料來源;下游對接的是篩選系統、API系統等展示系統。當上遊的資料發生變更的時候,如果不使用訊息系統,勢必要呼叫我們的介面來更新資料,就特別依賴產品中心介面的穩定性和處理能力。但其實,作為旅遊的產品中心,也許只有對於旅遊自建供應鏈,產品中心更新成功才是他們關心的事情。而對於團購等外部系統,產品中心更新成功也好、失敗也罷,並不是他們的職責所在。他們只需要保證在資訊變更的時候通知到我們就好了。
而我們的下游,可能有更新索引、重新整理快取等一系列需求。對於產品中心來說,這也不是我們的職責所在。說白了,如果他們定時來拉取資料,也能保證資料的更新,只是實時性沒有那麼強。但使用介面方式去更新他們的資料,顯然對於產品中心來說太過於“重量級”了,只需要釋出一個產品ID變更的通知,由下游系統來處理,可能更為合理。
再舉一個例子,對於我們的訂單系統,訂單最終支付成功之後可能需要給使用者傳送簡訊積分什麼的,但其實這已經不是我們系統的核心流程了。如果外部系統速度偏慢(比如簡訊閘道器速度不好),那麼主流程的時間會加長很多,使用者肯定不希望點選支付過好幾分鐘才看到結果。那麼我們只需要通知簡訊系統“我們支付成功了”,不一定非要等待它處理完成。
1.2 最終一致性
最終一致性指的是兩個系統的狀態保持一致,要麼都成功,要麼都失敗。當然有個時間限制,理論上越快越好,但實際上在各種異常的情況下,可能會有一定延遲達到最終一致狀態,但最後兩個系統的狀態是一樣的。
業界有一些為“最終一致性”而生的訊息佇列,如Notify(阿里)、QMQ(去哪兒)等,其設計初衷,就是為了交易系統中的高可靠通知。
以一個銀行的轉賬過程來理解最終一致性,轉賬的需求很簡單,如果A系統扣錢成功,則B系統加錢一定成功。反之則一起回滾,像什麼都沒發生一樣。
然而,這個過程中存在很多可能的意外:
- A扣錢成功,呼叫B加錢介面失敗。
- A扣錢成功,呼叫B加錢介面雖然成功,但獲取最終結果時網路異常引起超時。
- A扣錢成功,B加錢失敗,A想回滾扣的錢,但A機器down機。
可見,想把這件看似簡單的事真正做成,真的不那麼容易。所有跨VM的一致性問題,從技術的角度講通用的解決方案是:
- 強一致性,分散式事務,但落地太難且成本太高,後文會具體提到。
- 最終一致性,主要是用“記錄”和“補償”的方式。在做所有的不確定的事情之前,先把事情記錄下來,然後去做不確定的事情,結果可能是:成功、失敗或是不確定,“不確定”(例如超時等)可以等價為失敗。成功就可以把記錄的東西清理掉了,對於失敗和不確定,可以依靠定時任務等方式把所有失敗的事情重新搞一遍,直到成功為止。
回到剛才的例子,系統在A扣錢成功的情況下,把要給B“通知”這件事記錄在庫裡(為了保證最高的可靠性可以把通知B系統加錢和扣錢成功這兩件事維護在一個本地事務裡),通知成功則刪除這條記錄,通知失敗或不確定則依靠定時任務補償性地通知我們,直到我們把狀態更新成正確的為止。
整個這個模型依然可以基於RPC來做,但可以抽象成一個統一的模型,基於訊息佇列來做一個“企業匯流排”。
具體來說,本地事務維護業務變化和通知訊息,一起落地(失敗則一起回滾),然後RPC到達broker,在broker成功落地後,RPC返回成功,本地訊息可以刪除。否則本地訊息一直靠定時任務輪詢不斷重發,這樣就保證了訊息可靠落地broker。
broker往consumer傳送訊息的過程類似,一直髮送訊息,直到consumer傳送消費成功確認。
我們先不理會重複訊息的問題,通過兩次訊息落地加補償,下游是一定可以收到訊息的。然後依賴狀態機版本號等方式做判重,更新自己的業務,就實現了最終一致性。
最終一致性不是訊息佇列的必備特性,但確實可以依靠訊息佇列來做最終一致性的事情。另外,所有不保證100%不丟訊息的訊息佇列,理論上無法實現最終一致性。好吧,應該說理論上的100%,排除系統嚴重故障和bug。
像Kafka一類的設計,在設計層面上就有丟訊息的可能(比如定時刷盤,如果掉電就會丟訊息)。哪怕只丟千分之一的訊息,業務也必須用其他的手段來保證結果正確。
1.3 廣播
訊息佇列的基本功能之一是進行廣播。如果沒有訊息佇列,每當一個新的業務方接入,我們都要聯調一次新介面。有了訊息佇列,我們只需要關心訊息是否送達了佇列,至於誰希望訂閱,是下游的事情,無疑極大地減少了開發和聯調的工作量。
比如本文開始提到的產品中心釋出產品變更的訊息,以及景點庫很多去重更新的訊息,可能“關心”方有很多個,但產品中心和景點庫只需要釋出變更訊息即可,誰關心誰接入。
1.4 錯峰與流控
試想上下游對於事情的處理能力是不同的。比如,Web前端每秒承受上千萬的請求,並不是什麼神奇的事情,只需要加多一點機器,再搭建一些LVS負載均衡裝置和Nginx等即可。但資料庫的處理能力卻十分有限,即使使用SSD加分庫分表,單機的處理能力仍然在萬級。由於成本的考慮,我們不能奢求資料庫的機器數量追上前端。
這種問題同樣存在於系統和系統之間,如簡訊系統可能由於短板效應,速度卡在閘道器上(每秒幾百次請求),跟前端的併發量不是一個數量級。但使用者晚上個半分鐘左右收到簡訊,一般是不會有太大問題的。如果沒有訊息佇列,兩個系統之間通過協商、滑動視窗等複雜的方案也不是說不能實現。但系統複雜性指數級增長,勢必在上游或者下游做儲存,並且要處理定時、擁塞等一系列問題。而且每當有處理能力有差距的時候,都需要單獨開發一套邏輯來維護這套邏輯。所以,利用中間系統轉儲兩個系統的通訊內容,並在下游系統有能力處理這些訊息的時候,再處理這些訊息,是一套相對較通用的方式。
總而言之,訊息佇列不是萬能的。對於需要強事務保證而且延遲敏感的,RPC是優於訊息佇列的。
對於一些無關痛癢,或者對於別人非常重要但是對於自己不是那麼關心的事情,可以利用訊息佇列去做。
支援最終一致性的訊息佇列,能夠用來處理延遲不那麼敏感的“分散式事務”場景,而且相對於笨重的分散式事務,可能是更優的處理方式。
當上下游系統處理能力存在差距的時候,利用訊息佇列做一個通用的“漏斗”。在下游有能力處理的時候,再進行分發。
如果下游有很多系統關心你的系統發出的通知的時候,果斷地使用訊息佇列吧。
2 如何設計一個訊息佇列
2.1 綜述
我們現在明確了訊息佇列的使用場景,下一步就是如何設計實現一個訊息佇列了。
基於訊息的系統模型,不一定需要broker(訊息佇列服務端)。市面上的的Akka(actor模型)、ZeroMQ等,其實都是基於訊息的系統設計正規化,但是沒有broker。
我們之所以要設計一個訊息佇列,並且配備broker,無外乎要做兩件事情:
- 訊息的轉儲,在更合適的時間點投遞,或者通過一系列手段輔助訊息最終能送達消費機。
- 規範一種正規化和通用的模式,以滿足解耦、最終一致性、錯峰等需求。
掰開了揉碎了看,最簡單的訊息佇列可以做成一個訊息轉發器,把一次RPC做成兩次RPC。傳送者把訊息投遞到服務端(以下簡稱broker),服務端再將訊息轉發一手到接收端,就是這麼簡單。
一般來講,設計訊息佇列的整體思路是先build一個整體的資料流,例如producer傳送給broker,broker傳送給consumer,consumer回覆消費確認,broker刪除/備份訊息等。
利用RPC將資料流串起來。然後考慮RPC的高可用性,儘量做到無狀態,方便水平擴充套件。
之後考慮如何承載訊息堆積,然後在合適的時機投遞訊息,而處理堆積的最佳方式,就是儲存,儲存的選型需要綜合考慮效能/可靠性和開發維護成本等諸多因素。
為了實現廣播功能,我們必須要維護消費關係,可以利用zk/config server等儲存消費關係。
在完成了上述幾個功能後,訊息佇列基本就實現了。然後我們可以考慮一些高階特性,如可靠投遞,事務特性,效能優化等。
下面我們會以設計訊息佇列時重點考慮的模組為主線,穿插灌輸一些訊息佇列的特性實現方法,來具體分析設計實現一個訊息佇列時的方方面面。
2.2 實現佇列基本功能
2.2.1 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儘量選擇就近的機房就好了。
2.2.2 高可用
其實所有的高可用,是依賴於RPC和儲存的高可用來做的。先來看RPC的高可用,美團的基於MTThrift的RPC框架,阿里的Dubbo等,其本身就具有服務自動發現,負載均衡等功能。而訊息佇列的高可用,只要保證broker接受訊息和確認訊息的介面是冪等的,並且consumer的幾臺機器處理訊息是冪等的,這樣就把訊息佇列的可用性,轉交給RPC框架來處理了。
那麼怎麼保證冪等呢?最簡單的方式莫過於共享儲存。broker多機器共享一個DB或者一個分散式檔案/kv系統,則處理訊息自然是冪等的。就算有單點故障,其他節點可以立刻頂上。另外failover可以依賴定時任務的補償,這是訊息佇列本身天然就可以支援的功能。儲存系統本身的可用性我們不需要操太多心,放心大膽的交給DBA們吧!
對於不共享儲存的佇列,如Kafka使用分割槽加主備模式,就略微麻煩一些。需要保證每一個分割槽內的高可用性,也就是每一個分割槽至少要有一個主備且需要做資料的同步,關於這塊HA的細節,可以參考下篇pull模型訊息系統設計。
2.2.3 服務端承載訊息堆積的能力
訊息到達服務端如果不經過任何處理就到接收者了,broker就失去了它的意義。為了滿足我們錯峰/流控/最終可達等一系列需求,把訊息儲存下來,然後選擇時機投遞就顯得是順理成章的了。
只是這個儲存可以做成很多方式。比如儲存在記憶體裡,儲存在分散式KV裡,儲存在磁碟裡,儲存在資料庫裡等等。但歸結起來,主要有持久化和非持久化兩種。
持久化的形式能更大程度地保證訊息的可靠性(如斷電等不可抗外力),並且理論上能承載更大限度的訊息堆積(外存的空間遠大於記憶體)。
但並不是每種訊息都需要持久化儲存。很多訊息對於投遞效能的要求大於可靠性的要求,且數量極大(如日誌)。這時候,訊息不落地直接暫存記憶體,嘗試幾次failover,最終投遞出去也未嘗不可。
市面上的訊息佇列普遍兩種形式都支援。當然具體的場景還要具體結合公司的業務來看。
2.2.4 儲存子系統的選擇
我們來看看如果需要資料落地的情況下各種儲存子系統的選擇。理論上,從速度來看,檔案系統>分散式KV(持久化)>分散式檔案系統>資料庫,而可靠性卻截然相反。還是要從支援的業務場景出發作出最合理的選擇,如果你們的訊息佇列是用來支援支付/交易等對可靠性要求非常高,但對效能和量的要求沒有這麼高,而且沒有時間精力專門做檔案儲存系統的研究,DB是最好的選擇。
但是DB受制於IOPS,如果要求單broker 5位數以上的QPS效能,基於檔案的儲存是比較好的解決方案。整體上可以採用資料檔案+索引檔案的方式處理,具體這塊的設計比較複雜,可以參考下篇的儲存子系統設計。
分散式KV(如MongoDB,HBase)等,或者持久化的Redis,由於其程式設計介面較友好,效能也比較可觀,如果在可靠性要求不是那麼高的場景,也不失為一個不錯的選擇。
2.2.5 消費關係解析
現在我們的訊息佇列初步具備了轉儲訊息的能力。下面一個重要的事情就是解析傳送接收關係,進行正確的訊息投遞了。
市面上的訊息佇列定義了一堆讓人暈頭轉向的名詞,如JMS 規範中的Topic/Queue,Kafka裡面的Topic/Partition/ConsumerGroup,RabbitMQ裡面的Exchange等等。拋開現象看本質,無外乎是單播與廣播的區別。所謂單播,就是點到點;而廣播,是一點對多點。當然,對於網際網路的大部分應用來說,組間廣播、組內單播是最常見的情形。
訊息需要通知到多個業務叢集,而一個業務叢集內有很多臺機器,只要一臺機器消費這個訊息就可以了。
當然這不是絕對的,很多時候組內的廣播也是有適用場景的,如本地快取的更新等等。另外,消費關係除了組內組間,可能會有多級樹狀關係。這種情況太過於複雜,一般不列入考慮範圍。所以,一般比較通用的設計是支援組間廣播,不同的組註冊不同的訂閱。組內的不同機器,如果註冊一個相同的ID,則單播;如果註冊不同的ID(如IP地址+埠),則廣播。
至於廣播關係的維護,一般由於訊息佇列本身都是叢集,所以都維護在公共儲存上,如config server、zookeeper等。維護廣播關係所要做的事情基本是一致的:
- 傳送關係的維護。
- 傳送關係變更時的通知。
2.3 佇列高階特性設計
上面都是些訊息佇列基本功能的實現,下面來看一些關於訊息佇列特性相關的內容,不管可靠投遞/訊息丟失與重複以及事務乃至於效能,不是每個訊息佇列都會照顧到,所以要依照業務的需求,來仔細衡量各種特性實現的成本,利弊,最終做出最為合理的設計。
2.3.1 可靠投遞(最終一致性)
這是個激動人心的話題,完全不丟訊息,究竟可不可能?答案是,完全可能,前提是訊息可能會重複,並且,在異常情況下,要接受訊息的延遲。
方案說簡單也簡單,就是每當要發生不可靠的事情(RPC等)之前,先將訊息落地,然後傳送。當失敗或者不知道成功失敗(比如超時)時,訊息狀態是待傳送,定時任務不停輪詢所有待傳送訊息,最終一定可以送達。
具體來說:
- producer往broker傳送訊息之前,需要做一次落地。
- 請求到server後,server確保資料落地後再告訴客戶端傳送成功。
- 支援廣播的訊息佇列需要對每個待傳送的endpoint,持久化一個傳送狀態,直到所有endpoint狀態都OK才可刪除訊息。
對於各種不確定(超時、down機、訊息沒有送達、送達後資料沒落地、資料落地了回覆沒收到),其實對於傳送方來說,都是一件事情,就是訊息沒有送達。
重推訊息所面臨的問題就是訊息重複。重複和丟失就像兩個噩夢,你必須要面對一個。好在訊息重複還有處理的機會,訊息丟失再想找回就難了。
Anyway,作為一個成熟的訊息佇列,應該儘量在各個環節減少重複投遞的可能性,不能因為重複有解決方案就放縱的亂投遞。
最後說一句,不是所有的系統都要求最終一致性或者可靠投遞,比如一個論壇系統、一個招聘系統。一個重複的簡歷或話題被髮布,可能比丟失了一個釋出顯得更讓使用者無法接受。不斷重複一句話,任何基礎元件要服務於業務場景。
消費確認
當broker把訊息投遞給消費者後,消費者可以立即響應我收到了這個訊息。但收到了這個訊息只是第一步,我能不能處理這個訊息卻不一定。或許因為消費能力的問題,系統的負荷已經不能處理這個訊息;或者是剛才狀態機裡面提到的訊息不是我想要接收的訊息,主動要求重發。
把訊息的送達和訊息的處理分開,這樣才真正的實現了訊息佇列的本質-解耦。所以,允許消費者主動進行消費確認是必要的。當然,對於沒有特殊邏輯的訊息,預設Auto Ack也是可以的,但一定要允許消費方主動ack。
對於正確消費ack的,沒什麼特殊的。但是對於reject和error,需要特別說明。reject這件事情,往往業務方是無法感知到的,系統的流量和健康狀況的評估,以及處理能力的評估是一件非常複雜的事情。舉個極端的例子,收到一個訊息開始build索引,可能這個訊息要處理半個小時,但訊息量卻是非常的小。所以reject這塊建議做成滑動視窗/執行緒池類似的模型來控制,
消費能力不匹配的時候,直接拒絕,過一段時間重發,減少業務的負擔。
但業務出錯這件事情是隻有業務方自己知道的,就像上文提到的狀態機等等。這時應該允許業務方主動ack error,並可以與broker約定下次投遞的時間。
重複訊息和順序訊息
上文談到重複訊息是不可能100%避免的,除非可以允許丟失,那麼,順序訊息能否100%滿足呢? 答案是可以,但條件更為苛刻:
- 允許訊息丟失。
- 從傳送方到服務方到接受者都是單點單執行緒。
所以絕對的順序訊息基本上是不能實現的,當然在METAQ/Kafka等pull模型的訊息佇列中,單執行緒生產/消費,排除訊息丟失,也是一種順序訊息的解決方案。
一般來講,一個主流訊息佇列的設計正規化裡,應該是不丟訊息的前提下,儘量減少重複訊息,不保證訊息的投遞順序。
談到重複訊息,主要是兩個話題:
- 如何鑑別訊息重複,並冪等的處理重複訊息。
- 一個訊息佇列如何儘量減少重複訊息的投遞。
先來看看第一個話題,每一個訊息應該有它的唯一身份。不管是業務方自定義的,還是根據IP/PID/時間戳生成的MessageId,如果有地方記錄這個MessageId,訊息到來是能夠進行比對就
能完成重複的鑑定。資料庫的唯一鍵/bloom filter/分散式KV中的key,都是不錯的選擇。由於訊息不能被永久儲存,所以理論上都存在訊息從持久化儲存移除的瞬間上游還在投遞的可能(上游因種種原因投遞失敗,不停重試,都到了下游清理訊息的時間)。這種事情都是異常情況下才會發生的,畢竟是小眾情況。兩分鐘訊息都還沒送達,多送一次又能怎樣呢?冪等的處理訊息是一門藝術,因為種種原因重複訊息或者錯亂的訊息還是來到了,說兩種通用的解決方案:
- 版本號。
- 狀態機。
版本號
舉個簡單的例子,一個產品的狀態有上線/下線狀態。如果訊息1是下線,訊息2是上線。不巧訊息1判重失敗,被投遞了兩次,且第二次發生在2之後,如果不做重複性判斷,顯然最終狀態是錯誤的。
但是,如果每個訊息自帶一個版本號。上游傳送的時候,標記訊息1版本號是1,訊息2版本號是2。如果再傳送下線訊息,則版本號標記為3。下游對於每次訊息的處理,同時維護一個版本號。
每次只接受比當前版本號大的訊息。初始版本為0,當訊息1到達時,將版本號更新為1。訊息2到來時,因為版本號>1.可以接收,同時更新版本號為2.當另一條下線訊息到來時,如果版本號是3.則是真實的下線訊息。如果是1,則是重複投遞的訊息。
如果業務方只關心訊息重複不重複,那麼問題就已經解決了。但很多時候另一個頭疼的問題來了,就是訊息順序如果和想象的順序不一致。比如應該的順序是12,到來的順序是21。則最後會發生狀態錯誤。
參考TCP/IP協議,如果想讓亂序的訊息最後能夠正確的被組織,那麼就應該只接收比當前版本號大一的訊息。並且在一個session週期內要一直儲存各個訊息的版本號。
如果到來的順序是21,則先把2存起來,待2到來後,再處理1,這樣重複性和順序性要求就都達到了。
狀態機
基於版本號來處理重複和順序訊息聽起來是個不錯的主意,但凡事總有瑕疵。使用版本號的最大問題是:
- 對傳送方必須要求訊息帶業務版本號。
- 下游必須儲存訊息的版本號,對於要嚴格保證順序的。
還不能只儲存最新的版本號的訊息,要把亂序到來的訊息都儲存起來。而且必須要對此做出處理。試想一個永不過期的”session”,比如一個物品的狀態,會不停流轉於上下線。那麼中間環節的所有儲存就必須保留,直到在某個版本號之前的版本一個不丟的到來,成本太高。
就剛才的場景看,如果訊息沒有版本號,該怎麼解決呢?業務方只需要自己維護一個狀態機,定義各種狀態的流轉關係。例如,”下線”狀態只允許接收”上線”訊息,“上線”狀態只能接收“下線訊息”,如果上線收到上線訊息,或者下線收到下線訊息,在訊息不丟失和上游業務正確的前提下。要麼是訊息發重了,要麼是順序到達反了。這時消費者只需要把“我不能處理這個訊息”告訴投遞者,要求投遞者過一段時間重發即可。而且重發一定要有次數限制,比如5次,避免死迴圈,就解決了。
舉例子說明,假設產品本身狀態是下線,1是上線訊息,2是下線訊息,3是上線訊息,正常情況下,訊息應該的到來順序是123,但實際情況下收到的訊息狀態變成了3123。
那麼下游收到3訊息的時候,判斷狀態機流轉是下線->上線,可以接收訊息。然後收到訊息1,發現是上線->上線,拒絕接收,要求重發。然後收到訊息2,狀態是上線->下線,於是接收這個訊息。
此時無論重發的訊息1或者3到來,還是可以接收。另外的重發,在一定次數拒絕後停止重發,業務正確。
中介軟體對於重複訊息的處理
迴歸到訊息佇列的話題來講。上述通用的版本號/狀態機/ID判重解決方案裡,哪些是訊息佇列該做的、哪些是訊息佇列不該做業務方處理的呢?其實這裡沒有一個完全嚴格的定義,但回到我們的出發點,我們保證不丟失訊息的情況下儘量少重複訊息,消費順序不保證。那麼重複訊息下和亂序訊息下業務的正確,應該是由消費方保證的,我們要做的是減少訊息傳送的重複。
我們無法定義業務方的業務版本號/狀態機,如果API裡強制需要指定版本號,則顯得過於綁架客戶了。況且,在消費方維護這麼多狀態,就涉及到一個消費方的訊息落地/多機間的同步消費狀態問題,複雜度指數級上升,而且只能解決部分問題。
減少重複訊息的關鍵步驟:
- broker記錄MessageId,直到投遞成功後清除,重複的ID到來不做處理,這樣只要傳送者在清除週期內能夠感知到訊息投遞成功,就基本不會在server端產生重複訊息。
- 對於server投遞到consumer的訊息,由於不確定對端是在處理過程中還是訊息傳送丟失的情況下,有必要記錄下投遞的IP地址。決定重發之前詢問這個IP,訊息處理成功了嗎?如果詢問無果,再重發。
2.3.2 事務
永續性是事務的一個特性,然而只滿足永續性卻不一定能滿足事務的特性。還是拿扣錢/加錢的例子講。滿足事務的一致性特徵,則必須要麼都不進行,要麼都能成功。
解決方案從大方向上有兩種:
- 兩階段提交,分散式事務。
- 本地事務,本地落地,補償傳送。
分散式事務存在的最大問題是成本太高,兩階段提交協議,對於仲裁down機或者單點故障,幾乎是一個無解的黑洞。對於交易密集型或者I/O密集型的應用,沒有辦法承受這麼高的網路延遲,系統複雜性。
並且成熟的分散式事務一定構建與比較靠譜的商用DB和商用中介軟體上,成本也太高。
那如何使用本地事務解決分散式事務的問題呢?以本地和業務在一個資料庫例項中建表為例子,與扣錢的業務操作同一個事務裡,將訊息插入本地資料庫。如果訊息入庫失敗,則業務回滾;如果訊息入庫成功,事務提交。
然後傳送訊息(注意這裡可以實時傳送,不需要等定時任務檢出,以提高訊息實時性)。以後的問題就是前文的最終一致性問題所提到的了,只要訊息沒有傳送成功,就一直靠定時任務重試。
這裡有一個關鍵的點,本地事務做的,是業務落地和訊息落地的事務,而不是業務落地和RPC成功的事務。這裡很多人容易混淆,如果是後者,無疑是事務巢狀RPC,是大忌,會有長事務死鎖等各種風險。
而訊息只要成功落地,很大程度上就沒有丟失的風險(磁碟物理損壞除外)。而訊息只要投遞到服務端確認後本地才做刪除,就完成了producer->broker的可靠投遞,並且當訊息儲存異常時,業務也是可以回滾的。
本地事務存在兩個最大的使用障礙:
- 配置較為複雜,“綁架”業務方,必須本地資料庫例項提供一個庫表。
- 對於訊息延遲高敏感的業務不適用。
話說回來,不是每個業務都需要強事務的。扣錢和加錢需要事務保證,但下單和生成簡訊卻不需要事務,不能因為要求發簡訊的訊息儲存投遞失敗而要求下單業務回滾。所以,一個完整的訊息佇列應該定義清楚自己可以投遞的訊息型別,如事務型訊息,本地非持久型訊息,以及服務端不落地的非可靠訊息等。對不同的業務場景做不同的選擇。另外事務的使用應該儘量低成本、透明化,可以依託於現有的成熟框架,如Spring的宣告式事務做擴充套件。業務方只需要使用@Transactional標籤即可。
2.3.3 效能相關
非同步/同步
首先澄清一個概念,非同步,同步和oneway是三件事。非同步,歸根結底你還是需要關心結果的,但可能不是當時的時間點關心,可以用輪詢或者回撥等方式處理結果;同步是需要當時關心的結果的;而oneway是發出去就不管死活的方式,這種對於某些完全對可靠性沒有要求的場景還是適用的,但不是我們重點討論的範疇。
迴歸來看,任何的RPC都是存在客戶端非同步與服務端非同步的,而且是可以任意組合的:客戶端同步對服務端非同步,客戶端非同步對服務端非同步,客戶端同步對服務端同步,客戶端非同步對服務端同步。
對於客戶端來說,同步與非同步主要是拿到一個Result,還是Future(Listenable)的區別。實現方式可以是執行緒池,NIO或者其他事件機制,這裡先不展開講。
服務端非同步可能稍微難理解一點,這個是需要RPC協議支援的。參考servlet 3.0規範,服務端可以吐一個future給客戶端,並且在future done的時候通知客戶端。
整個過程可以參考下面的程式碼:
客戶端同步服務端非同步。
1 2 3 4 5 6 7 |
Future<Result> future = request(server);//server立刻返回future synchronized(future){ while(!future.isDone()){ future.wait();//server處理結束後會notify這個future,並修改isdone標誌 } } return future.get(); |
客戶端同步服務端同步。
1 |
Result result = request(server); |
客戶端非同步服務端同步(這裡用執行緒池的方式)。
1 2 3 4 |
Future<Result> future = executor.submit(new Callable(){public void call<Result>(){ result = request(server); }}) return future; |
客戶端非同步服務端非同步。
1 2 3 |
Future<Result> future = request(server);//server立刻返回future return future |
上面說了這麼多,其實是想讓大家脫離兩個誤區:
- RPC只有客戶端能做非同步,服務端不能。
- 非同步只能通過執行緒池。
那麼,服務端使用非同步最大的好處是什麼呢?說到底,是解放了執行緒和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端處理完畢,才解除阻塞繼續進行。
總結一句,同步能夠保證結果,非同步能夠保證效率,要合理的結合才能做到最好的效率。
批量
談到批量就不得不提生產者消費者模型。但生產者消費者模型中最大的痛點是:消費者到底應該何時進行消費。大處著眼來看,消費動作都是事件驅動的。主要事件包括:
- 攢夠了一定數量。
- 到達了一定時間。
- 佇列裡有新的資料到來。
對於及時性要求高的資料,可用採用方式3來完成,比如客戶端向服務端投遞資料。只要佇列有資料,就把佇列中的所有資料刷出,否則將自己掛起,等待新資料的到來。
在第一次把佇列資料往外刷的過程中,又積攢了一部分資料,第二次又可以形成一個批量。虛擬碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Executor executor = Executors.newFixedThreadPool(4); final BlockingQueue<Message> queue = new ArrayBlockingQueue<>(); private Runnable task = new Runnable({//這裡由於共享佇列,Runnable可以複用,故做成全域性的 public void run(){ List<Message> messages = new ArrayList<>(20); queue.drainTo(messages,20); doSend(messages);//阻塞,在這個過程中會有新的訊息到來,如果4個執行緒都佔滿,佇列就有機會囤新的訊息 } }); public void send(Message message){ queue.offer(message); executor.submit(task) } |
這種方式是訊息延遲和批量的一個比較好的平衡,但優先響應低延遲。延遲的最高程度由上一次傳送的等待時間決定。但可能造成的問題是傳送過快的話批量的大小不夠滿足效能的極致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
Executor executor = Executors.newFixedThreadPool(4); final BlockingQueue<Message> queue = new ArrayBlockingQueue<>(); volatile long last = System.currentMills(); Executors.newSingleThreadScheduledExecutor().submit(new Runnable(){ flush(); },500,500,TimeUnits.MILLS); private Runnable task = new Runnable({//這裡由於共享佇列,Runnable可以複用,顧做成全域性的。 public void run(){ List<Message> messages = new ArrayList<>(20); queue.drainTo(messages,20); doSend(messages);//阻塞,在這個過程中會有新的訊息到來,如果4個執行緒都佔滿,佇列就有機會屯新的訊息。 } }); public void send(Message message){ last = System.currentMills(); queue.offer(message); flush(); } private void flush(){ if(queue.size>200||System.currentMills()-last>200){ executor.submit(task) } } |
相反對於可以用適量的延遲來換取高效能的場景來說,用定時/定量二選一的方式可能會更為理想,既到達一定數量才傳送,但如果數量一直達不到,也不能幹等,有一個時間上限。
具體說來,在上文的submit之前,多判斷一個時間和數量,並且Runnable內部維護一個定時器,避免沒有新任務到來時舊的任務永遠沒有機會觸發傳送條件。對於server端的資料落地,使用這種方式就非常方便。
最後囉嗦幾句,曾經有人問我,為什麼網路請求小包合併成大包會提高效能?主要原因有兩個:
- 減少無謂的請求頭,如果你每個請求只有幾位元組,而頭卻有幾十位元組,無疑效率非常低下。
- 減少回覆的ack包個數。把請求合併後,ack包數量必然減少,確認和重發的成本就會降低。
2.4 push還是pull
上文提到的訊息佇列,大多是針對push模型的設計。現在市面上有很多經典的也比較成熟的pull模型的訊息佇列,如Kafka、MetaQ等。這跟JMS中傳統的push方式有很大的區別,可謂另闢蹊徑。
我們簡要分析下push和pull模型各自存在的利弊。
2.4.1 慢消費
慢消費無疑是push模型最大的致命傷,穿成流水線來看,如果消費者的速度比傳送者的速度慢很多,勢必造成訊息在broker的堆積。假設這些訊息都是有用的無法丟棄的,訊息就要一直在broker端儲存。當然這還不是最致命的,最致命的是broker給consumer推送一堆consumer無法處理的訊息,consumer不是reject就是error,然後來回踢皮球。
反觀pull模式,consumer可以按需消費,不用擔心自己處理不了的訊息來騷擾自己,而broker堆積訊息也會相對簡單,無需記錄每一個要傳送訊息的狀態,只需要維護所有訊息的佇列和偏移量就可以了。所以對於建立索引等慢消費,訊息量有限且到來的速度不均勻的情況,pull模式比較合適。
2.4.2 訊息延遲與忙等
這是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加一個時間上限比較好~
2.4.3 順序訊息
如果push模式的訊息佇列,支援分割槽,單分割槽只支援一個消費者消費,並且消費者只有確認一個訊息消費後才能push送另外一個訊息,還要傳送者保證全域性順序唯一,聽起來也能做順序訊息,但成本太高了,尤其是必須每個訊息消費確認後才能發下一條訊息,這對於本身堆積能力和慢消費就是瓶頸的push模式的訊息佇列,簡直是一場災難。
反觀pull模式,如果想做到全域性順序訊息,就相對容易很多:
- producer對應partition,並且單執行緒。
- consumer對應partition,消費確認(或批量確認),繼續消費即可。
所以對於日誌push送這種最好全域性有序,但允許出現小誤差的場景,pull模式非常合適。如果你不想看到通篇亂套的日誌~~
Anyway,需要順序訊息的場景還是比較有限的而且成本太高,請慎重考慮。
3 總結
本文從為何使用訊息佇列開始講起,然後主要介紹瞭如何從零開始設計一個訊息佇列,包括RPC、事務、最終一致性、廣播、訊息確認等關鍵問題。並對訊息佇列的push、pull模型做了簡要分析,最後從批量和非同步角度,分析了訊息佇列效能優化的思路。下篇會著重介紹一些高階話題,如儲存系統的設計、流控和錯峰的設計、公平排程等。希望通過這些,讓大家對訊息佇列有個提綱挈領的整體認識,並給自主開發訊息佇列提供思路。另外,本文主要是源自自己在開發訊息佇列中的思考和讀原始碼時的體會,比較不”官方”,也難免會存在一些漏洞,歡迎大家多多交流。
後續我們還會推出訊息佇列設計高階篇,內容會涵蓋以下方面:
- pull模型訊息系統設計理念
- 儲存子系統設計
- 流量控制
- 公平排程
敬請期待哦~
作者簡介
王燁,現在是美團旅遊後臺研發組的程式猿,之前曾經在百度、去哪和優酷工作過,專注Java後臺開發。對於網路程式設計和併發程式設計具有濃厚的興趣,曾經做過一些基礎元件,也翻過一些原始碼,屬於比較典型的宅男技術控。期待能夠與更多知己,在coding的路上並肩前行~