基于MSE XXL-JOB实现优雅下线

更新时间:
复制 MD 格式

MSE XXL-JOB在兼容开源版本基础上新增优雅下线功能:应用关闭前首先通知调度服务端摘流停止新任务派发,待存量任务执行完毕后再安全下线应用,实现业务无损重启。本文旨在为您介绍如何基于阿里云任务调度XXL-JOB版开启优雅下线功能,帮助您处理实际业务重启或下线的场景。

定时任务优雅下线概述

在实际业务中,应用进程内的定时任务会持续按固定频率执行。当应用发布重启时,运行中的定时任务将被强制中断,可能导致数据不完整和调度成功率骤降,最终出现业务数据受损。主要存在以下情况:

  • 任务执行中断:任务正在运行中,应用进程停机业务处理中断,可能会导致业务数据不完整。

  • 任务调度下跌:在发布重启过程中,调度器将任务分发给停机节点,导致调度失败影响整体处理效率。

因此,在使用定时任务调度的场景下,需要定时任务优雅下线,以实现滚动发布和重启过程中的业务平滑运行。

基于开源XXL-JOB优雅下线实践

开源XXL-JOB执行原理及优雅下线问题

目前开源XXL-Job在任务调度持续执行过程中,任务执行侧还无法有效地实现优雅下线功能。因此,想通过开源版本实现优雅下线需要自定义改造。在改造开源XXL-JOB优雅下线之前,可以先对XXL-JOB任务分发和任务执行的整体链路进行剖析。整个链路涉及XXL-JOB AdminXXL-Job Executor两个模块。参考如下图示例:

image

针对XXL-Job主要的交互执行链路进行解读说明,并标注出对优雅下线的影响逻辑点,以便作为自定义改造的参考,目前主要存在以下问题:

问题一:下线节点摘流延迟

执行器在下线时未能及时完成调度和执行机器列表更新,会出现定时调度至下线节点导致调度失败。

逻辑处理一:执行器注册
  • 业务应用在依赖XXL-JOB SDK启动后,会初始化ExecutorRegistryThread线程持续向调度中心汇报心跳。

  • 调度中心在接收到后,会通过JobRegistryHelper,将注册上来的执行器信息写入数据库表xxl_job_registry

  • JobRegistryHelper中存在一个线程,定期查询更新xxl_job_group表的address_list(实际使用的列表)。

// async execute
registryOrRemoveThreadPool.execute(new Runnable() {
    @Override
    public void run() {
        int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryUpdate(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
        if (ret < 1) {
            XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registrySave(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
        }
        // fresh
        freshGroupRegistryInfo(registryParam);
    }
}
);
return ReturnT.SUCCESS;
}
registryMonitorThread = new Thread(new Runnable() {
    @Override
    public void run() {
        while (!toStop) {
            try {
                // auto registry group
                List<XxlJobGroup> groupList = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().findByAddressType(0);
                if (groupList!=null && !groupList.isEmpty()) {
                    // remove dead address (admin/executor)
                    List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
                    if (ids!=null && ids.size()>0) {...}
                    // fresh online address (admin/executor)
                    HashMap&lt;String, List&lt;String&gt;> appAddressMap = new HashMap<>();
                    List<XxlJobRegistry> list = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findAll(RegistryConfig.DEAD_TIMEOUT, new Date());
                    if (list != null) {...}
                    // fresh group address
                    for (XxlJobGroup group: groupList) {
                        List<String> registryList = appAddressMap.get(group.getAppname());
                        String addressListStr = null;
                        if (registryList!=null && !registryList.isEmpty()) {...}
                        group.setAddressList(addressListStr);
                        group.setUpdateTime(new Date());
                        XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().update(group);
                    }
                }
            } catch (Exception e) {...}
            try {
                TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
            } catch (InterruptedException e) {
                if (!toStop) {...}
            }
        }
        logger.info(">>>>>>>>>>> xxl-job, job registry monitor thread stop");
    }
});
逻辑处理二:选择在线执行机器
  • 任务在通过调度线程触发后,会交给XxlJobTrigger完成触发执行动作。

  • 在触发执行前,会从xxl_job_groupaddress_list读取可用的执行器列表。

  • 通过ExecutorRouter在上述机器列表中,按对应的路由策略选择一台机器。

  • 完成机器选择后,会通过RPC请求将任务分发至对应IP节点运行;此时如选择一个已下线节点任务触发会失败。

public static void trigger(int jobId,
                           TriggerTypeEnum triggerType,
                           int failRetryCount,
                           String executorShardingParam,
                           String executorParam,
                           String addressList) {
    // load data
    XxlJobInfo jobInfo = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(jobId);
    if (jobInfo == null) {
        logger.warn(">>>>>>>>>>> trigger fail, jobId invalid, jobId={}", jobId);
        return;
    }
    if (executorParam != null) {
        jobInfo.setExecutorParam(executorParam);
    }
    int finalFailRetryCount = failRetryCount>=0?failRetryCount:jobInfo.getExecutorFailRetryCount();
    XxlJobGroup group = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().load(jobInfo.getJobGroup()); // IDE 标记此处 load 方法存在错误
    // cover addressList
    if (addressList!=null && addressList.trim().length()>0) {
        group.setAddressType(1);
        group.setAddressList(addressList.trim());
    }
}

总结说明:由于上述注册执行器逻辑和触发任务执行获取列表数据不实时同步,是通过异步定时更新,因此会产生可用在线机器列表刷新延迟。

问题二:任务执行强制中断

目前XXL-Job Executor在退出时会直接触发任务运行线程中断按失败处理,且队列中等待执行的任务执行请求会全部丢弃按失败处理。

逻辑处理一:将任务分发至对应机器执行
  • 业务应用执行器接收到对应任务后,会根据任务ID为每个任务创建一个JobThread线程用于任务执行。

  • 该任务本次触发执行请求,会被添加到当前任务线程待执行队列中,任务不同阻塞策略会有不同处理。

  • JobThread该线程会持续循环读取,队列中的触发记录并执行对应的JobHandler完成业务逻辑处理。

  • 任务执行结束后,会将本次执行结果信息提交给TriggerCallbackThread的执行应答队列,继续下一次执行。

  • 执行器在停止时会执行XxlJobExecutor.destroy方法,该方法会中断运行的线程及清理掉队列中等待执行的调度请求。

逻辑处理二:任务执行结果反馈
  • TriggerCallbackThread会持续运行加载当前执行结果队列,批量分发执行结果给调度中心。

  • 如向调度中心发送应答结果失败,则会写入本地文件落盘,会安排重试。

  • 调度中心在接收到执行结果后会进行执行记录的更新写入库。

public void destroy(){
    // destroy executor-server
    stopEmbedServer();
    // destroy jobThreadRepository
    if (jobThreadRepository.size() > 0) {
        for (Map.Entry&lt;Integer, JobThread&gt; item: jobThreadRepository.entrySet()) {
            JobThread oldJobThread = removeJobThread(item.getKey(), "web container destroy and kill the job.");
            // wait for job thread push result to callback queue
            if (oldJobThread != null) {
                try {
                    oldJobThread.join();
                } catch (InterruptedException e) {
                    logger.error(">>>>>>>>>>> xxl-job, JobThread destroy(join) error, jobId:{}", item.getKey(), e);
                }
            }
        }
        jobThreadRepository.clear();
    }
    jobHandlerRepository.clear();
    // destroy JobLogFileCleanThread
    JobLogFileCleanThread.getInstance().toStop();
    // destroy TriggerCallbackThread
    TriggerCallbackThread.getInstance().toStop();
}

总结说明:在上述下线处理过程中,removeJobThread底层会直接中断运行中的任务线程,且线程队列中等待执行的任务会直接忽略执行按失败处理。

开源XXL-JOB实现优雅下线

通过开源XXL-JOB执行原理的过程分析,根据分析原理基于开源代码实现优雅下线功能。应用优雅下线的核心三步骤是先摘流,再等待执行中业务完成,最后停机下线

image

XXL-JOB Core模块的com.xxl.job.core.executor.XxlJobExecutor#destroy,在SpringBoot模式下会在应用进程退出时自动进行回调处理,其中包含了应用执行器的一些下线回收动作,但目前此处的相关逻辑处理并不能完全实现优雅下线功能。因此,需结合上述的剖析进行以下步骤改造处理:

步骤一:应用节点摘流

  • 首先XxlJobExecutor#destroy方法中,存在stopEmbedServer()方法会停止心跳注册并向调度中心发送registryRemove请求,以移除当前执行节点。

  • 调度服务端在接收请求后会将数据库xxl_job_registry表中当前节点移除,但参考原理剖析说明实际使用的是xxl_job_groupaddress_list(并未被同步更新),此时并未真正完成摘流动作。

  • 需对调度服务端进行相应改造来实现摘流,改造点选择(选其一):

    • JobRegistryHelper.registryRemove方法中添加后续处理,直接刷新xxl_job_groupaddress_list,也可在freshGroupRegistryInfo实现刷新逻辑。

    • 改造XxlJobTrigger#trigger()方法,调整groupaddressList的读取方式,对于自动注册直接从xxl_job_registry表读取地址列表。

完成上述改造后,即可完成第一步摘流动作。

步骤二:等待执行中业务完成

  • 改造XxlJobExecutor#destroy方法中的下一步,需要等待所有待执行任务处理完成,参考如下代码:

    public void destroy(){
        // destroy executor-server
        stopEmbedServer();
        // destroy jobThreadRepository
        if (jobThreadRepository.size() > 0) {
            List keyList = new ArrayList(jobThreadRepository.keySet());
            for (int i=0; i < keyList.size(); i++) {
                JobThread jobThread = jobThreadRepository.get(keyList.get(i));
                // 等待所有任务队列执行完成
                while (jobThread != null && jobThread.isRunningOrHasQueue()) {
                    try {
                        TimeUnit.SECONDS.sleep(1L);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        jobHandlerRepository.clear();
        // destroy JobLogFileCleanThread
        JobLogFileCleanThread.getInstance().toStop();
        // destroy TriggerCallbackThread
        TriggerCallbackThread.getInstance().toStop();
    }
  • 等待答应结果队列反馈所有执行结果,目前开源实现的TriggerCallbackThread.getInstance().toStop()方法在中断应答结果线程后会最终同步一次处理结果,因此可不做额外处理。

至此,等待运行中任务处理即可完成。另外,您可自行发挥对不同任务类型做个性化的下线处理。

步骤三:应用进程停机

  • 应用停止建议在发布部署脚本中通过kill -15来触发上述JVMHook回调,可根据业务需要再控制超时强制停止。

  • 可以通过SpringBoot Actuator功能将任务调度优雅下线集成,再通过/actuator/shutdown接口下线应用。

视频演示

关于XXL-JOB版优雅下线功能的操作步骤演示,请参见MSE XXL-JOB优雅下线功能演示

前提条件

  • 引擎版本需2.1.0及以上。版本详情,请参见XXL-JOB 引擎版本

  • 客户端需接入SchedulerX plugin包。版本详情,请参见XXL-JOB插件版本

    <dependency>
      <groupId>com.aliyun.schedulerx</groupId>
      <artifactId>schedulerx3-plugin-xxljob</artifactId>
      <version>最新版本</version>
    </dependency>

优雅下线示意图

image.jpeg

如何启用优雅下线

在不同的业务形态和部署场景中,完整配置并启用优雅下线方案。该过程主要包括两个步骤:

步骤一:执行器各框架初始化集成

业务应用不同的部署形态,需要按不同方式进行初始化集成。

形态一:SpringBoot业务应用(推荐)

如果业务应用采用了SpringBoot方式集成XXL-Job的执行器,那么可自动完成相应的优雅下线能力初始化集成。只需完成如下两个步骤即可:

  1. 添加SchedulerX插件Maven依赖。版本详情,请参见XXL-JOB插件版本

    <dependency>
      <groupId>com.aliyun.schedulerx</groupId>
      <artifactId>schedulerx3-plugin-xxljob</artifactId>
      <version>最新版本</version>
    </dependency>
  2. 添加应用配置参数,开启优雅下线。详细参数说明,请参见配置参数说明

    # 配置优雅下线
    xxl.job.executor.shutdownMode=WAIT_ALL

形态二:Spring业务应用

如果业务应用是通过Spring框架启动的Web应用,除了添加POM依赖应用启动参数(参考形态一:SpringBoot业务应用(推荐)),还需通过初始化配置XxlJobExecutorEnhancerInitializer,在web.xml中添加如下配置:

<web-app>
  <context-param>
        <!-- Spring ApplicationContextInitializer 增强xxljob executor功能 -->
        <param-name>globalInitializerClasses</param-name>
        <param-value>com.aliyun.schedulerx.xxljob.enhance.XxlJobExecutorEnhancerInitializer</param-value>
    </context-param>
</web-app>

形态三:Frameless Java业务应用

如果业务应用采用xxl-job executor案例中的Frameless方式,通过纯Java编码方式启动业务应用时,可通过自定义编码完成优雅下线功能初始化集成。首先业务应用还是需要添加POM依赖应用启动参数(参考形态一:SpringBoot业务应用(推荐)),相关参考案例如下:

  • Executor启动前,添加:EnhancerLoader.load(xxlJobProp),完成功能增强加载。

  • Executor启动前,添加:Runtime.getRuntime().addShutdownHook(...),为当前应用添加下线Hook实现。

示例代码

public static void main(String[] args) {
    try {
        // load executor prop
        Properties xxlJobProp = FrameLessXxlJobConfig.loadProperties("xxl-job-executor.properties");
        // 初始化增加xxl-job executor加载
        EnhancerLoader.load(xxlJobProp);
        // start xxl-job executor
        FrameLessXxlJobConfig.getInstance().initXxlJobExecutor(xxlJobProp);
        // 添加系统优雅线下hook
        Runtime.getRuntime().addShutdownHook(new Thread(){
            @Override
            public void run() {
                FrameLessXxlJobConfig.getInstance().destroyXxlJobExecutor();
            }
        });
        // Blocks until interrupted
        while (true) {
            try {
                TimeUnit.HOURS.sleep(1);
            } catch (InterruptedException e) {
                break;
            }
        }
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
    } finally {
        // destroy
        FrameLessXxlJobConfig.getInstance().destroyXxlJobExecutor();
    }
}

步骤二:应用停机下线处理

自建部署,通过kill -15停机

在自建CD流程中通常会有一个应用进程停止的节点,该节点可通过构建一个stop.sh 脚本用于应用进程停止退出。脚本内容需包含应用优雅下线的相关逻辑处理,参考如下停机脚本。

image.jpeg

应用进程停机脚本案例:

# 应用启用成功后进程ID信息会写入app.pid文件
PID="{应用部署路径}/app.pid"
FORCE=1
if [ -f ${PID} ]; then
  TARGET_PID=`cat ${PID}`
  kill -15 ${TARGET_PID}
  loop=1
  while(( $loop<=5 ))
  do
    ## health 检查当前应用进程确实已经结束,可根据应用特征自定义
    health
    if [ $? == 0 ]; then
      echo "check $loop times, current app has not stop yet."
      sleep 5s
      let "loop++"
    else
      FORCE=0
      break
    fi
  done
  if [ $FORCE -eq 1 ]; then
  	echo "App(pid:${TARGET_PID}) stop timeout, forced termination."
    kill -9 ${TARGET_PID}
  if
  rm -rf ${PID}
  echo "App(pid:${TARGET_PID}) stopped successful."
fi

K8s容器化部署,通过PreStop停机

利用k8s pod的生命周期管理可默认实现优雅下线。同时还可以使用preStop hook,通过exec执行脚本和HTTP请求方式来实现优雅下线逻辑处理。

  • 默认无效修改:如果业务应用进程为容器中的主进程PID 1,则默认会先给主进程发送SIGTERM信号进行优雅下线。

  • 自定义preStop:如容器内为复杂的多进程关系,可通过配置preStop脚本,自定义通过kill -15 PID停止应用进程,或者调用提前预设的stop.sh脚本实现应用进程退出。

重要

方案中PodterminationGracePeriodSeconds参数会控制优雅下线的整体最长等待时间(默认30s),需要按业务需要合理配置。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
      - name: my-app-container
        image: my-app-image:latest
        lifecycle:
          preStop:
            exec:
              # command: ["/bin/sh", "-c", "kill -15 PID && sleep 30"]
              command: ["/bin/sh", "-c", "脚本路径/stop.sh"]

阿里云上应用发布平台自动集成

敬请期待。

配置参数说明

在业务应用中需要配置如下参数开启优雅下线功能,并且支持通过该参数配置两种不同的优雅下线策略。

image.jpeg

# 优雅下线模式,WAIT_ALL:等待全部; WAIT_RUNNING:等待运行中。
# 该参数不配置,则表示保持XXL-JOB原始逻辑不变(默认不开启优雅下线)。
xxl.job.executor.shutdownMode=WAIT_ALL

下线模式

描述

等待全部(WAIT_ALL

(推荐)该模式下,待所有已接收的任务(正在运行及队列中等待的任务)执行完成后,应用才退出。

等待运行中(WAIT_RUNNING

该模式下,应用在退出时,将等待已分配线程并在处理中的执行记录完成,队列中的任务将被放弃执行。