7min到40s:SpringBoot啟動最佳化實踐

碼猿技術專欄發表於2023-02-24

大家好,我是不才陳某~

公司 SpringBoot 專案在日常開發過程中發現服務啟動過程異常緩慢,常常需要6-7分鐘才能暴露埠,嚴重降低開發效率。透過 SpringBoot 的 SpringApplicationRunListenerBeanPostProcessor 原理和原始碼除錯等手段排查發現,在 Bean 掃描和 Bean 注入這個兩個階段有很大的效能瓶頸。

關注公眾號:碼猿技術專欄,回覆關鍵詞:1111 獲取阿里內部Java效能調優手冊

透過 JavaConfig 註冊 Bean, 減少 SpringBoot 的掃描路徑,同時基於 Springboot 自動配置原理對第三方依賴最佳化改造,將服務本地啟動時間從7min 降至40s 左右的過程。 本文會涉及以下知識點:

  • 基於 SpringApplicationRunListener 原理觀察 SpringBoot 啟動 run 方法;
  • 基於 BeanPostProcessor 原理監控 Bean 注入耗時;
  • SpringBoot Cache 自動化配置原理;
  • SpringBoot 自動化配置原理及 starter 改造;

1. 耗時問題排查

SpringBoot 服務啟動耗時排查,目前有2個思路:

  1. 排查 SpringBoot 服務的啟動過程;
  2. 排查 Bean 的初始化耗時;

1.1 觀察 SpringBoot 啟動 run 方法

該專案使用基於 SpringBoot 改造的內部微服務元件 XxBoot 作為服務端實現,其啟動流程與 SpringBoot 類似,分為 ApplicationContext 構造和 ApplicationContext 啟動兩部分,即透過建構函式例項化 ApplicationContext 物件,並呼叫其 run 方法啟動服務:

public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) {
    return new SpringApplication(primarySources).run(args);
}

ApplicationContext 物件構造過程,主要做了自定義 Banner 設定、應用型別推斷、配置源設定等工作,不做特殊擴充套件的話,大部分專案都是差不多的,不太可能引起耗時問題。透過在 run 方法中打斷點,啟動後很快就執行到斷點位置,也能驗證這一點。
接下就是重點排查 run 方法的啟動過程中有哪些效能瓶頸?SpringBoot 的啟動過程非常複雜,慶幸的是 SpringBoot 本身提供的一些機制,將 SpringBoot 的啟動過程劃分了多個階段,這個階段劃分的過程就體現在 SpringApplicationRunListener 介面中,該介面將 ApplicationContext 物件的 run 方法劃分成不同的階段:

public interface SpringApplicationRunListener {
    // run 方法第一次被執行時呼叫,早期初始化工作
    void starting();
    // environment 建立後,ApplicationContext 建立前
    void environmentPrepared(ConfigurableEnvironment environment);
    // ApplicationContext 例項建立,部分屬性設定了
    void contextPrepared(ConfigurableApplicationContext context);
    // ApplicationContext 載入後,refresh 前
    void contextLoaded(ConfigurableApplicationContext context);
    // refresh 後
    void started(ConfigurableApplicationContext context);
    // 所有初始化完成後,run 結束前
    void running(ConfigurableApplicationContext context);
    // 初始化失敗後
    void failed(ConfigurableApplicationContext context, Throwable exception);
}

目前,SpringBoot 中自帶的 SpringApplicationRunListener 介面只有一個實現類:EventPublishingRunListener,該實現類作用:透過觀察者模式的事件機制,在 run 方法的不同階段觸發 Event 事件,ApplicationListener 的實現類們透過監聽不同的 Event 事件物件觸發不同的業務處理邏輯。

透過自定義實現 ApplicationListener 實現類,可以在 SpringBoot 啟動的不同階段,實現一定的處理,可見SpringApplicationRunListener 介面給 SpringBoot 帶來了擴充套件性。

這裡我們不必深究實現類 EventPublishingRunListener 的功能,但是可以透過 SpringApplicationRunListener 原理,新增一個自定義的實現類,在不同階段結束時列印下當前時間,透過計算不同階段的執行時間,就能大體定位哪些階段耗時比較高,然後重點排查這些階段的程式碼。
先看下 SpringApplicationRunListener 的實現原理,其劃分不同階段的邏輯體現在 ApplicationContextrun 方法中:

public ConfigurableApplicationContext run(String... args) {
    ...
    // 載入所有 SpringApplicationRunListener 的實現類
    SpringApplicationRunListeners listeners = getRunListeners(args);
    // 呼叫了 starting
    listeners.starting();
    try {
        ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
        // 呼叫了 environmentPrepared
        ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
        configureIgnoreBeanInfo(environment);
        Banner printedBanner = printBanner(environment);
        context = createApplicationContext();
        exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[] { ConfigurableApplicationContext.class }, context);
        // 內部呼叫了 contextPrepared、contextLoaded
        prepareContext(context, environment, listeners, applicationArguments, printedBanner);
        refreshContext(context);
        afterRefresh(context, applicationArguments);
        stopWatch.stop();
        if (this.logStartupInfo) {
            new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
        }
        // 呼叫了 started
        listeners.started(context);
        callRunners(context, applicationArguments);
    }
    catch (Throwable ex) {
        // 內部呼叫了 failed
        handleRunFailure(context, ex, exceptionReporters, listeners);
        throw new IllegalStateException(ex);
    }
    try {
        // 呼叫了 running
        listeners.running(context);
    }
    catch (Throwable ex) {
        handleRunFailure(context, ex, exceptionReporters, null);
        throw new IllegalStateException(ex);
    }
    return context;
}

run 方法中 getRunListeners(args) 透過 SpringFactoriesLoader 載入 classpathMETA-INF/spring.factotries 中配置的所有 SpringApplicationRunListener 的實現類,透過反射例項化後,存到區域性變數 listeners 中,其型別為 SpringApplicationRunListeners;然後在 run 方法不同階段透過呼叫 listeners 的不同階段方法來觸發 SpringApplicationRunListener 所有實現類的階段方法呼叫。

因此,只要編寫一個 SpringApplicationRunListener 的自定義實現類,在實現介面不同階段方法時,列印當前時間;並在 META-INF/spring.factotries 中配置該類後,該類也會例項化,存到 listeners 中;在不同階段結束時列印結束時間,以此來評估不同階段的執行耗時。
在專案中新增實現類 MySpringApplicationRunListener

@Slf4j
public class MySpringApplicationRunListener implements SpringApplicationRunListener {
    // 這個建構函式不能少,否則反射生成例項會報錯
    public MySpringApplicationRunListener(SpringApplication sa, String[] args) {
    }
    @Override
    public void starting() {
        log.info("starting {}", LocalDateTime.now());
    }
    @Override
    public void environmentPrepared(ConfigurableEnvironment environment) {
        log.info("environmentPrepared {}", LocalDateTime.now());
    }
    @Override
    public void contextPrepared(ConfigurableApplicationContext context) {
        log.info("contextPrepared {}", LocalDateTime.now());
    }
    @Override
    public void contextLoaded(ConfigurableApplicationContext context) {
        log.info("contextLoaded {}", LocalDateTime.now());
    }
    @Override
    public void started(ConfigurableApplicationContext context) {
        log.info("started {}", LocalDateTime.now());
    }
    @Override
    public void running(ConfigurableApplicationContext context) {
        log.info("running {}", LocalDateTime.now());
    }
    @Override
    public void failed(ConfigurableApplicationContext context, Throwable exception) {
        log.info("failed {}", LocalDateTime.now());
    }
}

這邊 (SpringApplication sa, String[] args) 引數型別的建構函式不能少,因為原始碼中限定了使用該引數型別的建構函式反射生成例項。

resources 檔案下的 META-INF/spring.factotries 檔案中配置上該類:

# Run Listeners
org.springframework.boot.SpringApplicationRunListener=\
com.xxx.ad.diagnostic.tools.api.MySpringApplicationRunListener

run 方法中是透過 getSpringFactoriesInstances 方法來獲取 META-INF/spring.factotries 下配置的 SpringApplicationRunListener 的實現類,其底層是依賴 SpringFactoriesLoader 來獲取配置的類的全限定類名,然後反射生成例項;

這種方式在 SpringBoot 用的非常多,如 EnableAutoConfigurationApplicationListenerApplicationContextInitializer 等。

重啟服務,觀察 MySpringApplicationRunListener 的日誌輸出,發現主要耗時都在 contextLoadedstarted 兩個階段之間,在這兩個階段之間呼叫了2個方法:refreshContextafterRefresh 方法,而 refreshContext 底層呼叫的是 AbstractApplicationContext#refresh,Spring 初始化 context 的核心方法之一就是這個 refresh

至此基本可以斷定,高耗時的原因就是在初始化 Spring 的 context,然而這個方法依然十分複雜,好在 refresh 方法也將初始化 Spring 的 context 的過程做了整理,並詳細註釋了各個步驟的作用:

透過簡單除錯,很快就定位了高耗時的原因:

  1. invokeBeanFactoryPostProcessors(beanFactory) 方法中,呼叫了所有註冊的 BeanFactory 的後置處理器;
  2. 其中,ConfigurationClassPostProcessor 這個後置處理器貢獻了大部分的耗時;
  3. 查閱相關資料,該後置處理器相當重要,主要負責@Configuration@ComponentScan@Import@Bean 等註解的解析;
  4. 繼續除錯發現,主要耗時都花在主配置類的 @ComponentScan 解析上,而且主要耗時還是在解析屬性 basePackages

即專案主配置類上 @SpringBootApplication 註解的 scanBasePackages 屬性:

透過該方法 JavaDoc、檢視相關程式碼,大體瞭解到該過程是在遞迴掃描、解析 basePackages 所有路徑下的 class,對於可作為 Bean 的物件,生成其 BeanDefinition;如果遇到 @Configuration 註解的配置類,還得遞迴解析其 @ComponentScan。 至此,服務啟動緩慢的原因就找到了:

  1. 作為資料平臺,我們的服務引用了很多第三方依賴服務,這些依賴往往提供了對應業務的完整功能,所以提供的 jar 包非常大;
  2. 掃描這些包路徑下的 class 非常耗時,很多 class 都不提供 Bean,但還是花時間掃描了;
  3. 每新增一個服務的依賴,都會線性增加掃描的時間;

弄明白耗時的原因後,我有2個疑問:

  1. 是否所有的 class 都需要掃描,是否可以只掃描那些提供 Bean 的 class?
  2. 掃描出來的 Bean 是否都需要?我只接入一個功能,但是注入了所有的 Bean,這似乎不太合理?

1.2 監控 Bean 注入耗時

第二個最佳化的思路是監控所有 Bean 物件初始化的耗時,即每個 Bean 物件例項化、初始化、註冊所花費的時間,有沒有特別耗時 Bean 物件?
同樣的,我們可以利用 SpringBoot 提供了 BeanPostProcessor 介面來監控 Bean 的注入耗時,BeanPostProcessor 是 Spring 提供的 Bean 初始化前後的 IOC 鉤子,用於在 Bean 初始化的前後執行一些自定義的邏輯:

public interface BeanPostProcessor {
    // 初始化前
    default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }
    // 初始化後
    default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }   
}

對於 BeanPostProcessor 介面的實現類,其前後置處理過程體現在 AbstractAutowireCapableBeanFactory#doCreateBean,這也是 Spring 中非常重要的一個方法,用於真正例項化 Bean 物件,透過 BeanFactory#getBean 方法一路 Debug 就能找到。在該方法中呼叫了 initializeBean 方法:

protected Object initializeBean(String beanName, Object bean, @Nullable RootBeanDefinition mbd) {
    ...
    Object wrappedBean = bean;
    if (mbd == null || !mbd.isSynthetic()) {
        // 應用所有 BeanPostProcessor 的前置方法
        wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
    }
    try {
        invokeInitMethods(beanName, wrappedBean, mbd);
    }
    catch (Throwable ex) {
        throw new BeanCreationException(
                (mbd != null ? mbd.getResourceDescription() : null),
                beanName, "Invocation of init method failed", ex);
    }
    if (mbd == null || !mbd.isSynthetic()) {
        // 應用所有 BeanPostProcessor 的後置方法
        wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
    }
    return wrappedBean;
}

透過 BeanPostProcessor 原理,在前置處理時記錄下當前時間,在後置處理時,用當前時間減去前置處理時間,就能知道每個 Bean 的初始化耗時,下面是我的實現:

@Component
public class TimeCostBeanPostProcessor implements BeanPostProcessor {
    private Map<String, Long> costMap = Maps.newConcurrentMap();
        
    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        costMap.put(beanName, System.currentTimeMillis());
        return bean;
    }
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (costMap.containsKey(beanName)) {
            Long start = costMap.get(beanName);
            long cost  = System.currentTimeMillis() - start;
            if (cost > 0) {
                costMap.put(beanName, cost);
                System.out.println("bean: " + beanName + "\ttime: " + cost);
            }
        }
        return bean;
    }
}

BeanPostProcessor 的邏輯是在 Beanfactory 準備好後處理的,就不需要透過 SpringFactoriesLoader 載入了,直接 @Component 注入即可。

重啟服務,透過以上方法排查 Bean 初始化過程,還真的有所發現:

這個 Bean 初始化耗時43s,具體看下這個 Bean 的初始化方法,發現會從資料庫查詢大量配置後設資料,並更新到 Redis 快取中,所以初始化非常慢:

另外,還發現了一些非專案自身服務的service、controller物件,這些 Bean 來自於第三方依賴:UPM服務,專案中並不需要:

其實,原因上文已經提到:我只接入一個功能,但我注入了該服務路徑下所有的 Bean,也就是說,服務裡注入其他服務的、對自身無用的 Bean。

2.1 最佳化方案

2.1 如何解決掃描路徑過多?

想到的解決方案比較簡單粗暴:
梳理要引入的 Bean,刪掉主配置類上掃描路徑,使用 JavaConfig 的方式顯式手動注入。
以 UPM 的依賴為例,之前的注入方式 是,專案依賴其 UpmResourceClient 物件,Pom 已經引用了其 Maven 座標,並在主配置類上的 scanBasePackages 中新增了其服務路徑:"com.xxx.ad.upm",透過掃描整個服務路徑下的 class,找到 UpmResourceClient 並注入,因為該類註解了 @Service,因此會注入到服務的 Spring 上下文中,UpmResourceClient 原始碼片段及主配置類如下:

使用 JavaConfig 的改造方式是:不再掃描 UPM 的服務路徑,而是主動注入。刪除"com.xxx.ad.upm",並在服務路徑下新增以下配置類:

@Configuration
public class ThirdPartyBeanConfig {
    @Bean
    public UpmResourceClient upmResourceClient() {
        return new UpmResourceClient();
    }
}
Tips:如果該 Bean 還依賴其他 Bean,則需要把所依賴的 Bean 都注入; 針對 Bean 依賴情況複雜的場景梳理起來就比較麻煩了,所幸專案用到的服務 Bean 依賴關係都比較簡單,一些依賴關係複雜的服務,觀察到其路徑掃描耗時也不是很高,就不處理了。

同時,透過 JavaConfig 按需注入的方式,就不存在冗餘 Bean 的情況了,也有利於降低服務的記憶體消耗;解決了上面的引入無關的 upmService、upmController 的問題。

2.2 如何解決 Bean 初始化高耗時?

Bean 初始化耗時高,就需要 case by case 地處理了,比如專案中遇到的初始化配置後設資料的問題,可以考慮透過將該任務提交到執行緒池的方式非同步處理或者懶載入的方式來解決。

3. 新的問題

完成以上最佳化後,本地啟動時間從之前的 7min 左右降低至 40s,效果還是非常顯著的。本地自測透過後,便釋出到預發進行驗證,驗證過程中,有同學發現專案接入的 Redis 快取元件失效了。
該元件接入方式與上文描述的接入方式類似,透過新增掃描服務的根路徑"com.xxx.ad.rediscache",注入對應的 Bean 物件;檢視該快取元件專案的原始碼,發現該路徑下有一個 config 類注入了一個快取管理物件 CacheManager,其實現類是 RedisCacheManager


快取元件程式碼片段:

本次最佳化中,我是透過 每次刪除一條掃描路徑,啟動服務後根據啟動日誌中 Bean 缺失錯誤的資訊,來逐個梳理、新增依賴的 Bean,保證服務正常啟動 的方式來改造的,而刪除"com.xxx.ad.rediscache"後啟動服務並無異常,因此就沒有進一步的操作,直接上預發驗證了。這就奇怪了,既然不掃描該元件的業務程式碼根路徑,也就沒有執行注入該元件中定義的 CacheManager 物件,為啥用到快取的地方沒有報錯呢?
嘗試在未新增掃描路徑的情況下,從 ApplicationContext 中獲取 CacheManager 型別的物件看下是否存在?結果發現確實存在 RedisCacheManager 物件:

其實,前面的分析並沒有錯,刪除掃描路徑後生成的 RedisCacheManager 並不是快取元件程式碼中配置的,而是 SpringBoot 的自動化配置生成的,也就是說該物件並不是我們想要的物件,是不符合預期的,下文介紹其原因。

3.1 SpringBoot 自動化裝配,讓人防不勝防

查閱 SpringBoot Cache 相關資料,發現 SpringBoot Cache 做了一些自動推斷和注入的工作,原來是 SpringBoot 自動化裝配的鍋呀,接下來就分析下 SpringBoot Cache 原理,明確出現以上問題的原因。
SpringBoot 自動化配置,體現在主配置類上覆合注解 @SpringBootApplication 中的@EnableAutoConfiguration 上,該註解開啟了 SpringBoot 的自動配置功能。該註解中的@Import(AutoConfigurationImportSelector.class) 透過載入 META-INF/spring.factotries 下配置一系列 *AutoConfiguration 配置類,根據現有條件推斷,儘可能地為我們配置需要的 Bean。這些配置類負責各個功能的自動化配置,其中用於 SpringBoot Cache 的自動配置類是 CacheAutoConfiguration,接下來重點分析這個配置類就行了。

@SpringBootApplication 複合註解中整合了三個非常重要的註解:@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan,其中 @EnableAutoConfiguration 就是負責開啟自動化配置功能;
SpringBoot 中有多[ ]()@EnableXXX 的註解,都是用來開啟某一方面的功能,其實現原理也是類似的:透過 @Import 篩選、匯入滿足條件的自動化配置類。

可以看到 CacheAutoConfiguration 上有許多註解,重點關注下@Import({CacheConfigurationImportSelector.class})CacheConfigurationImportSelector 實現了 ImportSelector 介面,該介面用於動態選擇想匯入的配置類,這個 CacheConfigurationImportSelector 用來匯入不同型別的 Cache 的自動配置類:

透過除錯 CacheConfigurationImportSelector 發現,根據 SpringBoot 支援的快取型別(CacheType),提供了10種 cache 的自動配置類,按優先順序排序,最終只有一個生效,而本專案中恰恰就是 RedisCacheConfiguration,其內部提供的是 RedisCacheManager,和引入第三方快取元件一樣,所以造成了困惑:


看下 RedisCacheConfiguration 的實現:

這個配置類上有很多條件註解,當這些條件都滿足的話,這個自動配置類就會生效,而本專案恰恰都滿足,同時專案主配置類上還加上了 @EnableCaching,開啟了快取功能,即使快取元件沒生效,SpringBoot 也會自動生成一個快取管理物件;

即:快取元件服務掃描路徑存在的話,快取元件中的程式碼生成快取管理物件,@ConditionalOnMissingBean(CacheManager.class) 失效;掃描路徑不存在的話,SpringBoot 透過推斷,自動生成一個快取管理物件。

這個也很好驗證,在 RedisCacheConfiguration 中打斷點,不刪除掃描路徑是走不到這邊的SpringBoot 自動裝配過程的(快取元件顯式生成過了),刪除了掃描路徑是能走到的(SpringBoot 自動生成)。

上文多次提到@Import,這是 SpringBoot 中重要註解,主要有以下作用:
1、匯入 @Configuration 註解的類;
2、匯入實現了 ImportSelectorImportBeanDefinitionRegistrar 的類;
3、匯入普通的 POJO。

3.2 使用 starter 機制,開箱即用

瞭解快取失效的原因後,就有解決的辦法了,因為是自己團隊的元件,就沒必要透過 JavaConfig 顯式手動匯入的方式改造,而是透過 SpringBoot 的 starter 機制,最佳化下快取元件的實現,可以做到自動注入、開箱即用。 只要改造下快取元件的程式碼,在 resources 檔案中新增一個 META-INF/spring.factotries 檔案,在下面配置一個 EnableAutoConfiguration 即可,這樣專案在啟動時也會掃描到這個 jar 中的 spring.factotries 檔案,將 XxxAdCacheConfiguration 配置類自動引入,而不需要掃描"com.xxx.ad.rediscache"整個路徑了:

# EnableAutoConfigurations
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.ad.rediscache.XxxAdCacheConfiguration

SpringBoot 的 EnableAutoConfiguration 自動配置原理還是比較複雜的,在載入自動配置類前還要先載入自動配置的後設資料,對所有自動配置類做有效性篩選,具體可查閱 EnableAutoConfiguration 相關程式碼;

最後說一句(別白嫖,求關注)

陳某每一篇文章都是精心輸出,如果這篇文章對你有所幫助,或者有所啟發的話,幫忙點贊在看轉發收藏,你的支援就是我堅持下去的最大動力!

關注公眾號:【碼猿技術專欄】,公眾號內有超讚的粉絲福利,回覆:加群,可以加入技術討論群,和大家一起討論技術,吹牛逼!

相關文章