赞
踩
不管是生产环境还是测试环境,在发布新代码的时候,不可避免的进行项目的重启
kill -9 `ps -ef|grep tomcat|grep -v grep|grep server_0001|awk '{print $2}'
以上是我司生产环境停机脚本,可以看出使用了 kill -9 命令把服务进程杀掉了,这个命令是非常暴力的,类似于直接按了这个服务的电源,显然这种方式对进行中的服务是很不友善的,当在停机时,正在进行RPC调用、执行批处理、缓存入库等操作,会造成不可挽回的数据损失,增加后期维护成本。
所以就需要优雅停机出场了,让服务在收到停机指令时,从容的拒绝新请求的进入,并执行完当前任务,然后关闭服务。
简单来说,信号就是为 linux 提供的一种处理异步事件的方法,用来实现服务的软中断。
服务间可以通过 kill -数字 PID 的方式来传递信号
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 | 系统调用异常 |
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()方法内部为自定义的清理逻辑),来接收停机信息,并执行停机前的自定义代码。
钩子在以下场景会被触发:
我们使用的是kill -15 PID命令来触发钩子
上面代码我们已经注册了自己的钩子,里面调用了几个停服务的方法,那为什么要删除其他钩子呢
很多服务都会注册自己的钩子,注册的地方可以看出,每个钩子都是一个新的线程,所以当收到关闭指令时,这些钩子之间是并发执行的,一些服务之间的依赖关系会被打破,导致不能按我们的想法正确的停掉服务。
取出并停掉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) 方法,可以在项目启动后,对注册的钩子进行清理
对于Dubbo的优雅停机,网上众说纷纭,大部分说法是不支持优雅停机,想支持优雅停机的话,需要修改源码。而且2.6以下的版本,与spring的钩子之间不兼容,导致服务停机会出现异常
本司用的Dubbo版本为2.5.6,我修改了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()方法的作用是关闭所有已创建注册中心,会调用每个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()的作用:
在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"/>
这里提供者和消费者必须都配置,否则会在业务完成前关闭连接
在手动关闭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
这个比较简单,只需要调用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);
}
}
万万没想到,当我着手做服务平滑退出的时候,以为关闭Servlet很简单,当执行spring contex的销毁方法时,会注销掉所有的bean以及bean工厂,致使所有Http请求都不能正确分发并返回404。然后我就放弃了这个“最简单的”,去探索Dubbo服务的优雅关闭了。
等到我回到阻断Http请求进入服务的时候,一切和我想的完全不一样,让我们一起看看,到底要怎样阻止Http请求进入服务
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();两个方法
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都删除调。
最开始想的就是用这种办法,用spring自己的方式去销毁,所以有了下面第一次的错误尝试
以下是没能成功拦截Http请求的错误探索方向
为了拿到两个上下文,我定义了一个类缓存启动时创建的两个上下文
spring使用mvc时会产生两个context上下文,一个是ContextLoaderListener产生的,一个是由DispatcherServlet产生的,它们俩是父子关系
public class DemoCache {
public static Set<ContextRefreshedEvent> contextRefreshedEvents = new HashSet<>();
}
获取到上下文后,调用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();并试图销毁之前注册的钩子
为了验证Bean是不是全都被销毁了,我尝试在destroy()后,获取我要执行方法的Bean
final Object shutDownHookServiceImpl = context.getBean("shutDownHookServiceImpl");
final Object demoController = context.getBean("demoController");
不出意外,我收到了:
java.lang.IllegalStateException:BeanFactory not initialized or already closed - call 'refresh' before accessing beans via the ApplicationContext
到这里一切进行的还很顺利,我注掉获取bean的方法并使用sleep使当前关闭前处理方法停在destroy()方法之后,防止方法结束后整个程序退出,然后用postman对服务发起Http请求,结果,悲剧发生了,请求还会如常的响应,并且Controller里面使用@Resource注入的 Sercice依旧可以正常运行。
很显然,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对象,我们可以定义一个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并等待其关闭,下周过来在研究研究
更多好玩好看的内容,欢迎到我的博客交流,共同进步 胡萝卜啵的博客
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。