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