先说一下为什么要定时任务:
- 数据备份。
- 下单一定时间未支付则取消
- 钉钉定时发送日志
- 博客定时发送文章
- app定时推送消息
这些情况其实都是需要定时任务来完成的。但是其实java中定时任务的实现是有多种多样的,下面我们一一细说。
单机定时任务技术选型
Timer
这个是JDK1.3就开始支持的一种定时任务的实现方式。当然了反正我工作以来也没用过这个。其内部是使用一个叫做TaskQueue的类来存放定时任务。基于最小堆实现的优先级队列。按照任务下一次执行时间的前后将任务排序。保证堆顶的任务最先执行。这样需要执行任务的时候每次只要取出堆顶的任务运行即可。
使用起来也比较简单,如下的demo代码:
public static void main(String[] args) {
TimerTask timerTask1 = new TimerTask() {
@Override
public void run() {
try {
Thread.sleep(2000l);
}catch (Exception e){
}
System.out.println(Thread.currentThread().getName()+":"+DateUtil.getDate(new Date()));
}
};
TimerTask timerTask2 = new TimerTask() {
@Override
public void run() {
try {
Thread.sleep(2000l);
}catch (Exception e){
}
System.out.println(Thread.currentThread().getName()+":"+DateUtil.getDate(new Date()));
}
};
Timer timer = new Timer();
System.out.println(Thread.currentThread().getName()+":"+DateUtil.getDate(new Date()));
timer.schedule(timerTask1,1000l);
timer.schedule(timerTask2,1000l);
}
我先截图展示下上面代码的运行结果:
其实从运行结果上我们能看到一些东西:
- 所有的任务都是在一个线程中执行的。而且我明明两个任务应该同时执行,但是结果是串行执行的。这样任务多了肯定会影响性能
-
还有一点是任务如果异常停止,那么之后的所有任务都不会执行了。下面是代码。
不过在Timer的注解上有一段注释,提到了ScheduledThreadPoolExecutor 支持多线程执行定时任务并且功能更强大,可以替代Timer
ScheduledExecutorService
ScheduledExecutorService 是一个接口,有多个实现类,其中比较常用的就是上面Timer注释中推荐使用的ScheduledThreadPoolExecutor。
ScheduledThreadPoolExecutor本身就是一个线程池,支持并发执行任务。并且其内部是DelayQueue延迟队列 作为任务队列。感觉就是Timer的多线程版本,其本质上还是指定延迟的时间,而不是cron表达式,不太容易满足我们现在的需求的。所以这个怎么使用就不说了。
Spring Task
这个应该算是被spring包装的最无脑的一个工具了。启动类加个注解开启定时任务@EnableScheduling。 只要确定类被spring管理,然后在方法上加个@Scheduled注解指定时间。就可以在指定的时间运行此方法。
因为现在大多数项目本身就是spring项目,所以这里直接演示:
其实支持cron表达式相比于上面两个就算是史诗级进步了。但是目前springTask只支持单机,并且其错误重试机制也不是很好(比如该执行任务的那个时间服务重启,那么这次任务就不会执行).当然了Spring Task底层据说是基于ScheduledThreadPoolExecutor 线程池来实现的。
其优点是简单方便,支持cron表达式
缺点是功能单一,重试机制不好。
时间轮
kafka,dubbo,zookeeper,netty等都有对时间轮的实现。
简单来说就是一个环形的队列(底层基于数组实现),队列中每一个元素(时间格)都可以存放一个定时任务列表。
时间轮中的每个时间格都代表了时间轮的基本时间跨度或者说时间精度,假如时间一秒走一个时间格的话,那么这个时间轮的最高精度就是1s。
简单理解下:我们比如把时间轮的格子设置成60,时间精度是1s。如果接下来我们要新建一个3s后执行的定时任务。那么我们把定时任务放在下标为3的格子里就行。如果新建20s后的任务,那么把它放在下标是20的格子里。如果我们新建了一个100s后执行的任务。因为这个时间轮只有60个格子,所以要引入一个叫做圈数/轮数的概念。也就是说这个会放在下标是40的格子里。但是它的圈数是2.
当然了这种做法还是有很大的问题。如果时间精度很低,那么单圈的时间长度会很小。比如说我上面说一圈60s。那么表示几天,几个月那就圈数太多了。但是时间精度太大的话,又会导致一个格子里的任务过多。所以就有了升级款多层次时间轮。kafka就是采用这种方案的。
所谓的多层次时间轮有点类似钟表的设计。比如说时针表示小时,分针表示分钟,秒针表示秒。这就是三层的时间轮。
我们想判断一个时间点,比如我们想看八点二十六分二十一秒。会先去看时针,如果时针在8到9之间,那么会继续去看分针,是不是在5-6之间。如果满足这个条件我们才会取看秒针,是不是在4-5的位置。
二多层次时间轮也是类似的功能。只不过是指针只有一个,在多个层之间升降机。
比如说三个层级就按照钟表来设置精度和格子位(时是12个格子。分60,秒60)。我们现在想设置一个4小时2分钟21秒后执行的任务。
那么我们会把这个任务添加到第三层(精度是小时那一层) 的下标是4的位置。
当指针真的来到了这个位置,第三层的第4个格子的任务会被移动到第二层。然后到了第二层的第2个格子上,第二层第2个格子的任务继续移动到第一层。最终移动到第一层的第21个格子完成。
时间轮比较适合任务数量比较多的定时任务场景,它的任务写入和执行的时间复杂度都是O(1).
分布式定时任务技术选型
上面提到的一些定时任务的解决方案都是单机下执行的,适用于单体项目。如果我们需要一些高级特性,比如支持分布式场景下的分片和高可用的话,我们就需要用到分布式任务调度框架了。
通常情况下,一个定时任务的执行会有三个角色:
- 任务:要执行的具体任务。比如发日志
- 调度器:也叫调度中心,主要负责任务管理,会分配任务给执行器。
- 执行器:接收调度器分派的任务并执行。
Quartz
这个是一个很火的开源任务调度框架,完全由java写成。这个也是我接触的第一个任务调度框架。可以说是java定时任务领域的参考标准,其余的任务调度框架基本上都是基于quartz开发的。比如elastic-job就是基于quartz二次开发之后的分布式调度解决方案。
使用quartz可以很方便的与spring集成。并且支持动态添加任务和集群。但是quartz使用起来有点麻烦,api繁琐。
而且quartz为了防止系统宕机任务丢失等意外,是支持把任务维护到数据库中的。但是是通过数据库的锁机制做的,系统侵入性严重,节点负载不均衡,有点伪分布式的味道。
- 优点:可以与spring 集成,支持动态添加任务和集群。
- 缺点:分布式支持不友好,没有ui管理平台,相比于其它同类型的框架使用更麻烦。
Elastic-job
上面有说过这个,当当网基于Quartz和zookeeper的分布式调度解决方案。由两个相互独立的子项目Elastic-job-lite和Elastic-job-cloud组成。一般我们使用前者就好(这个其实我没在工作中用过)。
ElasticJob支持任务在分布式场景下的分片和高可用,任务可视化管理等功能。
从上图可以看出Elastic-Job没有调度中心的概念。是用zookeeper作为注册中心,负责协调分配任务到不同的节点上。
Elastic-Job中的定时调度都是由执行器自行触发,这种设计也被称为去中心化设计(调度和处理都是执行器单独完成)。
下面是使用demo:
@Component
@ElasticJobConf(name = "dayJob", cron = "0/10 * * * * ?", shardingTotalCount = 2,
shardingItemParameters = "0=AAAA,1=BBBB", description = "简单任务", failover = true)
public class TestJob implements SimpleJob {
@Override
public void execute(ShardingContext shardingContext) {
log.info("TestJob任务名:【{}】, 片数:【{}】, param=【{}】", shardingContext.getJobName(), shardingContext.getShardingTotalCount(),
shardingContext.getShardingParameter());
}
}
附上elastic-job的官方地址:https://shardingsphere.apache.org/elasticjob/index_zh.html
- 优点:与spring集成,支持分布式,集群,性能不错。
- 缺点:依赖zookeeper,复杂度增加,可靠性降低,维护成本高。
XXL-JOB
这个 是2015年开源的一款优秀的轻量级分布式任务调度框架。支持任务可视化管理,弹性扩容收缩,任务失败重试和告警,任务分片等功能。
从上图可以看出XXL-JOB由调度中心和执行器组成。调度中心负责任务管理,执行器管理和日志管理。执行器主要接收调度信号并处理。另外调度中心进行任务调度时,是通过自研RPC来实现的。
不同于Elastic-job的去中心化设计,XXL-JOB这种设计也称为 中心化设计。
XXL-JOB也是基于数据库锁调度任务,存在性能瓶颈。不过一般不是任务量特别大的情况也没什么影响,我上家公司就是使用XXL-JOB来实现定时任务的。
实际上我们使用XXL-JOB来实现定时任务很简单,只要继承IJobHandler就行了。
@JobHandler(value="myApiJobHandler")
@Component
public class MyApiJobHandler extends IJobHandler {
@Override
public ReturnT<String> execute(String param) throws Exception {
//......
return ReturnT.SUCCESS;
}
}
也可以直接基于注解定义任务:
@XxlJob("myAnnotationJobHandler")
public ReturnT<String> myAnnotationJobHandler(String param) throws Exception {
//......
return ReturnT.SUCCESS;
}
我个人觉得还是基于注解比较好,因为有多个任务的话,第一种方式要创建多个类。而注解的话只需要多个方法。注意这个job的名字要和在xxl-job的平台上设置的一样。
至于使用也是比较简单的,我直接附上官方地址(官方非常友好,是我觉得少有的特别喜欢的官方手册之一,其余比较喜欢的是mybatis plus的还有vue的):
https://www.xuxueli.com/xxl-job/
这个优点是开箱即用,与spring集成,支持分布式,集群,内置ui管理控制台。缺点暂时觉得没有,非要鸡蛋里挑骨头的话就是任务配置有问题会导致项目起不来?
PowerJob
这个是个新的分布式调度框架。据说是当时的作者在阿里巴巴实习,阿里使用的是自研的 SchedulerX(阿里云付费产品)。实习期满之后,作者离开了阿里,觉得游戏玩够了,所以打算扛起新一代分布式任务调度与计算框架的大旗。然后PowerJob就诞生了。
由于 SchedulerX 属于人民币产品,我这里就不过多介绍。PowerJob 官方也对比过其和 QuartZ、XXL-JOB 以及 SchedulerX。
因为这个PowerJob我也没用过,所以不介绍了。上面说到的除了PowerJob和Elastic_Job剩下的我都用过。从我用过的技术中总结起来如下:
单体的不介意因为系统原因导致任务步触发的,强烈推荐spring task.简单便捷直接上手,不用五分钟就能会用。
单体的但是很介意系统原因(执行任务的点宕机了)会不执行任务的,建议用持久化的定时任务框架,建议xxl-job.
分布式项目任务量不大直接xxl-job走起。任务量巨大可以选elastic-job。
单体的我就不说了,说下quartz,我还是好多年前用过,说实话比较繁琐,学习成本很大,不建议使用了。然后elastic-job,我之前为了写这篇文章也搭建demo了,我个人感觉是不如xxl-job用着方便。而且用到了zk。我是认为我们采用技术尽量选择直观和可控的。
最后上面介绍了xxl-job是基于数据库锁,所以有性能瓶颈,虽然我还没经历过定时任务量很大的场景,不过如果真有这种需求的话,可以选择别的分布式定时任务框架。
本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注。也祝大家工作顺顺利利,生活愉快!