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

1,927 阅读10分钟

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("========注销Servlet服务========");
              shutServlet();
              Date start = new Date();
              logger.info("========注销Dubbo服务========");
              shutdownDubbo();
              logger.info("========注销ActiveMQ服务========");
              shutdownActiveMQ();
              logger.info("========注销Quartz服务========");
              shutdownQuartzJobs();
              //销毁servlet后等待20s,保证当前业务跑完
              shutdownHood(start, 20000L);
              logger.info("========组件关闭完成========");
            }
        }, 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

使用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 来管理

​ 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 ​ 如图所示,对于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并等待其关闭,下周过来在研究研究 暂时没找到获取线程的方法,只好让服务睡一下

    /** 
     * 在servlet注销后挂起程序指定时长,保证Https业务完成
     * @author youdmeng
     * @date 2021/3/18 16:11
     * @param start 开始销毁的时间(servlet注销后) 
     * @Param ms    等待毫秒数
     **/
    private static void shutdownHood(Date start, long ms) {
        while (((new Date()).getTime() - start.getTime()) <= ms) {
            try {
                Thread.sleep(100L);
            } catch (Exception e) {
                logger.warn("注销Servlet服务异常", e);
            }
        }
    }

​ 保证服务在注销servlet之后保持20s不关闭,具体情况还要看自己的业务,当然,这期间如果dubbo,mq等服务销毁用时超过20s,这个也就不会起作用了




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