Spring微服务项目实现优雅停机(平滑退出)

Spring微服务项目实现优雅停机(平滑退出)

为什么要优雅停机(平滑退出)

​ 不管是生产环境还是测试环境,在发布新代码的时候,不可避免的进行项目的重启

kill -9 `ps -ef|grep tomcat|grep -v grep|grep server_0001|awk '{print $2}'

​ 以上是我司生产环境停机脚本,可以看出使用了 kill -9 命令把服务进程杀掉了,这个命令是非常暴力的,类似于直接按了这个服务的电源,显然这种方式对进行中的服务是很不友善的,当在停机时,正在进行RPC调用、执行批处理、缓存入库等操作,会造成不可挽回的数据损失,增加后期维护成本。

所以就需要优雅停机出场了,让服务在收到停机指令时,从容的拒绝新请求的进入,并执行完当前任务,然后关闭服务。

Java优雅停机(平滑退出)实现原理

linux信号机制

​ 简单来说,信号就是为 linux 提供的一种处理异步事件的方法,用来实现服务的软中断。

​ 服务间可以通过 kill -数字 PID 的方式来传递信号

linux信号表

kill -l

​ 可以通过 kill -l 命令来查看信号列表:

取值 名称 解释 默认动作
1 SIGHUP 挂起
2 SIGINT 中断
3 SIGQUIT 退出
4 SIGILL 非法指令
5 SIGTRAP 断点或陷阱指令
6 SIGABRT abort发出的信号
7 SIGBUS 非法内存访问
8 SIGFPE 浮点异常
9 SIGKILL kill信号 不能被忽略、处理和阻塞
10 SIGUSR1 用户信号1
11 SIGSEGV 无效内存访问
12 SIGUSR2 用户信号2
13 SIGPIPE 管道破损,没有读端的管道写数据
14 SIGALRM alarm发出的信号
15 SIGTERM 终止信号
16 SIGSTKFLT 栈溢出
17 SIGCHLD 子进程退出 默认忽略
18 SIGCONT 进程继续
19 SIGSTOP 进程停止 不能被忽略、处理和阻塞
20 SIGTSTP 进程停止
21 SIGTTIN 进程停止,后台进程从终端读数据时
22 SIGTTOU 进程停止,后台进程想终端写数据时
23 SIGURG I/O有紧急数据到达当前进程 默认忽略
24 SIGXCPU 进程的CPU时间片到期
25 SIGXFSZ 文件大小的超出上限
26 SIGVTALRM 虚拟时钟超时
27 SIGPROF profile时钟超时
28 SIGWINCH 窗口大小改变 默认忽略
29 SIGIO I/O相关
30 SIGPWR 关机 默认忽略
31 SIGSYS 系统调用异常

Java通过ShutdownHook钩子接收linux停机信号

 Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override
            public void run() {
                logger.info("========收到关闭指令========");
                logger.info("========注销Dubbo服务========");
                shutdownDubbo();
                logger.info("========注销ActiveMQ服务========");
                shutdownActiveMQ();
                logger.info("========注销Quartz服务========");
                shutdownQuartzJobs();
            }
        }, SHUTDOWN_HOOK));//public static final String SHUTDOWN_HOOK = "Manual-ShutdownHook-@@@"

​ java提供了以上方法给程序注册钩子(run()方法内部为自定义的清理逻辑),来接收停机信息,并执行停机前的自定义代码。

​ 钩子在以下场景会被触发:

  1. 程序正常退出
  2. 使用System.exit()
  3. 终端使用Ctrl+C触发的中断
  4. 系统关闭
  5. 使用Kill pid命令干掉进程(kill -9 不会触发)
我们使用的是 kill -15 PID命令来触发钩子

定义停止钩子的风险

  1. 钩子run()方法的执行速度会严重影响服务关闭的快慢
  2. run()方法内务必保证不会出现死锁、死循环,否则会导致服务长时间不能正常关闭

Java优雅停机(平滑退出)实现

注册自定义钩子并移除服务默认注册的钩子

上面代码我们已经注册了自己的钩子,里面调用了几个停服务的方法,那为什么要删除其他钩子呢

​ 很多服务都会注册自己的钩子,注册的地方可以看出,每个钩子都是一个新的线程,所以当收到关闭指令时,这些钩子之间是并发执行的,一些服务之间的依赖关系会被打破,导致不能按我们的想法正确的停掉服务。

​ 取出并停掉shutdownhook的方法很简单,ApplicationShutdownHooks类内部维护了IdentityHashMap hooks,里面存着所有已注册的钩子,我们只需要把他取出来,然后清除掉就可以了

@Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        logger.info("========初始化ShutDownHook========");
        try {
            Class clazz = Class.forName(SHUTDOWN_HOOK_CLAZZ);//SHUTDOWN_HOOK_CLAZZ = "java.lang.ApplicationShutdownHooks";
            Field field = clazz.getDeclaredField("hooks");
            field.setAccessible(true);
            IdentityHashMap excludeIdentityHashMap = new IdentityHashMap<>();
            synchronized (clazz) {
                IdentityHashMap map = (IdentityHashMap) field.get(clazz);
                for (Thread thread : map.keySet()) {
                    logger.info("查询到默认hook: " + thread.getName());
                    if (StringUtils.equals(thread.getName(), SHUTDOWN_HOOK)) {//SHUTDOWN_HOOK = "Manual-ShutdownHook-@@@";
                        excludeIdentityHashMap.put(thread, thread);
                    }
                }
                field.set(clazz, excludeIdentityHashMap);
            }
        } catch (Exception e) {
             logger.info("========初始化ShutDownHook失败========", e);
        }
    }
这里使用了该类继承了 ApplicationListener

使用onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) 方法,可以在项目启动后,对注册的钩子进行清理

shutdownhook实现Dubbo优雅停机(平滑退出)

对于Dubbo的优雅停机,网上众说纷纭,大部分说法是不支持优雅停机,想支持优雅停机的话,需要修改源码。而且2.6以下的版本,与spring的钩子之间不兼容,导致服务停机会出现异常

本司用的Dubbo版本为2.5.6,我修改了Dubbo连接参数后,在本地测试的话,是可以正常跑完服务并关闭连接的(实时上是先关闭与注册中心的连接,然后业务执行完毕,关闭提供者与消费者之间的长连接)

Dubbo在优雅停机(平滑退出)时都干了什么

​ Dubbo注销完整代码

 private static void shutdownDubbo() {
        AbstractRegistryFactory.destroyAll();
        try {
            Thread.sleep(NOTIFY_TIMEOUT);
        } catch (InterruptedException e) {
            logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!");
        }
        ExtensionLoader loader = ExtensionLoader.getExtensionLoader(Protocol.class);
        for (String protocolName : loader.getLoadedExtensions()) {
            try {
                Protocol protocol = loader.getLoadedExtension(protocolName);
                if (protocol != null) {
                    protocol.destroy();
                }
            } catch (Throwable t) {
                logger.warn(t.getMessage(), t);
            }
        }
    }

​ 先来看看Dubbo在AbstractConfig中自己注册的shutdownhook:

static {
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            public void run() {
                if (logger.isInfoEnabled()) {
                    logger.info("Run shutdown hook now.");
                }
                ProtocolConfig.destroyAll();
            }
        }, "DubboShutdownHook"));
    }

​ 只是在run()方法中调用了ProtocolConfig.destroyAll()方法

// TODO: 2017/8/30 to move this method somewhere else
public static void destroyAll() {
    if (!destroyed.compareAndSet(false, true)) {
        return;
    }
    AbstractRegistryFactory.destroyAll();
    ExtensionLoader loader = ExtensionLoader.getExtensionLoader(Protocol.class);
    for (String protocolName : loader.getLoadedExtensions()) {
        try {
            Protocol protocol = loader.getLoadedExtension(protocolName);
            if (protocol != null) {
                protocol.destroy();
            }
        } catch (Throwable t) {
            logger.warn(t.getMessage(), t);
        }
    }
}
AbstractRegistryFactory.destroyAll()

​ AbstractRegistryFactory.destroyAll()方法的作用是关闭所有已创建注册中心,会调用每个ZkClient的close()方法来从注册中心注销掉

​ AbstractRegistryFactory.destroyAll()方法执行前

[zk: 2] ls /Dubbo/com.ebiz.ebiz.demo.service.ShutDownHookService/providers 
[Dubbo%3A%2F%2F10.14.0.221%3A21761%2Fcom.ebiz.ebiz.demo.service.ShutDownHookService%3Fanyhost%3Dtrue%26application%3Dsale-center%26Dubbo%3D2.5.6%26generic%3Dfalse%26interface%3Dcom.ebiz.ebiz.demo.service.ShutDownHookService%26methods%3DdoService%26pid%3D8787%26side%3Dprovider%26timeout%3D50000%26timestamp%3D1615362505995]

​ AbstractRegistryFactory.destroyAll()方法执行后 (Debug停止在后面一行)

[zk: 3] ls /Dubbo/com.ebiz.ebiz.demo.service.ShutDownHookService/providers 
[]

特别注意的是

这里只是从注册中心注销掉,并不会关闭正在执行业务的长连接,不影响当前正在处理业务的响应与返回

​ 当服务从注册中心注销掉之后,我们在关闭当前执行的长连接之前,需要停止一段时间,来保证消费者均收到注册中心发送的销毁请求,不再向本台机器发送请求。

Thread.sleep(NOTIFY_TIMEOUT);//Long NOTIFY_TIMEOUT = 10000L;

​ AbstractRegistryFactory.destroyAll()执行完成后,循环执行protocol.destroy();

public void destroy() {
    for (String key : new ArrayList(serverMap.keySet())) {
        ExchangeServer server = serverMap.remove(key);
        if (server != null) {
            try {
                if (logger.isInfoEnabled()) {
                    logger.info("Close Dubbo server: " + server.getLocalAddress());
                }
                server.close(getServerShutdownTimeout());
            } catch (Throwable t) {
                logger.warn(t.getMessage(), t);
            }
        }
    }......

​ protocol.destroy()的作用:

  1. 取消该协议所有已经暴露和引用的服务
  2. 释放协议所占用的所有资源,比如连接和端口

​ 在destroy()方法中,对server和client分别进行销毁,调用 server.close(getServerShutdownTimeout());

public void close(final int timeout) {
    startClose();
    if (timeout > 0) {
        final long max = (long) timeout;
        final long start = System.currentTimeMillis();
        if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, true)) {
            sendChannelReadOnlyEvent();
        }
        while (HeaderExchangeServer.this.isRunning()
                && System.currentTimeMillis() - start < max) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                logger.warn(e.getMessage(), e);
            }
        }
    }
    doClose();
    server.close(timeout);
}

​ 可以看出当我们配置了关闭连接的超时时间时,关闭前会等待直到超时时间结束,以保证服务在此段时间内完成响应

​ 所有我们给提供者和消费者同时配置相同的断开超时时间 wait="50000"

这里提供者和消费者必须都配置,否则会在业务完成前关闭连接

shutdownhook实现ActiveMQ优雅停机(平滑退出)

在手动关闭Mq监听的时候,发现项目代码里面,DefaultMessageListenerContainer 是没被spring管理的,我们关闭监听,注销Consumers时需要调用它的shutdown()方法,所以手动维护了一个HashSet 来管理

​ JmsConnectionRegistry

@Configuration
public class JmsConnectionRegistry {
    public HashSet containers = new HashSet<>();
    @Bean
    public JmsConnectionRegistry getBean() {
        return new JmsConnectionRegistry();
    }
}

​ 手动管理containers:

JmsConnectionRegistry jmsConnectionRegistry = (JmsConnectionRegistry) SpringContext.getBean("jmsConnectionRegistry");
...
jmsConnectionRegistry.containers.add(listenerContainer);

​ ActiveMQ注销代码:

      private static void shutdownActiveMQ() {
        //关闭监听
        JmsConnectionRegistry jmsConnectionRegistry = (JmsConnectionRegistry) SpringContext.getBean("jmsConnectionRegistry");
        for (JmsDestinationAccessor container : jmsConnectionRegistry.containers) {
            try {
                ((DefaultMessageListenerContainer) container).shutdown();
            } catch (JmsException e) {
                logger.warn(e.getMessage(), e);
            }
        }
    }
MQ的停机逻辑就是关闭监听的task

shutdownhook实现Quartz优雅停机(平滑退出)

这个比较简单,只需要调用scheduler.shutdown(true);
  public static void shutdownQuartzJobs() {
        Scheduler scheduler = SpringContext.getBean(Scheduler.class);
        try {
            scheduler.shutdown(true);
        } catch (SchedulerException e) {
            logger.warn(e.getMessage(), e);
        }
    }

shutdownhook实现Restful接口(HTTP请求)优雅停机(平滑退出)

万万没想到,当我着手做服务平滑退出的时候,以为关闭Servlet很简单,当执行spring contex的销毁方法时,会注销掉所有的bean以及bean工厂,致使所有Http请求都不能正确分发并返回404。然后我就放弃了这个“最简单的”,去探索Dubbo服务的优雅关闭了。

等到我回到阻断Http请求进入服务的时候,一切和我想的完全不一样,让我们一起看看,到底要怎样阻止Http请求进入服务

Spring 是怎么定义自己的shutdownhook的

​ Spring这么优秀的框架,也设计了注册钩子的入口。不过项目中使用的spring mvc3.2.16 默认并没有注册钩子,可能是没有开启注册钩子的监听器。

public void registerShutdownHook() {
     if (this.shutdownHook == null) {
         // No shutdown hook registered yet.
         this.shutdownHook = new Thread() {
             @Override
             public void run() {
                 doClose();
             }
         };
         Runtime.getRuntime().addShutdownHook(this.shutdownHook);
     }
}

​ 上面就是spring context中注册钩子的入口,和我们注册钩子的操作是一样的。

​ 销毁的核心就是doClose()方法

protected void doClose() {
     boolean actuallyClose;
     synchronized (this.activeMonitor) {
         actuallyClose = this.active && !this.closed;
         this.closed = true;
     }
     if (actuallyClose) {
         if (logger.isInfoEnabled()) {
             logger.info("Closing " + this);
         }
         LiveBeansView.unregisterApplicationContext(this);
         try {
             // Publish shutdown event.
             publishEvent(new ContextClosedEvent(this));
         }
         catch (Throwable ex) {
             logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex);
         }
         // Stop all Lifecycle beans, to avoid delays during individual destruction.
         try {
             getLifecycleProcessor().onClose();
         }
         catch (Throwable ex) {
             logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
         }
         // Destroy all cached singletons in the context's BeanFactory.
         destroyBeans();
         // Close the state of this context itself.
         closeBeanFactory();
         // Let subclasses do some final clean-up if they wish...
         onClose();
         synchronized (this.activeMonitor) {
             this.active = false;
         }
     }
}

​ 其实上面代码的逻辑很简单,核心是 destroyBeans(); closeBeanFactory();两个方法

  1. destroyBeans()

protected void destroyBeans() {
     getBeanFactory().destroySingletons();
}
public void destroySingletons() {
  if (logger.isInfoEnabled()) {
      logger.info("Destroying singletons in " + this);
  }
  synchronized (this.singletonObjects) {
      this.singletonsCurrentlyInDestruction = true;
  }
  String[] disposableBeanNames;
  synchronized (this.disposableBeans) {
      disposableBeanNames = StringUtils.toStringArray(this.disposableBeans.keySet());
  }
  for (int i = disposableBeanNames.length - 1; i >= 0; i--) {
      destroySingleton(disposableBeanNames[i]);
  }
  this.containedBeanMap.clear();
  this.dependentBeanMap.clear();
  this.dependenciesForBeanMap.clear();
  synchronized (this.singletonObjects) {
      this.singletonObjects.clear();
      this.singletonFactories.clear();
      this.earlySingletonObjects.clear();
      this.registeredSingletons.clear();
      this.singletonsCurrentlyInDestruction = false;
  }
}
   。。。
protected void removeSingleton(String beanName) {
     synchronized (this.singletonObjects) {
         this.singletonObjects.remove(beanName);
         this.singletonFactories.remove(beanName);
         this.earlySingletonObjects.remove(beanName);
         this.registeredSingletons.remove(beanName);
     }
 }
 

​ 这里其实做的就是从缓存中,把可移除的所有bean都删除调。

  1. closeBeanFactory() 就是注销掉BeanFactory。
最开始想的就是用这种办法,用spring自己的方式去销毁,所以有了下面第一次的错误尝试

Spring mvc 自定义钩子的方式销毁Servlet的错误尝试

以下是没能成功拦截Http请求的错误探索方向

  1. 为了拿到两个上下文,我定义了一个类缓存启动时创建的两个上下文

    spring使用mvc时会产生两个context上下文,一个是ContextLoaderListener产生的,一个是由DispatcherServlet产生的,它们俩是父子关系
    public class DemoCache {
        public static Set contextRefreshedEvents = new HashSet<>();
    }
  2. 获取到上下文后,调用context的destroy方法来销毁

     for (ContextRefreshedEvent contextRefreshedEvent : DemoCache.contextRefreshedEvents) {
         ((AbstractRefreshableWebApplicationContext) contextRefreshedEvent.getSource()).destroy();
     }

    destroy():

    public void destroy() {
            close();
    }
    public void close() {
            synchronized (this.startupShutdownMonitor) {
                doClose();
                // If we registered a JVM shutdown hook, we don't need it anymore now:
                // We've already explicitly closed the context.
                if (this.shutdownHook != null) {
                    try {
                        Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
                    }
                    catch (IllegalStateException ex) {
                        // ignore - VM is already shutting down
                    }
                }
            }
        }

    可以看出,destroy()方法就是直接运行了doClose();并试图销毁之前注册的钩子

  3. 为了验证Bean是不是全都被销毁了,我尝试在destroy()后,获取我要执行方法的Bean

    final Object shutDownHookServiceImpl = context.getBean("shutDownHookServiceImpl");
    final Object demoController = context.getBean("demoController");
  4. 不出意外,我收到了:

    java.lang.IllegalStateException:BeanFactory not initialized or already closed - call 'refresh' before accessing beans via the ApplicationContext
  5. 到这里一切进行的还很顺利,我注掉获取bean的方法并使用sleep使当前关闭前处理方法停在destroy()方法之后,防止方法结束后整个程序退出,然后用postman对服务发起Http请求,结果,悲剧发生了,请求还会如常的响应,并且Controller里面使用@Resource注入的 Sercice依旧可以正常运行。

为什么销毁Context,还是不能拦截Http请求?

​ 很显然,Http请求中用到的Controller以及Service并不是我们在context中销毁掉的,或者说,他们只是在mvc的上下文中被清理了,但是在接收Restful请求的时候,还可以从别的地方拿到。

​ 那一切的源头,就要从请求的入口DispatcherServlet来看了。

​ DispatcherServlet继承了HttpServlet,是tomcat与spring之间的纽带,当tomcat接收到请求时,会转发到DispatcherServlet,并由它对请求根据mapping进行分发。

protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
  ...
    doDispatch(request, response);
  ...
}
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
  ...
    mappedHandler = getHandler(processedRequest, false);
  ...
}

​ DispatcherServlet中的doService()方法,是请求的入口,里面的doDispatch(HttpServletRequest request, HttpServletResponse response)方法是实际处理请求分发的方法

protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
   for (HandlerMapping hm : this.handlerMappings) {
      if (logger.isTraceEnabled()) {
         logger.trace(
               "Testing handler map [" + hm + "] in DispatcherServlet with name '" + getServletName() + "'");
      }
      HandlerExecutionChain handler = hm.getHandler(request);
      if (handler != null) {
         return handler;
      }
   }
   return null;
}

​ 而getHandler则处理的是请求改如何分发,这里面,所有handlerMapping都是储存在这个对象的实例中,debug看一下,里面都存了什么。

Spring微服务项目实现优雅停机(平滑退出)_第1张图片
​ 如图所示,对于mapping “/shutdown”,DispatcherServlet在服务启动,初始化的时候,自己维护了mapping对应的Controller,以及controller内部的属性以及属性的属性,所以,从这里出发,请求还是可以完整的执行完成的。

​ 至此,新的思路出现了,当我们把DispatcherServlet中的 this.handlerMappings 中的数据清空,请求进来时,没有目的地可以分发,就能成功阻止Http请求的进入。

定义DispatcherServlet子类来缓存DispatcherServlet对象,也就是this

​ 为了能拿到DispatcherServlet对象,我们可以定义一个ManualDispatcherServlet来继承DispatcherServlet,并重写init(ServletConfig config),在初始化时,缓存servlet。

public class ManualDispatcherServlet extends DispatcherServlet {
    private static DispatcherServlet servlet;
    private final static String DISPATCHER_SERVLET ="org.springframework.web.servlet.DispatcherServlet";
    private final static String HANDLER_MAPPINGS ="handlerMappings";
    @Override
    public void init(ServletConfig config) throws ServletException {
        super.init(config);
        servlet = this;
    }
    /** 
     * 提供DispatcherServlet中handlerMappings销毁的方法
     * 供JVM优雅退出时,阻断新Restful请求进入服务
     * @return   
     * @author Youdmeng 
     * Date 2021-03-12 
     **/ 
    public static void cleanHandlerMappings() throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        Class dispatcherServlet = Class.forName(DISPATCHER_SERVLET);
        Field handlerMappings = dispatcherServlet.getDeclaredField(HANDLER_MAPPINGS);
        handlerMappings.setAccessible(true);
        handlerMappings.set(servlet, new ArrayList());
    }
}

​ 还需要记得将web.xml中注册的servlet替换成自己的,来使自定义文件生效。

​ 将


     web
     org.springframework.web.servlet.DispatcherServlet
     1

替换成:


     web
     com.ebiz.ebiz.demo.ManualDispatcherServlet
     1

​ 当需要拒绝http请求时,调用cleanHandlerMappings()方法利用反射,获取到handlerMappings,并将其赋值为空集合。这样一来,Http优雅停机(平滑退出)也就完成了。

还有个小问题,当拒绝请求进入后,对于仍然处在运行中的请求,我还没能在线程池中准确定位或者识别哪些来自DispatcherServlet并等待其关闭,下周过来在研究研究





更多好玩好看的内容,欢迎到我的博客交流,共同进步        胡萝卜啵的博客


你可能感兴趣的:(spring-mvcJVM)