0 前言

应用启动居然也这么“讲究”?好比我们日常生活中的热车,行驶之前让发动机空跑一会,可以让汽车的各个部件都“热”起来,减小磨损。

运行了一段时间后的应用,执行速度会比刚启动的应用更快。因为Java里,运行过程中,JVM把高频代码编译成机器码,被加载过的类会被缓存到JVM缓存,再使用时不会触发临时加载,使“热点”代码执行不用每次都通过解释,提升了执行速度。

但这些“临时数据”都在重启后消失了。重启后的这些“红利”没了后,若让刚启动的应用就承担像停机前一样的流量,会使应用在启动之初就处于高负载状态,导致调用方过来的请求可能出现大面积超时。

问题关键在于:刚重启的服务提供方,因为没有预热就承担大流量,是否能通过某些方法,让应用一开始只接少许流量?低功耗运行一段时间后,再逐渐提升至最佳状态。

这就是RPC里的实用功能——启动预热。

1 启动预热

让刚启动的服务提供方应用不承担全部流量,而是让它被调用次数随时间移动缓慢增加,最终让流量缓和地增到和已运行一段时间后的水平一致。

2 方案设计

要控制调用方发送到服务提供方的流量。

回顾调用方发起的RPC调用流程,调用方应用通过服务发现,能获取到服务提供方的IP地址,然后每次发请求前,都通过负载均衡,从连接池中选个可用连接。就可以让负载均衡在选择连接时,区分是否是刚启动不久的应用?对刚启动的应用,可让它被选择到的概率很低,但这概率随时间推移慢慢变大,从而实现一个动态增加流量过程。

3 实现

对调用方,要知道服务提供方启动的时间,如何获取?

  • 服务提供方启动时,把自己启动的时间告诉注册中心
  • 注册中心收到的服务提供方的请求注册时间

这两个时间都可。

如何确保所有机器的日期时间一样?

整个预热过程的时间是个粗略值,即使机器之间的日期时间存在1min误差也影响不大,并且真实环境中,机器都会默认开启NTP时间同步功能,保证所有机器时间的一致性。

不管选哪个时间,最终结果:调用方通过服务发现,除了可拿到IP列表,还可拿到对应启动时间。要把这时间作用在负载均衡上。基于权重的负载均衡,这权重是由服务提供方设置,属于一个固定状态。现在要让这个权重变成动态,并随时间推移慢慢增加到服务提供方设定的固定值,预热过程图:

RPC框架的优雅启动机制详解_重启

通过这小逻辑的改动,就能保证当服务提供方运行时长小于预热时间时,对服务提供方进行降权,减少被负载均衡选择的概率,避免让应用在启动之初就处高负载状态,实现服务提供方在启动后的预热过程。

当我在大批量重启服务提供方时,是否会导致未重启的机器因扛流量太大而异常?

当你大批量重启服务提供方的时候,对于调用方,这些刚重启的机器权重基本一样,即这些机器被选中概率一样,也就不存在权重区分的问题。但对于那些没重启过的应用提供方,它们被负载均衡选中的概率相对较高,但可通过自适应负载均衡平缓切换,所以也没问题。

启动预热,更多是在调用方的角度,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达正常水平,实现平滑上线。但对服务提供方本身,有相关方案实现这效果吗?即延迟暴露!

4 延迟暴露

应用启动都是通过main入口,顺序加载各种相关依赖的类。以Spring应用启动,加载的过程中,Spring容器会顺序加载Spring Bean,若某Bean是RPC服务,不光要把它注册到Spring-BeanFactory,还要把这Bean对应的接口注册到注册中心。注册中心在收到新上线的服务提供方地址时,会把该地址推送到调用方应用内存;当调用方收到该服务提供方地址时,就会去建立连接,发请求。

但这时,是否可能服务提供方没有启动完成?因为服务提供方应用可能还在加载其它Bean。对调用方来说,只要获取到服务提供方的IP,就可能发起RPC调用,但若这时服务提供方没启动完成,就会调用失败,导致业务受损。

4.1 问题本质

思考该问题的根本原因。因为服务提供方应用在没启动完成时,调用方的请求就来了,而调用方请求过来的原因是,服务提供方应用在启动过程中,把解析到的RPC服务注册到了注册中心,导致在后续加载没有完成,服务提供方的地址就被服务调用方感知到了。

因此,可将接口注册到注册中心的时间挪到应用启动完成后

4.2 具体方案

在应用启动加载、解析Bean时,若遇到RPC服务Bean,只先把Bean注册到Spring-BeanFactory,不把Bean对应的接口注册到注册中心。等应用启动完成后,才把接口注册到注册中心用于服务发现,实现让服务调用方延迟获取到服务提供方地址。

这样可保证应用在启动完后才开始接入流量,但这还是没有实现最开始目标。因为这时,应用虽启动完成,但并没有执行相关业务代码,JVM内存里还是冷的。若这时大量请求过来,应用还是在高负载模式下运行,导致无法及时返回请求结果。

而且实际业务中,一个服务的内部业务逻辑一般会依赖其它资源,如缓存数据。若能在服务正式提供服务前,先完成缓存初始化,而不是等请求来了才去加载,就能降低重启后第一次请求异常的概率。

怎么实现?

利用服务提供方把接口注册到注册中心的那段时间。

可在服务提供方应用启动后,接口注册到注册中心前,预留一个Hook过程,让用户实现可扩展的Hook逻辑。用户可在Hook里面模拟调用逻辑,从而使JVM指令能预热,并且用户也可在Hook里事先预加载一些资源,只有等所有的资源都加载完成后,最后才把接口注册到注册中心。

整个应用启动过程:

RPC框架的优雅启动机制详解_重启_02

5 总结

虽然启停机流程看起来不属于RPC主流程,但若你能在RPC里把这些细节做好,就能让你的技术团队更加收益于微服务。

启动预热与延迟暴露不是RPC专属功能,开发其它系统时,也可利用这两点,减少冷启动对业务的影响。

FAQ

在启动预热那部分,“当大批量重启服务提供方时,会导致请求大概率发到没重启的机器,这时服务提供方有可能扛不住,是否有好的解决方案呢?

若大批量重启,可通过:

  • 分时分批启动,就和灰度发布一样
  • 在请求低峰把,在热点的应用肯定是有使用低峰的
  • 如果必须同时大批量重启,为了保证服务的可用性,可以在低峰时期,限流,为PLUS服务,非PLUS的就提醒暂时不可用之类的友好提示
  • 速度很重要。但也可以通过在调用方那边快速加权重到新启动的实例上

启动成功,后早单独搞个任务来注册?这就是延迟暴露。

控制发布的步长,类似K8s里面的Deployment的RollingUpgrade,滚动升级保证有足够的服务在抗流量。即控制速度。