7min 到 40s:Spring Boot 启动优化实践 上

简介: 7min 到 40s:Spring Boot 启动优化实践 上


背景

公司 SpringBoot 项目在日常开发过程中发现服务启动过程异常缓慢,常常需要6-7分钟才能暴露端口,严重降低开发效率。通过 SpringBoot 的 SpringApplicationRunListenerBeanPostProcessor 原理和源码调试等手段排查发现,在 Bean 扫描和 Bean 注入这个两个阶段有很大的性能瓶颈。

通过 JavaConfig 注册 Bean, 减少 SpringBoot 的扫描路径,同时基于 Springboot 自动配置原理对第三方依赖优化改造,将服务本地启动时间从7min 降至40s 左右的过程。 本文会涉及以下知识点:

  • 基于 SpringApplicationRunListener 原理观察 SpringBoot 启动 run 方法;
  • 基于 BeanPostProcessor 原理监控 Bean 注入耗时;
  • SpringBoot Cache 自动化配置原理;
  • SpringBoot 自动化配置原理及 starter 改造;

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

耗时问题排查

SpringBoot 服务启动耗时排查,目前有2个思路:

  1. 排查 SpringBoot 服务的启动过程;
  2. 排查 Bean 的初始化耗时;

观察 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 文件中配置上该类:

> 基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
>
> * 项目地址:<https://github.com/YunaiV/yudao-cloud>
> * 视频教程:<https://doc.iocoder.cn/video/>
# 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

至此基本可以断定,高耗时的原因就是在初始化 Spring 的 context,然而这个方法依然十分复杂,好在 refresh 方法也将初始化 Spring 的 context 的过程做了整理,并详细注释了各个步骤的作用:

初始化 Spring

通过简单调试,很快就定位了高耗时的原因:

  1. invokeBeanFactoryPostProcessors(beanFactory) 方法中,调用了所有注册的 BeanFactory 的后置处理器;
  2. 其中,ConfigurationClassPostProcessor 这个后置处理器贡献了大部分的耗时;
  3. 查阅相关资料,该后置处理器相当重要,主要负责@Configuration@ComponentScan@Import@Bean 等注解的解析;
  4. 继续调试发现,主要耗时都花在主配置类的 @ComponentScan 解析上,而且主要耗时还是在解析属性 basePackages

basePackages

即项目主配置类上 @SpringBootApplication 注解的 scanBasePackages 属性:

scanBasePackages

通过该方法 JavaDoc、查看相关代码,大体了解到该过程是在递归扫描、解析 basePackages 所有路径下的 class,对于可作为 Bean 的对象,生成其 BeanDefinition;如果遇到 @Configuration 注解的配置类,还得递归解析其 @ComponentScan。 至此,服务启动缓慢的原因就找到了:

  1. 作为数据平台,我们的服务引用了很多第三方依赖服务,这些依赖往往提供了对应业务的完整功能,所以提供的 jar 包非常大;
  2. 扫描这些包路径下的 class 非常耗时,很多 class 都不提供 Bean,但还是花时间扫描了;
  3. 每添加一个服务的依赖,都会线性增加扫描的时间;

弄明白耗时的原因后,我有2个疑问:

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

监控 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 初始化过程

这个 Bean 初始化耗时43s,具体看下这个 Bean 的初始化方法,发现会从数据库查询大量配置元数据,并更新到 Redis 缓存中,所以初始化非常慢:

初始化方法

另外,还发现了一些非项目自身服务的service、controller对象,这些 Bean 来自于第三方依赖:UPM服务,项目中并不需要:

第三方依赖

其实,原因上文已经提到:我只接入一个功能,但我注入了该服务路径下所有的 Bean,也就是说,服务里注入其他服务的、对自身无用的 Bean。

相关文章
|
2月前
|
存储 人工智能 Java
AI 超级智能体全栈项目阶段四:学术分析 AI 项目 RAG 落地指南:基于 Spring AI 的本地与阿里云知识库实践
本文介绍RAG(检索增强生成)技术,结合Spring AI与本地及云知识库实现学术分析AI应用,利用阿里云Qwen-Plus模型提升回答准确性与可信度。
1148 90
AI 超级智能体全栈项目阶段四:学术分析 AI 项目 RAG 落地指南:基于 Spring AI 的本地与阿里云知识库实践
|
4月前
|
前端开发 Java API
利用 Spring WebFlux 技术打造高效非阻塞 API 的完整开发方案与实践技巧
本文介绍了如何使用Spring WebFlux构建高效、可扩展的非阻塞API,涵盖响应式编程核心概念、技术方案设计及具体实现示例,适用于高并发场景下的API开发。
397 0
|
2月前
|
缓存 监控 Java
《深入理解Spring》性能监控与优化——构建高性能应用的艺术
本文系统介绍了Spring生态下的性能监控与优化实践,涵盖监控体系构建、数据库调优、缓存策略、线程池配置及性能测试等内容,强调通过数据驱动、分层优化和持续迭代提升应用性能。
|
2月前
|
人工智能 监控 Java
Spring AI Alibaba实践|后台定时Agent
基于Spring AI Alibaba框架,可构建自主运行的AI Agent,突破传统Chat模式限制,支持定时任务、事件响应与人工协同,实现数据采集、分析到决策的自动化闭环,提升企业智能化效率。
Spring AI Alibaba实践|后台定时Agent
|
2月前
|
消息中间件 缓存 Java
Spring框架优化:提高Java应用的性能与适应性
以上方法均旨在综合考虑Java Spring 应该程序设计原则, 数据库交互, 编码实践和系统架构布局等多角度因素, 旨在达到高效稳定运转目标同时也易于未来扩展.
150 8
|
3月前
|
缓存 Java 应用服务中间件
Spring Boot配置优化:Tomcat+数据库+缓存+日志,全场景教程
本文详解Spring Boot十大核心配置优化技巧,涵盖Tomcat连接池、数据库连接池、Jackson时区、日志管理、缓存策略、异步线程池等关键配置,结合代码示例与通俗解释,助你轻松掌握高并发场景下的性能调优方法,适用于实际项目落地。
631 5
|
4月前
|
Java 应用服务中间件 开发者
Spring Boot 技术详解与应用实践
本文档旨在全面介绍 Spring Boot 这一广泛应用于现代企业级应用开发的框架。内容将涵盖 Spring Boot 的核心概念、核心特性、项目自动生成与结构解析、基础功能实现(如 RESTful API、数据访问)、配置管理以及最终的构建与部署。通过本文档,读者将能够理解 Spring Boot 如何简化 Spring 应用的初始搭建和开发过程,并掌握其基本使用方法。
367 2
|
5月前
|
缓存 安全 Java
Spring 框架核心原理与实践解析
本文详解 Spring 框架核心知识,包括 IOC(容器管理对象)与 DI(容器注入依赖),以及通过注解(如 @Service、@Autowired)声明 Bean 和注入依赖的方式。阐述了 Bean 的线程安全(默认单例可能有安全问题,需业务避免共享状态或设为 prototype)、作用域(@Scope 注解,常用 singleton、prototype 等)及完整生命周期(实例化、依赖注入、初始化、销毁等步骤)。 解析了循环依赖的解决机制(三级缓存)、AOP 的概念(公共逻辑抽为切面)、底层动态代理(JDK 与 Cglib 的区别)及项目应用(如日志记录)。介绍了事务的实现(基于 AOP
197 0
|
关系型数据库 Java 数据格式
Spring 实践 -拾遗
Spring 实践 标签: Java与设计模式 Junit集成 前面多次用到@RunWith与@ContextConfiguration,在测试类添加这两个注解,程序就会自动加载Spring配置并初始化Spring容器,方便Junit与Spring集成测试.
1189 0