作者:Debugger
链接:https://juejin.cn/post/7181342523728592955
0 背景
公司 SpringBoot 项目在日常开发过程中发现服务启动过程异常缓慢,常常需要6-7分钟才能暴露端口,严重降低开发效率。通过 SpringBoot 的 SpringApplicationRunListener 、BeanPostProcessor 原理和源码调试等手段排查发现,在 Bean 扫描和 Bean 注入这个两个阶段有很大的性能瓶颈。
通过 JavaConfig 注册 Bean, 减少 SpringBoot 的扫描路径,同时基于 Springboot 自动配置原理对第三方依赖优化改造,将服务本地启动时间从7min 降至40s 左右的过程。 本文会涉及以下知识点:
- 基于 SpringApplicationRunListener 原理观察 SpringBoot 启动 run 方法;
- 基于 BeanPostProcessor 原理监控 Bean 注入耗时;
- SpringBoot Cache 自动化配置原理;
- SpringBoot 自动化配置原理及 starter 改造;
1 耗时问题排查
SpringBoot 服务启动耗时排查,目前有2个思路:
- 排查 SpringBoot 服务的启动过程;
- 排查 Bean 的初始化耗时;
Spring Boot 基础就不介绍了,推荐看这个免费教程:
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 的实现原理,其划分不同阶段的逻辑体现在 ApplicationContext 的 run 方法中:
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 加载 classpath 下 META-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 用的非常多,如 EnableAutoConfiguration、ApplicationListener、ApplicationContextInitializer 等。
重启服务,观察 MySpringApplicationRunListener 的日志输出,发现主要耗时都在 contextLoaded 和 started 两个阶段之间,在这两个阶段之间调用了2个方法:refreshContext 和 afterRefresh 方法,而 refreshContext 底层调用的是 AbstractApplicationContext#refresh,Spring 初始化 context 的核心方法之一就是这个 refresh。
至此基本可以断定,高耗时的原因就是在初始化 Spring 的 context,然而这个方法依然十分复杂,好在 refresh 方法也将初始化 Spring 的 context 的过程做了整理,并详细注释了各个步骤的作用:
通过简单调试,很快就定位了高耗时的原因:
- 在 invokeBeanFactoryPostProcessors(beanFactory) 方法中,调用了所有注册的 BeanFactory 的后置处理器;
- 其中,ConfigurationClassPostProcessor 这个后置处理器贡献了大部分的耗时;
- 查阅相关资料,该后置处理器相当重要,主要负责@Configuration、@ComponentScan、@Import、@Bean 等注解的解析;
- 继续调试发现,主要耗时都花在主配置类的 @ComponentScan 解析上,而且主要耗时还是在解析属性 basePackages;
即项目主配置类上 @SpringBootApplication 注解的 scanBasePackages 属性:
通过该方法 JavaDoc、查看相关代码,大体了解到该过程是在递归扫描、解析 basePackages 所有路径下的 class,对于可作为 Bean 的对象,生成其 BeanDefinition;如果遇到 @Configuration 注解的配置类,还得递归解析其 @ComponentScan。 至此,服务启动缓慢的原因就找到了:
作为数据平台,我们的服务引用了很多第三方依赖服务,这些依赖往往提供了对应业务的完整功能,所以提供的 jar 包非常大;
扫描这些包路径下的 class 非常耗时,很多 class 都不提供 Bean,但还是花时间扫描了;
每添加一个服务的依赖,都会线性增加扫描的时间;
弄明白耗时的原因后,我有2个疑问:
是否所有的 class 都需要扫描,是否可以只扫描那些提供 Bean 的 class?
扫描出来的 Bean 是否都需要?我只接入一个功能,但是注入了所有的 Bean,这似乎不太合理?