【456期】面试官:如何确保服务平稳发布?

温馨提示:400多期 Java 面试题汇总,可以点击文末的阅读原文,已经做了汇总,欢迎刷题!


服务滚动发布,如何确保进程退出期间,待处理和处理中的请求 服务正确处理请求,不出现业务异常呢?我最近遇到Spring Event的线上事故,原因就在于进程退出时,有应用线程从Spring GetBean,然而Spring 不允许BeanFactory 销毁期间获取bean,导致请求处理失败、数据不一致的未知结果。

为什么Spring 都销毁了,业务请求还要从Spring获取bean呢?这里面是否存在关闭顺序上的问题。要解决这个问题,先要了解JVM的退出方式有哪几种。

了解JVM优雅退出机制,能够保证服务在线上环境可以平稳发布。

1JVM退出方式

Java 虚拟机有三种退出方式,分别为正常退出、异常退出和强制关闭。简单介绍下三种方式各自包含哪些场景。

正常关闭

正常退出时,JVM会执行shutdown hook 钩子程序。钩子提供了java代码响应进程退出的机制,例如Spring等框架通过注册shutdownHook钩子程序,监测到进程要退出的信号,然后关闭Spring上下文。

正常关闭的4种方式

  1. 所有非守护线程退出

  2. System.exit(0)

  3. Ctrl+ C 命令行中退出进程

  4. kill Pid 通知进程退出。

解释下第一条,所有非守护线程退出,即main方法开始后创建的线程和main线程都退出后,进程就可以退出。非守护线程指应用自己创建的线程,守护线程包括JVM垃圾回收等jvm内部线程。值得一提的是:用户线程被创建后,如果调用Thread.setDaemon也可修改为守护线程。

以下的代码演示了,主线程退出后,进程并未退出,直到第二个子线程退出后,所有非守护线程都退出,进程才退出。

Thread thread = new Thread(new Runnable() {
   @Override
   public void run() {
      int cnt = 4;
      for (int i = 0; i < cnt; i++) {
         try {
            Thread.sleep(10000);
         } catch (InterruptedException e) {
            System.out.println("被中断");
            break;
         }
         System.out.println("子线程执行中");
      }
      System.out.println("子线程退出");
   }
});
thread.start();

try {
   Thread.sleep(20000);
catch (InterruptedException e) {
   e.printStackTrace();
}
System.out.println("主线程提出");

执行结果

【456期】面试官:如何确保服务平稳发布?

注意:千万不要使用Idea 执行junit 单元测试方法验证这个样例。因为idea 在执行junit方法完成后,会调用System.exit()退出进程,从而不会等待子线程结束再退出进程。所以会造成一个假象 —— junit方法代表的主线程退出后,进程就退出。

当前应用服务动辄1000个线程,如果主动让1000个线程退出,然后走正常关闭的流程,还是比较困难的,所以一般情况下都是通过System.exit()Kill pid方式退出进程。

强制退出

强制退出的方式,JVM无法执行shutdown hook钩子程序,应用程序无法释放资源,无法等待剩余请求执行完毕,无法优雅退出。

  1. Kill -9 关闭进程

  2. Runtime.halt()

  3. 机器断电、操作系统关机

  4. 操作系统强制杀死一个进程

  5. 进程Crash

如果kill -9 一个进程,那么这个进程是感受不到任何“疼痛”的,和直接断掉电源差异不大。强制关闭进程,除了操作系统会回收进程的一系列资源例如文件和网络句柄等。应用进程内部不会感知到被关闭,更无法在关闭前执行一段代码。

除了人为kill-9进程,实际上操作系统在内存不足时,也会强制关闭一个内存占用最大的进程。例如我曾经遇到过一个诡异的线上问题。java进程突然就没了,没有留下任何日志。

通过排查发现JVM的内存占用量每到达同一个值附近时,就会导致操作系统kill进程。最终定位原因是内存不足,docker实例一共8G的内存,Java占用了6G,其他进程和操作系统占用2G。在服务刚发布时,由于jvm内存较少,虽然名义是8G,但是没有实际占用物理内存,但随着java进程内存逐渐增加(还未触发Full GC),触达物理系统内存上限,操作系统(linux kernel)优先kill最大内存的进程。

一般情况下强制退出的原因就包括以上几点,出现进程突然消失的情况,重点从内存和进程Crash上着手。进程被强制关闭,就不要想着如何优雅关闭了,不可能做到的。

异常关闭

OOM是指一个线程新申请一块内存,JVM发现内存不足,于是触发Full GC,但是FullGC以后依然内存不足,于是该线程触发OOM异常。一般情况下线上进程如果出现OOM,需要及时关闭,否则可能不停触发OOM,导致更多的异常。

OOM只是在一个线程抛出异常,如果异常没有捕获,最多只会关闭这一个线程,不会殃及整个JVM。但是服务既然出现OOM,已经说明服务的内存模型存在问题,可能存在内存泄漏,这种情况下理应退出进程,避免更多的失败。

一般情况下,我们无法获知服务在哪行代码哪个线程出现OOM,自然无法有效catch异常,执行System.exit退出进程。好在JVM提供参数 配置,可以在OOM发生后执行某个策略

  1. -XX:+HeapDumpOnOutOfMemoryError参数表示当JVM发生OOM时,自动生成DUMP文件。-XX:HeapDumpPath= 目录,也可以指定文件名称,例如:−XX:HeapDumpPath={目录},也可以指定文件名称,例如:-  XX:HeapDumpPath=目录,也可以指定文件名称,例如:−XX:HeapDumpPath={目录}/java_heapdump.hprof

  2. -XX:OnOutOfMemoryError 在程序发生OOM异常时,执行指定命令,该参数接下来会详细介绍,也是JVM优雅退出的关键参数

  3. -XX:+ExitOnOutOfMemoryError  在程序发生OOM异常时,强制退出

  4. -XX:+CrashOnOutOfMemoryError 在程序发生OOM异常时,强制退出,并生成Crash日志

可以在OnOutOfMemberError 配置脚本,通过脚本kill进程。可以使用以下参数配置,在OOM发生后,kill进程,如果进程在60s未退出,执行kill-9强制关闭。

-XX:OnOutOfMemoryError="kill -15 %p && sleep 60 && kill -9 %p &"

参考链接[1]

相比于优雅关闭进程,如果你更关心OOM发生后堆栈的状态,则可以使用HeapDumpOnOutOfMemoryError,OOM后打印heap dump,然后强制退出进程。由于无法同时配置这两个参数,所以需要选择一个。更关心heapDump文件排查问题还是优雅退出,需要各位开发者根据业务实际场景决策。 可以参考这个博客分析为什么推荐使用 ExitOnOutOfMemoryError[2]

服务优雅关闭只能在进程正常退出时和配置了OnOutOfMemoryError的OOM场景可能触发优雅退出。这两种情况JVM都会执行shutdownHook 程序

2ShutdownHook钩子程序

在jvm收到Kill 信号时,会开始执行注册的钩子程序。如何添加shutdown hook呢?

Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
   @Override
   public void run() {
      System.out.println("shutdown hook started");
   }
}));

JDK在addShutdownHook方法上提供了详细的注释,我翻译一下

当虚拟机开始关闭时,会以不确定的顺序启动所有的钩子程序,它们并发执行

当所有的钩子完成后,虚拟机才会关闭。在此期间守护线程和非守护线程都会继续运行。

如果通过调用System.exit()方法关机,一旦开始执行关闭程序,只能通过System.halt()方法强制终止虚拟机。

一旦开始执行关闭程序,不得再新建钩子或注销钩子,尝试以上两个操作会导致抛出IllegalStateException

shutdown钩子在一个微妙时刻执行,应该进行防御编码,要写的线程安全,尽量避免死锁。

shutdown钩子应该尽快完成(虚拟机需要钩子程序全部执行完成,才能关闭)。

我曾经实验在钩子程序中调用System.exit,这会阻塞中虚拟的关闭,使用Kill或者System.exit触发虚拟的关闭程序,钩子程序中再次调用System.exit会导致虚拟机被阻塞,无法被关闭。所以严禁在钩子程序中调用 System.exit方法。

此外钩子程序会并发执行、无法保证顺序,这导致如果资源之间存在依赖,存在被依赖的资源可能先关闭,这将导致未知的结果。

所以应用程序最好使用一个钩子程序,恰好,我们的应用程序大多是Spring Boot应用,它已经接入了shutdown hook,我们只需要实现Spring提供的关闭扩展点即可。无需自己再新增shutdown hook,避免了和spring 并发关闭可能导致的不可知结果

3Spring提供的关闭扩展点

spring 关闭流程的源码

protected void doClose() {
 if (this.active.get() && this.closed.compareAndSet(falsetrue)) {
  // ...(省略)

  publishEvent(new ContextClosedEvent(this));

  this.lifecycleProcessor.onClose();
   
  destroyBeans();

  closeBeanFactory();
                
  onClose();

  this.active.set(false);
 }
}
  1. 发布 ContextClosedEvent,可以使用EventListener注解 监听该事件

  2. 执行 Lifecycle 类型Bean 的所有stop方法。

  3. 关闭BeanFactory,单例Bean中声明init-methoddestroy-method 的会被销毁。如果单例bean未声明两个方法,则不会被销毁。

public interface Lifecycle {

    void start();

    void stop();

    boolean isRunning();
}

Spring提供了三个位置感知进程关闭事件。首先发布容器关闭事件,此时BenaFactory还未关闭,Bean资源都还未销毁。这是一个绝佳的关闭资源的地方。

当然有一个疑问:既然Spring会销毁需要销毁的bean,为什么还要在关闭事件中关闭资源呢?这就要说到Spring销毁bean的顺序问题

Spring销毁bean的顺序

我找到Spring销毁的代码,如下图,最后被创建的单例Bean,最先被回收。

【456期】面试官:如何确保服务平稳发布?

如果是XML方式定义bean,那么最后在xml定义的最后被创建,也就是最先被回收。所以xml中靠后的bean会先被回收。

那么如果是通过注解被加载进Spring呢?答案是未知。并不确定扫描包的时候,哪个类率先被扫到,加载进Spring。除非通过 DependOn注解声明依赖顺序。

为什么Spring销毁bean的顺序如此重要,想象一个场景,Kafka消费者还在运行中,此时消费逻辑要发送一个Kafka消息,但是发送时生产者已经被销毁了,消息发送失败,应用数据处于不一致状态。 这不是推测,这个case 是我们在线上实际遇到的问题。

避免被依赖的bean先被回收,要严格控制Spring 单例Bean的依赖顺序?但是bean与bean之间依赖如此复杂,如果靠通过dependOn注解声明的方式,肯定会有疏漏。有什么好的办法呢?

在关闭BeanFactory之前,通过ContextCloseEvent 切断线上流量,服务请求的入口一般分为 http、Rpc、MQ。

4关闭请求入口

关闭MQ入口

MQ无论是推拉模型,消费端一定提供了close或者destroy方法。通过在 ContextCloseEvent消费逻辑中 关闭所有的消费者,切断所有的消费逻辑。但是要注意,因为BeanFacotry阶段还要销毁bean,所以要保证close方法的幂等。可通过状态字段控制。

关闭RPC入口

Rpc的入口应用程序也可以主动关闭,Rpc提供方一般都会在服务发布后对外暴露端口,到注册中心注册。同样销毁时 从注册中心取消注册,即可保证不会再有rpc流量进来。

关闭Http入口

http入口实际上是通过SpringBoot 内嵌tomcat关闭的,Sprigboot中在 lifestyle的关闭流程中 会关闭Tomcat容器,这个流程发生在 beanFactory bean被销毁之前。所以不存在入口未关闭,bean被销毁的情况。

详细请参考Spring Boot 优雅停机原理详解[3]

此外默认情况下是立即关闭Tomcat,进行中的请求可能会失败,可以配置优雅关闭

server:
  # 设置关闭方式为优雅关闭
  shutdown: graceful
spring:
  lifecycle:
    # 优雅关闭超时时间, 默认30s
    timeout-per-shutdown-phase: 30s

以上我们分别处理了http、rpc、mq的入口流量。但是假设线程池中依然存在进行中的请求怎么办,如果此时销毁bean和回收资源,则依然存在,调用被销毁的bean导致未知异常的可能性。所以需要主动关闭线程池。

可以在 ContextCloseEvent中 关闭各种线程池,例如Rpc线程池、业务逻辑中创建的线程池。

5线程池的优雅关闭

线程池关闭方法分为shutdownshutdownNow

1.shutdown

  • 拒绝新任务提交

  • 待执行的任务不会取消

  • 正在执行的任务也不会取消,将继续执行

2.shutdownNow

  • 拒绝新任务提交

  • 取消待执行的任务

  • 尝试取消执行中的任务

无论哪个方法都会拒绝新任务提交。设想一下如果关闭线程池时,还有流量进来会怎样? 会被线程池拒绝,然后上游感知到大量的失败。

所以一定要先切断上游http/mq/rpc的流量,再关闭线程池,才能避免关闭期间请求被拒绝的情况发生。

取消待执行任务,会导致任务执行失败,所以不推荐使用shutdownNow强行关闭,而是推荐使用shutdown优雅关闭。但是如果任务长时间无法结束,要一直等待吗?不能。可以考虑使用awaitTermination 等待指定时间后。如果依然无法关闭,那么放弃等待。

后续的流程,Spring开始销毁bean,执行剩余的资源清理逻辑。等Spring执行完资源清理,shutdown hook就执行完成。此时虽然 线程池可能还没有关闭,但是Java进程也会关闭。

自此 从切流量、关闭线程池、等待线程池结束、销毁bean。整个过程保证了不存在新进的请求,等待处理进行中的请求,最终才会销毁 bean。这样Java进程才能算上功德圆满,优雅关闭!

6总结

  1. Java一共三种进程退出方式。

  2. 只有正常退出和OOM配置OnOutOfMemberError=”kill脚本”才会执行优雅关闭逻辑

  3. 优雅退出时,会并发执行shutdown钩子,小心并发和不要执行太久

  4. Spring 接入了钩子。可以通过ContextCloseEvent中 切断http/mq/rpc等入口流量。shutdown关闭线程池。

  5. 要始终保证服务在关闭期间,不会出现 被依赖的资源、被依赖的bean已经被销毁或回收。否则服务无法优雅关闭,一定会出现未知的调用异常。

参考资料

[1]

参考链接: https://www.bilibili.com/read/cv17442601/

[2]

为什么推荐使用 ExitOnOutOfMemoryError: https://www.cnblogs.com/east4ming/p/17034195.html

[3]

Spring Boot 优雅停机原理详解: https://www.leyeah.com/article/spring-boot-elegant-shutdown-principle-detailed-699369

来源:juejin.cn/post/7278247100979888191
后端专属技术群

构建高质量的技术交流社群,欢迎从事编程开发、技术招聘HR进群,也欢迎大家分享自己公司的内推信息,相互帮助,一起进步!

文明发言,以交流技术职位内推行业探讨为主

广告人士勿入,切勿轻信私聊,防止被骗

【456期】面试官:如何确保服务平稳发布?

加我好友,拉你进群

原文始发于微信公众号(Java面试题精选):【456期】面试官:如何确保服务平稳发布?

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/178263.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!