本文核心问题:Spring中配置定时任务,封装任务执行流程;同一时刻只让一台机器执行,尽可能避免并发和并行,避免任务数据被处理2次。

项目中,基本都会存在一些后台性质的工作,可以用定时任务搞定。
Spring中配置定时任务,个人倾向使用Spring自带的Task配置,不用引入新的技术点,简单的项目足够了。
1、Spring定时任务配置 spring-worker.xml

<!-- 0 0/1 * * * ?  1分钟执行1次-->
     <!-- 0 0 5 * * ?  凌晨5点执行-->
     <!-- 0 0 */1 * * ?  每小时执行1次-->
     <bean id="blacklistTimeoutTask" class="com.jd.cav.web.task.BlacklistTimeoutTask" />
     <task:scheduled-tasks>
         <task:scheduled ref="blacklistTimeoutTask" method="run"
             cron="0 0 */1 * * ?" />
     </task:scheduled-tasks>

京东内部,习惯把“定时任务”叫做worker,我个人习惯spring-task.xml这种名字。

2、普通的任务配置

public class BlacklistTimeoutTask {
  
     public void run(){
        //code
    }
  
 }

3、任务流程封装
在实际开发过程中,发现很多项目的定时任务执行,符合一定的流程,于是定义了一个父类CronTask,封装了任务执行的流程。

/**
  * 定时任务模型。<br/>
  * 约定1个任务的处理流程:<br/>
  * 1、是否有“权限”或“锁”执行;<br/>
  * 2、查询有哪些数据需要处理;<br/>
  * 3、for循环,开启新线程,包装上下文,执行1个任务;<br/>
  * 4、结束任务,释放“权限”或“锁”。<br/>
  *
  */import java.util.List;
  
 import javax.annotation.Resource;
  
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.log4j.Logger;
 import org.springframework.scheduling.SchedulingTaskExecutor;
  
 public abstract class CronTask<T> {
     private Logger logger = Logger.getLogger(getClass());
     @Resource
     private TaskCheckService taskCheckService;
  
     /**
      * 任务调度的入口
      */
     public void run() {
         // 第1步
         final String taskKey =getTaskKey();
         if(StringUtils.isEmpty(taskKey)){
             throw new RuntimeException("The taskKey is null or empty");
         }
         logger.info(String.format("The task %s start",taskKey));
         //如果只让1台机器执行,需要判断是否有锁
         boolean canDoTask = true;
         if(mustOneMarchine()){
             Long timeout = lockTimeoutMiliSeconds() ;
             canDoTask=taskCheckService.startTask(taskKey,timeout );
         }
         
         if (!canDoTask) {
             logger.info(String.format("The task %s does not get lock,exit.", taskKey));
             return;
         }
         try {
             // 第2步 查询任务列表
             List<T> taskList = findTaskList();
             // 第3步 执行任务,检查是否为空,是否需要多线程
             if (CollectionUtils.isNotEmpty(taskList)) {
                 logger.info(String.format("taskKey=%s,taskSize=%s",taskKey,taskList.size()));
                 // 逐个执行
                 for (final T task : taskList) {
                     SchedulingTaskExecutor executor = getExecutor();
                     if(executor == null){
                         logger.info(String.format("Do task in the main thread,taskKey=%s", taskKey));
                         doOneTask(task);
                     }else{
                         logger.info(String.format("Do task by thread pool,taskKey=%s", taskKey));
                         executor.execute(new Runnable(){
  
                             @Override
                             public void run() {
                                 logger.info(String.format("Do task by thread pool start,taskKey=%s", taskKey));
                                 doOneTask(task);
                                 logger.info(String.format("Do task by thread pool end,taskKey=%s", taskKey));
                             }
                             
                         });
                     
                     }
                     
                 }
             }else{
                 logger.info(String.format("The task %s size is 0,exit",taskKey));
             }
         } catch (Exception e) {
             logger.error(e);
         } finally {
             // 第4步
             taskCheckService.endTask(taskKey);
         }
         logger.info(String.format("The task %s end",taskKey));
     }
     
     /**
      * 查询任务列表
      * @return 任务列表
      */
     protected abstract List<T> findTaskList();
     /**
      * 执行1个任务
      * @param task 将要执行的任务
      */
     protected abstract void doOneTask(T task);
     /**
      * 任务的key,最好是唯一的
      * @return 任务的key
      */
     protected abstract String getTaskKey();
     /**
      * 默认不需要多线程,如果需要多线程,重载此方法,返回SchedulingTaskExecutor的1个实例,比如org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
      * @return 线程池
      */
     protected  SchedulingTaskExecutor getExecutor(){
         return null;
     }
     
     /**
      * 默认只让1台机器执行,但是数据库task_config表必须先插入1条key-value记录
      * @return 是否只让1台机器执行
      */
     protected boolean mustOneMarchine(){
         return true;
     }
     
     /**
      * 定时任务占居锁的最长时间
      * @return
      */
     protected Long lockTimeoutMiliSeconds(){
         return TaskCheckService.DEFAULT_TIMEOUT_MILISECONDS;
     }
 }

4、具体的任务BlacklistTimeoutTask
继承父类CronTask,重写3个方法。
findTaskList:查找任务列表
doOneTask:处理具体的1个Task
getTaskKey:这个任务的唯一标记

如果想让多个任务同时执行,重写getExecutor方法。

public class BlacklistTimeoutTask extends CronTask<Blacklist> {
     private Logger logger = Logger.getLogger(getClass());    @Resource
     private BlacklistService blacklistService;    @Resource
     private ThreadPoolTaskExecutor coreTaskExecutor;    @Override
     protected List<Blacklist> findTaskList() {
         return blacklistService.listAllTimeout();
     }    @Override
     protected void doOneTask(Blacklist blacklist) {
         Integer id = blacklist.getId();
         try {
             blacklistService.remove(id);
         } catch (Exception e) {
             logger.error(e);
         }    }
    @Override
     protected String getTaskKey() {
         return TaskKeyConsts.task_blacklist_timeout_running;
     }    @Override
     protected SchedulingTaskExecutor getExecutor() {
         return coreTaskExecutor;
     }
 }

5、Spring中多线程配置
spring-info-threadpool.xml

<!-- spring线程池,执行关键任务 -->
     <bean id="coreTaskExecutor"
         class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
         <!-- 线程池维护线程的最少数量 -->
         <property name="corePoolSize" value="5" />
         <!-- 线程池维护线程的最大数量 -->
         <property name="maxPoolSize" value="20" />
         <!-- 缓存队列 -->
         <property name="queueCapacity" value="1000" />
         <!-- 允许的空闲时间 -->
         <property name="keepAliveSeconds" value="1800" />
         <!-- 对拒绝task的处理策略 -->
         <property name="rejectedExecutionHandler">
             <bean class="java.util.concurrent.ThreadPoolExecutor$CallerRunsPolicy" />
         </property>
     </bean>

6、Spring中异步线程和注解@Async
之前配置@Async没有生效:少了xml配置或内部方法调用。
因此,这个注解配置异步线程很鸡肋,还不如自己配置多线程bean,注入到项目中,手动启动新线程来执行。

<bean id="asyncTaskExecutor" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
         <!-- 核心线程数  -->  
         <property name="corePoolSize" value="10" />  
         <!-- 最大线程数 -->  
         <property name="maxPoolSize" value="50" />  
         <!-- 队列最大长度 >=mainExecutor.maxSize -->  
         <property name="queueCapacity" value="10000" />  
         <!-- 线程池维护线程所允许的空闲时间 -->  
         <property name="keepAliveSeconds" value="300" />  
         <!-- 线程池对拒绝任务(无线程可用)的处理策略 -->  
         <property name="rejectedExecutionHandler">  
           <bean class="java.util.concurrent.ThreadPoolExecutor$CallerRunsPolicy" />  
         </property>  
     </bean>
       <!-- 基于类生成代理类,共用线程池,扫描Async注解 -->
     <task:annotation-driven proxy-target-class="true" executor="asyncTaskExecutor"></task:annotation-driven>

7、同一时刻只让一台机器执行,尽可能避免并发和并行
目前做过的项目,数据量都不算大,1台机器10分钟就可以把所有该做的任务处理完成。
另外,线上机器部署2台服务,2台机器时间总体一致,同一时刻1个定时任务会执行2次,这样必须保证2台机器处理的数据是不同的。如果2个任务处理了同样的数据,只能有1个成功,意味着会浪费1半的执行。
因此,个人习惯于使用悲观锁,让1个任务执行处理就可以了。任务处理完成,标记相关任务标志成功,下一次任务就不会再查询到了。
另外,就算是1个任务执行,也还是可能出现并发的情况。
a.任务中使用多线程。大部分情况不需要,但有的时候,出于政治因素,非要使用多线程。
b.定时处理1个任务数据的时候,可能有其它异步任务也再修改这个数据。
比如,京东内部消息服务JMQ。
c.前台用户或后台用户,恰好也修改了这个任务相关的数据。
因此,要么使用悲观锁,修改之前先抢占锁。要么悲观锁,让某一个修改失败。

具体到2个任务,只让其中1个执行,采用的是“悲观锁”方式实现。
每个任务在数据库有1条任务记录,执行的时候“事务+select for update”判断是否能执行。
如代码所示,封装了startTask和endTask方法。

import java.util.Date;
  
 import javax.annotation.Resource;
  
 import org.apache.commons.lang3.StringUtils;
 import org.apache.log4j.Logger;
 import org.springframework.transaction.annotation.Transactional;
  
 import com.jd.cav.dao.ConfigMapper;
 import com.jd.cav.domain.Config;
 public class TaskCheckService {
     /**
      * 默认超时时间为50分钟,定时任务通常1个小时1次就足够了
      */
     public static final Long DEFAULT_TIMEOUT_MILISECONDS = 1 * 50 * 60 * 1000L;
     private static Logger logger = Logger.getLogger(TaskCheckService.class);
  
     static final String TASK_RUNNING_NO = "0";
     static final String TASK_RUNNING_YES = "1";
     @Resource
     private ConfigMapper configMapper;
     
     // 事务+forUpdate
     @Transactional
     // 可以执行任务,就返回config配置
     public boolean startTask(String key,Long timeout) {
         logger.info(String.format("Try to get taskLock,key=%s,start",key));
         // 简单锁1行,防止多个任务同时执行
         Config config = configMapper.getForUpdate(key);
         if (config == null) {
             logger.error("The task config is null,key=" + key);
             return false;
         }
         boolean canDoTask = canDoTask(key, config,timeout);
         if (canDoTask) {
             // 开始执行任务,标记为1
             config.setValue(TaskCheckService.TASK_RUNNING_YES);
             config.setTime(new Date());
             configMapper.update(config);
         }
         logger.info(String.format("Try to get taskLock,key=%s,end",key));
         return canDoTask;
     }
  
     @Transactional
     public void endTask(String key) {
         logger.info(String.format("Try to reback taskLock,key=%s,start",key));
         Config config = configMapper.getForUpdate(key);
         // 任务结束,标记为0
         config.setValue(TaskCheckService.TASK_RUNNING_NO);
         config.setTime(new Date());
         configMapper.update(config);
         logger.info(String.format("Try to reback taskLock,key=%s,end",key));
     }
     
     private static boolean canDoTask(String key, Config config,Long timeout) {
         if(timeout == null){
             timeout = DEFAULT_TIMEOUT_MILISECONDS;
         }
         // 有任务正在执行
         if (StringUtils.equals(config.getValue(), TaskCheckService.TASK_RUNNING_NO)) {
             return true;
         } else if (StringUtils.equals(config.getValue(), TaskCheckService.TASK_RUNNING_YES)) {
             // 超过1小时,产生了“死锁”,正常执行任务
             Date lastUpdateTime = config.getTime();
             Date nowTime = new Date();
             long timeResult = nowTime.getTime() - lastUpdateTime.getTime();
             boolean isTimeout = timeResult > timeout;
             if (isTimeout) {
                 logger
                         .error("Has a task is running timeout,exit,task key=" + key);
                 return true;
             } else {
                 return false;
             }
         }
         return true;
     }
 }

任务配置数据库表

CREATE TABLE `task_config` (
   `name` varchar(50) NOT NULL DEFAULT '' COMMENT '参数的名字',
   `value` varchar(50) NOT NULL DEFAULT '0' COMMENT '参数的值',
   `time` datetime DEFAULT NULL COMMENT '上次更新时间',
   `yn` int(11) NOT NULL DEFAULT '1' COMMENT '是否有效',
   PRIMARY KEY (`name`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='系统参数配置和运行状态';

另外,为了防止“死锁”,根据上次更新时间time和现在时间比较。如果value=1表示在执行,但是已经执行了50分钟以上,仍然判定任务可以执行。

小雷FansUnion-京东程序员一枚
2017年10月
北京-亦庄
--------------------- 
作者:小雷FansUnion