关于spring-mvc:Spring微服务项目实现优雅停机平滑退出

7次阅读

共计 14584 个字符,预计需要花费 37 分钟才能阅读完成。

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<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 else
public 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

@Configuration
public 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 并期待其敞开,下周过去在钻研钻研

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

正文完
 0