关于服务预热那些事

微信公众号:PersistentCoder

一、概述

服务启动成功后,只是简单的服务进程启动成功,并且做一些简单的初始化,对于启动成功后到提供最优质的服务质量之间,会有一段时间把服务的状态调整到最优,那么如果对服务稳定性或者质量有特别高的诉求和要求,那么在服务启动伊始,并不能满足qps高吞吐和rt高响应,并且由于并没有完全做好初始化准备,也可能造成连锁反应,导致服务cpu飙高甚至服务无法启动。

二、服务预热

所谓服务预热,就是在服务启动完成到对外提供服务之前,针对特定场景提供一些初始化准备操作,比如线程池预热、缓存预热、数据库预热、web预热和jvm预热等等,需要注意的是,预热操作需要在应用真正对外提供服务之前完成,那么我们就可以基于框架的事件或者扩展点来完成这个操作,比如ContextRefreshedEvent和ApplicationReadyEvent事件,InitializingBean和ApplicationContextAware扩展点,以及@PostConstruct和init方法等等。

接下来我们选择一些典型的预热方式进行分析。

三、数据库预热

连接池预热

所谓连接池预热,就是应用启动时根据需要创建若干数据库连接,放到连接池中,然后应用启动处理数据库读写请求时,可以直接从连接池中拿连接来用,避免了读写请求创建连接并放入连接池的流程耗时。常见的连接池Druid提供了比较方便的连接池预热能力。有两种方式:

  • 连接参数
spring.datasource.initialSize=10
  • 显式调用
DruidDataSource.fill(10);

其他连接池类型也都有类似的特性支持。

数据库预热

对于InnoDB存储引擎的mysql实例,重启完毕后,一开始十几分钟的性能是非常差的,原因是因为InnoDB有innodb buffer pool,其对应参数innodb_buffer_pool_size,size越大,可以放到内存的数据越多,而大多数的项目都会有热点数据的存在,当热点数据经过LRU算法进入到buffer pool之后,读磁盘的次数减少,读的都是内存,速度是最快的。问题在于5.6以下版本,mysql实例一旦重启,热点数据都被清空。等待请求的sql请求过来让buffer pool填满数据是一个方法,但短时间内内很难把热点数据都装载进来,这个时候,我们可以采取人工预热的办法来让buffer pool满足我们的诉求。5.6以上版本可以通过修改my.cnf配置来实现。

  • 5.1到5.5版本
SELECT table_schema, table_name FROM information_schema.tables;
  • 5.6及以上版本
//在关闭时把热数据dump到本地磁盘。
innodb_buffer_pool_dump_at_shutdown = 1

//采用手工方式把热数据dump到本地磁盘。
innodb_buffer_pool_dump_now = 1

//在启动时把热数据加载到内存。
innodb_buffer_pool_load_at_startup = 1

//采用手工方式把热数据加载到内存。
innodb_buffer_pool_load_now = 1

在关闭MySQL时,会把内存中的热数据保存在磁盘里ib_buffer_pool文件中,位于数据目录下。如果是异常关闭,可以使用5.1的手动预热方法。

四、缓存预热

连接池预热

和数据库连接池一样,我们可以在应用启动时,根据需要初始化若干连接放入连接池,从而避免请求过来的时候再创建而影响性能,比如我们使用的是jedis客户端,那么我们在配置连接池的时候做如下改造。

@Component
public class PreheatJedisPool extends JedisPool implements InitializingBean {
    
    @Autowired
    JedisPoolConfig jedisPoolConfig;
    
    @Override
    public void afterPropertiesSet() throws Exception {
        for (int i = 0; i < this.jedisPoolConfig.getMinIdle(); i++) {
            this.getResource();
        }
    }
}

系统启动时,尝试获取若干连接(这里取最小闲置),此时连接池为空,生成连接后会放入连接池,等请求进来的时候就不会再去创建连接了。

热点数据预热

对于一些热点数据,我们可以提前收集到样本,在系统启动的时候就放入缓存,这样就避免了首次访问热点数据流量打到数据库层。

@Component
@Slf4j
public class PreheatHotData implements ApplicationListener<ApplicationReadyEvent> {

    @Autowired
    RedisTemplate redisTemplate;
    
    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        try {
            //加载热点数据到缓存
            this.redisTemplate.execute();
        } catch (Exception e) {
            log.error("onApplicationEvent occur error;",e);
        }
    }
}

热点数据本地缓存

当然对于一些热点数据,在极端情况下放入redis缓存是不太理想的,毕竟应用层到redis有网络延迟以及序列化和反序列化消耗,那么对于这些数据我们可以考虑放入本地缓存,当然这些数据量不能太大,jvm不是一个存储数据的地方,对于极端的热点数据放入本地缓存后,请求获取数据在应用维度基本没有任何消耗,没有网络延迟也没有序列化和反序列化消耗。

@Component
@Slf4j
public class PreheatHotData implements ApplicationListener<ApplicationReadyEvent> {

    @Autowired
    BuzzService buzzService;
    
    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        try {
            //加载热点数据到本地缓存
            this.buzzService.loadData2Local();
        } catch (Exception e) {
            log.error("onApplicationEvent occur error;",e);
        }
    }
}

五、线程池预热

我们经常接触到的有用于接收web请求的tomcat线程池,rpc调用的dubbo线程池,以及业务处理中用到的自定义线程池,在系统启动时初始化一些线程到线程池中,能够避免在服务启动初期由于请求触发创建线程而造成的性能不佳问题。

请求接收线程池

对于tomcat作为servlet容器的场景下我们可以配置初始化创建线程的数量来做线程池预热,当然具体数量取决于业务评估。

server.tomcat.min-SpareThreads=20

rpc线程池

拿常用的dubbo来分析,dubbo里有4种线程池实现,分别是FixedThreadPool,CachedThreadPool,LimitedThreadPool,EagerThreadPool,默认是FixedThreadPool,并且由于是框架自己实现,我们并不能从源代码层面嵌入预热逻辑。

当然我们可以使用dubbo的SPI机制,让dubbo框架使用我们自定义的带有预热逻辑的线程池,定义如下:

public class PreheatThreadPool implements ThreadPool {
    @Override
    public Executor getExecutor(URL url) {
        String name = url.getParameter(THREAD_NAME_KEY, DEFAULT_THREAD_NAME);
        int threads = url.getParameter(THREADS_KEY, DEFAULT_THREADS);
        int queues = url.getParameter(QUEUES_KEY, DEFAULT_QUEUES);
        return new CustomThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS,
                queues == 0 ? new SynchronousQueue<Runnable>() :
                        (queues < 0 ? new LinkedBlockingQueue<Runnable>()
                                : new LinkedBlockingQueue<Runnable>(queues)),
                new NamedInternalThreadFactory(name, true), new AbortPolicyWithReport(name, url));
    }
    
    class CustomThreadPoolExecutor extends ThreadPoolExecutor{
        
        public CustomThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {
            super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
            this.prestartAllCoreThreads();
        }
    }
}

然后在配置中添加:

preheat=xxx.PreheatThreadPool

在使用的时候激活就可以使用我们自己定义的带有预热功能的线程池了,预热是先初始化coreSize个核心线程放入线程池。

dubbo的资深使用者可能会有印象,dubbo有预热能力,也就是消费端调用服务端接口做负载均衡时会把服务端的启动时间当做权重的考量标准之一,比方说服务刚启动流入1%的流量,随着启动时长增加,权重增加,路由到该服务的流量也慢慢增加到一个稳定的水平。

但是dubbo服务预热和线程池预热的目的虽然一样,但是做的事情和达到的效果却还是有所不同,比方说服务刚启动路由进来了1%的流量进来,但是这1%可能是1个调用也可能是1000个调用,请求进来的时候对于dubbo服务端的线程池模型来说,还是要经历创建核心线程的步骤,RT多多少少也会受到影响,所以开启服务预热和开启线程池预热并不是互斥的,相互协同工作会把服务短时间内达到最佳状态。

自定义线程池

对于自定义线程池,初始化时候调用prestartAllCoreThreads方法即可。

六、web预热

对于springboot应用,服务启动后接收处理第一个请求时,会看到以下日志打印:

INFO: Initializing Servlet 'dispatcherServlet'
INFO: Initializing Spring DispatcherServlet 'dispatcherServlet'

是在初始化Servlet,也就是springmvc最核心的DispatcherServlet,但是它默认是懒加载的,也就是前边所说的第一次处理请求时触发,可以通过如下配置开启热加载:

spring.mvc.servlet.load-on-startup: 1

当然在有些场景会开启健康检查接口,在应用启动完成后网关层会调用应用的健康检查接口来保证服务正常启动,这样也就提前触发了Servlet加载,那么也就可以省去配置操作。

七、jvm预热

JIT编译

jvm执行代码分为编译执行和解释执行,解释执行是每次执行都要把java字节码编译成机器码执行,而解释执行是把编译好的机器码缓存起来,执行java指令的时候直接执行机器码,不经过编译,效率和性能要比编译执行好,jvm默认开启jit编译,所以只存在关闭jit问题,也可能在不知情的情况下被人关闭了jit,可以检查启动参数-Xint和-Djava.compiler=NONE,如果有说明关闭了jit,根据需要可以移除重新开启jit,并且既然jit会把编译好的代码放入缓存,那必定涉及到空间大小问题,可通过以下参数配置:

//初始空间大小
-XX:InitialCodeCacheSize
//最大代码缓存大小
-XX:ReservedCodeCacheSize
//空间不足时进行清理
-XX:+UseCodeCacheFlushing

lambda表达式预热

jdk1.8引入了lambda表达式,给开发带来了极大的便利,但是过度的使用lambda表达式也会带来负面效果,lambda表达式的原理是在执行时生成匿名内部类,并且需要加载和编译,并且容易被回收,所以对于一些热点代码要么不使用lambda表达式,要么在服务启动时进行预热,简单点就是mock调用,在真实调用来的时候就会跳过生成匿名内部类、加载的步骤,经过充分的调用之后使其成为热点代码,后续的调用就会走jit编译,也就提升了执行效率。

八、总结

当然,既然在服务启动初始化阶段嵌入了预热逻辑,那肯定是有损耗的,最直接的体现就是服务启动特别慢,服务启动特别慢,有时候可能几分钟到几十分钟,慢不一定就是绝对的坏,本人见过阿里有些应用启动需要十几分钟甚至半个小时,其实就是在对外提供服务之前做了充足的初始化以及预热准备,当然事情都有两面性,关键的是要在启动快与慢之间找到一个平衡点,在保证服务能够在可接受的时间内启动,并且上线之后短时间内就能提供高质量的服务,那这就是一个高质量的服务。

但是对于非生产环境,研发人员可能会频繁的修改代码、部署验证某些功能,并且非生产环境流量来源确定并且流量稳定,对可用性诉求没那么高,这种场景下我们就可以追求快速启动并提供服务,而不是关注提供启动后极致的服务体验,鉴于环境和使用场景,我们可以针对部署环境做一些动态配置,比如非生产环境关闭预热能力,生产环境开启预热能力,把开关作为一个功能点做好验证即可。

本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
关于服务预热那些事
服务启动成功后,只是简单的服务进程启动成功,并且做一些简单的初始化,对于启动成功后到提供最优质的服务质量之间,会有一段时间把服务的状态调整到最优,那么如果对服务...
<<上一篇
下一篇>>