java中执行定时任务的6种姿势

12,409 阅读13分钟

定时任务的场景

所谓定时任务实际上有两种情况, 一种是在某个特定的时间点触发执行某个任务, 例如每天凌晨, 每周六下午2点等等. 另外一种是以特定的间隔或频率触发某个任务,例如每小时触发一次等.

在我们实际的工作中,用到定时任务的场景是非常的多的,例如:

  1. 每天统计某些业务数据, 做报表展示
  2. 用户下单后, 30分钟未支付则取消订单
  3. 特定的时间点给用户发送消息(祝福短信等)
  4. 补偿机制, 定期扫描数据库和日志,对比差异的数据并进行补偿
  5. 等等...

正是因为应用场景非常的广, 所以前辈程序员们也是绞尽脑汁, 为我们创造了很多实用用的工具和框架, 我们今天才得以站在巨人的肩膀上,看得更远,走得更快.

下面我们就来列举一下这些方法和工具.

crontab

crontab严格来说并不是属于java内的. 它是linux自带的一个工具, 可以周期性地执行某个shell脚本或命令.

但是由于crontab在实际开发中应用比较多, 而且crontab表达式跟我们后面介绍的其他定时任务框架的cron表达式是类似的, 所以这里还是最先介绍crontab

crontab的用法是:

crontabExpression command

首先, command可以是一个linux命令(例如echo 123), 或一个shell脚本(例如 test.sh), 也可以是两者结合(例如: cd /tmp; sh test.sh)

crontabExpression大概是长下面这样子

# 每小时的第5分钟执行一次命令
5 * * * * Command 
# 指定每天下午的 6:30 执行一次命令
30 18 * * * Command 
# 指定每月8号的7:30分执行一次命令
30 7 8 * * Command
# 指定每年的6月8日5:30执行一次命令
30 5 8 6 * Command 
# 指定每星期日的6:30执行一次命令
30 6 * * 0 Command 

其中crontabExpression一共有5列, 含义如下:

  1. 第一列表示是分钟, 取值为0-59
  2. 第二列表示是时, 取值为0-59
  3. 第三列表示是日
  4. 第四列表示是月, 取值是0-12
  5. 第5列表示是星期

此外, 每列还可以是* ? -等等特殊字符, 具体含义可以参考这篇文章, 里面总结得比较好, 我这里就不再多说了

timer

即jdk里面提供的java.util.Timer和java.util.TimerTask两个类.

其中TimerTask表示具体的任务,而Timer调度任务.

简单的例子如下:

import java.util.Timer;
import java.util.TimerTask;

public class TimerTest extends TimerTask {

    private String jobName = "";

    public TimerTest(String jobName) {
        super();
        this.jobName = jobName;
    }

    @Override
    public void run() {
        System.out.println("execute " + jobName);
    }

    public static void main(String[] args) {
        Timer timer = new Timer();
        long delay1 = 1 * 1000;
        long period1 = 1000;
        // 从现在开始 1 秒钟之后,每隔 1 秒钟执行一次 job1
        timer.schedule(new TimerTest("job1"), delay1, period1);
        long delay2 = 2 * 1000;
        long period2 = 2000;
        // 从现在开始 2 秒钟之后,每隔 2 秒钟执行一次 job2
        timer.schedule(new TimerTest("job2"), delay2, period2);
    }
}

当然在生产环境中Timer是不建议使用了的. 它在多线程的环境下, 会存在一定的问题:

1. 当一个线程抛出异常时,整个timer都会停止运行.例如上面的job1抛出异常的话, 
job2也不会再跑了.
2. 当一个线程里面处理的时间非常长的话, 会影响其他job的调度. 
例如, 如果job1处理的时间要60秒的话, 那么job2就变成了60秒跑一次了.

基于上面的原因, timer现在一般都不会再使用了.

ScheduledExecutorService

ScheduledExecutorService 就是JDK里面自定义的几种线程池中的一种.

从API上看, 感觉它就是用来替代Timer的,而且完全可以替代的. 只是不知道为何Timer还是没有被标记为过期, 想必是还有一些应用的场景吧

首先, Timer能做到的事情ScheduledExecutorService都能做到;

其次, ScheduledExecutorService可以完美的解决上面所说的Timer存在的两个问题:

1. 抛异常时, 即使异常没有被捕获, 线程池也还会新建线程, 所以定时任务不会停止

2. 由于ScheduledExecutorService是不同线程处理不同的任务, 因此,不管一个线程的运行时间有多长, 都不会影响到另外一个线程的运行.

当然, ScheduledExecutorService也不是万能的. 例如如果我想实现"在每周六下午2点"执行某行代码这个需求时, ScheduledExecutorService实现起来就有点麻烦了.

ScheduledExecutorService更适合调度这些简单的以特定频率执行的任务.其他的, 就要轮到我们大名鼎鼎的quartz上场了.

quartz

在java的世界里, quartz绝对是总统山级别的王者的存在. 市面上大多数的开源的调度框架也基本都是直接或间接基于这个框架来开发的.

先来看通过一个最简单的quartz的例子, 来简单地认识一下它.

使用cron表达式来让quartz每10秒钟执行一个任务:

先引入maven依赖:

        <!-- https://mvnrepository.com/artifact/org.quartz-scheduler/quartz -->
        <dependency>
            <groupId>org.quartz-scheduler</groupId>
            <artifactId>quartz</artifactId>
            <version>2.3.2</version>
        </dependency>

编写代码:

import com.alibaba.fastjson.JSON;
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

import static org.quartz.CronScheduleBuilder.cronSchedule;
import static org.quartz.JobBuilder.newJob;
import static org.quartz.TriggerBuilder.newTrigger;

public class QuartzTest implements Job {

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        System.out.println("这里是你的定时任务: " + JSON.toJSONString( jobExecutionContext.getJobDetail()));
    }


    public static void main(String[] args) {
        try {
            // 获取到一个StdScheduler, StdScheduler其实是QuartzScheduler的一个代理
            Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();

            // 启动Scheduler
            scheduler.start();
            // 新建一个Job, 指定执行类是QuartzTest(需实现Job), 指定一个K/V类型的数据, 指定job的name和group
            JobDetail job = newJob(QuartzTest.class)
                    .usingJobData("jobData", "test")
                    .withIdentity("myJob", "group1")
                    .build();
            // 新建一个Trigger, 表示JobDetail的调度计划, 这里的cron表达式是 每10秒执行一次
            Trigger trigger = newTrigger()
                    .withIdentity("myTrigger", "group1")
                    .startNow()
                    .withSchedule(cronSchedule("0/10 * * * * ?"))
                    .build();


            // 让scheduler开始调度这个job, 按trigger指定的计划
            scheduler.scheduleJob(job, trigger);


            // 保持进程不被销毁
           //  scheduler.shutdown();
            Thread.sleep(10000000);

        } catch (SchedulerException se) {
            se.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

上面这个简单的例子已经包含了quartz的几个核心组件:

Scheduler - 可以理解为是一个调度的实例,用来调度任务
Job - 这个是一个接口, 表示调度要执行的任务. 类似TimerTask.
JobDetail - 用于定义作业的实例。进一步封装和拓展Job的具体实例
Trigger(即触发器) - 定义JobDetail的调度计划。例如多久执行一次, 什么时候执行, 以什么频率执行等等
JobBuilder - 用于定义/构建JobDetail实例。
TriggerBuilder - 用于定义/构建触发器实例。
1. Scheduler

Scheduler是一个接口, 它一共有4个实现:

JBoss4RMIRemoteMBeanScheduler
RemoteMBeanScheduler
RemoteScheduler
StdScheduler

我们上面的例子使用的是StdScheduler, 表示的直接在本地进行调度(其他的都带有remote字样, 明显是跟远程调用有关).

来看一下StdScheduler的注释和构造方法

/**
 * <p>
 * An implementation of the <code>Scheduler</code> interface that directly
 * proxies all method calls to the equivalent call on a given <code>QuartzScheduler</code>
 * instance.
 * </p>
 * 
 * @see org.quartz.Scheduler
 * @see org.quartz.core.QuartzScheduler
 *
 * @author James House
 */
public class StdScheduler implements Scheduler {

    /**
     * <p>
     * Construct a <code>StdScheduler</code> instance to proxy the given
     * <code>QuartzScheduler</code> instance, and with the given <code>SchedulingContext</code>.
     * </p>
     */
    public StdScheduler(QuartzScheduler sched) {
        this.sched = sched;
    }
}

原来StdScheduler只不过是一个代理而已, 它最终都是调用org.quartz.core.QuartzScheduler类的方法.

查看RemoteScheduler等另外三个的实现, 也都是代理QuartzScheduler而已.

所以很明显, quartz的核心是QuartzScheduler类.

所以来看一下QuartzScheduler的javadoc注释:

/**
 * <p>
 * This is the heart of Quartz, an indirect implementation of the <code>{@link org.quartz.Scheduler}</code>
 * interface, containing methods to schedule <code>{@link org.quartz.Job}</code>s,
 * register <code>{@link org.quartz.JobListener}</code> instances, etc.
 * </p>
 * 
 * @see org.quartz.Scheduler
 * @see org.quartz.core.QuartzSchedulerThread
 * @see org.quartz.spi.JobStore
 * @see org.quartz.spi.ThreadPool
 * 
 * @author James House
 */
public class QuartzScheduler implements RemotableQuartzScheduler {
	...
}

大概意思就是说: QuartzScheduler是quartz的心脏, 间接实现了org.quartz.Scheduler接口, 包含了调度Job和注册JobListener的方法等等

说是间接实现说Scheduler接口,但是来看一下它的继承图, 你会发现它跟Scheduler接口没有半毛钱关系(果然够间接的), 完全是自己独立搞了一套, 基本所有调度相关的逻辑都在里面实现了

image

另外从这个继承图中的RemotableQuartzScheduler也可以看出, QuartzScheduler是天生就可以支持远程调度的(通过rmi远程触发调度, 调度的管理和调度的执行可以分离).

当然, 实际应用中也大多数都是这么用, 只是我们这个最简单的例子是本地触发调度,本地执行任务而已.

2. Job, JobDetail

Job是一个接口, 它只定义了一个execute方法, 代表任务执行的逻辑.

public interface Job {
    void execute(JobExecutionContext context)
        throws JobExecutionException;
}

JobDetail其实也是一个接口, 它的默认实现是JobDetailImpl.JobDetail内部指定了JobDetail的实现类, 另外还新增了一些参数:

1. name和group, 会组合成一个JobKey对象, 作为这个JobDetail的唯一标识ID
2. jobDataMap, 可以给Job传递一些额外参数
3. durability, 是否需要持久化.这就是quartz跟一般的Timer之流不一样的地方了. 他的job是可以持久化到数据库的

可以看的出来, JobDetail其实是对Job类的一种增强. Job用来表示任务的执行逻辑, 而JobDetail更多的是跟Job管理相关.

3. Trigger

Trigger接口可以说才是quartz的核心功能. 因为quartz是一个定时任务调度框架, 而定时任务的调度逻辑, 就是在Trigger中实现的.

来看一下Trigger的实现类, 乍一看还挺多. 但是实际就图中红圈圈出来的那几个是真正的实现类, 其他的都是接口或实现类:

image

而实际上, 我们用得最多的也只是SimpleTriggerImpl和CronTriggerImpl, 前者表示简单的调度逻辑,例如每1分钟执行一次. 后者可以使用cron表达式来 指定更复杂的调度逻辑.

很明显, 上面简单的例子我们用的是CronTriggerImp

不过需要注意的是, quartz的cron表达式和linux下crontab的cron表达式是有一定区别的, 它可以直接到秒级别:

1. Seconds
2. Minutes
3. Hours
4. Day-of-Month
5. Month
6. Day-of-Week
7. Year (optional field)

例如: "0 0 12?* WED" - 这意味着"每个星期三下午12:00"

使用CronTrigger的时候, 直接写cron表达式是比较容易出错的, 所以最好有个工具验证一下自己的cron表达式是否写正确, 以及验证触发的时间是否是我们期待的.

这个工作已经有人帮我们做好了, 例如下面这个网站:

tool.lu/crontab/

实际效果如下:

iamge

以上就算是quartz的一个入门教程了. 但是确实也只是一个入门教程而已.实际上quartz远比这个例子表现出来的复杂, 也同时也远比这个例子体现出来的强大.

例如:

1. quartz可以配置成集群模式,可以提供失败转移,负载均衡等功能, 在提升计算能力的同时,也提升了系统的可用性
2. quartz还支持JTA事务, 可以将一些job运行在一个事务中
3. 只要服务器资源上能支持, quartz理论上能运行成千上万的job
4. 等等等...

当然, quartz也不是没有缺点; 整个框架的重点都是在于"调度"上,而忽略了一些其他的方面, 例如交互和性能.

  1. 交互上, quartz只是提供了"scheduler.scheduleJob(job, trigger)" 这种api的方式. 没有提供任何的管理界面,这是非常的不人性化的.

  2. quartz并没有原生地支持分片的功能.这会导致运行一个大的任务时, 运行时间会非常的长. 例如要跑一亿个会员的数据时, 有可能一天都跑不完.如果是支持分片的那就好办很多了.可以把一亿会员拆分到多个实例上跑, 性能更高.

在这两点上, 一些其他的框架做得就更好了.

elastic-job 和 xxlJob

elastic-job和xxl-job是两个非常优秀的分布式任务调度框架, 在我使用过的所有分布调度框架中, 这两个框架起码能排前2位(因为我就用过这两个, 哈哈哈)

这两个框架各有各的特点, 其中共同点都有: 分布式, 轻量级, 交互人性化

elastic-job

elastic-job是当当基于quartz二次开发而开源的一个分布式框架, 功能十分强大. 但在我使用的经验来看, elastic-job最大的亮点有两个: 1是作业分片, 2是弹性扩容缩容

1. 作业分片就是上面所说的, 把一个大的任务拆分成多个子任务, 然后由多个作业节点去处理这些子任务, 以此缩短作业的时间.
2. 弹性扩容缩容其实是跟作业分片息息相关的, 简单的理解就是增加或减少一个作业节点, 都能保证每一个分片都有节点处理, 每个节点都有分片可处理.

更多elastic-job的知识和原理请参考官网, 相信我再怎么总结也没有官网总结得清晰和完善了.

image

xxl-job

xxl-job是被广泛使用的另外一款使用的分布式任务调度框架. 早起的xxljob也是基于quartz开发的, 不过现在慢慢去quartz化了, 改成自研的调度模块.

相对于elastic-job, 我更加喜欢使用xxl-job, 其优点如下:

1. 功能更强大. elastic-job支持的功能, xxl-job基本都支持. 本来我想截一下图的, 结果发现一屏根本截不过来. 大家还是去官网自己看一下吧.
2. 真正实现调度和执行分离, 相对而言, elastic-job的调度和执行其实糅杂在一起的,都是嵌入到业务系统中, 这一点我就不太喜欢了
3. xxl-job的管理后台更加丰富和灵活, 还有我最喜欢的一个点, 就是可以在控制台里面看到任务执行的日志.

同样, 由于官方的文档非常详细, 所以我这里再怎么介绍也比不过官网的. 所以更多的特性和原理, 大家可以移步官网

image

总结

本文一共从简单到复杂, 一共介绍了6种调度任务的处理的方案. 当然生产环境中一般都是建议使用elastic-job和xxl-job. 但是如果是简单的任务的话, 使用简单crontab等也不是不可, 我之前就经常使用crontab做业务相关的定时任务.

当然, 在数据量越来越大, 大数据技术发展得也越来越快的今天, 像Hadoop,Spark等生态中也出现了不少优秀的定时调度框架.但那就不在本文中的讨论范畴中了.