本文作者:喵內
0. 前言
SpringBoot應用已經作為Java開發中的首選方式,在雲音樂中有著廣泛的應用。在雲音樂的實踐中,為了簡化拉取新工程的成本,有一個腳手架作為工程的初始化模版。而隨著業務的不斷迭代,有一些腳手架的工程啟動變地非常慢,嚴重影響研發效能,並當在需要重啟線上叢集來進行止血線上問題時,啟動的耗時越長,可能造成的資損也就越大。基於此業務痛點,進行了腳手架應用的啟動分析與最佳化。此篇文章主要介紹了這個分析和最佳化過程,並給出了一些SpringBoot應用的通用分析與最佳化思路。
1. 專案背景
雲音樂中部分應用啟動速度慢,平均在2min以上,部分大型工程啟動甚至需要將近10min,如我們的主應用iplay-server為例,下面為其在開發環境本地的啟動時間(此時間的統計方式可以檢視4.1節)。針對此類耗時,將導致阻塞研發流程,大大降低測試和開發人員的效率,並且當線上環境需要重新發布叢集來進行線上問題止血時,啟動的耗時越長,可能造成的資損也就越大。基於此痛點,我們進行了腳手架應用的啟動分析,並針對於分析得到的結果進行了相應最佳化。本專案的主要難點在於如何在整合了大量元件以及業務程式碼的應用中分析並定位到最佳化點,並且最佳化應該對於業務程式碼來說應該儘可能無感知。
2. 腳手架在SpringBoot之上提供的能力
腳手架本質是是一個maven的archetype模板應用,整體構建在SpringBoot之上,除了提供了統一的依賴管理,並額外提供了雲音樂相關業務中介軟體的starter,應用生命週期管理以及配置檔案解析的能力
3. 腳手架應用啟動整體流程
腳手架應用啟動的整體流程,腳手架應用的啟動流程本質上就是SpringBoot應用的啟動流程,其中主要流程包括:
- 建立並初始化Environment:建立並初始化應用環境Environment物件
- props檔案解析:根據應用環境,佔位符等配置資訊,透過Scala語言解析props檔案(可以理解為properties檔案的變種)中kv值put到Spring的Enviroiment中
- 建立並初始化ApplicationContext:建立並初始化應用上下文物件
- 載入BeanDefinition:載入應用程式中的Bean定義資訊
- 重新整理ApplicationContext:IOC容器核心啟動流程,包括bean的建立,初始化,依賴注入等
- 下發Context重新整理完成事件:下發ApplicationContext重新整理完成事件
- 應用健康檢查:對應用中各種元件(如db,redis等)進行流量測試,校驗是否正常work
- 下發健康檢測完成事件:應用健康檢查完成事件下發
元件online:元件進行online操作,實現服務註冊,如api註冊到閘道器zk,rpc服務註冊到zk等
4. 啟動耗時分析階段
4.1 階段目標與驗證工具
- 目標:透過日誌打點方式,分析啟動過程中,腳手架中各流程的啟動耗時並確認最佳化目標。
驗證工具:如上文所述,腳手架應用的啟動流程本質上就是SpringBoot應用的啟動流程,所以我們本質上就是需要分析SpringBoot應用的各個啟動階段耗時,所以可以透過SpringBoot提供的擴充套件點SpringApplicationRunListener進行啟動過程中各階段的耗時統計,此擴充套件點會在SpringBoot應用啟動過程中一些重要階段進行回撥,詳細見下圖:
如需要統計應用啟動的總耗時,只需要starting和finished回撥中進行日誌打點即可,核心實現程式碼:public class LifecycleAnalysisSpringApplicationRunListener implements SpringApplicationRunListener { private long originStartTime; @Override public void starting() { long now = System.currentTimeMillis(); originStartTime = now; } @Override public void finished(ConfigurableApplicationContext context, Throwable exception) { long now = System.currentTimeMillis(); DefaultTimeAnalysis.getInstance().logCost(getApplicationName() + ": 容器啟動完成耗時",now - originStartTime); } }
4.2 分析過程
以下分析過程以我們的主應用iplay-server為例
4.2.1 執行時Scala解析props檔案解析耗時
props檔案解析流程:
由於解析的SDK中已經在解析流程前後進行了時間戳記錄,並將耗時時間的日誌資訊預設輸出在程式的標準錯誤流中,所以無需再進行額外的採集工作4.2.2 各類bean初始化耗時佔比
首先,根據腳手架中的元件,對bean進行類別劃分,得到如下類別:
public enum BeanClassifierEnums { /** * rpc builder型別 * 注:rpc builder型別是rpc key(可理解為叢集的一個標識)維度的bean,主要建立與註冊中心的網路連線,後續用於構建此叢集關聯的rpc service */ RPC_BUILDER, /** * rpc service型別 注:rpc service型別是interface維度的bean,封裝了rpc呼叫相關細節的動態代理 */ RPC_SERVICE, /** * nydus型別 (雲音樂MQ) */ NYDUS, /** * redis型別 */ REDIS, /** * memcached型別 */ MC, /** * SqlManager型別 (雲音樂dao框架中負責與DB進行通訊的元件) */ SQL_MANAGER, /** * dao層實現類型別 (業務層dao的實現類,可理解為業務層在SqlManager之上的封裝層) */ SQL_IMPL, /** * 未具體分類的型別,其中主要包括業務程式碼邏輯中定義的bean以及其他未細化的腳手架元件bean */ OTHER ; }
接下來需要對各個bean進行分類並進行初始化時間的打點,這時需要使用到Spring的擴充套件點BeanPostProcessor,在bean初始化的前後進行打點處理,採集初始化耗時時間。具體實現方案:
根據此擴充套件點,得到以下資料:
上圖中分析資料單位為ms,將上面的資料視覺化:
從中,可以看到除了other之外,耗時佔比最高的兩個為RpcBuilder和RpcService這兩個元件,即後續需要最佳化的重點目標4.2.3 如何在複雜的程式碼中快速定位耗時邏輯
從上一階段的分析結果中確認了最佳化的重點目標是RpcBuilder和RpcService,接下來需要分析定位到耗時的原因。其中RpcBuilder的主要邏輯是在初始化與註冊中心的網路連線,這裡就不在贅述。而RpcService的初始化邏輯較複雜,單純地透過查閱程式碼並不能定位到耗時邏輯,此時就需要利用profiler工具來進行分析,我們這裡使用了Arthas的profiler工具來生成應用啟動時的火焰圖,協助進行分析。具體操作流程:
在應用啟動的JVM引數中,新增debugger引數,注意其中的suspend引數需要設為y,表示在debugger連線之前,程式會進行阻塞等待。
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=10000
啟動程式之後,JVM會等待debugger的連線:
- 啟動Arthas並連線對應的JVM程式,注意由於我們的程式在阻塞等待debugger,還未開始執行,程式的main class資訊是獲取不到的,所以我們需要連線的是無main class資訊的JVM程式
連線完成之後進入Arthas的互動介面:
啟動profiler,由於我們需要分析啟動過程的耗時階段,所以我們需要指定跟蹤採集的事件為wall
profiler -e wall start
利用jdb(jdk自帶的debugger工具)連線對應的JVM程式,將此應用run起來
jdb -attach localhost:10000
連線完成後,執行cont命令,讓程式執行起來
cont
應用啟動完成後,進行profiler的stop,並透過file引數指定生成火焰圖的路徑
profiler stop --file /tmp/server.html
最終我們得到應用啟動過程中的火焰圖:
通常我們應用程式啟動自身的堆疊是最高的,所以從中找到高度最高的堆疊,點選進入
關於火焰圖,針對當前場景來說,堆疊中寬度越寬的棧幀,代表在取樣時間中,佔用cpu的比例越大。透過火焰圖左上角自帶的搜尋,我們找到需要進行分析的元件初始化流程的堆疊(滿足搜尋條件的棧幀會被標為紫色):
從中,我們就看到了RpcService在初始化流程的堆疊中,寬度最寬即耗時佔比最大的棧幀為3次DefaultConfigClient.getConfigValue的http請求讀取配置中心的配置值。4.2.4 再來看看沒有具體分類的other
- 由於舉例應用中,other分類的bean數量達2900+,故上圖中只擷取了部分分析資料
未進行分類的bean中,大部分為業務程式碼定義的bean,此處在不變更業務程式碼的前提下,這部分bean對框架層來說應該是無感知的。而由於這部分bean主要是業務邏輯相關,我們可以使用Spring的懶載入實現bean的按需載入,而不是啟動過程中全量載入。
4.3 結果分析
- 從4.2.1的分析資料來看,每次應用啟動時,都需要走一遍Scala解析props配置檔案的流程,通常情況下,我們應用釋出時都是按批次進行灰度釋出,將會導致解析的流程走多次,比如分了3批次,那我們整個釋出流程的耗時中就包含了3次解析props檔案的耗時。針對此,我們可以透過maven外掛,將解析流程提前到構建編譯階段,這樣整個釋出流程中只需要解析一次即可
- 從4.2.2和4.2.3的分析結果來看,RpcBuilder和RpcService的耗時邏輯主要集中在IO操作,所以可以採用非同步初始化的方式來解決。將IO相關的操作獨立到單獨的執行緒中去完成。
從4.2.4的分析結果中,針對大量的業務邏輯bean時,採用開啟懶載入的方式。
5. 最佳化落地階段
5.1 props檔案解析的maven外掛
透過maven外掛,將props檔案的解析提前到編譯期,提前生成相應的scala檔案,然後在執行時進行載入。
實現原理:5.2 RpcBuilder和RpcService
採用獨立的執行緒池,將建立網路連線的流程非同步化,並在應用健康檢查之前,等待所有的非同步化任務完成並銷燬執行緒池,類似於一個CountDownLatch的邏輯,此處不再贅述。
5.3 懶載入
5.3.1 懶載入落地中的考慮
懶載入帶來啟動加速效果的同時,於此帶來的最明顯的副作用就是第一次請求訪問時rt會變高,而對於有些rt敏感的應用來說,這個副作用是不可接受的。所以綜合考慮之後,最終選擇僅在測試環境(包括開發環境)開啟懶載入,主要原因有:
- 保證框架層的適用性,儘量適用於所有型別的應用
- 測試環境更適配懶載入的特性,因為絕大多數情況下測試環境只是測應用中的部分功能,而非全量功能,在未開啟懶載入之前,需要等待與待測試功能無關的其他bean初始化,這部分時間是毫無意義的。
- 測試環境的重啟發布頻率遠高於線上,懶載入帶來的收益更顯著。
如此一來,既能保證框架層的適配性,又可基於懶載入的特性帶來研發效能中的提升。
5.3.2 懶載入的實現落地
由於目前我們使用的SpringBoot版本為1.x,並未支援spring.main.lazy-initialization配置,所以需要我們自己來實現這個邏輯。這時需要使用到Spring另外的一個擴充套件點BeanDefinitionRegistryPostProcessor,這個擴充套件點主要作用於IOC容器收集完bean定義資訊BeanDefinition之後的後置處理。透過此擴充套件點遍歷所有的BeanDefinition,過濾出非Configuration的bean(部分配置類懶載入後不生效),透過BeanDefinition的api開啟懶載入,核心實現程式碼:
public class LazyInitPostProcessor implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
// 非測試環境不開啟
if (! isTestEnv() && ! isDevEnv()) {
return;
}
for ( String name : registry.getBeanDefinitionNames() ) {
BeanDefinition beanDefinition = registry.getBeanDefinition(name);
String beanClassName = beanDefinition.getBeanClassName();
// 如果是@Configuration標識的bean不能設為lazyinit
if (null != beanClassName) {
try {
Class<?> beanClazz = Class.forName(beanClassName);
Configuration annotation = AnnotationUtils.findAnnotation(beanClazz, Configuration.class);
if (null != annotation) {
continue;
}
} catch (ClassNotFoundException e) {
log.warn("class not found,class -> {}",beanClassName);
}
}
// 設定為懶載入
registry.getBeanDefinition( name ).setLazyInit(true);
}
}
}
public interface BeanDefinition extends AttributeAccessor, BeanMetadataElement {
// 省略其他無關的api
/**
* Set whether this bean should be lazily initialized.
* <p>If {@code false}, the bean will get instantiated on startup by bean
* factories that perform eager initialization of singletons.
*/
void setLazyInit(boolean lazyInit);
}
5.4 最佳化結果
最佳化後各分類bean耗時:
最佳化後開發環境本地總啟動耗時:
整體最佳化效果從最初的452s,下降到276s,整體啟動時間下降了大約40%
6. 總結
本文總體描述了雲音樂服務端腳手架應用從分析定位,到最佳化落地的整體過程。之後根據底層元件的特性,總結了一些可以用於後續的程式設計實踐,並給出了一些SpringBoot應用的通用分析與最佳化思路。
7. 思考擴充套件
Spring框架本身也是一大問題,大量使用反射技術進行BeanDefiniton和Bean初始化,也是影響應用啟動時間的重要原因。同時,現在有一些Compile Dependency Inject模式的框架很有效的解決這類問題,比如micronaut,根據簡單的demo測試結果,應用啟動時間大約只需要SpringBoot的1/3。
8. 參考資料
- Arthas Profiler工具:https://arthas.aliyun.com/doc/profiler.html
- Arthas Profiler命令引數:https://www.dounaite.com/article/6264549c7b5653d739b0bb74.html
- SpringBoot懶載入:https://spring.io/blog/2019/03/14/lazy-initialization-in-spri...
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!