服務啟動過程效能波動的分析與解決方案

羊都是我吃的發表於2022-05-27

1. 前言

  • 本文僅分享自己在工作中遇到的問題時的解決方案和思路,以及排查的過程。重點還是分享排查的思路,知識點其實已經挺老了。如有疑問或描述不妥,歡迎賜教。

2. 問題表象

  • 在工程啟動的時候,系統的請求會有一波超時,從監控來看,JVM 的GC(G1) 波動較大,CPU波動較大,各個業務使用的執行緒池波動較大,外部IO耗時增加。系統呼叫產生較多異常(也是由於超時導致)
  • 釋出過程中的異常次數:

image

3. 先說結論

  • 由於JIT的優化,導致系統啟動時觸發了熱點程式碼的編譯,且為C2編譯,引發了CPU佔用較高,進而引發一系列問題,最終導致部分請求超時。

4. 排查過程

其實知識點就放在那裡,重要的是能夠將實際遇到的問題和知識點聯絡到一起並能更深刻的理解這部分知識。這樣才能轉化為經驗。

4.1 最初的排查

  • 我們的工程是一個演算法排序工程,裡面或多或少也加了一些小的模型和大大小小的快取,而且從監控上來看,JVM 的GC 突刺和 CPU 突刺時間極為接近(這也是一個監控平臺時間不夠精準的原因)。所以在前期,我耗費了大量精力和時間去排查JVM,GC 的問題。
  • 首先推薦給大家一個網站:https://gceasy.io/ ,真的分析GC日誌巨好用。配合以下的JVM引數列印GC日誌:
-XX:+PrintGC 輸出GC日誌
-XX:+PrintGCDetails 輸出GC的詳細日誌
-XX:+PrintGCTimeStamps 輸出GC的時間戳(以基準時間的形式,你啟動的時候相當於12點,跟真實時間無關)
-XX:+PrintGCDateStamps 輸出GC的時間戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-Xloggc:../logs/gc.log 日誌檔案的輸出路徑
  • 因為看到YGC嚴重,所以先後嘗試瞭如下的方法:

    • 調整JVM 的堆大小。即 -Xms, -Xmx 引數。無效。
    • 調整回收執行緒數目。即 -XX:ConcGCThreads 引數。無效。
    • 調整期望單次回收時間。即 -XX:MaxGCPauseMillis 引數,無效,甚至更慘。
    • 以上調整混合測試,均無效。
    • 雞賊的方法。在載入模型之後sleep 一段時間,讓GC平穩,然後再放請求進來,這樣操作之後GC確實有些好轉,但是剛開始的請求仍然有超時。(當然了,因為問題根本不在GC上)

4.2 換個思路

  • 根據監控上來看,執行緒池,外部IO,啟動時都有明顯的RT上升然後下降,而且趨勢非常一致,這種一般都是系統性問題造成的,比如CPU,GC,網路卡,雲主機超售,機房延遲等等。所以GC既然無法根治,那麼就從CPU方面入手看看。
  • 因為系統啟動時JVM會產生大量GC,無法區分是由於系統啟動還沒預熱好就來了流量,還是說無論系統啟動了多久,流量一來就會出問題。而我之前排查GC的操作,即加上了sleep時間,恰好幫我看到了這個問題,因為能明顯的看出,GC波動的時間,和超時的時間,時間點上已經差了很多了,那就是說,波動與GC無關,無論GC已經多麼平穩,流量一來,還是要超時。

4.3 分析利器Arthas

不得不說,Arthas 真的是一個很好用的分析工具,節省了很多複雜的操作。
  • Arthas 文件: https://arthas.aliyun.com/doc...
  • 其實要分析的核心還是流量最開始到來的時候,我們的CPU到底做了什麼,於是我們使用Arthas分析流量到來時的CPU情況。其實這部分也可以使用top -Hp pid , jstack 等命令配合完成,不展開敘述。
  • CPU情況:
    image

圖中可以看出C2 CompilerThread 佔據了非常多的CPU資源。

4.4 問題的核心

  • 那麼這個C2 CompilerThread 究竟是什麼呢。
  • 《深入理解JAVA虛擬機器》其實有對這部分的敘述,這裡我就大白話給大家解釋一下。
  • 其實Java在最開始執行的時候,你可以理解為,就是傻乎乎的按照你寫的程式碼執行下去,稱之為"直譯器",這樣有一個好處,就是很快,Java搞成.class ,很快就能啟動,跑起來了,但是問題也很明顯啊,就是執行的慢,那麼聰明的JVM開發者們做了一件事情,他們如果發現你有一些程式碼頻繁的執行,那麼他們就會在執行期間幫你把這段程式碼編譯成機器碼,這樣執行就會飛快,這就是即時編譯(just-in-time compilation 也就是JIT)。但是這樣也有一個問題,就是編譯的那段時間,耗費CPU。而C2 CompilerThread,正是JIT中的一層優化(共計五層,C2 是第五層)。所以,罪魁禍首找到了。

5. 嘗試解決

  • 直譯器和編譯器的關係可以如下所示:

  • 就像上面說的,直譯器啟動快,但是執行慢。而編譯器又分為以下五個層次。
第 0 層:程式解釋執行,預設開啟效能監控功能(Profiling),如果不開啟,可觸發第二層編譯;
第 1 層:可稱為 C1 編譯,將位元組碼編譯為原生程式碼,進行簡單、可靠的優化,不開啟 Profiling;
第 2 層:也稱為 C1 編譯,開啟 Profiling,僅執行帶方法呼叫次數和迴圈回邊執行次數 profiling 的 C1 編譯;
第 3 層:也稱為 C1 編譯,執行所有帶 Profiling 的 C1 編譯;
第 4 層:可稱為 C2 編譯,也是將位元組碼編譯為原生程式碼,但是會啟用一些編譯耗時較長的優化,甚至會根據效能監控資訊進行一些不可靠的激進優化。
  • 所以我們可以嘗試從C1,C2編譯器的角度去解決問題。

5.1 關閉分層編譯

增加引數 : -XX:-TieredCompilation -client (關閉分層編譯,開啟C1編譯)
  • 效果稀爛。
  • CPU使用率持續高水位(相比於調整前)。確實沒了C2 thread 的問題,但是猜測由於程式碼編譯的不夠C2那麼優秀,所以程式碼持續效能低下。
  • CPU截圖:

5.2 增加C2 執行緒數

增加引數 :-XX:CICompilerCount=8 恢復引數:-XX:+TieredCompilation
  • 效果一般,仍然有請求超時。但是會少一些。
  • CPU截圖:

5.3 推論

  • 其實從上面的分析可以看出,如果繞不過C2,那麼必然會有一些抖動,如果繞過了C2,那麼整體效能就會低很多,這是我們不願看見的,所以關閉C1,C2,直接以直譯器模式執行我並沒有嘗試。

6. 解決方案

6.1最終方案

  • 既然這部分抖動繞不過去,那麼我們可以使用一些mock 流量來承受這部分抖動,也可以稱之為預熱,在工程啟動的時候,使用提前錄製好的流量來使系統熱點程式碼完成即時編譯,然後再接收真正的流量,這樣就可以做到真實流量不抖動的效果。
  • 在系統正常執行的過程中採集部分流量,並序列化為檔案儲存下來,當系統啟動的時候,將檔案反序列化為請求物件,進行流量重放。進而觸發JIT的C2 compile,使CPU的波動在預熱期間內完成,不影響正常的線上的流量。

6.2先放結果

  • 預計每次釋出減少10000次異常請求(僅計算異常不包括超時)。
  • 減少因搜尋導流帶來的其他業務的營收損失。
  • 其他相關搜尋的引流操作均減少每次釋出10000次請求的損失。
  • 異常的減少情況:

image

  • RT 的變化情況:

image

  • 整體變化,可以監控系統上來看,對比兩次釋出過程中的RT變化,發現經過治理之後的系統,釋出更加平穩,RT基本沒有較大的波動,而未經過治理的介面RT較高:

image

image

6.3 預熱設計

6.3.1 整體的流程表示

  • 下圖表達了正常線上服務時候順便採集流量的流量採集過程,以及當發成重啟,釋出等操作時候的重播過程。

image

6.3.2 對其中的細節解釋

  • ①:排序系統接收不同的code的請求(可以理解為不同的業務的請求),在圖中,不同的請求以不同的顏色標記出來。
  • ②:表達排序系統請求的入口,雖然內部都是鏈式執行,但是對外的RPC是不同的介面。
  • ③:此處使用的AOP是Around方式來完成的,設計了特定註解來減少warmup操作對既有程式碼的入侵。此註解放置在入口的RPC實現處,即可自動採集請求資訊。
  • ④:表達的是排序系統的流式編排系統,對外有不同的RPC的介面,但是其實內部最終都使用flowexecutor.run 來實現不同業務的不同鏈路的串聯和實現。
  • ⑤:AOP中使用非同步儲存的方式,這樣可以避免因為warmup在採集流量的時候影響正常請求的RT,但是這裡需要注意的是,這裡的非同步儲存一定要注意物件的深度拷貝,否則將會出現很奇怪的異常,因為後續的鏈路中。排序系統都是拿著Request物件來操作的,而warmup的非同步操作由於檔案等操作會略慢,所以如果Request物件已經被變動之後再序列化下來下次使用,就會因為已經破壞了原始的請求導致下次啟動時warmup會有異常。所以在AOP中也進行了深度拷貝的操作,使得正常的業務請求和warmup序列化儲存操作的不是同一個物件。
  • ⑥:最初的AOP設計其實是使用的before設計的,也就是不關心執行的結果,在Request到來的時候就將流量持久化下來。但是後來發現,由於排序系統中本身就存在之前遺留的bug,可能有些請求就是會產生異常,如果我們不關注結果,仍然將可能觸發異常的請求記錄下來,那麼預熱的時候可能會產生大量的異常,從而引發報警。所以,AOP的切面由before調整為了Around,關注結果,如果結果不為空,才將流量序列化並持久化儲存下來。
  • ⑦:序列化之後的檔案其實是需要分資料夾儲存的,因為不同的code,也就是請求不同的業務RPC的時候,Request<T> 的泛型是不同的,所以需要加以區分,並在反序列化的時候指定泛型。
  • ⑧:最初的設計是單執行緒完成整個預熱操作,後來發現速度太慢,需要預熱12分鐘左右,且排序系統機器較多,如果每組都增加12分鐘是不可接受的。所以採用多執行緒方式預熱,最後縮短為3分鐘左右。
  • ⑨:釋出系統的釋出方式其實是不斷的呼叫check介面,如果有返回了,則表示程式啟動成功,接下來會嘗試呼叫online介面完成rpc,訊息佇列等元件的上線,所以修改了原有的check介面,由無意義的返回“ok”,調整為測試warmup流程是否完成。如果沒完成則丟擲異常,否則返回ok,這樣既可完成在online之前,也就是接收流量之前,完成warmup,不會發生warmup還沒結束,流量就來了的情況。

7. 最後

  • 本文描述了為一個系統設計預熱的原因,結果以及期間遇到的各種細節的問題。最終上線取得的效果還是較為可觀的,解決了每次釋出時候的瘋狂報警和真真實實存在的流量的損失,重點在於分享排查及解決問題的思維,遇到類似問題的同學們或許可以結合自己公司的釋出體系來實現這套操作。
  • 在整個的開發和自測過程中,著重關注以下的事項:

    • 是不是真的解決了線上的問題。
    • 是否引入了新的問題。
    • 預熱的流量是否做了獨特的標識以避免預熱部分流量的資料迴流。
    • 如何和公司既有的釋出體系進行較好的契合。
    • 怎樣能夠減少入侵性,對本工程其他的開發者以及系統的使用者做到完全無感知。
    • 是否能做到完全不需要開發人員關注warmup,能夠全自動的完成整套操作,讓他們根本不知道我上線了一個新功能,但是真的解決了問題。
    • 如果預熱系統出現問題是否能夠直接關閉預熱來保障線上的穩定性。

8. 參考文章

相關文章