年终盘点 | 七年零故障支撑 双11 的消息中间件 RocketMQ,怎么做到的?
作者 | 愈安
來源|阿里巴巴云原生公眾號
2020 年雙十一交易峰值達到 58.3W 筆/秒,消息中間件 RocketMQ 繼續數年 0 故障絲般順滑地完美支持了整個集團大促的各類業務平穩。今年雙十一大促中,消息中間件 RocketMQ 發生了以下幾個方面的變化:
-
云原生化實踐。完成運維層面的云原生化改造,實現 Kubernetes 化。
-
性能優化。消息過濾優化交易集群性能提升 30%。
-
全新的消費模型。對于延遲敏感業務提供新的消費模式,降低因發布、重啟等場景下導致的消費延遲。
云原生化實踐
1. 背景
Kubernetes 作為目前云原生化技術棧實踐中重要的一環,其生態已經逐步建立并日益豐富。目前,服務于集團內部的 RocketMQ 集群擁有巨大的規模以及各種歷史因素,因此在運維方面存在相當一部分痛點,我們希望能夠通過云原生技術棧來嘗試找到對應解決方案,并同時實現降本提效,達到無人值守的自動化運維。
消息中間件早在 2016 年,通過內部團隊提供的中間件部署平臺實現了容器化和自動化發布,整體的運維比 2016 年前已經有了很大的提高,但是作為一個有狀態的服務,在運維層面仍然存在較多的問題。
中間件部署平臺幫我們完成了資源的申請,容器的創建、初始化、鏡像安裝等一系列的基礎工作,但是因為中間件各個產品都有自己不同的部署邏輯,所以在應用的發布上,就是各應用自己的定制化了。中間件部署平臺的開發也不完全了解集團內 RocketMQ 的部署過程是怎樣的。
因此在 2016 年的時候,部署平臺需要我們去親自實現消息中間件的應用發布代碼。雖然部署平臺大大提升了我們的運維效率,甚至還能實現一鍵發布,但是這樣的方案也有不少的問題。比較明顯的就是,當我們的發布邏輯有變化的時候,還需要去修改部署平臺對應的代碼,需要部署平臺升級來支持我們,用最近比較流行的一個說法,就是相當不云原生。
同樣在故障機替換、集群縮容等操作中,存在部分人工參與的工作,如切流,堆積數據的確認等。我們嘗試過在部署平臺中集成更多消息中間件自己的運維邏輯,不過在其他團隊的工程里寫自己的業務代碼,確實也是一個不太友好的實現方案,因此我們希望通過 Kubernetes 來實現消息中間件自己的 operator 。我們同樣希望利用云化后云盤的多副本能力來降低我們的機器成本并降低主備運維的復雜程度。
經過一段時間的跟進與探討,最終再次由內部團隊承擔了建設云原生應用運維平臺的任務,并依托于中間件部署平臺的經驗,借助云原生技術棧,實現對有狀態應用自動化運維的突破。
2. 實現
整體的實現方案如上圖所示,通過自定義的 CRD 對消息中間件的業務模型進行抽象,將原有的在中間件部署平臺的業務發布部署邏輯下沉到消息中間件自己的 operator 中,托管在內部 Kubernetes 平臺上。該平臺負責所有的容器生產、初始化以及集團內一切線上環境的基線部署,屏蔽掉 IaaS 層的所有細節。
Operator 承擔了所有的新建集群、擴容、縮容、遷移的全部邏輯,包括每個 pod 對應的 brokerName 自動生成、配置文件,根據集群不同功能而配置的各種開關,元數據的同步復制等等。同時之前一些人工的相關操作,比如切流時候的流量觀察,下線前的堆積數據觀察等也全部集成到了 operator 中。當我們有需求重新修改各種運維邏輯的時候,也再也不用去依賴通用的具體實現,修改自己的 operator 即可。
最后線上的實際部署情況去掉了圖中的所有的 replica 備機。在 Kubernetes 的理念中,一個集群中每個實例的狀態是一致的,沒有依賴關系,而如果按照消息中間件原有的主備成對部署的方案,主備之間是有嚴格的對應關系,并且在上下線發布過程中有嚴格的順序要求,這種部署模式在 Kubernetes 的體系下是并不提倡的。若依然采用以上老的架構方式,會導致實例控制的復雜性和不可控性,同時我們也希望能更多的遵循 Kubernetes 的運維理念。
云化后的 ECS 使用的是高速云盤,底層將對數據做了多備份,因此數據的可用性得到了保障。并且高速云盤在性能上完全滿足 MQ 同步刷盤,因此,此時就可以把之前的異步刷盤改為同步,保證消息寫入時的不丟失問題。云原生模式下,所有的實例環境均是一致性的,依托容器技術和 Kubernetes 的技術,可實現任何實例掛掉(包含宕機引起的掛掉),都能自動自愈,快速恢復。
解決了數據的可靠性和服務的可用性后,整個云原生化后的架構可以變得更加簡單,只有 broker 的概念,再無主備之分。
3. 大促驗證
上圖是 Kubernetes 上線后雙十一大促當天的發送 RT 統計,可見大促期間的發送 RT 較為平穩,整體符合預期,云原生化實踐完成了關鍵性的里程碑。
性能優化
1. 背景
RocketMQ 至今已經連續七年 0 故障支持集團的雙十一大促。自從 RocketMQ 誕生以來,為了能夠完全承載包括集團業務中臺交易消息等核心鏈路在內的各類關鍵業務,復用了原有的上層協議邏輯,使得各類業務方完全無感知的切換到 RocketMQ 上,并同時充分享受了更為穩定和強大的 RocketMQ 消息中間件的各類特性。
當前,申請訂閱業務中臺的核心交易消息的業務方一直都在不斷持續增加,并且隨著各類業務復雜度提升,業務方的消息訂閱配置也變得更加復雜繁瑣,從而使得交易集群的進行過濾的計算邏輯也變得更為復雜。這些業務方部分沿用舊的協議邏輯(Header過濾),部分使用 RocketMQ 特有的 SQL 過濾。
2. 主要成本
目前集團內部 RocketMQ 的大促機器成本絕大部分都是交易消息相關的集群,在雙十一零點峰值期間,交易集群的峰值和交易峰值成正比,疊加每年新增的復雜訂閱帶來了額外 CPU 過濾計算邏輯,交易集群都是大促中機器成本增長最大的地方。
3. 優化過程
由于歷史原因,大部分的業務方主要還是使用 Header 過濾,內部實現其實是 aviator 表達式( https://github.com/killme2008/aviatorscript )。仔細觀察交易消息集群的業務方過濾表達式,可以發現絕大部分都指定類似 MessageType == xxxx 這樣的條件。翻看 aviator 的源碼可以發現這樣的條件最終會調用 Java 的字符串比較 String.compareTo()。
由于交易消息包括大量不同業務的 MessageType,光是有記錄的起碼有幾千個,隨著交易業務流程復雜化,MessageType 的增長更是繁多。隨著交易峰值的提高,交易消息峰值正比增長,疊加這部分更加復雜的過濾,持續增長的將來,交易集群的成本極可能和交易峰值指數增長,因此決心對這部分進行優化。
原有的過濾流程如下,每個交易消息需要逐個匹配不同 group 的訂閱關系表達式,如果符合表達式,則選取對應的 group 的機器進行投遞。如下圖所示:
對此流程進行優化的思路需要一定的靈感,在這里借助數據庫索引的思路:原有流程可以把所有訂閱方的過濾表達式看作數據庫的記錄,每次消息過濾就相當于一個帶有特定條件的數據庫查詢,把所有匹配查詢(消息)的記錄(過濾表達式)選取出來作為結果。為了加快查詢結果,可以選擇 MessageType 作為一個索引字段進行索引化,每次查詢變為先匹配 MessageType 主索引,然后把匹配上主索引的記錄再進行其它條件(如下圖的 sellerId 和 testA )匹配,優化流程如下圖所示:
以上優化流程確定后,要關注的技術點有兩個:
-
技術點 1:如何抽取每個表達式中的 MessageType 字段?
-
技術點 2:如何對 MessageType 字段進行索引化?
對于技術點 1 ,需要針對 aviator 的編譯流程進行 hook ,深入 aviator 源碼后,可以發現 aviator 的編譯是典型的 Recursive descent :http://en.wikipedia.org/wiki/Recursive_descent_parser,同時需要考慮到提取后父表達式的短路問題。
在編譯過程中針對 messageTypeXXX 這種類型進行提取后,把原有的 messageXXX 轉變為 true/false 兩種情況,然后針對 true、false 進行表達式的短路即可得出表達式優化提取后的情況。例如:
表達式: messageType=='200-trade-paid-done' && buyerId==123456 提取為兩個子表達式: 子表達式1(messageType==200-trade-paid-done):buyerId==123456 子表達式2(messageType!=200-trade-paid-done):false- 具體到 aviator 的實現里,表達式編譯會把每個 token 構建一個 List ,類似如下圖所示(為方便理解,綠色方框的是 token ,其它框表示表達式的具體條件組合):
提取了 messageType ,有兩種情況:
- 情況一:messageType == ‘200-trade-paid-done’,則把之前 token 的位置合并成true,然后進行表達式短路計算,最后優化成 buyerId==123456 ,具體如下:
- 情況二:messageType != ‘200-trade-paid-done’,則把之前 token 的位置合并成 false ,表達式短路計算后,最后優化成 false ,具體如下:
這樣就完成 messageType 的提取。這里可能有人就有一個疑問,為什么要考慮到上面的情況二,messageType != ‘200-trade-paid-done’,這是因為必須要考慮到多個條件的時候,比如:
(messageType==‘200-trade-paid-done’ && buyerId123456) || (messageType’200-trade-success’ && buyerId==3333)
就必須考慮到不等于的情況了。同理,如果考慮到多個表達式嵌套,需要逐步進行短路計算。但整體邏輯是類似的,這里就不再贅述。
說完技術點 1,我們繼續關注技術點 2,考慮到高效過濾,直接使用 HashMap 結構進行索引化即可,即把 messageType 的值作為 HashMap 的 key ,把提取后的子表達式作為 HashMap 的 value ,這樣每次過濾直接通過一次 hash 計算即可過濾掉絕大部分不適合的表達式,大大提高了過濾效率。
4. 優化效果
該優化最主要降低了 CPU 計算邏輯,根據優化前后的性能情況對比,我們發現不同的交易集群中的訂閱方訂閱表達式復雜度越高,優化效果越好,這個是符合我們的預期的,其中最大的 CPU 優化有 32% 的提升,大大降低了本年度 RocketMQ 的部署機器成本。
全新的消費模型 —— POP 消費
1. 背景
RocketMQ 的 PULL 消費對于機器異常 hang 時并不十分友好。如果遇到客戶端機器hang住,但處于半死不活的狀態,與 broker 的心跳沒有斷掉的時候,客戶端 rebalance 依然會分配消費隊列到 hang 機器上,并且 hang 機器消費速度很慢甚至無法消費的時候,這樣會導致消費堆積。另外類似還有服務端 Broker 發布時,也會由于客戶端多次 rebalance 導致消費延遲影響等無法避免的問題。如下圖所示:
當 Pull Client 2 發生 hang 機器的時候,它所分配到的三個 Broker 上的 Q2 都出現嚴重的紅色堆積。對于此,我們增加了一種新的消費模型——POP 消費,能夠解決此類穩定性問題。如下圖所示:
POP 消費中,三個客戶端并不需要 rebalance 去分配消費隊列,取而代之的是,它們都會使用 POP 請求所有的 broker 獲取消息進行消費。broker 內部會把自身的三個隊列的消息根據一定的算法分配給請求的 POP Client。即使 Pop Client 2 出現 hang,但內部隊列的消息也會讓 Pop Client1 和 Pop Client2 進行消費。這樣就 hang 機器造成的避免了消費堆積。
2. 實現
POP 消費和原來 PULL 消費對比,最大的一點就是弱化了隊列這個概念,PULL 消費需要客戶端通過 rebalance 把 broker 的隊列分配好,從而去消費分配到自己專屬的隊列,新的 POP 消費中,客戶端的機器會直接到每個 broker 的隊列進行請求消費, broker 會把消息分配返回給等待的機器。隨后客戶端消費結束后返回對應的 Ack 結果通知 broker,broker 再標記消息消費結果,如果超時沒響應或者消費失敗,再會進行重試。
POP 消費的架構圖如上圖所示。Broker 對于每次 POP 的請求,都會有以下三個操作:
-
對應的隊列進行加鎖,然后從 store 層獲取該隊列的消息。
-
然后寫入 CK 消息,表明獲取的消息要被 POP 消費。
-
最后提交當前位點,并釋放鎖。
CK 消息實際上是記錄了 POP 消息具體位點的定時消息,當客戶端超時沒響應的時候,CK 消息就會重新被 broker 消費,然后把 CK 消息的位點的消息寫入重試隊列。如果 broker 收到客戶端的消費結果的 Ack ,刪除對應的 CK 消息,然后根據具體結果判斷是否需要重試。
從整體流程可見,POP 消費并不需要 reblance ,可以避免 rebalance 帶來的消費延時,同時客戶端可以消費 broker 的所有隊列,這樣就可以避免機器 hang 而導致堆積的問題。
加入我們
阿里云云原生消息中間件致力于打造世界級的消息隊列,我們持續引領業界先進的消息隊列技術,歡迎各位有志者加入。
簡歷投遞:longji.lqs@alibaba-inc.com 。
招聘 JD:https://job.alibaba.com/zhaopin/PositionDetail.htm?positionId=98461
總結
以上是生活随笔為你收集整理的年终盘点 | 七年零故障支撑 双11 的消息中间件 RocketMQ,怎么做到的?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 刚刚,阿里云知行动手实验室正式开放公测了
- 下一篇: OpenKruise v0.7.0 版本