來源:
一、背景介紹
二、優化衡量指標和思路
三、熱點代碼優化篇
3.1 優化1:盡量避免原生 String.split 方法
3.2 優化2:加快 map 的查表效率
四、JVM GC優化篇
4.1 優化3:使用堆外緩存代替堆內緩存
4.2 思考題
五、結束語
作者:vivo 互聯網服務器團隊- Chen Dongxing、Li Haoxuan、Chen Jinxia
隨著業務的日漸復雜,性能優化儼然成為了每一位技術人的必修課。性能優化從何著手?如何從問題表象定位到性能瓶頸?如何驗證優化措施是否有效?本文將介紹分享 vivo push 推薦項目中的性能調優實踐,希望給大家提供一些借鑒和參考。
一、背景介紹
在 Push 推薦中,線上服務從 Kafka 接收需要觸達用戶的事件,之后為這些目標用戶選出最合適的文章進行推送。服務由 Java 開發,CPU 密集計算型。
隨著業務的不斷發展,請求并發及模型計算量越來越大,導致工程上遇到了性能瓶頸,Kafka 消費出現嚴重的積壓現象,無法及時完成目標用戶的分發,業務增長訴求得不到滿足,故亟需進行性能專項優化。
基于 Spring Boot + MyBatis Plus + Vue & Element 實現的后臺管理系統 + 用戶小程序,支持 RBAC 動態權限、多租戶、數據權限、工作流、三方登錄、支付、短信、商城等功能
項目地址:https://gitee.com/zhijiantianya/ruoyi-vue-pro
視頻教程:https://doc.iocoder.cn/video/
二、優化衡量指標和思路
我們的性能衡量指標是吞吐量 TPS ,由經典公式 *TPS = 并發數 / 平均響應時間RT * 可以知道,若需提高 TPS,可以有 2 種方式:
提高并發數 ,比如提升單機的并行線程數,或者橫向擴容機器數;
降低平均響應時間 RT ,包括應用線程(業務邏輯)執行時間,以及 JVM 本身的 GC 耗時。
實際情況中,我們的機器 CPU 利用率已經很高,達到 80% 以上,提升單機并發數的預期收益有限,故把主要精力投入到降低 RT 上。
下面將從 熱點代碼 和 JVM GC 兩個方面進行詳解,我們如何分析定位到性能瓶頸點,并使用 3 招將吞吐量提升 100% 。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現的后臺管理系統 + 用戶小程序,支持 RBAC 動態權限、多租戶、數據權限、工作流、三方登錄、支付、短信、商城等功能
項目地址:https://gitee.com/zhijiantianya/yudao-cloud
視頻教程:https://doc.iocoder.cn/video/
三、熱點代碼優化篇
如何快速找到應用中最耗時的熱點代碼呢?借助阿里巴巴開源的 arthas 工具,我們獲取到線上服務的 CPU 火焰圖。
火焰圖說明:火焰圖是基于 perf 結果產生的 SVG 圖片,用來展示 CPU 的調用棧。
y 軸表示調用棧,每一層都是一個函數。調用棧越深,火焰就越高,頂部就是正在執行的函數,下方都是它的父函數。
x 軸表示抽樣數,如果一個函數在 x 軸占據的寬度越寬,就表示它被抽到的次數多,即執行的時間長。注意,x 軸不代表時間,而是所有的調用棧合并后,按字母順序排列的。
火焰圖就是看頂層的哪個函數占據的寬度最大。只要有“平頂”(plateaus),就表示該函數可能存在性能問題。
顏色沒有特殊含義,因為火焰圖表示的是 CPU 的繁忙程度,所以一般選擇暖色調。
3.1 優化1:盡量避免原生 String.split 方法
3.1.1 性能瓶頸分析
從火焰圖中,我們首先發現了有 13% 的 CPU 時間花在了 java.lang.String.split 方法上。
熟悉性能優化的同學會知道,原生 split 方法是性能殺手,效率比較低,頻繁調用時會耗費大量資源。
不過業務上特征處理時確實需要頻繁地 split,如何優化呢?
通過分析 split 源碼,以及項目的使用場景,我們發現了 3 個優化點:
(1)業務中未使用正則表達式,而原生 split 在處理分隔符為 2 個及以上字符時,默認按正則表達式方式處理;眾所周知,正則表達式的效率是低下 的。
(2)當分隔符為單個字符(且不為正則表達式字符)時,原生 String.split 進行了性能優化處理,但中間有些內部轉換處理,在我們的實際業務場景中反而是多余的、消耗性能的。
其具體實現 是:通過 String.indexOf 及 String.substring 方法來實現分割處理,將分割結果存入 ArrayList 中,最后將 ArrayList 轉換為 string[] 輸出。而我們業務中,其實很多時候需要 list 型結果,多了 2 次 list 和 string[] 的互轉。
(3)業務中調用 split 最頻繁的地方,其實只需要 split 后的第 1 個結果;原生 split 方法或其它工具類有重載優化方法,可以指定 limit 參數,滿足 limit 數量后可以提前返回;但業務代碼中,使用 str.split(delim)[0] 方式,非性能最佳。
3.1.2 優化方案
針對業務場景,我們自定義實現了性能優化版的 split 實現。
import?java.util.ArrayList; import?java.util.List; import?org.apache.commons.lang3.StringUtils; ?? /** ?*?自定義split工具 ?*/ public?class?SplitUtils?{ ?? ????/** ?????*?自定義分割函數,返回第一個 ?????* ?????*?@param?str???待分割的字符串 ?????*?@param?delim?分隔符 ?????*?@return?分割后的第一個字符串 ?????*/ ????public?static?String?splitFirst(final?String?str,?final?String?delim)?{ ????????if?(null?==?str?||?StringUtils.isEmpty(delim))?{ ????????????return?str; ????????} ?? ????????int?index?=?str.indexOf(delim); ????????if?(index?0)?{ ????????????return?str; ????????} ????????if?(index?==?0)?{ ????????????//?一開始就是分隔符,返回空串 ????????????return?""; ????????} ?? ????????return?str.substring(0,?index); ????} ?? ????/** ?????*?自定義分割函數,返回全部 ?????* ?????*?@param?str???待分割的字符串 ?????*?@param?delim?分隔符 ?????*?@return?分割后的返回結果 ?????*/ ????public?static?List?split(String?str,?final?String?delim)?{ ????????if?(null?==?str)?{ ????????????return?new?ArrayList<>(0); ????????} ?? ????????if?(StringUtils.isEmpty(delim))?{ ????????????List ?result?=?new?ArrayList<>(1); ????????????result.add(str); ?? ????????????return?result; ????????} ?? ????????final?List ?stringList?=?new?ArrayList<>(); ????????while?(true)?{ ????????????int?index?=?str.indexOf(delim); ????????????if?(index?0)?{ ????????????????stringList.add(str); ????????????????break; ????????????} ????????????stringList.add(str.substring(0,?index)); ????????????str?=?str.substring(index?+?delim.length()); ????????} ????????return?stringList; ????} ?? }
相比原生 String.split ,主要有幾方面的改動:
放棄正則表達式的支持,僅支持按分隔符進行 split;
出參直接返回 list。分割處理實現,與原生實現中針對單字符的處理類似,使用 string.indexOf 及 string.substring 方法,分割結果放入 list 中,出參直接返回 list,減少數據轉換處理;
提供 splitFirst 方法,業務場景只需要分隔符前第一段字符串時,進一步提升性能。
3.1.3 微基準測試
如何驗證我們的優化效果呢?首先選用 jmh 作為微基準測試工具 ,對照選用 原生 String.split 以及 apache 的 StringUtils.split方法,測試結果如下:
選用單字符作為分隔符
可以看出,原生實現與apache的工具類性能差不多,而自定義實現性能提升了約 50% 。
選用多字符作為分隔符
當分隔符使用 2 個長度的字符時,原始實現的性能大幅降低,只有單 char 時的 1/3 ;而apache的實現也降低至原來的 2/3 ,而自定義實現與原來基本保持一致。
選用單字符作為分隔符,只需返回第 1 個分割結果
選用單字符作為分隔符,并只需第 1 個分割結果時,自定義實現的性能是原生實現的 2 倍,并是取原生實現完整結果的 5 倍。
3.1.4 端到端優化效果
經微基準測試驗證收益后,我們將優化部署到在線服務中,驗證端到端整體的性能收益;
重新使用arthas采集火焰圖,split 方法耗時降低至 2% 左右;端到端整體耗時下降了 31.77% ,吞吐量上漲了 45.24% ,性能收益特別明顯。
3.2 優化2:加快 map 的查表效率
3.2.1 性能瓶頸分析
從火焰圖中,我們發現 HashMap.getOrDefault 方法耗時占比也特別多,達到了 20%,主要在查詢權重 map 上,這是因為:
業務中確實需高頻調用,特征交叉處理后數量膨脹,單機的調用并發達到了約 1000w ops/s。
權重 map 本身也很大,存儲了 1000 萬多的 entry,占用了很大一塊內存;同時 hash 碰撞的概率也增大,碰撞時的查詢效率由 O(1) 降低成了 O(n) (鏈表) 或 O(logn) (紅黑樹)。
Hashmap 本身是非常高效的 map 實現,起初我們嘗試了調整加載因子 loadFactor 或 換用其它 map 實現,均未取得明顯收益。
如何才能提升 get 方法的性能呢?
3.2.2 優化方案
分析過程中我們發現查詢 map 的 key(交叉處理后的特征 key )是字符串型,且平均長度在 20 以上;我們知道 string 的 equals 方法其實是遍歷比對 char[] 中的字符,key 越長則比對效率越低。
???public?boolean?equals(Object?anObject)?{ ???????if?(this?==?anObject)?{ ???????????return?true; ???????} ???????if?(anObject?instanceof?String)?{ ???????????String?anotherString?=?(String)anObject; ???????????int?n?=?value.length; ???????????if?(n?==?anotherString.value.length)?{ ???????????????char?v1[]?=?value; ???????????????char?v2[]?=?anotherString.value; ???????????????int?i?=?0; ???????????????while?(n--?!=?0)?{ ???????????????????if?(v1[i]?!=?v2[i]) ???????????????????????return?false; ???????????????????i++; ???????????????} ???????????????return?true; ???????????} ???????} ???????return?false; ???}
是否可以將 key 的長度縮短,或者甚至換成數值型?通過簡單的微基準測試,我們發現思路應該是可行的。
于是與算法同學溝通,巧的是算法同學正好也有相同訴求,他們在切換新訓練框架過程中發現 string 的效率特別低,需要把特征換成數值型。
一拍即合,方案很快確定:
算法同學將特征 key 映射成 long 型數值,映射方法為自定義的 hash 實現,盡量減少 hash 碰撞概率;
算法同學訓練輸出新模型的權重 map ,可以保留更多 entry ,以打平基線模型的效果指標;
打平基線模型的效果指標后,在線服務端灰度新模型,權重 map 的 key 改用 long 型 ,驗證性能指標。
3.2.3 優化效果
在增加了 30% 的特征 entry 數下(模型效果超過基線),工程上的性能也達到了明顯收益;
端到端整體耗時下降了 20.67% ,吞吐量上漲了 26.09% ;此外內存使用上也取得了良好收益,權重map的內存大小下降了30% 。
四、JVM GC優化篇
Java 設計垃圾自動回收的目的是將應用程序開發人員從手動動態內存管理中解放出來。開發人員無需關心內存的分配與回收,也不用關注分配的動態內存的生存期。這完全消除了一些與內存管理相關的錯誤,代價是增加了一些運行時開銷。
在小型系統上開發時,GC 的性能開銷可以忽略,但擴展到大型系統(尤其是那些具有大量數據、許多線程和高事務率的應用程序)時,GC 的開銷不可忽視,甚至可能成為重要的性能瓶頸。
上圖模擬了一個理想的系統,除了垃圾收集之外,它是完全可伸縮的。紅線表示在單處理器系統上只花費 1% 時間進行垃圾收集的應用程序。這意味著在擁有 32 個處理器的系統上,吞吐量損失超過 20% 。洋紅色線顯示,對于垃圾收集時間為 10% 的應用程序(在單處理器應用程序中,垃圾收集時間不算太長),當擴展到 32 個處理器時,會損失 75% 以上的吞吐量。
故 JVM GC 也是很重要的性能優化措施。
我們的推薦服務使用高配計算資源(64核256G),GC的影響因素挺可觀;通過采集監控在線服務 GC 數據,發現我們的服務 GC 情況挺糟糕的,每分鐘YGC累計耗時約 10s。
GC 開銷為何這么大,如何降低 GC 的耗時呢?
4.1 優化3:使用堆外緩存代替堆內緩存
4.1.1 性能瓶頸分析
我們 dump 了服務的存活堆對象,使用 mat 工具進行內存分析,發現有 2 個對象特別巨大,占了總存活堆內存的 76.8%。其中:
第 1 大對象是本地緩存,存儲了細粒度級別的常用數據,每臺機器千萬級別數據量;使用 caffine 緩存組件,緩存自動刷新周期設定 1 小時;目的是盡量減少 IO 查詢次數;
第 2 大對象是模型權重 map 本身,常駐內存中,不會 update,等新模型載入后被作為舊模型進行卸載。
4.1.2 優化方案
如何能盡量緩存較多的數據,同時避免過大的 GC 壓力呢?
我們想到了把緩存對象移到堆外,這樣可以不受堆內內存大小的限制;并且堆外內存,并不受 JVM GC 的管控,避免了緩存過大對 GC 的影響。經過調研,我們決定采用成熟的開源堆外緩存組件 OHC 。
(1)OHC 介紹
簡介
OHC 全稱為 off-heap-cache,即堆外緩存,是 2015 年針對 Apache Cassandra 開發的緩存框架,后來從 Cassandra 項目中獨立出來,成為單獨的類庫,其項目地址為
https://github.com/snazy/ohc 。
特性
數據存儲在堆外,只有少量元數據存儲堆內,不影響 GC
支持為每個緩存項設置過期時間
支持配置 LRU、W_TinyLFU 驅逐策略
能夠維護大量的緩存條目
支持異步加載緩存
讀寫速度在微秒級別
(2)OHC 用法
快速開始:
OHCache?ohCache?=?OHCacheBuilder.newBuilder(). ????????keySerializer(yourKeySerializer) ????????.valueSerializer(yourValueSerializer) ????????.build();
可選配置項:
在我們的服務中,設置 capacity 容量 12G,segmentCount 分段數 1024,序列化協議使用 kryo。
4.1.3 優化效果
切換到堆外緩存后,服務 YGC 降低到了 800ms / 每分鐘,端到端的整體吞吐量上漲了約 20% 。
4.2 思考題
在Java GC優化中,我們把本地緩存對象從Java堆內移到了堆外,取得了不錯的性能收益。 還記得上文提到的另一個巨型對象, 模型權重 map 嗎 ?模型權重 map 能否也從 Java 堆內移除?
答案是可以的。我們使用C++改寫了模型推理計算部分,包括權重map的存儲與檢索、排序得分計算等邏輯;然后將C++代碼輸出為 so 庫文件,Java程序通過 native 方式調用,實現將權重map從 Jvm 堆內移出,獲得了很好的性能收益。
五、結束語
通過上文介紹的 3 個措施,我們從 熱點代碼優化 與 Jvm GC兩方面改善了服務負載與性能,整體吞吐量翻了 1 倍,達到了階段性的預期目標。
不過性能調優是永無止境的,而且每個業務場景、每個系統的實際情況也都是千差萬別,很難用1篇文章去涵蓋介紹所有的優化場景。希望本文介紹的一些調優實戰經驗,比如如何確定優化方向、如何著手分析以及如何驗證收益,能給大家一些借鑒和參考。
編輯:黃飛
?
評論
查看更多