发布时间:2023-01-28 文章分类:编程知识 投稿人:李佳 字号: 默认 | | 超大 打印

13 | 优雅关闭:如何避免服务停机带来的业务损失?

我们在RPC架构下,需要考虑当服务重启时,如何做到让调用方系统不出问题。

当服务提供方要上线时,一般是通过部署系统完成实例重启,在这个过程汇总,服务提供方不会事先告诉调用方哪些实例会被重启,从而让调用方切换流量。而对调用方来说,它也无法预测服务提供方哪些实例会重启,因此负载均衡还是有可能降正在重启的实例挑选出来,这样导致请求被分发到正在重启的服务实例中,造成调用方无法拿到正确的响应结果。

在服务重启的时候,对于调用方来说,有以下2种情况:

  1. 调用方发请求前,目标服务已经下线。对于调用方来说,跟目标节点的连接会断开,这时调用可以立刻感知到,并在其健康列表中将该实例删除,这样就不会被负载均衡选中。
  2. 调用方发请求时,目标服务正在关闭,但调用方并不知道它正在关闭,而且两者之间的连接没有断开,所以这个节点还会存在健康列表里面,有可能会被负载均衡选中。

我们可以通过服务发现来实时通知服务调用方关于服务提供方是否可用吗?

不可以。这样做的话,整个过程会依赖两次RPC调用:一次是服务提供方通知注册中心下线操作,一次是注册中心通知服务调用方下线节点操作。注册中心通知服务调用方都是异步的,服务发现只保证最终一致性,并不保证实时性,所以当注册中心收到服务提供方下线的时候,并不能保证把这次要下线的节点推送给所有调用方,这样,调用方还是有可能将请求发送给错误的服务提供方节点。

如何做到优雅关闭服务?

我们可以尝试让服务提供方来通知调用方,RPC里面调用方和提供方之间是长连接,我们可以在提供方应用内存中维护一份调用方连接集合,当服务关闭时,挨个通知调用方去下线相关实例,这样整个调用链路就变短了,对于每个调用方来说只一次RPC,可以确保调用的成功率很高。

但是上述方法不能彻底解决问题,因为有时出问题请求的时间点和收到提供方关闭通知的时间点很接近,再加上网络延迟,还是有可能在服务提供方关闭服务后再接收到新的请求。

解决办法是我们在关闭的时候,在服务提供方设置一个请求“挡板”,它的作用是告诉调用方,我已经进入关闭流程,不能再处理新的请求了。

当服务提供方正在关闭,如果在之后还收到了新的业务请求,服务提供方直接返回一个特定的异常给调用方(ShutdownException),这个异常就是告诉调用方“我已经收到这个请求了,但是我正在关闭,没有处理这个请求”,然后调用方收到这个异常响应后,RPC框架就把这个节点从健康列表中挪出,并把请求自动重试到其他节点,因为这个请求没有被服务提供方处理过,所以可以安全的重试到其他节点,这样可以实现对业务无损。

我们还可以加上主动通知流程,让服务提供方给相关调用方发送关闭通知,这样既可以保证实时性,也可以避免通知失败的情况。

在Java语言中,我们可以使用Runtime.addShudownHook方法,来注册关闭的钩子,在RPC启动的时候,我们提前去注册关闭钩子,并在里面添加连个处理程序:一个复杂开启关闭标识,一个负责安全关闭服务对象,服务对象在关闭的时候会通知调用方下线节点。同时我们需要再调用链里面加上挡板处理器,当新的请求进来时,会判断关闭标识,如果正在关闭,就抛出特定异常。

对于关闭过程中还在处理的请求,我们可以根据引用计数器,等待正在处理的请求全部结束后再真正关闭服务,同时还可以设置一个超时控制,当超过指定时间,请求还没有处理完,就强制退出应用。

总结一下,关于如何优雅关闭服务,包括以下步骤:

  1. 开启关闭挡板,拒绝新的请求
  2. 利用引用计数器确保正在执行的请求处理完
  3. 设置超时时间,保证服务可以正常关闭
  4. 执行关闭时,服务提供方通知服务调用方下线相关节点

服务优雅关闭的示意图如下。
《RPC实战与核心原理》学习笔记Day11

“优雅关闭”的概念除了在RPC里面,在其他很多框架中也很常见,例如Tomcat在关闭的时候,也是先从外层到里层逐层进行关闭,先保证不接收新的请求,然后再处理关闭前收到的请求。

14 | 优雅启动:如何避免流量达到没有启动完成的节点?

为什么Java程序运行一段时间会执行速度会变快?

这是因为在Java里面,在运行过程中,JVM虚拟机会把高频的代码编译成机器码,被加载过的类也会被缓存到JVM缓存中,再次使用的时候就不会触发临时加载,这样就使得
“热点”代码的执行不用每次都通过解释,从而提升执行速度。

什么是启动预热?

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

服务调用方应用通过服务发现能够取得服务提供方的IP地址,然后每次发送请求前,都需要通过负载均衡算法从连接池中选择一个可用连接,我们可以让负载均衡在选择连接的时候,区分一下是不是刚启动的应用,如果是刚启动的应用,我们可以调低它的权重值,这样它被选中的概率会很低,随着时间推移,我们逐渐增大它的权重值,从而实现一个动态增加流量的效果。

我们如何获取服务提供方应用的启动时间?有两种方法:

  1. 服务提供方在启动的时候,把自己启动的时间告诉注册中心。
  2. 注册中心收到的服务提供方请求注册的时间。

启动越热更多是从调用方的角度出发,去解决服务提供方应用冷启动的问题,让调用方的请求量通过一个时间窗口过渡,慢慢达到一个正常的水平,从而实现平滑上线。

从服务提供方的角度来说,有什么优化方案吗?服务提供方可以使用延迟暴露的方法来优化热启动过程。

问题:服务提供方应用在没有完成启动的时候,调用方的请求就过来了,而调用方请求过来的原因,在于服务提供方应用启动过程中把解析到的RPC服务注册到了注册中心,这就导致了后续加载没有完成的情况下,服务提供方地址就被服务调用方感知到了。

解决办法:我们在应用启动加载、解析Bean的时候,如果遇到了RPC服务的Bean,只先把这个Bean注册到Spring-BeanFactory里面,而不把这个Bean对应的接口注册到注册中心,只有等应用启动完成后,才被接口注册到注册中心用于服务发现,从而实现让服务调用方延迟获取到服务提供方地址。

我们还可以利用服务启动完成到注册到注册中心的那段时间,预留一个Hook,让用户可以扩展Hook逻辑,在Hook里面模拟业务调用逻辑,从而使得JVM指令能够预热起来,同时还可以在Hook中预先加载一些资源,只有等所有缓存和资源都加载完成后,才把接口注册到注册中心,这样也就完成了热启动整个流程。

如果我们有大批量的服务都需要重启,如何避免同时重启造成请求被分发到新启动的应用实例而造成超时错误?

我们可以采取一些措施:

  1. 分时分批启动,就像灰度发布一样。
  2. 根据重启比例来设置重启服务的权重。
  3. 在请求低峰重启应用。
  4. 在重启过程中,如有必要,对服务进行限流处理。