前言
在日常工作中相信大家都會遇到資料洪峰這樣的場景,例如電商平台搞活動時,大量的請求集中在一小段時間内,此時對系統造成的壓力遠超平常,如果不事先做好相應的防範措施,系統将極有可能崩潰、不可用。
業務背景
我們的應用系統每天都會産生大量的業務資料(可以簡單了解為商品、訂單等),有很多與我們合作的 外部平台 需要 訂閱 這些資料,此時我們内部存在一個 資料推送平台 負責将我們系統内部資料推送至外部合作夥伴,資料鍊路如下:
内部業務系統投遞 不同類型的業務資料 至 MQ , 資料推送平台 通過消費 MQ 消息,進行一系列處理後采用 異步 的方式将資料推送至不同的合作夥伴 。
技術背景
方案選擇
一般應對高并發場景常見的三闆斧就是 緩存 、 熔斷(降級) 、 限流 :
緩存 常用于高 QPS 的業務場景,顯然不太适用這種資料推送的情況。
熔斷(降級) 一般應用于調用下遊服務失敗,防止雪崩效應的場景。 資料推送平台 相對獨立,不存在内部服務調用的情況;但是 外部的合作夥伴 确實存在服務可用性的問題,經常出現各種情況導緻資料推送異常,是以是可以針對出現異常的外部合作夥伴采用 熔斷(降級) 的處理。
限流 就是當高并發或者瞬時高并發時,為了保證系統的穩定性、可用性,系統以犧牲部分請求為代價或者延遲處理請求為代價,保證系統整體服務可用,該種方案與我們的業務場景極為契合。
熔斷(降級) 确實可以解決部分外部平台偶發性不可用導緻我們的系統資源被占用問題,但無法解決我們資料洪峰場景帶來的根本性問題: 系統資源的有限性 ,是以資料推送平台選擇了 限流 來應對資料洪峰的場景。
方案應用
限流的具體方案有很多,常見的有 令牌桶方式 、 漏桶方式 、 計數器方式 ,其中 計數器方式 按照實作方式又可以細分為 AtomicInteger 、 Semaphore 、 線程池 等,本次我們選擇計數器的方式。
在資料推送時,如果采用同步推送的方式,推送效率将會因MQ消費者線程數量(預設設定20)受到極大的限制,如果采用線程池的方式而線程池的大小也不便設定(因為每個消息體的大小差異極大,從1K到5M不等)。
結合上述因素,同時與 外部合作夥伴 對接下來絕大部分場景都是采用 HTTP 的傳輸協定,最終推送時采用 Apache 的 HttpAsyncClient (内部基于 Reactor 模型) 異步模式 執行網絡請求, Callback 回調的方式來擷取推送結果。 将業務資料類型作為限流次元,根據在目前應用執行個體中正在推送的數量進行限流。
例如手機相關的商品資料業務類型為 PRODUCT_PHONE, 預設每種業務類型的 限流數設為 50 ,當 單台應用執行個體記憶體中 該種業務類型 正在推送 的資料量達到 50 後,該業務類型的資料從第 51 條開始都将會被拒絕,直到 正在推送 的資料量降至 50 以下。針對被拒絕的消息返給 MQ 稍後消費的狀态, MQ 将會間歇性消費重試。
僞代碼如下:
//該業務類型在目前節點的流量 Integer flowCount = BizFlowLimitUtil.get(data.getBizType()); //該種業務類型對應的限流 Integer overload = BizFlowLimitUtil.getOverloadOrDefault(data.getBizType(), this.defaultLimit); if (flowCount >= overload) { throw new OverloadException("業務類型:" + data.getBizType() + "負載過高,門檻值:" + overload + ",目前負載值:" + flowCount); }複制代碼
資料推送平台内增加了業務限流的一環:
可能存在的問題
按照上述的方案,系統應對資料洪峰的 所需最大資源 = 業務類型種數 * 限流數 ,而随着業務的擴張, 業務類型種數 也在不斷地增加, 所需最大資源 也會不斷地增加,然而服務執行個體的資源始終是有限。在該種情況下,隻根據業務資料類型的資料量來進行限流,效果将會逐漸變得不理想,極端場景下甚至可能出現服務崩潰的情況。
壓力測試
當然以上方案存在的問題隻是我們的一個設想,我們進行壓測來觀察推送系統的整體情況。
資源配置
應用執行個體數量:1
執行個體配置:1核2G
jvm參數:
-Xmx1g -Xms1g -Xmn512m -XX:SurvivorRatio=10 -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSMaxAbortablePrecleanTime=5000 -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExplicitGCInvokesConcurrent -XX:ParallelGCThreads=2 -Xloggc:/opt/modules/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/modules/java.hpro複制代碼
資料名額及工具選擇
在壓測時我們通常會關注 cpu 、 記憶體 、 網絡io 、 資料庫 等多項資料名額,在不考慮 網絡io 、 資料庫 等外部中間件因素的情況下, cpu 、 記憶體 是我們觀察系統穩定性最為直覺的資料名額。
Arthas 是 Alibaba 開源的 JAVA 診斷工具,具體使用可閱讀官方文檔: arthas.aliyun.com/doc/。我們使用 Arthas 來對服務進行觀測,登入伺服器打開控制台,使用如下指令安裝并啟動 Arthas :
curl -O https://arthas.aliyun.com/arthas-boot.jarjava -jar arthas-boot.jar複制代碼
Arthas 提供了 dashboard 指令,可以檢視服務 JVM 的實時運作狀态,如不指定重新整理間隔時間,預設 5s 重新整理一次。在啟動 Arthas 後的控制台鍵入 dashboard 出現如下畫面:
上半部分主要是目前服務 JVM 中的線程情況,可以看到各線程對 cpu 的使用率極低,基本處于閑置狀态。
下半部分 Memory 框中的資訊,我們主要關心以下幾項資料名額:
- heap(堆大小)
- par_eden_space(伊甸區大小)
- par_survivor_space(S區大小)
- cms_old_gen(老年代大小)
- gc.parnew.count(young gc總次數)
- gc.parnew.time(young gc總耗時)
- gc.concurrentmarksweep.count(full gc總次數)
- gc.concurrentmarksweep.time(full gc總耗時)
各列代表的意思也很清楚,分别是已使用、總大小、最大值、已使用百分比。光看名詞可能一時想不起 JVM 内部的劃分 ,來一張圖幫助大家回憶下:
5s後 Arthas 控制台輸出如下:
結合 **5s **前的資料,我們主要關注以下名額:
- 線程情況:線程cpu使用率并沒有明顯變化
- heap(堆大小): 堆使用大小增加 3m
- par_eden_space(伊甸區大小): 年輕代中的伊甸區隻增加 3m,按照伊甸區 426m、s區 42m 的大小,大約需要780秒(約13分鐘)才會觸發一次 young gc
- par_survivor_space(S區大小):無變化
- cms_old_gen(老年代大小):無變化
- gc.parnew.count(young gc總次數):無變化
- gc.parnew.time(young gc總耗時):無變化
- gc.concurrentmarksweep.count(full gc總次數):無變化
- gc.concurrentmarksweep.time(full gc總耗時):無變化
這是服務無流量基本處于閑置狀态時一個情況,接下來模拟積壓大量 不同業務類型資料 進行推送時的場景,資料由測試同學提前通過自動化腳本投遞到 MQ 當中。
積壓5000條資料
使用 Arthas 指令 dashboard -i 1000 ,按照 1s 的間隔輸出:
1s 後:
對比兩次資料發現:
- 線程情況: MQ 預設的20個消費者線程都處于活躍狀态占用cpu資源
- heap(堆大小): 已使用大小從 293m 上升至 478m
- par_eden_space(伊甸區大小): 發生 young gc 之前伊甸區使用 23m,伊甸區總大小為 426m,發生 young gc 之後伊甸區使用了 211m,這說明在 1s 之内至少增加了(426-23)+211 = 614m 大小的對象
- par_survivor_space(S區大小): young gc 之前S區大小為 31m,young gc 之後S區大小為 29m
- cms_old_gen(老年代大小):無變化
- gc.parnew.count(young gc總次數): 發生了 1 次 young gc
- gc.parnew.time(young gc總耗時): 時間增加了(9018-8992)= 26 毫秒,為一次 young gc 的時長
- gc.concurrentmarksweep.count(full gc總次數):無變化
- gc.concurrentmarksweep.time(full gc總耗時):無變化
按照 1s 的間隔發現發生了 young gc ,而老年代的資料沒有變化,可能是時間間隔較短導緻的,我們按照 5s 的間隔來觀察下,鍵入 dashboard -i 5000 輸出如下:
5s 後:
對比兩次資料,關鍵資訊如下:
- 5s 之内發生了 7 次 young gc
- 老年代由 233m 增長至 265m,增長了 32m 左右,按照老年代 512m 的大小,大約 80s 就會發生一次 full gc
積壓1W條資料
按照 1s 的時間間隔開始:
1s 後:
對比兩次資料得知:
- 1s之内發生了兩次 young gc
- 同時老年代從 304m 增長至 314m,1s 增長了 10m,老年代大小為 512m,按照這個速率,大約 50s 就會觸發一次 full gc
由于采用 異步 的方式進行資料推送,此時推送平台的下遊還未将資料推送完成,而上遊還在不斷的從 MQ 中消費消息,繼續觀察:
1s 後:
對比兩次資料發現:
- GC 線程的 cpu 使用率居高不下
- 1s 内發生了一次 full gc
一秒前老年代已使用大小為 418m,總大小為 512m,1s 後發現觸發了一次 full gc,按照上面的資料分析出老年代以每 10m/s 的速度增長,顯然老年代的剩餘空間是足夠的,為什麼還會提前出現 full gc 這種情況呢?
首先我們回顧一下 full gc 發生的時機:
第一種情況:老年代可用記憶體小于年輕代全部對象大小,同時沒有開啟空間擔保參數( -XX:-HandlePromotionFailure )。
從 JDK6 之後, HandlePromotionFailure 參數不會再影響到虛拟機的空間配置設定擔保政策,我們使用的都是 JDK8 ,是以第一種情況不滿足。
第二種情況:老年代可用記憶體小于年輕代全部對象大小,開啟了空間擔保參數,但是可用記憶體小于曆次年輕代GC後進入老年代的平均對象大小。
根據之前的分析,每秒進入老年代的對象大小大約為 10m ,而目前老年代剩餘大小約為( 512-418)= 94m ,是以第二種情況也不滿足。
第三種情況:年輕代 young gc 後存活對象大于s區,就會進入老年代,但是老年代記憶體不足。
同第二種情況,第三種情況也不太滿足。
第四種情況:設定了參數 -XX:CMSInitiatingOccupancyFaction ,老年代可用記憶體大于曆次年輕代GC後進入老年代的對象的平均大小,但是老年代已使用記憶體超過該參數指定的比例,自動觸發 full gc 。
檢視服務執行個體的資源配置資訊,發現 JVM 啟動參數中加了該參數: -XX:CMSInitiatingOccupancyFraction=80 , 該參數表示老年代在達到 512 * 80% = 409M 大小時就會觸發一次 full gc 。該參數主要是為了解決 CMF ( Concurrent Mode Failure )問題,不過該參數在某些情況也會導緻 full gc 更加頻繁。看來就是該參數就是老年代空間未滿卻提前出現了 full gc 的原因。
現在我們知道了提前觸發了 full gc 的原因是由于 CMSInitiatingOccupancyFraction 參數的配置,正常情況下設為 80% 也不會有什麼問題,但是有沒有這種極端情況呢:
發生 full gc 後老年代的空間并沒有回收多少,老年代已使用空間大小一直在CMSInitiatingOccupancyFraction設定的門檻值之上,導緻不停的 full gc ?
積壓2W條資料
按照 5s 的時間間隔開始:
5s 後:
對比兩次資料發現:
- 線程情況: cms垃圾回收線程cpu占用率極高
- 老年代: 已使用 511m(總大小 512m)
- full gc次數: 5s 内發生了 3 次 full gc
- full gc總耗時: 總耗時由 15131ms 增加至 14742ms
5s内發生了 3 次 full gc ,老年代始終處于 已使用 511m ( 總大小512m )的情況,每次的 full gc 平均耗時 (81942-79740)/ 3 = 734 ms ,相當于 5s 内有 2.2s 的時間都在 full gc 。
此時檢視日志發現資料推送時發生大量的 socket連接配接逾時 :
再檢視下當時的 **gc **日志,發現兩次 full gc 之間隻差了 1.4s 左右,從 524287k 回收至 524275k , 隻回收了 12k 的記憶體空間,卻花費了 0.71s , 系統有一半的時間都在進行 full gc!
使用監控大盤 grafana 檢視下當時的 cpu 、 網絡 io 情況,可以看到由于 full gc 頻繁引發 Stop the World 、 cpu 負載過高 等問題,網絡請求相關的線程得不到有效的排程,導緻 網絡 io 吞吐下降 。
優化方案
問題分析
通過上面的測試可以發現系統存在的問題主要是:由于下遊消費速率(執行網絡請求進行資料推送)跟不上上遊的投遞速率(mq消費),導緻 jvm 堆記憶體逐漸被打滿,系統頻繁 full gc 造成服務不可用,直至産生 OOM 程式崩潰。
優化思路
在該場景中系統的主要瓶頸在于 jvm 堆記憶體大小上面,避免系統頻繁 full gc 即可達到提升系統穩定性的目的,可以從以下兩方面着手。
JVM參數優化
原來的 jvm 參數為:
-Xmx1g -Xms1g -Xmn512m -XX:SurvivorRatio=10 -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSMaxAbortablePrecleanTime=5000 -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExplicitGCInvokesConcurrent -XX:ParallelGCThreads=2 -Xloggc:/opt/modules/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/modules/java.hpro複制代碼
調整點如下:
- 執行個體總記憶體為 2G,執行個體上除了我們的服務之外也沒有安裝其他比較占用記憶體的服務,原來給 jvm 的堆大小隻配置設定了 1G,有點浪費,是以調整 jvm 的堆大小為 1.5G: -Xmx1536M -Xms1536M
- 之前年輕代大小設為 512M 在資料推送平台這種業務場景并不太恰當。在晚上業務低峰期,通過 jmap 指令觸發 full gc 後觀察老年代發現常駐對象約 150M 左右,考慮浮動垃圾等,老年代配置設定 521M ,再考慮到元空間以及線程棧所需的資源,是以年輕代調整為 1G 大小: -Xmn1024M
- 年輕代中的伊甸區和s區的比例由 10:1:1 調整為 8:1:1 ,避免 young gc 後存活對象過多s區空間不足導緻直接進入老年代: -XX:SurvivorRatio=8
- 元空間大小一般配置設定 256m : -XX:MaxMetaspaceSize=256M -XX:MetaspaceSize=256M
- 線程棧一般設為 1m : -Xss1M
- 針對年輕代使用ParNew垃圾收集器: -XX:+UseParNewGC
最終優化後的 jvm 參數為:
-Xmx1536M -Xms1536M -Xmn1024M -Xss1M -XX:MaxMetaspaceSize=256M -XX:MetaspaceSize=256M -XX:SurvivorRatio=8 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSMaxAbortablePrecleanTime=5000 -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExplicitGCInvokesConcurrent -Xloggc:/opt/modules/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/modules/java.hprof複制代碼
JVM資源限流
通過壓測發現老年代的空間使用率過高(超過 -XX:CMSInitiatingOccupancyFraction 參數值)導緻頻繁發生 full gc ,那麼是否可以嘗試基于 jvm 堆記憶體使用率來對上遊進行限流控制 ,起到一個類似背壓的效果。我們添加一個 JVM 資源限流器 ,限流核心邏輯為:
設定一個 jvm 堆記憶體的使用率,當超過這個門檻值後對目前的消費線程進行阻塞或直接拒絕消費,直到使用率低于門檻值後再進行放行。
僞代碼如下:
public class ResourceLimitHandler{ /** * jvm堆限流門檻值 */ private Integer threshold = 70; /** * 單次睡眠時間(毫秒) */ private Integer sleepTime = 1000; /** * 最大阻塞時間(毫秒) */ private Integer maxBlockTime = 15000; private MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); @SneakyThrows public void process() { long startTime = System.currentTimeMillis(); double percent = this.getHeapUsedPercent(); //jvm heap使用率超過門檻值,進入限流邏輯 while (percent >= this.threshold ) { //資源使用過高,但超過最大阻塞時間,采用放行政策 if (this.maxBlockTime >= 0 && (System.currentTimeMillis() - startTime) > this.maxBlockTime) { //兜底,防止因為限流導緻年輕代無新對象産生,達不到young gc 觸發條件的極端情況,是以手動觸發一次full gc synchronized (ResourceLimitHandler.class) { if ((percent = this.getHeapUsedPercent()) >= this.threshold) { System.gc(); } } return; } TimeUnit.MILLISECONDS.sleep(this.sleepTime); percent = this.getHeapUsedPercent(); } } /** * 計算堆的使用百分比 * * @return */ private double getHeapUsedPercent() { long max = this.getHeapMax(); long used = this.getHeapUsed(); double percent = NumberUtil.div(used, max) * 100; return percent; } /** * 可用堆最大值 * * @return */ private long getHeapMax() { MemoryUsage memoryUsage = this.memoryMXBean.getHeapMemoryUsage(); return memoryUsage.getMax(); } /** * 已使用堆大小 * * @return */ private long getHeapUsed() { MemoryUsage memoryUsage = this.memoryMXBean.getHeapMemoryUsage(); return memoryUsage.getUsed(); } }複制代碼
代碼還是比較簡單的,其中的 jvm 堆記憶體門檻值的設定比較關鍵,該值的設定給出以下參考
最大門檻值
由于 -XX:CMSInitiatingOccupancyFraction 參數而觸發 full gc 的臨界情況為:年輕代可用空間被全部使用,同時老年代空間使用率達到 -XX:CMSInitiatingOccupancyFraction 所設定的比例,是以得出如下計算公式:
最大門檻值百分比 = (年輕代可使用大小 + 老年代大小 * CMSInitiatingOccupancyFraction 參數值)/ 堆大小複制代碼
* 年輕代可使用大小 :*伊甸區大小+單個s區的大小(因為兩個s區輪流替換,始終隻有一個在存放對象)
代入優化的 jvm 參數得出 最大門檻值百分比 = ( 1024 * 0.9 + 512*0.8 )/ 1536 = 87%
最小門檻值
門檻值設定過低會影響正常的業務處理,至少要保證能夠觸發 young gc ,而實際觸發 young gc 的情況有很多,這裡不做進一步讨論,暫時隻考慮最常見的由于年輕代空間不足以放下新對象的場景,是以得出:
最小門檻值百分比= 年輕代可使用大小 / 堆大小最小門檻值百分比 = 年輕代可使用大小 / 堆大小
代入優化後的 jvm 參數得出 最小門檻值百分比 = (1024 * 0.9)/ 1536 = 60%
方案驗證
實踐出真章,我們按照之前的方式再次測試
資源配置
應用執行個體數量:2
執行個體配置:2核4G
資料量: MQ 中積壓5w條資料
說明:測試過程中的資料這裡不再做展示,隻取所有資料最終推送完成後的結果。
優化前
優化前推送完成後 Arthas 儀表盤:
Grafana 監控大盤:
優化後
在對 jvm 參數進行優化 以及 添加資源限流器 後,推送完成後 Arthas 儀表盤:
Grafana 監控大盤:
結果比對
資料推送總耗時 | full gc次數 | full gc總耗時 | 單次full gc平均耗時 | |
優化前 | 約35分鐘 | 312 | 309232 | 991ms |
優化後 | 約18分鐘 | 104 | 45387 | 436ms |
優化前後進行比對,可以發現優化後無論是 資料推送總耗時 還是 full gc 的次數 或是 full gc平均耗時 都有了很大的減少,整體效能近乎提升了一倍。
總結
基于資料推送平台的業務場景、技術背景,我們推測在資料洪峰場景下單純的從 任務并發數 進行流控,可能會達不到保障系統穩定性的目的,并通過壓測驗證了我們這一猜想。通過分析發現系統的瓶頸主要是 jvm 堆記憶體資源有限,由于下遊消費速率(執行網絡請求進行資料推送)不及上遊的投遞速率(消費 MQ 消息、組裝推送任務), jvm 中堆積的對象不斷增長并且無法被回收,造成頻繁 full gc ,導緻系統不可用。
通常我們系統中主流的限流方式都是 基于并發數 來處理,需要測試同學進行壓測,綜合考慮網絡IO、資料庫等外部中間件的情況下得出一個相對合理的數值,在日常工作中不同環境下的服務執行個體配置都略有不同, 承載的并發數 也會存在一定的差異。如果 限流并發數 設定的過高,将會存在高并發場景下服務崩潰的風險,此時如果 輔以系統資源級别的限流 ,可以保證服務不會被暴增的流量瞬間打崩。
方案适用場景:
- 系統級别的全局限流,防止服務崩潰,例如運用在 Spring MCV 過濾器、 dubbo 過濾器等。
- 需要設定一個緩存隊列,而隊列中的每個任務中的 資料對象大小差異極大 , 隊列的大小難以設定 ,單純使用 無界隊列 又存在 OOM 的風險,此時可配合該方案對 無界隊列 進行限制。
方案不足:
- 此次基于 jvm 堆記憶體限流的方案因為依賴于 CMSInitiatingOccupancyFraction 參數對 full gc 引發的作用,是以僅适用于老年代使用 CMS 垃圾回收器的服務,而大部分 **16G **記憶體以上的服務都使用 G1 垃圾回收器。
- jvm 堆使用率門檻值的具體值依賴 jvm 相關參數設定,需要使用者對 jvm 的内部機制有一定的了解。
- 不建議作為唯一的限流處理邏輯,因為實際場景中服務的承載能力還與網絡io、資料庫等其他因素有關。
- 該方案的穩定性、可靠性需要更多的案例驗證。
結語:每種方案都有各自的優缺點、局限性,根據 jvm 堆記憶體使用率進行限流,并不适用所有的業務場景,隻是作為一個新的限流方案供大家參考擴充思路,起到一個抛磚引玉的作用,文中如有不對之處還請指正。
原文連結:https://juejin.cn/post/7129656760209506311