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并期待其敞开,下周过去在钻研钻研
更多好玩难看的内容,欢送到我的博客交换,共同进步 胡萝卜啵的博客
发表回复