作者:闲鱼技术——泊垚
背景
应用的发布是一件非常耗时的事情,尤其是当应用迭代了比较长的时间之后,一次预发的部署就可能需要花费十几分钟,其中服务启动一次,就可能花费五六分钟。如此漫长的发布可能带来两方面的问题:
- 开发过程中,在使用测试环境的时候发布验证的时候,我们往往希望快速迭代,快速验证,但是每次改完一个feature发布验证都要经过十几分钟的话,效率是非常低下的。
- 在线上发布过程中,如果遇到机器数量非常多的应用,那么一次发布,一批一批的部署下来,耗费的时间非常长,很容易超出发布窗口,带来线上风险。
针对应用发布耗时长的问题,笔者结合对手头应用idle-local的耗时分析,参考和尝试了多数的方案之后,制定了一套实施方案,能够有效提高应用的编译和启动速度。同时也着手优化和沉淀了一个启动加速工具(在其他同学的项目上迭代得到),能够针对整个过程中最耗时的启动阶段进行加速,有不错的效果。
构建部署耗时分析
idle-local项目耗时统计
注:
- mtop是我们项目的api层
- hsf是阿里的RPC框架
- pandora是一个的轻量级的隔离容器,用来隔离Webapp和中间件的依赖
重点优化分析
根据统计得到的应用编译部署耗时情况,以及理论上的加速空间,我们制定了以下几项优化的重点:
- 部署过程中,启动应用是最主要的耗时项,也是最容易随着应用迭代膨胀的部分,其中启动应用的主要耗时是bean的初始化。
- 构建过程中,代码编译是主要的耗时项,理论上存在较大的优化空间。
- 镜像中最后一层打包内容的大小会影响镜像push和pull的耗时,如果能够将变动范围分层优化,会有较大的收益。
- 停止应用的过程中,为了处理RPC服务HSF优雅下线和应用优雅关闭,花了比较多的时间,在非生产环境可以省略
构建部署速度治理方案
应用启动加速-Spring Bean异步初始化的原理与落地
加速效果:☆☆☆☆
配置简易:☆☆
推荐指数:☆☆☆☆
我们通过分析spring的初始化过程会发现,spring对于bean的创建,不论是通过遍历还是通过依赖触发,都是通过同步的方式对bean进行初始化的。
这就导致了当一个bean的初始化过程很久的时候,会严重阻塞后续bean的初始化,哪怕这两个bean之间完全没有相互依赖。
如果能够将bean的初始化过程放到异步线程中,则会大大提升bean的创建效率。
如图,在完成bean的实例化后,异步进行初始化,异步初始化实现的关键是保证bean在被使用之前初始化完成。
本节我们将从理论出发,逐步分析springBean的异步初始化办法:
孤立的Bean
我们将不被其他Bean依赖的Bean,定义为孤立的Bean。如图,beanA和beanB1两个bean,相互不依赖也不被其他bean依赖。
理论上,容器中存在孤立的Bean,这些Bean由于不被其他Bean依赖,在容器初始化过程中,这些Bean不会被其他bean使用,是可以自己异步进行初始化的,只需要容器初始化完成时,保障这些Bean已经被初始化完成即可。
然而完全孤立的Bean其实比较少,同时,找出孤立的Bean,需要遍历整个依赖树。我们需要一种覆盖范围更广,更容易定义的方式进行异步化。
暂时孤立的Bean
我们有这样的认知:
- 孤立的Bean一定是被spring通过遍历的方式创建的
- 被spring通过遍历的方式创建的bean不一定是孤立的Bean,他可能被后来的bean依赖并注入
- 被spring通过遍历的方式创建的bean有较大概率是孤立的Bean
- 被spring通过遍历的方式创建的bean的初始化距离它被其他的Bean依赖并注入,有一些时间差
基于这样的认知,我们可以按照以下方案进行异步化:
如图所示,被spring通过遍历的方式创建的bean我们可以暂时认为他是孤立的Bean,当我们发现他不是的时候(被其他Bean调用了getBean),阻塞并等待他的初始化,那么期间的异步初始化过程,也能为我们节省时间;如果他始终没有被依赖,那么说明他就是孤立的Bean。
至于在实际实现中,如何判断一个bean是被spring遍历到还是被其他bean依赖导致的创建,有一个可行的方法是:定义一个全局的标记,用来记录当前spring遍历到的bean,当且仅当这个标记是null的时候,表示当前正在获取的bean是被spring遍历到的,然后立即将当前bean写入标记,并在bean返回前将标记清除,我们可以通过增强beanFactory的getBean方法实现这部分逻辑。
这个方案的优点是能够自动识别并尝试异步初始化,无需复杂的配置即可实现效果不错的加速。
暂时不被使用的Bean
上述的两种异步化中,我们通过bean是否被“依赖”来决定是否异步初始化。但还有很多Bean,不是被spring通过遍历的方式创建的,这就导致我们上面的方案覆盖不到一些耗时的bean。
但事实上,我们Bean的初始化过程,只要在Bean被“使用”前完成即可,被“依赖”这个条件,是过于严格的。如果我们将Bean对象的方法访问判定为被“使用”的入口,那么我们可以通过对Bean进行代理,拦截Bean的方法访问,在其被“使用”之前,等待他初始化完成。
这样我们可以指定任意的Bean进行异步初始化。但有一种情况是不安全的:这个Bean定义了公共的变量,如果在初始化之前被访问,是不能被代理拦截的。我们在实现bean的时候,要注意不要暴露内部变量,这是一个很重要的习惯。
FactoryBean的处理
然而上述的并行化方式,对于有一种类型的bean是不适用的,那就是FactoryBean。不论有没有刻意注意过,写java应用的同学应该都接触过FactoryBean,最常见的就是我们的Mapper。FactoryBean创建bean的过程比较特殊,他会先创建一个FactoryBean的实例,然后由这个FactoryBean实例如创建出我们最终想要的bean实例。因此FactoryBean初始化的不是最终得到的实例,而是生成这个实例的工厂,而这个工厂的初始化完成与否,大概率会影响到bean的生成,因此他不能简单的将初始化过程异步化。
如图是一个Bean的获取过程:
- 如果这个Bean是一个单例并且没有被创建过,那么就会进入createBean,并且在其中完成初始化。
- 如果这个Bean是一个FactoryBean,那么createBean返回的不是bean本身,而是一个factory,真正的bean要在之后的getInstance方法中获取。
在我们的项目中,存在着大量HSFSpringConsumerBean的实例,他们都是FactoryBean,而且这些bean的初始化还相当的耗时。(HSFSpringConsumerBean是我们RPC框架HSF的consumer的FactoryBean)
好在FactoryBean也不是完全不能异步初始化,我们分析一个Bean的get获取过程,会发现,他分为createBean和getInstance两个阶段,在FactoryBean的处理中,如果能够将两个阶段人为分开,先异步完成第一阶段的调用,再触发第二阶段,就可以实现我们的目标。
如图,实现对factoryBean的加速,我们需要在FactoryBean被getBean之前将需要加速的Bean一起找出来,先手动触发它们的异步初始化,但不触发getObject方法,等这些FactoryBean初始化完成后,再交由spring按照原来的创建顺序,去触发他们的getBean方法(此时singleton已经创建,会直接进入getObject调用)。但如果这些Bean对其他的bean有依赖,可能会导致在第一步的异步初始化中产生间接依赖而触发getBean。
好消息是HSFSpringConsumerBean不对其他的bean有依赖,而且项目中绝大多数耗时的FactoryBean都是HSFSpringConsumerBean。如果在Spring初始化所有Bean之前,我们可以一次性并行把所有HSFSpringConsumerBean初始化掉,也能够获得较大的提升。对于其他不产生依赖的FactoryBean,也可以按照一样的方式处理,比如我们比较常见的mapper。
对于可能对其他bean产生依赖的FactoryBean,理论上我们也可以通过去阻塞这些bean的getBean方法,等待我们第一阶段预初始化的完成。目前项目中这些bean的比例很小,因此这部分功能尚未着手实现。
编译加速-module依赖关系优化
加速效果:☆☆☆
配置简易:☆
推荐指数:☆☆☆
目前项目使用的多mudule结构,往往含有start,mtop(接口层),service等层,其中module之间又相互依赖,导致一个低层module依赖的中间件,又会继续被上层模块解析。而往往上层模块自身非常薄,却因为依赖了低层模块,不得不反复解析庞大的依赖树,导致编译时间非常长。调整module的方式,是一种解决方案,但会破坏项目的module,失去来原来多module的优势。
我们针对含有start,mtop,service的项目,提出一种改动较小的优化方式:
如图所示:
- mtop层在依赖service层的时候,排除service层的所有间接依赖,仅针对mtop层自身也需要依赖的内容进行手动引入。
- start层依赖mtop和service,这里不能再做排除,因为项目是在start层进行打包的,如果排除,则会导致依赖包没有被正常打包到项目中而无法启动。
- 按照这种方式优化,能够节省在mtop层解析service依赖树的开销,往往有几十秒之多。
我们同时也提出约定,在使用这种module结构的时候,控制好mtop和service的边界:
- mtop层是对service提供的服务进行mtop接口级别的封装,尽量仅依赖应用内service层和common定义的服务和对象,不处理复杂的中间件逻辑
- 对于外部服务的使用和中间件定义的服务和对象的使用,尽量在service层封装,同时不宜将外部服务和中间件定义的对象直接透给mtop层,造成依赖扩散
- 这约定之后,清晰mtop层与service层的边界,mtop层就是操作service层定义的方法和对象来完成接口,涉及外部定义的方法和对象的,收口到service。
镜像治理-分层构建
加速效果:☆☆☆
配置简易:☆☆☆
推荐指数:☆☆☆☆
如图,我们使用docker进行构建时,push/pull image 的时候, 如果某一层的镜像已经存在了, 就会直接使用缓存, 跳过重复的推送和拉取过程。而正常情况下,应用打出的包 (一般是 tgz 包) 是一个整体, 即使用户只修改了一行代码, 也要打出一个完整的包,包含所有依赖的jar文件, 导致每次push和pull的时候,都要传输所有的jar包,导致效率低下。
如果在打包的时候将jar包和项目代码分到不同的层里面,在绝大多数构建中,jar包不发生变化,则需要被更新的内容大大减小,进而提升push和pull的速度。同时,在应用启动过程中,tgz包解压需要花费一定的时间,在分层打包的改造中,去除了压缩解压过程,使得速度进一步提升。
该方案对镜像构建、镜像拉取、应用启动三个过程均有提速,在idle-local中,综合收益超过30秒(一次构建加一次部署)。
应用停止过程加速
加速效果:☆☆☆☆
配置简易:☆☆
推荐指数:☆☆☆☆
停止应用过程中,比较耗时的有两个步骤:1、hsf优雅下线;2、应用停止。其中,hsf优雅下线过程,会先通知应用进行hsf provider下线,然后等待一个比较安全的时间,对于生产环境来说,15秒是相对安全的值,对于预发环境来说,可以不等待。应用停止是通过kill -0 信号进行应用停止,应用会进行一些停止前的操作,不同应用不尽相同,如果停止失败,则使用kill -9强制退出;对于已经进行HSF优雅下线并且没有其他关键退出动作的应用来说,可以直接关闭应用,可以加速停止的耗时,对于非线上环境环境来说,是比较适用的。
效果及展望
经过一系列的优化措施,我们可以看到我们的系统编译和启动过程得到了不错的优化。其中紫色部分的耗时基本可以忽略,红色部分的耗时减少了一倍。
到目前为止,我们落地了一套有效的加速方案,在idle-local上取得了不错的效果。后续我们将继续完善这一系列方案,一方面,我们将着力建设一个基于监控的长效管控方案,能够让应用长期保持较好的状态;另一方面,我们会将上述内容抽象成一个一站式落地方案,支持在其他项目快速的配置落地。
项目 | 优化效果 | 配置成本 |
---|---|---|
Spring Bean异步初始化(自动) | 10秒*n,随代码规格提升效果更明显 | 引入实现了加速的jar包 |
Spring Bean异步初始化(手动) | 20秒*n,随代码规格提升效果更明显 | 引入实现了加速的jar包并配置bean |
HSFSpringConsumerBean优化 | 10秒*n,随代码规格提升效果更明显 | 引入实现了加速的jar包 |
module依赖关系优化 | 40秒 | 需要调整pom并处理依赖 |
docker镜像分层 | 30秒 | 修改打包脚本 |
停止过程加速 | 40秒(非线上环境) | 修改停止脚本 |