安全优雅的关闭SpringBoot应用程序

什么叫优雅停机?简单说就是,在对应用进程发送停止指令之后,能保证正在执行的业务操作不受影响。应用接收到停止指令之后的步骤应该是,停止接收访问请求,等待已经接收到的请求处理完成,并能成功返回。对于内部执行的其他定时任务,也要等当前正在执行的定时任务执行完毕,并且不再启动新的定时任务。这时才真正停止应用。

如果暴力的关闭应用程序,即应用收到停止指令后,立即终止,则可能会导致进程持有的全局资源得不到释放,而其他进程也因无法获取资源而不能处理业务。比如如果某个任务处理需要首先获取一个redis锁,而锁又没有设置过期时间,如果任务获取锁后还未释放锁就终止了,会导致资源被锁,无法再进行处理。

本文主要针对以下两种情形进行应用的安全优雅关闭:

  • 对于web接口请求,应用收到终止指令后,不再接受新的web请求,对于已经接收到的请求继续正常处理,处理完毕后再终止应用。
  • 对于应用内部执行的定时任务(Quartz实现),不再启动新的定时任务,并等待当前正在执行的所有定时任务执行完毕,然后才终止。

核心方法

核心方法就是获取对应的线程池,通过调用Executor的shutdown来通知线程池停止接收新的任务,并等待当前已经执行的任务执行完毕。

kill命令的正确使用姿势

正常关闭应用应该使用kill -15,而不是kill -9,-9是暴力终止,直接在操作系统底层将应用杀死,是应用程序被动终止。而-15是通知应用终止,应用收到-15终止信号后会主动执行一些善后操作,最终主动终止,是安全终止的方式。

安全优雅地关闭web请求

这里主要针对SpringBoot内置的Tomcat容器,不过思路都是一样的。

主要思路就是获取Tomcat的Connector连接器,然后通过Connector获取其连接线程池,最终通过操作线程池安全终止来达到web请求也安全终止的目的。

实现代码如下

@Configuration
public class CbShutdownConfig {

    public static final int waitTime = 30;

    @Bean
    public GracefulShutdown gracefulShutdown() {
        return new GracefulShutdown();
    }

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
        tomcat.addConnectorCustomizers(gracefulShutdown());
        return tomcat;
    }

    @Slf4j
    private static class GracefulShutdown implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {

        private volatile Connector connector;

        @Override
        public void customize(Connector connector) {
            this.connector = connector;
        }

        @Override
        public void onApplicationEvent(ContextClosedEvent event) {
            log.info("application is going to stop. try to stop tomcat gracefully");
            this.connector.pause();
            Executor executor = this.connector.getProtocolHandler().getExecutor();
            if (executor instanceof ThreadPoolExecutor) {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
                threadPoolExecutor.shutdown();
                try {
                    if (!threadPoolExecutor.awaitTermination(waitTime, TimeUnit.SECONDS)) {
                        log.info("Tomcat did not terminate in the specified time.");
                        threadPoolExecutor.shutdownNow();
                    }
                } catch (Exception ex) {
                    log.error("awaitTermination failed.", ex);
                    threadPoolExecutor.shutdownNow();
                }
            }
        }
    }
}

安全优雅地关闭Quartz定时任务

这里的主要思路还是获取Quartz的线程池,通过操作线程池安全终止来达到安全终止定时任务的目的。

首先手动指定定时任务的线程池

  private static final ExecutorService executorService = Executors.newFixedThreadPool(14);

  SchedulerJobFactory jobFactory = new SchedulerJobFactory();
  jobFactory.setApplicationContext(applicationContext);

  SchedulerFactoryBean factory = new SchedulerFactoryBean();
  factory.setGlobalJobListeners(quartzExceptionListener);
  factory.setJobFactory(jobFactory);
  factory.setTaskExecutor(executorService);

指定安全关闭此线程池的方法

public static void stopJobs() {
        log.info("start to stop all message jobs...");

        executorService.shutdown();
        try {
            if (!executorService.awaitTermination(CbShutdownConfig.waitTime, TimeUnit.SECONDS)) {
                log.warn("Executor did not terminate in the specified time.");
                List<Runnable> droppedTasks = executorService.shutdownNow();
                log.warn("Executor was abruptly shut down. " + droppedTasks.size() + " tasks will not be executed.");
            }
        } catch (Exception e) {
            log.error("stop service awaitTermination failed.", e);
            executorService.shutdownNow();
        }

        log.info("end of stopping all message jobs...");
    }

到这里,我们完成了stopJobs函数的编写,但是这个函数应该在哪里调用呢?
一种调用方式是使用Java的钩子函数addShutdownHook,在Java程序收到终止指令后回调stopJobs(kill -9不会回调addShutdownHook)。但是实际测试发现,如果任务中有有关数据库的操作,会报异常:

druid datasource already closed

此异常说明数据库连接池在任务完成之前关闭了!所以我们的目标又变成了在数据库连接池关闭之前完成未完成的任务。

经过搜索得知,数据库连接池之所以会提前关闭,是因为其对应的Bean被销毁了,所以目标是在数据库连接池Bean销毁之前完成任务。可以使用springboot的事件监听。

springboot的事件监听:为bean之间的消息通信提供支持。当一个bean做完一件事以后,通知另一个bean知晓并做出相应处理。这时,我们需要另一个bean,监听当前bean所发生的事件。

  • Spring提供5种标准的事件监听:

上下文更新事件(ContextRefreshedEvent):该事件会在ApplicationContext被初始化或者更新时发布。也可以在调用ConfigurableApplicationContext接口中的refresh()方法时被触发。
上下文开始事件(ContextStartedEvent):当容器ConfigurableApplicationContext的Start()方法开始/重新开始容器时触发该事件。
上下文停止事件(ContextStoppedEvent):当容ConfigurableApplicationContext的Stop()方法停止容器时触发该事件。
上下文关闭事件(ContextClosedEvent):当ApplicationContext被关闭时触发该事件。容器被关闭时,其管理的所有单例Bean都被销毁。
请求处理事件(RequestHandledEvent):在Web应用中,当一个http请求(request)结束触发该事件。

其中ContextClosedEvent事件是通知应用即将销毁容器中的Bean的消息。所以我们可以监听SpringBoot的ContextClosedEvent,在这个事件中调用任务终止的方法:

@Service
    @Slf4j
    public static class CbJobStopListener implements ApplicationListener {

        @Override
        public void onApplicationEvent(ApplicationEvent event) {
            // 在spring bean容器销毁之前执行的事件,防止数据库连接池在任务终止前销毁
            if (event instanceof ContextClosedEvent) {
                log.info("event ContextClosedEvent");
                MessageConfig.stopJobs();
            }
        }
    }

最后测试,通过日志可以发现,在应用收到终止信号后,会等待当前已经启动的定时任务终止,并且拒绝执行新的定时任务。完美完成目标。

2019-07-04 16:25:40 [schedulerFactoryBean_QuartzSchedulerThread] ERROR org.quartz.core.QuartzSchedulerThread - ThreadPool.runInThread() return false!
2019-07-04 16:25:40 [schedulerFactoryBean_QuartzSchedulerThread] INFO  org.quartz.simpl.RAMJobStore - All triggers of Job DEFAULT.asyncFileTaskJob set to ERROR state.
2019-07-04 16:25:40 [schedulerFactoryBean_QuartzSchedulerThread] ERROR org.springframework.scheduling.quartz.LocalTaskExecutorThreadPool - Task has been rejected by TaskExecutor
java.util.concurrent.RejectedExecutionException: Task org.quartz.core.JobRunShell@2bc6c064 rejected from java.util.concurrent.ThreadPoolExecutor@64c55739[Shutting down, pool size = 6, active threads = 6, queued tasks = 0, completed tasks = 96]
    at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
    at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
    at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
    a

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,695评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,569评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,130评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,648评论 1 297
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,655评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,268评论 1 309
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,835评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,740评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,286评论 1 318
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,375评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,505评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,873评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,357评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,466评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,921评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,515评论 2 359

推荐阅读更多精彩内容