Spring的优雅停机
优雅停机是指在停止应用时,执行的一系列保证应用正常关闭的操作。这些操作往往包括等待已有请求执行完成、关闭线程、关闭连接和释放资源等,优雅停机可以避免非正常关闭程序可能造成数据异常或丢失,应用异常等问题。优雅停机本质上是JVM即将关闭前执行的一些额外的处理代码。
一般来说,优雅停机主要处理
- 池化资源的释放:数据库连接池,HTTP 连接池
- 在处理线程的释放:已经被连接的HTTP请求
- 隐形受影响的资源的处理:Eureka实例下线等
JVM 中的实现
编程语言都会提供监听当前线程终结的函数,比如在Java中,我们可以通过如下操作监听我们的退出事件:
1 | public class Main { |
我们会得到如下的结果:
1 | Do something, will exit |
聪明的你一定发现了,我们可以在 ExitHook
去处理那些资源的释放。那么,在实际应用中是如何体现优雅停机呢?
1 | kill -15 pid |
通过该命令发送一个关闭信号给到jvm, 然后就开始执行 Shutdown Hook 了,但是值得注意的是不能够使用
1 | kill -9 pid |
如果这么干的话,相当于从OS方面直接将其所有的资源回收,类比一下好比强行断电,就没有任何进行优雅停机的机会了。不过这里是最简单的实现,在实际的工作中,我们会遇见各种情况:在清理退出的时候出现异常怎么处理?清理的时间过程怎么处理?等等问题。因此在Spring中,为了简化这样的操作已经帮助我们封装了一些。
Spring 的模式
让我们想想,Spring一个IOC容器,他能够管理的是收到其托管的对象,因此我们也可以很合理的想到我们需要定义托管对象的解构函数才能够被Spring在退出时释放,我们将问题简单化点,有三个事情是Spring需要解决的:
- Spring 需要值得 Runtime 在退出
- Spring 知道需要释放哪些资源
- Spring 需要知道如何释放资源
因为 Spring 版本繁杂,以 org.springframework.boot:2.1.14.RELEASE
版本分析为例。
Spring 的 Runtime Hook
毕竟 Spring 也是 JVM 上的实现,这一切势必也依赖于 JVM 的 Shuthook,秘密就在 org.springframework.context.support.AbstractApplicationContext#registerShutdownHook
处
1 |
|
Spring 知道需要释放哪些资源
回忆一下 Spring Bean Lifecycle
没错,当 Spring Context 销毁的时候,会调用 destroy()
函数,实际上这行函数也非常的简单也就是
1 | // org.springframework.context.support.AbstractApplicationContext#destroy |
实则我们定位到最终的释放资源处就是 org.springframework.context.support.AbstractApplicationContext#doClose
函数,我们在尽情的分析一下。
1 | protected void doClose() { |
剪去那些无影响的代码部分,我们可以发现对于 Spring 来说,真正关闭的顺序是
- 发布一个关闭事件
- 调用生命周期处理器
- 销毁所有的Bean
- 关闭Bean工厂
- 调用子类的Close函数
对于 ➀➁ 是回调机制,不涉及到对象的销毁,➄ 是对于继承类的调用,所有的销毁都在 ➂ 中。受到 Spring 托管的对象繁多,不一定所有的对象都需要销毁行为。进一步定位一下,我们就发现了
1 | public void destroySingleton(String beanName) { |
实际上那些需要销毁的对象都应该是 DisposableBean
对象。
那我们对于第二个问题也知道了,Spring会销毁那些 DisposableBean
类型的 Bean对象。
Spring 需要知道如何释放资源
其实这是一个不是问题的问题,对于 DisposableBean
来说仅仅需要实现一个接口
1 | public interface DisposableBean { |
对于需要实现释放资源的对象需要自行实现此接口。
组合在一起
我们已经知道我们最开始提出的 3 个问题,让我们试着用这3个问题的答案拼凑处一个 Spring Web Server 是如何优雅的停机的。那我们需要证实一件事情: Web Server内部的资源都是 DisposableBean,并且受 Spring 托管。
通过反向定位的办法,可以快速的定位到比如 数据库的资源 org.springframework.orm.jpa.AbstractEntityManagerFactoryBean#destroy
在销毁的阶段会将 Entity 对象进行销毁。对于收到 Spring 托管的对象的优雅停机的路径是:
Runtine Shutdown Hook
-> Context:destory()
-> DisposableBean:destroy()
, 对于大部分的资源比如数据库,服务发现,等等都是这样的销毁方式。
进阶: Web 容器
一个疑问
对于普通的 Bean 的销毁我们已经完全了解,但是对于动手做实验的不知道有没有发现,其实 Web Server
并不是在 destroySingleton
阶段进行销毁的,那他是在哪里销毁的呢?回忆一下,我们除了销毁Beans 之外,是不是还有最后一个 close()
函数可以调用,没有错!对于 Tomcat ...
这些web容器来说,本身就是 ApplicationContext
的一个子类并非是 Bean
一部分,因此他们的 close()
函数在 org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext
处
1 | protected void onClose() { |
这里的函数是直接调用底层代码就不做展开,但是隐约有些觉得不对? 如果我们在接收到一个远程访问的时候,触发了 Shutdown
事件,我们实际上是无法获得正确的返回的。
1 | +------------------+ +---------------------------+ +--------------------------------------+ |
如上图所示,因为我们知道 stop webserver
是在 destory context
之后的,那我们岂不是会出现一边在接受请求但是这些请求都是会失败的吗?如果是是这样的,可真是太愚蠢的设计了。
另辟蹊径
对于这样的情况,我们在 shutdown 之前让 Web server 停止接受任何的请求,但可惜的是在此版本的 tomcat
不支持此特效,需要待 9.0.33+
spring-boot-2-3-0-available-now。
在早期的版本中,我们依然可以通过一些额外的方式将这件事情做到:
1 |
|
我们可以自定义自己的 ContextClosedEvent
事件,然后将 Tomcat
的处理线程先暂停。
又一个疑问?
在 Spring-Boot-2.3.0
之前我们都需要这么处理吗?我的天,很多写了 Spring 已经超过十年了,难道 Spring
一直没有解决这个问题吗?答案也是否定的。
还记得我们在 Spring 的早期阶段,我们通过 XML 来构造一个Spring项目的时候吗?那时候的方案是将我们的Web 程序作为一个 War 提供给 Tomcat
的 webapps中,此时tomcat会尝试构造我们的 DispatcherServlet
将整个系统运作起来,而在 Shutdown 阶段,这样的逻辑也是由 Tomcat
进行处理的。也就是说,对于 Embeded Web Server
的 Spring Boot
和 传统的 Web Server
在 Destory Spring Applicaion Context
这一步的时间是不一样的,Spring Boot with Embeded Web Server
在 2.3.0
之前的版本都是先关闭 Context
上下文再关闭 Web容器,而传统的 Web Server
是先关闭 Web容器
再去关闭 Context
上下文。
对于 传统的 Web Server
:
Runtime Shutdwon Hook
-> org.apache.catalina.util.LifecycleBase#stop
-> Spring Context Stop
因此出现优雅停机问题的集中在 Spring Boot < 2.3.0
的版本内。
优雅停机 in action
传统的 Tomcat 容器
我们执行 catalina.sh stop <WAITING SECONDS>
就可以执行 Tomcat 的优雅停机
Spring Boot > 2.3.0:内置容器
默认完成了优雅停机
Spring Boot 内置容器
Way 1: 自己实现优雅停机
请查阅参考部分,或者是上文处代码自行实现
Way 2:基于平台实现
现在的很多应用都跑在 kubernetes
这样的容器平台上,此时我们 POD 在进行 terminated 操作的时候,会首先向运行的进程发送一个 SIGTERM
指令,然后等待 30秒, 在30后没有终结的话,会再次发送一个 SIGKILL
进行强制终结,因此对于容器平台,我们要注意的这一个等待时间是否足够进行资源的回收。
但是我们还有一个问题,就是正在请求的流量问题,对于这个问题我们需要进行组合拳,还记得 kubernetes
中有 readinessProbe
的概念吗?当我们的Pod启动的时候,如果 Readiness
未就绪, kubernetes
也不会将我们的 POD 作为 SVC
的可选地址(采用SVC负债均衡的情况下)。因此我们的应用在接受到 SIGTERM
的第一时刻就将
Readiness
的地址进行失败行为,并且等待一定时间之后再进行 Spring Context Shutdown
操作。但是对于超长时间的请求依然会有失败的可能,不过对于大部分的应用来说,优雅停机本身也只是等待固定时间,因此对于超长持续的请求让其失败也是可选的方案。
Spring Cloud 下的复杂场景
我们来看下更加复杂的 Spring Cloud 下的场景,对于 Spring Cloud 体系内有一个特殊的点: Eureka Server
和 Eureka Client
的关系,我们来看下当接入时候的 destory()
行为。
Eureka 主动下线
当 Context:destory()
的时候,Spring会关闭一个名为 org.springframework.cloud.netflix.eureka.serviceregistry.EurekaServiceRegistry
对象,此对象在 org.springframework.cloud.netflix.eureka.EurekaClientAutoConfiguration#eurekaAutoServiceRegistration
进行初始化的。因此在 destory()
阶段,实例就会自动从 Eureka
下线。
Eureka Client 侧
因为 Eureka
的服务发现机制并没有通知客户端更新的机制,因此,此时的 Eureka Client
侧的实例依然会去尝试访问已经下线的 Instance
,这是由于Eureka是AP系统导致的缺陷并没有解决之道。
此时当其他的服务访问到此服务的此实例的时候,会导致服务访问错误。为了避免这样的错误发生,一般来说有几种解决之道:1.我们可以为客户端制定重试机制(参考 How To Customize Feign’s Retry Mechanism), 2. 手动提前离线:我们使用 Spring Boot Admin 手动在停机之前让实例先下线,这样等到一段时间之后我们就可以将实例进行真正的销毁,Spring Boot Admin,3. 不再使用 Eureka 这样的客户端负债均衡策略,使用类似 K8s 的 SVC
机制可以将这个时间缩短。