安全优雅的关闭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 {

        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 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

你可能感兴趣的:(安全优雅的关闭SpringBoot应用程序)