二 垃圾回收:第08講:案例實戰:億級流量高併發下如何進行估算和調優

本課時主要講解如何在大流量高併發場景下進行估算和調優。

我們知道,垃圾回收器一般使用默認參數,就可以比較好的運行。但如果用錯了某些參數,那麼後果可能會比較嚴重,我不只一次看到有同學想要驗證某個剛剛學到的優化參數,結果引起了線上 GC 的嚴重問題。

所以你的應用程序如果目前已經滿足了需求,那就不要再隨便動這些參數了。另外,優化代碼獲得的性能提升,遠遠大於參數調整所獲得的性能提升,你不要純粹爲了調參數而走了彎路。

那麼,GC 優化有沒有可遵循的一些規則呢?這些“需求”又是指的什麼?我們可以將目標歸結爲三點:

    1. 系統容量(Capacity)

    2. 延遲(Latency)

    3. 吞吐量(Throughput)

考量指標

系統容量

系統容量其實非常好理解。比如,領導要求你每個月的運維費用不能超過 x 萬,那就決定了你的機器最多是 2C4G 的。

舉個比較極端的例子。假如你的內存是無限大的,那麼無論是存活對象,還是垃圾對象,都不需要額外的計算和回收,你只需要往裏放就可以了。這樣,就沒有什麼吞吐量和延遲的概念了。

但這畢竟是我們的一廂情願。越是資源限制比較嚴格的系統,對它的優化就會越明顯。通常在一個資源相對寬鬆的環境下優化的參數,平移到另外一個限制資源的環境下,並不是最優解。

吞吐量-延遲

接下來我們看一下吞吐量和延遲方面的概念。

假如你開了一個麪包店,你的首要目標是賣出更多的麪包,因爲賺錢來說是最要緊的。

爲了讓客人更快買到麪包,你引進了很多先進的設備,使得製作麪包的間隔減少到 30 分鐘,一批麪包可以有 100 個。

工人師傅是拿工資的,並不想和你一樣加班。按照一天 8 小時工作制,每天就可以製作 8x2x100=1600 個麪包。

但是你很不滿意,因爲每天的客人都很多,需求大約是 2000 個麪包。

你只好再引進更加先進的設備,這種設備可以一次做出 200 個麪包,一天可以做 2000~3000 個麪包,但是每運行一段時間就需要冷卻一會兒。

原來每個客人最多等 30 分鐘就可以拿到麪包,現在有的客人需要等待 40 分鐘。客人通常受不了這麼長的等待時間,第二天就不來了。

考慮到我們的營業目標,就可以抽象出兩個概念。

  • 吞吐量,也就是每天製作的麪包數量。
  • 延遲,也就是等待的時間,涉及影響顧客的滿意度。

吞吐量大不代表響應能力高,吞吐量一般這麼描述:在一個時間段內完成了多少個事務操作;在一個小時之內完成了多少批量操作。

響應能力是以最大的延遲時間來判斷的,比如:一個桌面按鈕對一個觸發事件響應有多快;需要多長時間返回一個網頁;查詢一行 SQL 需要多長時間,等等。

這兩個目標,在有限的資源下,通常不能夠同時達到,我們需要做一些權衡。

選擇垃圾回收器

接下來,再回顧一下前面介紹的垃圾回收器,簡單看一下它們的應用場景。

  • 如果你的堆大小不是很大(比如 100MB),選擇串行收集器一般是效率最高的。參數:-XX:+UseSerialGC。
  • 如果你的應用運行在單核的機器上,或者你的虛擬機核數只有 1C,選擇串行收集器依然是合適的,這時候啓用一些並行收集器沒有任何收益。參數:-XX:+UseSerialGC。
  • 如果你的應用是“吞吐量”優先的,並且對較長時間的停頓沒有什麼特別的要求。選擇並行收集器是比較好的。參數:-XX:+UseParallelGC。
  • 如果你的應用對響應時間要求較高,想要較少的停頓。甚至 1 秒的停頓都會引起大量的請求失敗,那麼選擇 G1、ZGC、CMS 都是合理的。雖然這些收集器的 GC 停頓通常都比較短,但它需要一些額外的資源去處理這些工作,通常吞吐量會低一些。參數:-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC 等。

從上面這些出發點來看,我們平常的 Web 服務器,都是對響應性要求非常高的。選擇性其實就集中在 CMS、G1、ZGC 上。

而對於某些定時任務,使用並行收集器,是一個比較好的選擇。

大流量應用特點

這是一類對延遲非常敏感的系統。吞吐量一般可以通過堆機器解決。

如果一項業務有價值,客戶很喜歡,那億級流量很容易就能達到了。假如某個接口一天有 10 億次請求,每秒的峯值大概也就 5~6 w/秒,雖然不算是很大,但也不算小。最直接的影響就是:可能你發個版,幾萬用戶的請求就抖一抖。

一般達到這種量級的系統,承接請求的都不是一臺服務器,接口都會要求快速響應,一般不會超過 100ms。

這種系統,一般都是社交、電商、遊戲、支付場景等,要求的是短、平、快。長時間停頓會堆積海量的請求,所以在停頓發生的時候,表現會特別明顯。我們要考量這些系統,有很多指標。

  • 每秒處理的事務數量(TPS);
  • 平均響應時間(AVG);
  • TP 值,比如 TP90 代表有 90% 的請求響應時間小於 x 毫秒。

可以看出來,它和 JVM 的某些指標很像。

尤其是 TP 值,最能代表系統中到底有多少長尾請求,這部分請求才是影響系統穩定性的元兇。大多數情況下,GC 增加,長尾請求的數量也會增加。

我們的目標,就是減少這些停頓。本課時假定使用的是 CMS 垃圾回收器。

估算

在《編程珠璣》第七章裏,將估算看作程序員的一項非常重要的技能。這是一種化繁爲簡的能力,不要求極度精確,但對問題的分析有着巨大的幫助。

拿一個簡單的 Feed 業務來說。查詢用戶在社交網站上發送的帖子,還需要查詢第一頁的留言(大概是 15 條),它們共同組成了每次查詢後的實體。

class Feed{
   private User user;
   private List<Comment> commentList;
   private String content;
}

這種類型的數據結構,一般返回體都比較大,大概會有幾 KB 到幾十 KB 不等。我們就可以對這些數據進行以大體估算。具體的數據來源可以看日誌,也可以分析線上的請求。

這個接口每天有 10 億次請求,假如每次請求的大小有 20KB(很容易達到),那麼一天的流量就有 18TB 之巨。假如高峯請求 6w/s,我們部署了 10 臺機器,那麼每個 JVM 的流量就可以達到 120MB/s,這個速度算是比較快的了。

如果你實在不知道怎麼去算這個數字,那就按照峯值的 2 倍進行準備,一般都是 OK 的。

調優

問題是這樣的,我們的機器是 4C8GB 的,分配給了 JVM 1024*8GB/3*2= 5460MB 的空間。那麼年輕代大小就有 5460MB/3=1820MB。進而可以推斷出,Eden 區的大小約 1456MB,那麼大約只需要 12 秒,就會發生一次 Minor GC。不僅如此,每隔半個小時,會發生一次 Major GC。

不管是年輕代還是老年代,這個 GC 頻率都有點頻繁了。

提醒一下,你可以算一下我們的 Survivor 區大小,大約是 182MB 左右,如果稍微有點流量偏移,或者流量突增,再或者和其他接口共用了 JVM,那麼這個 Survivor 區就已經裝不下 Minor GC 後的內容了。總有一部分超出的容量,需要老年代來補齊。這些垃圾信息就要保存更長時間,直到老年代空間不足。

我們發現,用戶請求完這些信息之後,很快它們就會變成垃圾。所以每次 MinorGC 之後,剩下的對象都很少。

也就是說,我們的流量雖然很多,但大多數都在年輕代就銷燬了。如果我們加大年輕代的大小,由於 GC 的時間受到活躍對象數的影響,回收時間並不會增加太多。

如果我們把一半空間給年輕代。也就是下面的配置:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn2730M

重新估算一下,發現 Minor GC 的間隔,由 12 秒提高到了 18 秒。

線上觀察:
[ParNew: 2292326K‐>243160K(2795520K), 0.1021743 secs]
3264966K‐>10880154K(1215800K), 0.1021417 secs]
[Times: user=0.52 sys=0.02, real=0.2 secs]

Minor GC 有所改善,但是並沒有顯著的提升。相比較而言,Major GC 的間隔卻增加到了 3 小時,是一個非常大的性能優化。這就是在容量限制下的初步調優方案。

此種場景,我們可以更加激進一些,調大年輕代(順便調大了倖存區),讓對象在年輕代停留的時間更長一些,有更多的 buffer 空間。這樣 Minor GC 間隔又可以提高到 23 秒。參數配置:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M

一切看起來很美好,但還是有一個瑕疵。

問題如下:由於每秒的請求都非常大,如果應用重啓或者更新,流量瞬間打過來,JVM 還沒預熱完畢,這時候就會有大量的用戶請求超時、失敗。

爲了解決這種問題,通常會逐步的把新發布的機器進行放量預熱。比如第一秒 100 請求,第二秒 200 請求,第三秒 5000 請求。大型的應用都會有這個預熱過程。

如圖所示,負載均衡器負責服務的放量,server4 將在 6 秒之後流量正常流通。但是奇怪的是,每次重啓大約 20 多秒以後,就會發生一次詭異的 Full GC。

注意是 Full GC,而不是老年代的 Major GC,也不是年輕代的 Minor GC。

事實上,經過觀察,此時年輕代和老年代的空間還有很大一部分,那 Full GC 是怎麼產生的呢?

一般,Full GC 都是在老年代空間不足的時候執行。但不要忘了,我們還有一個區域叫作 Metaspace,它的容量是沒有上限的,但是每當它擴容時,就會發生 Full GC。

使用下面的命令可以看到它的默認值:
java -XX:+PrintFlagsFinal 2>&1 | grep Meta

默認值如下:
size_t MetaspaceSize = 21807104      {pd product} {default}
size_t MaxMetaspaceSize = 18446744073709547520      {product} {default}

可以看到 MetaspaceSize 的大小大約是 20MB。這個初始值太小了。

現在很多類庫,包括 Spring,都會大量生成一些動態類,20MB 很容易就超了,我們可以試着調大這個數值。

按照經驗,一般調整成 256MB 就足夠了。同時,爲了避免無限制使用造成操作系統內存溢出,我們同時設置它的上限。配置參數如下:

-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M

經觀察,啓動後停頓消失。

這種方式通常是行之有效的,但也可以通過擴容機器內存或者擴容機器數量的辦法,顯著地降低 GC 頻率。這些都是在估算容量後的優化手段。

我們把部分機器升級到 8C16GB 的機器,使用如下的參數:

-XX:+UseConcMarkSweepGC -Xmx10920M -Xms10920M -Xmn5460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M

相比較其他實例,系統運行的特別棒,系統平均 1 分鐘左右發生一次 MinorGC,老年代觀察了一天才發生 GC,響應水平明顯提高。

這是一種非常簡單粗暴的手段,但是有效。我們看到,對 JVM 的優化,不僅僅是優化參數本身。我們的目的是解決問題,尋求多種有用手段

總結

其實,如果沒有明顯的內存泄漏問題和嚴重的性能問題,專門調優一些 JVM 參數是非常沒有必要的,優化空間也比較小。

所以,我們一般優化的思路有一個重要的順序:

    1. 程序優化,效果通常非常大;

    2. 擴容,如果金錢的成本比較小,不要和自己過不去;

    3. 參數調優,在成本、吞吐量、延遲之間找一個平衡點。

本課時主要是在第三點的基礎上,一步一步地增加 GC 的間隔,達到更好的效果。

我們可以再加一些原則用以輔助完成優化。

    1. 一個長時間的壓測是必要的,通常我們使用 JMeter 工具。

    2. 如果線上有多個節點,可以把我們的優化在其中幾個節點上生效。等優化真正有效果之後再全面推進。

    3. 優化過程和目標之間可能是循環的,結果和目標不匹配,要推翻重來。

我們的業務場景是高併發的。對象誕生的快,死亡的也快,對年輕代的利用直接影響了整個堆的垃圾收集。

    1. 足夠大的年輕代,會增加系統的吞吐,但不會增加 GC 的負擔。

    2. 容量足夠的 Survivor 區,能夠讓對象儘可能的留在年輕代,減少對象的晉升,進而減少 Major GC。

我們還看到了一個元空間引起的 Full GC 的過程,這在高併發的場景下影響會格外突出,尤其是對於使用了大量動態類的應用來說。通過調大它的初始值,可以解決這個問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章