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()方法内部为自定义的清理逻辑),来接收停机信息,并执行停机前的自定义代码。
钩子在以下场景会被触发:
- 程序正常退出
- 使用System.exit()
- 终端使用Ctrl+C触发的中断
- 系统关闭
- 使用Kill pid命令干掉进程(kill -9 不会触发)
我们使用的是kill -15 PID命令来触发钩子
定义停止钩子的风险
- 钩子run()方法的执行速度会严重影响服务关闭的快慢
- 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()的作用:
- 取消该协议所有已经暴露和引用的服务
- 释放协议所占用的所有资源,比如连接和端口
在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();两个方法
- 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都删除调。
- closeBeanFactory() 就是注销掉BeanFactory。
最开始想的就是用这种办法,用spring自己的方式去销毁,所以有了下面第一次的错误尝试
Spring mvc 自定义钩子的方式销毁Servlet的错误尝试
以下是没能成功拦截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()后,获取我要执行方法的Beanfinal 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依旧可以正常运行。
为什么销毁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并等待其关闭,下周过来在研究研究
更多好玩好看的内容,欢迎到我的博客交流,共同进步 胡萝卜啵的博客
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。