螞蟻消息中間件 (MsgBroker) 在 YGC 優化上的探索

原創聲明:本文系作者原創,謝絕個人、媒體、公眾號或網站未經授權轉載,違者追究其法律責任。

導讀

GC 一直是 Java 應用中被討論得最多的話題之一,尤其對於消息中間件這樣的基礎應用,GC 停頓產生的延遲會嚴重影響其在線服務能力,是開發和運維人員關注的重點。

關於 GC 優化,首先最容易想到的就是調整那些影響 GC 性能的 JVM 參數(如新生代與老年代的大小、晉陞到老年代的年齡、甚至是 GC 回收器類型等),使得老年代中存活的對象數量儘可能的少,從而降低 GC 停頓時間。然而,除了少數較為通用的參數設置方法可以參照和遵循,在大部分場景下,由於不同應用所創建對象的大小與生命周期不盡相同,GC 參數調優實際上是個非常複雜且極具個性化的工作,並不存在萬能的調優策略可以滿足所有的場景。同時,由於虛擬機內部已經做了很多優化來盡量降低 GC 的停頓時間,GC 參數調優並不一定能達到預期的效果,甚至很可能適得其反。

拋開被老生常談的 GC 參數調優,本文將通過講述螞蟻消息中間件(MsgBroker) 的 YGCT 從 120ms 優化到 30ms 的歷程,並從中總結出較為通用的 YGC 優化策略。

背景

談到 GC,很多人的第一反應是 JVM 長時間停頓或 FGC 導致服務長時間不可用,但對於 MsgBroker 這樣的基礎消息服務而言,對 GC 停頓會更加敏感,需要解決的 GC 問題也更加複雜:

  1. 對於普通應用,如果 YGC 耗時在 100ms 以內,一般是無需進行優化的。但對於 MsgBroker 這類在線基礎服務,GC 停頓產生的延遲根據業務的複雜程度會被放大數倍甚至數十倍,過高的 YGC 耗時會嚴重損害業務的實時性和用戶體驗,因此需要被嚴格控制在 50ms 以內,且越低越好。然而,隨著新特性的開發和消息量的增長,我們發現 MsgBroker 的 YGC 平均耗時已緩慢增長至 50ms~60ms,甚至部分機房的 YGC 平均耗時已高達 120ms。
  2. 一方面,為保證消息數據的高可靠,MsgBroker 使用 DB 進行消息的持久化,並使用消息緩存降低消息投遞時對 DB 的讀壓力;另一方面,作為一個主要服務於在線業務的消息系統,為嚴格保證消息的實時性,MsgBroker 使用推模型進行消息投遞。然而,我們發現,當訂閱端的能力與發送端不匹配時,會產生大量的投遞超時,並進一步加重MsgBroker 的內存和 GC 壓力。訂閱端的消費能力會對 MsgBroker 的服務質量造成影響,這在絕大部分場景下是難以接受的。
  3. 在某些極端場景下(例如訂閱端容量出現問題,大量消息持續投遞超時,隨著積壓的消息越來越多,甚至可能引發下游鏈路「雪崩」,導致長時間無法恢復),YGC 耗時非常高,同時也有可能發生 FULL GC,而觸發原因主要為 promotion failed 以及 concurrent mode failure,懷疑是因為內存碎片過多所致。

需要指出的是,MsgBroker 運行在普通的 4C8G 機器上,堆大小為 4G,因此使用的是 ParNew CMS 垃圾回收器。

JVM 基礎

為了更好地理解後面提及的 YGC 優化思路和策略,需要先回顧一下與 GC 相關的基礎知識。

GC 分代假設

對傳統的、基本的 GC 實現來說,由於它們在 GC 的整個工作過程中都要 「stop-the-world」,如何縮短 GC 的工作時長是一件非常重要的事情。為了降低單次回收的時間,目前絕大部分的 GC 演算法,都是將堆內存進行分代 (Generation) 處理,將不同年齡的對象置於不同的內存空間,並針對不同空間中對象的特性使用更有效率的回收演算法分別進行回收,而這主要是基於如下的分代假設:

  • 絕大部分對象的生命周期都非常短暫
  • 剩下的對象,則很可能會存活很長時間,並不太可能使用到年輕對象

基於這個假設,JVM 將內存分為年輕代和老年代,讓新創建的對象都從年輕代中分配,通過頻繁對年輕代進行回收,絕大部分垃圾都能在 YGC 中被回收,只剩下極少部分的對象需要晉陞到老年代中。由於整個年輕代通常比較小,只佔整個堆內存的 1/3 ~ 1/2,並且處於其內對象的存活率很低,非常適合使用拷貝演算法來進行回收,能有效降低 YGC 時的停頓時間,降低對應用的影響。

然而,如果應用中的對象不滿足上述提到的分代假設,例如出現了大量生命周期中等的對象,則會嚴重影響 YGC 的效率。

YGC 的基本過程

基於標記-複製演算法的 YGC 大致分為如下幾個步驟:

  1. GC Roots 開始查找並標註存活的對象
  2. 將 eden 區和 from 區存活的對象拷貝到 to 區
  3. 清理 eden 區和 from 區

YGC 耗時分析

當使用 G1 收集器時,通過 -XX:+PrintGCDetails 參數可以生成最為詳細的 GC 日誌,通過該詳細日誌,可以查看到 GC 各個階段的耗時,為 GC 優化提供便利。然而如果使用的是 ParNew CMS 垃圾回收器,實際上官方並未提供可以查看 GC 各階段耗時的方法。所幸在 AliJDK 中,提供了類似的功能,通過 PrintGCRootsTraceTime 能列印出 ParNew CMS 的詳細耗時。MsgBroker 的 GC 詳情日誌如下:

從上述詳細日誌中可以看出,YGC 主要存在如下各個階段:

  • 各種 Roots 階段:從各類型 root 對象出發標記存活對象
  • older-gen scanning:掃描老年代到新生代的引用以及拷貝 eden 區和 from 區中的存活對象至 to 區
  • other:將需要晉陞的對象從新生代拷貝到老年代

通常情況下,older-gen scanning 階段會在YGC中佔用大部分耗時。從上述 GC 詳細日誌中也能看出,MsgBroker 的 YGC 耗時大約在 90ms,而 older-gen scanning 階段就佔用了約 80ms。

old-gen scanning 階段

為了有針對性地對 old-gen scanning 階段耗時進行優化,有必要先了解一下為什麼會有 old-gen scanning 階段。

在常見的垃圾回收演算法中,無論是拷貝演算法,還是標記-清除演算法,又或者是標記-整理演算法,都需要從一系列的 Roots 節點出發,根據引用關係遍歷和標記所有存活的對象。

對於 YGC,在從 GC Roots 開始遍歷並標記所有的存活對象時,會放棄追蹤處於老年代的對象,由於需要遍歷的對象數目減少,能顯著提升 GC 的效率。

但這會產生一個問題:如果某個年輕代對象並不能通過 GC Roots 遍歷到,而某個老年代對象卻引用了該年輕代的對象,那麼該如何正確標記到該對象?

為解決這個問題,一個最直觀的想法就是遍歷整個老年代,找到其中持有年輕代引用的對象,但顯然這樣做的開銷太大,且違背了分代 GC 的設計。因此,垃圾回收器必須能夠以較高的效率準確找到並跟蹤那些處於老年代且持有年輕代引用的對象,並將這部分對象放到和 GC Roots 同等的位置,這就是 old-gen scanning 階段的來歷。

下圖大致展示了 YGC 時是如何追蹤和標記存活的對象的。圖中的箭頭表示對象之間的引用關係,其中紅色箭頭表示老年代到年輕代的引用,這部分對象會被添加到 old-gen scanning 中,而藍色的箭頭表示 GC Roots 或年輕代對象到老年代的引用,這部分對象在 YGC 階段實際上是無需進行追蹤的。

Card marking

回憶之前提到的分代假設,其中一條即是:存在少部分對象,可能會存活很長時間,並不太可能使用到年輕對象。這意味著,只有極少部分的老年代對象,會持有年輕代對象的引用,如果使用遍歷整個老年代的方式找出這部分對象,顯然效率十分低下。

一般而言,如下兩種情況會使得老年代對象持有年輕代的引用:

  • 持有其他年輕代對象引用的對象被晉陞到老年代
  • 某個老年代對象持有的引用被修改為指向某個年輕代對象

對於第一種情況,因為晉陞本身就發生在 YGC 執行期間,垃圾回收器能夠明確知曉哪些對象需要被晉陞到老年代,而對於第二種情況,則需要依賴額外的設計。

在 HotSpot JVM 的實現中,ParNew 使用 Card marking 演算法來識別老年代對象所持有引用的修改。在該演算法中,老年代空間被分成大小為 512B 的若干個 card,並由 JVM 使用一個數組來維護其映射關係,數組中的每一位代表一個 card。每當修改堆中對象的引用時,就會將對應的 card 置為 dirty。當進行 YGC 時,只需要先通過掃描 card 數組,就可以很快識別出哪部分空間可能存在老年代對象持有年輕代對象引用的情況,通過空間換時間的方式,避免對整個老年代進行掃描。

YGC 優化

ParGCCardsPerStrideChunk 參數

既然 old-gen scanning 在 YGC 中佔用大部分耗時,是 YGC 耗時高的主要原因,那麼首先想到的是,能否通過調整參數加快 old-gen scanning 的掃描速度?

old-gen scanning 階段,老年代會被切分為若干個大小相等的區域,每個工作線程負責處理其中的一部分,包括掃描對應的 card 數組以及掃描被標記為 dirty 的老年代空間。由於處理不同的老年代區域所需要的處理時間相差可能很大,為防止部分工作線程過於空閑,通常被切分出的老年代區域數需要大於工作線程的數目,而 ParGCCardsPerStrideChunk 參數則是用於控制被切分出的區域的大小。

默認情況下,ParGCCardsPerStrideChunk 的值為 256,由於每個card 對應 512 位元組的老年代空間,因此在掃描時每個區域的大小為 128KB,對於 4GB 的堆,會存在超過 3 萬個區域,比工作線程數足足高了 4 個數量級。下圖即為將ParGCCardsPerStrideChunk參數分別設置為 256,2K,4K 和 8K 來運行 GC 基準測試[1],結果顯示,默認值 256 在任何情況下都顯得有些小,並且堆越大,GC 停頓時間相比其他值也會越長。

考慮到 MsgBroker 的堆大小為 4G,ParGCCardsPerStrideChunk設置為4K已經足夠大。然而,在修改了 ParGCCardsPerStrideChunk 後,並沒有取得預期內的效果,實際上 MsgBroker 的 YGC 耗時沒有得到任何降低。這說明,被置為dirty的card可能非常多,破壞了 GC 的分代假設,使得掃描任務本身過於繁重,其耗費的時間遠遠大於工作線程頻繁切換掃描區域的開銷。

消息緩存優化

基於上面的猜測,我們將優化聚焦到了消息緩存上。為了避免消息緩存中消息數量過多導致 OOM,MsgBroker 基於 LinkedHashMap 實現了 LRU Cache FIFO Cache 。眾所周知,LinkedHashMapHashMap 的子類,並額外維護了一個雙向鏈表用於保持迭代順序,然而,這可能會帶來以下三個問題:

  • 消息緩存中可能存在一些一直未投遞成功的消息,這些消息對象都處於老年代;同時,當收到發送端的發消息請求時,MsgBroker 會將消息插入到緩存中,這部分消息對象處於年輕代。當不斷向消息緩存中插入新的元素時,內部雙向鏈表的引用關係會頻繁發生變化,YGC 時會觸發大規模的老年代掃描。
  • 當訂閱端出現問題時,大量未投遞成功的消息都會被緩存起來,即使存在 LRU 等淘汰機制,被淘汰出的消息也很有可能已經晉陞到老年代,無論是 YGC 時拷貝、晉陞的壓力,還是 CMS GC 的頻率,都會顯著提升。
  • 不同業務所發送的消息的大小區別非常大,當訂閱端出現問題時,會有大量消息被晉陞到老年代,這可能會產生大量的內存碎片,甚至引發 FGC。

上述第一個和第二個問題會使得 YGC 時 old-gen scanning 階段的掃描、拷貝成本更高,other 階段晉陞的對象更多,而第三個問題則會產生更多的內存碎使得 FGC 的概率升高。

既然消息緩存的插入、查詢、移除、銷毀都是由 MsgBroker 自己控制,那麼,如果這部分內存不再委託給 JVM,而是完全由 MsgBroker 自行管理其生命周期,上述 GC 問題就都能得到解決。

談到讓 JVM 看不見,最直觀的想法就是使用堆外解決方案。然而,在上面的場景中,如果僅僅只是將消息移動到堆外,是無法完全解決問題的。如果要解決上述所有問題,需要有一個完整運行在堆外的類似 LinkedHashMap 的數據結構,同時需要具備良好的並發訪問能力,且不能有性能損失。

ohc 作為一個足夠簡單、侵入性低的堆外緩存庫,最開始是 Apache Cassandra 的堆外內存分配方案,後來 Cassandra 將這塊實現單獨抽象出來,作為一個獨立的包,使得其他有同樣需求的應用也能使用。由於 ohc 提供了完整的堆外緩存實現,支持無鎖的並發寫入和查詢,同時也支持LRU,十分契合 MsgBroker 的需求,本著不重複造輪子的原則,我們決定基於其實現堆外消息緩存。

與堆內消息緩存相比,使用堆外消息緩存會多一次內存拷貝的開銷。不過,從實際的測試數據看,在給定的吞吐量下,堆外緩存下的 RT 並沒有出現惡化,僅僅 CPU Util 略微有所提升(從 60% 升到 63%),完全在可以接受的範圍內。

通過上述消息緩存優化,並將 ParGCCardsPerStrideChunk 參數設置為 4K 後,線上大部分機器的 YGC 耗時從 60ms 降低到 30ms 左右,同時 CMS GC 出現的頻率也大大降低。

然而,對於那些 YGC 耗時特別高的機房中的機器,即使通過消息緩存優化,YGC 耗時也只是從 120ms 降低到 80ms 左右,耗時仍然偏高,且 old-gen scanning 階段依然佔用了絕大部分時間。

消息對象引用與生命周期的優化

通過對線上機器的 GC 情況進行觀察和總結,我們發現,YGC 耗時在 50ms 左右的機器,連接數比較正常,基本都維持在 5000 左右,而那些 YGC 耗時為 120ms 左右的機器,其連接數接近甚至超過 20000。基於這些發現,YGC 問題很可能與通信層密切相關。

原本,MsgBroker 的網路通信層是使用自己開發的網路框架 Gecko,Gecko 默認會為每個網路連接分配 64KB 的內存,如果網路連接數過多,就會佔用大量的內存,導致頻繁 GC,嚴重限制了 MsgBroker 的性能。在這個背景下,MsgBroker 使用自研的 Bolt 網路框架(基於 Netty)對網路層進行了重構,默認將網路連接使用的內存分配到堆外,解決了高連接數下的性能問題。同時,Bolt 的基準性能測試也顯示,即使在 100000 的連接數下,服務端的性能也不會受到連接數的影響。

如果通信框架本身不會遇到連接數的問題,那麼很有可能是 MsgBroker 在對通信框架的使用上存在一些問題。通過 review 代碼、dump 內存等手段,我們發現問題主要出在消息請求的 decode 上。

如下面的代碼所示,在對消息請求進行 decode 時,RequestDecoder 會首先嘗試解析消息的 header 部分,如果 byteBuf 中的數據足夠,RequestDecoder 會將 header 完整解析出來,並保存在 requestCommand 中。這樣,如果 byteBuf 中的數據不夠解析出消息的 body 部分,下次 decode 時也可以直接從 body 部分開始,降低重複讀取的開銷。

RequestDecoder 持有 RequestCommand 的引用,本意是為了避免重複讀取 byteBuf。然而,這卻會帶來以下問題:

  • RequestDecoder 基本都處於老年代,而 RequestCommand 處於年輕代。當服務端的某個連接不斷接收發消息請求時,其老年代與年輕代之間的引用關係也會不斷變換,這會加重 YGC 時的老年代掃描壓力,連接數越多,壓力越大。
  • 對於消息量較少的連接,雖然引用關係不會頻繁變換,但由於 RequestDecoder 會長期持有某個 RequestCommand 的引用,使得該消息無法被及時回收,容易因達到一定年齡而晉陞到老年代,這會加重 YGC 時的拷貝壓力。同樣,連接數越多,壓力也越大。

其實解決思路也非常簡單,讓 RequestDecoder 不再持有對 RequestCommand 的引用。在 decode 時,如果 byteBuf 中可讀取的內容不夠完整解析出消息,則回滾讀取 index 到初始位置並放棄本次 decode 操作,直到 byteBuf 中存在足夠多的數據。這樣雖然可能會存在重複讀取,但與 GC 比起來,這點開銷完全可以接受。

通過上述優化,即使是那些連接數特別高的機器,其 YGC 耗時也進一步從 80ms 下降到了 30ms。

訂閱端異常場景下的自我保護

MsgBroker 作為推模式的消息中間件,無論何種情況都能夠有效保證消息投遞的實時性。但如果訂閱端因為頻繁 GC,CPU 或 IO 出現瓶頸,甚至下游鏈路 RT 變高導致消息的消費速度跟不上消息的生產速度,就容易使得大量被實時推送過來的消息堆積在訂閱端的消息處理線程池隊列中,而這其中的絕大部分消息,可能都還不及出隊列得到被線程執行的機會,就已經被 MsgBroker 判定為投遞超時,從而引發大量的投遞超時錯誤,導致大量消息需要被重投。

當新產生的消息疊加上需要被重投的消息,會更加重訂閱端的負擔,使得因投遞超時而需要被重投的消息越來越多,即使後續訂閱端的消費能力恢復正常,也可能因為失敗量過大導致需要很長的消化時間,如果失敗持續時間過長,甚至可能引發這個消費鏈路的雪崩,訂閱端無法再恢復正常。

儘管通過上述優化,能有效解決內存碎片問題,以及正常場景下的 YGC 耗時高問題。但在異常場景下,YGC 耗時仍然較高(在實驗室構造的超時場景下,儘管連接數維持在個位數,YGC 平均耗時也上漲到了 147ms),在而通過上述優化手段,YGC 耗時也僅從 147ms 降低到了 83ms。通過進一步的分析,我們發現:

  • 由於 MsgBroker 的默認投遞超時時間為 10s,與其他的投遞失敗不同,一旦出現大量投遞超時,消息至少會在 MsgBroker 的內存中停留 10s,這會給 YGC 帶來非常大的壓力。
  • 由於投遞失敗後的更新操作是非同步的,同時為了避免消息更新操作對消息新增造成影響,更新操作通常不會有太多的線程資源。當存在大量投遞失敗時,對消息的更新操作很可能因為任務量過大而積壓在內存中。

為了解決上述問題,MsgBroker 實現了一種自適應投遞限流演算法,如下圖所示。演算法的基本思路就是服務端會不斷根據訂閱端的消費結果估計訂閱端的消費能力,並按照估計出的訂閱端消費能力進行限流投遞,對於被限流的消息,能夠快速失敗掉,不必在內存中再停留 10s,同時也無需再執行 DB 更新操作。這樣,即保護了訂閱端,有利於積壓消息的快速消化,也能保護服務端不受訂閱端的影響,並進一步降低 DB 的壓力。

通過引入自適應投遞限流,在實驗室測試環境下,MsgBroker 在異常場景下的 YGC 耗時進一步從 83ms 降低到 40ms,恢復了正常的水平。

YGC 優化總結

通過上面的 YGC 問題以及優化過程可以看出,YGC 的惡化,主要就在於應用中的對象違背了 GC 的分代假設,而上述所提及的所有優化手段,也是為了盡量讓應用中的對象滿足 GC 的分代假設。因此,在平時的研發活動中,程度的設計和實現都應該盡量滿足分代假設。

reference

  • Garbage collection in the HotSpot JVM (ibm.com/developerworks/)
  • Secret HotSpot option improving GC pauses on large heaps (blog.ragozin.info/2012/)
  • OHC - An off-heap-cache (github.com/snazy/ohc/)

公眾號:金融級分散式架構(Antfin_SOFA)

推薦閱讀:

TAG:中間件 | 分散式系統 | 消息隊列 |