本文作者:喵内
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!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。