導讀:雲原生化實踐+效能最佳化+全新的消費模型,訊息中介軟體 RocketMQ 在 2020 年迎來了這些變化。
作者|愈安
前言2020 年雙 11 交易峰值達到 58.3W 筆/秒,訊息中介軟體 RocketMQ 繼續數年 0 故障絲般順滑地完美支援了整個集團大促的各類業務平穩。今年雙 11 大促中,訊息中介軟體 RocketMQ 發生了以下幾個方面的變化:
雲原生化實踐。完成運維層面的雲原生化改造,實現 Kubernetes 化。效能最佳化。訊息過濾最佳化交易叢集效能提升 30%。全新的消費模型。對於延遲敏感業務提供新的消費模式,降低因釋出、重啟等場景下導致的消費延遲。訊息中介軟體早在 2016 年,透過內部團隊提供的中介軟體部署平臺實現了容器化和自動化釋出,整體的運維比 2016 年前已經有了很大的提高,但是作為一個有狀態的服務,在運維層面仍然存在較多的問題。
中介軟體部署平臺幫我們完成了資源的申請,容器的建立、初始化、映象安裝等一系列的基礎工作,但是因為中介軟體各個產品都有自己不同的部署邏輯,所以在應用的釋出上,就是各應用自己的定製化了。中介軟體部署平臺的開發也不完全瞭解集團內 RocketMQ 的部署過程是怎樣的。
因此在 2016 年的時候,部署平臺需要我們去親自實現訊息中介軟體的應用釋出程式碼。雖然部署平臺大大提升了我們的運維效率,甚至還能實現一鍵釋出,但是這樣的方案也有不少的問題。比較明顯的就是,當我們的釋出邏輯有變化的時候,還需要去修改部署平臺對應的程式碼,需要部署平臺升級來支援我們,用最近比較流行的一個說法,就是相當不雲原生。
同樣在故障機替換、叢集縮容等操作中,存在部分人工參與的工作,如切流、堆積資料的確認等。我們嘗試過在部署平臺中整合更多訊息中介軟體自己的運維邏輯,不過在其他團隊的工程裡寫自己的業務程式碼,確實也是一個不太友好的實現方案,因此我們希望透過 Kubernetes 來實現訊息中介軟體自己的 operator。我們同樣希望利用雲化後雲盤的多副本能力來降低我們的機器成本並降低主備運維的複雜程度。
經過一段時間的跟進與探討,最終再次由內部團隊承擔了建設雲原生應用運維平臺的任務,並依託於中介軟體部署平臺的經驗,藉助雲原生技術棧,實現對有狀態應用自動化運維的突破。
實現整體的實現方案如上圖所示,透過自定義的 CRD 對訊息中介軟體的業務模型進行抽象,將原有的在中介軟體部署平臺的業務釋出部署邏輯下沉到訊息中介軟體自己的 operator 中,託管在內部 Kubernetes 平臺上。該平臺負責所有的容器生產、初始化以及集團內一切線上環境的基線部署,遮蔽掉 IaaS 層的所有細節。
Operator 承擔了所有的新建叢集、擴容、縮容、遷移的全部邏輯,包括每個 pod 對應的 brokerName 自動生成、配置檔案,根據叢集不同功能而配置的各種開關,元資料的同步複製等等。同時之前一些人工的相關操作,比如切流時候的流量觀察,下線前的堆積資料觀察等也全部整合到了 operator 中。當我們有需求重新修改各種運維邏輯的時候,也再也不用去依賴通用的具體實現,修改自己的 operator 即可。
雲化後的 ECS 使用的是高速雲盤,底層將對資料做了多備份,因此資料的可用性得到了保障。並且高速雲盤在效能上完全滿足 MQ 同步刷盤,因此,此時就可以把之前的非同步刷盤改為同步,保證訊息寫入時的不丟失問題。雲原生模式下,所有的例項環境均是一致性的,依託容器技術和 Kubernetes 的技術,可實現任何例項掛掉(包含宕機引起的掛掉),都能自動自愈,快速恢復。
解決了資料的可靠性和服務的可用性後,整個雲原生化後的架構可以變得更加簡單,只有 broker 的概念,再無主備之分。
大促驗證上圖是 Kubernetes 上線後雙十一大促當天的傳送 RT 統計,可見大促期間的傳送 RT 較為平穩,整體符合預期,雲原生化實踐完成了關鍵性的里程碑。
效能最佳化背景RocketMQ 至今已經連續 7 年 0 故障支援集團的雙十一大促。自從 RocketMQ 誕生以來,為了能夠完全承載包括集團業務中臺交易訊息等核心鏈路在內的各類關鍵業務,複用了原有的上層協議邏輯,使得各類業務方完全無感知的切換到 RocketMQ 上,並同時充分享受了更為穩定和強大的 RocketMQ 訊息中介軟體的各類特性。
當前,申請訂閱業務中臺的核心交易訊息的業務方一直都在不斷持續增加,並且隨著各類業務複雜度提升,業務方的訊息訂閱配置也變得更加複雜繁瑣,從而使得交易叢集的進行過濾的計算邏輯也變得更為複雜。這些業務方部分沿用舊的協議邏輯(Header 過濾),部分使用 RocketMQ 特有的 SQL 過濾。
主要成本目前集團內部 RocketMQ 的大促機器成本絕大部分都是交易訊息相關的叢集,在雙 11 零點峰值期間,交易叢集的峰值和交易峰值成正比,疊加每年新增的複雜訂閱帶來了額外 CPU 過濾計算邏輯,交易叢集都是大促中機器成本增長最大的地方。
最佳化過程由於歷史原因,大部分的業務方主要還是使用 Header 過濾,內部實現其實是 aviator 表示式。仔細觀察交易訊息叢集的業務方過濾表示式,可以發現絕大部分都指定類似 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
同時需要考慮到提取後父表示式的短路問題。
在編譯過程中針對 messageType==XXX 這種型別進行提取後,把原有的 message==XXX 轉變為 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' && buyerId==123456) || (messageType=='200-trade-success' && buyerId==3333)
就必須考慮到不等於的情況了。同理,如果考慮到多個表示式巢狀,需要逐步進行短路計算。但整體邏輯是類似的,這裡就不再贅述。
說完技術點 1,我們繼續關注技術點 2,考慮到高效過濾,直接使用 HashMap 結構進行索引化即可,即把 messageType 的值作為 HashMap 的 key,把提取後的子表示式作為 HashMap 的 value,這樣每次過濾直接透過一次 hash 計算即可過濾掉絕大部分不適合的表示式,大大提高了過濾效率。
最佳化效果該最佳化最主要降低了 CPU 計算邏輯,根據最佳化前後的效能情況對比,我們發現不同的交易叢集中的訂閱方訂閱表示式複雜度越高,最佳化效果越好,這個是符合我們的預期的,其中最大的 CPU 最佳化有 32% 的提升,大大降低了本年度 RocketMQ 的部署機器成本。
全新的消費模型——POP 消費背景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 Client 1 和 Pop Client 2 進行消費。這樣就 hang 機器造成的避免了消費堆積。
實現POP 消費和原來 PULL 消費對比,最大的一點就是弱化了佇列這個概念,PULL 消費需要客戶端透過 rebalance 把 broker 的佇列分配好,從而去消費分配到自己專屬的佇列,新的 POP 消費中,客戶端的機器會直接到每個 broker 的佇列進行請求消費, broker 會把訊息分配返回給等待的機器。隨後客戶端消費結束後返回對應的 Ack 結果通知 broker,broker 再標記訊息消費結果,如果超時沒響應或者消費失敗,再會進行重試。
POP 消費的架構圖如上圖所示。Broker 對於每次 POP 的請求,都會有以下三個操作:
1. 對應的佇列進行加鎖,然後從 store 層獲取該佇列的訊息;
2. 然後寫入 CK 訊息,表明獲取的訊息要被 POP 消費;
3. 最後提交當前位點,並釋放鎖。
從整體流程可見,POP 消費並不需要 reblance,可以避免 rebalance 帶來的消費延時,同時客戶端可以消費 broker 的所有佇列,這樣就可以避免機器 hang 而導致堆積的問題。