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 命令来查看信号列表:

取值名称解释默认动作
1SIGHUP挂起
2SIGINT中断
3SIGQUIT退出
4SIGILL非法指令
5SIGTRAP断点或陷阱指令
6SIGABRTabort收回的信号
7SIGBUS非法内存拜访
8SIGFPE浮点异样
9SIGKILLkill信号不能被疏忽、解决和阻塞
10SIGUSR1用户信号1
11SIGSEGV有效内存拜访
12SIGUSR2用户信号2
13SIGPIPE管道破损,没有读端的管道写数据
14SIGALRMalarm收回的信号
15SIGTERM终止信号
16SIGSTKFLT栈溢出
17SIGCHLD子过程退出默认疏忽
18SIGCONT过程持续
19SIGSTOP过程进行不能被疏忽、解决和阻塞
20SIGTSTP过程进行
21SIGTTIN过程进行,后盾过程从终端读数据时
22SIGTTOU过程进行,后盾过程想终端写数据时
23SIGURGI/O有紧急数据达到以后过程默认疏忽
24SIGXCPU过程的CPU工夫片到期
25SIGXFSZ文件大小的超出下限
26SIGVTALRM虚构时钟超时
27SIGPROFprofile时钟超时
28SIGWINCH窗口大小扭转默认疏忽
29SIGIOI/O相干
30SIGPWR关机默认疏忽
31SIGSYS零碎调用异样

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<Thread, Thread> 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<Thread, Thread> excludeIdentityHashMap = new IdentityHashMap<>();            synchronized (clazz) {                IdentityHashMap<Thread, Thread> map = (IdentityHashMap<Thread, Thread>) 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<ContextRefreshedEvent>

应用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<Protocol> 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 elsepublic static void destroyAll() {    if (!destroyed.compareAndSet(false, true)) {        return;    }    AbstractRegistryFactory.destroyAll();    ExtensionLoader<Protocol> 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<String>(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"

<Dubbo:registry protocol="zookeeper" address="127.0.0.1:2181" wait="50000"/>
这里提供者和消费者必须都配置,否则会在业务实现前敞开连贯

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

在手动敞开Mq监听的时候,发现我的项目代码外面,DefaultMessageListenerContainer 是没被spring治理的,咱们敞开监听,登记Consumers时须要调用它的shutdown()办法,所以手动保护了一个HashSet<JmsDestinationAccessor> 来治理

JmsConnectionRegistry

@Configurationpublic class JmsConnectionRegistry {    public HashSet<JmsDestinationAccessor> 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<ContextRefreshedEvent> 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看一下,外面都存了什么。


如图所示,对于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<HandlerMapping>());    }}

还须要记得将web.xml中注册的servlet替换成本人的,来使自定义文件失效。

<servlet>     <servlet-name>web</servlet-name>     <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>     <load-on-startup>1</load-on-startup></servlet>

替换成:

<servlet>     <servlet-name>web</servlet-name>     <servlet-class>com.ebiz.ebiz.demo.ManualDispatcherServlet</servlet-class>     <load-on-startup>1</load-on-startup></servlet>

当须要回绝http申请时,调用cleanHandlerMappings()办法利用反射,获取到handlerMappings,并将其赋值为空集合。这样一来,Http优雅停机(平滑退出)也就实现了。

还有个小问题,当拒绝请求进入后,对于依然处在运行中的申请,我还没能在线程池中精确定位或者辨认哪些来自DispatcherServlet并期待其敞开,下周过去在钻研钻研





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