【技术选型】定时任务

参考:

Java定时任务技术趋势:https://developer.aliyun.com/article/882393

https://www.biaodianfu.com/timingwheel.html

https://blog.csdn.net/yangbindxj/article/details/123295341

 

定时任务场景:每分钟扫描超时支付的订单,每小时清理一次数据库历史数据,每天统计前一天的数据并生成报表等等。

实现方案:

一、Java原生能力

方案1:java.util.Timer 

    问题:

  • Timer 是单线程模式。如果某个 TimerTask 执行时间很久,会影响其他任务的调度。
  • Timer 的任务调度是基于系统绝对时间的,如果系统时间不正确,可能会出现问题。
  • TimerTask 如果执行出现异常,Timer 并不会捕获,会导致线程终止,其他任务永远不会执行。

也就是说,对于同一个Timer里的多个TimerTask任务,如果一个TimerTask任务在执行中,其它TimerTask即使到达执行的时间,也只能排队等待。如果有异常产生,线程将退出,整个定时任务就失败      

import java.util.Timer;
import java.util.TimerTask;
public class TestTimerTask {   
    
    public static void main(String[] args) {
        TimerTask timerTask = new TimerTask() {
            @Override
            public void run() {
                System.out.println("hell world");
            }
        };
        Timer timer = new Timer();
        timer.schedule(timerTask, 10, 3000);
    }  
    
}
View Code

Timer内部实现

public class Timer {
    private final TaskQueue queue = new TaskQueue();
    private final TimerThread thread = new TimerThread(queue);
    public Timer(String name) {
        thread.setName(name);
        thread.start();
    }
}

TaskQueue 是由数组结构实现的小根堆(内部是TimerTask数组),新的TimerTask被加入到该Queue中;deadline 最近的任务位于堆顶端,queue[1] 始终是最优先被执行的任务。所以使用小根堆的数据结构,Run 操作时间复杂度 O(1),新增 Schedule 和取消 Cancel 操作的时间复杂度都是 O(logn)。

Timer 启动了一个 TimerThread 异步线程。TimerThread 会定时轮询 TaskQueue 中的任务,如果堆顶的任务的 deadline 已到,那么执行任务;如果是周期性任务,执行完成后重新计算下一次任务的 deadline,并再次放入小根堆;如果是单次执行的任务,执行结束后会从 TaskQueue 中删除。

 

方案2:DelayQueue

DelayedQueue 是 JDK 中一种可以延迟获取对象的阻塞队列,其内部是采用优先级队列 PriorityQueue 存储对象。DelayQueue 中的每个对象都必须实现 Delayed 接口,并重写 compareTo 和 getDelay 方法。DelayedQueue 的使用方法如下:

public class DelayQueueTest {
    public static void main(String[] args) throws Exception {
        BlockingQueue<SampleTask> delayQueue = new DelayQueue<>();
        long now = System.currentTimeMillis();
        delayQueue.put(new SampleTask(now + 1000));
        delayQueue.put(new SampleTask(now + 2000));
        delayQueue.put(new SampleTask(now + 3000));
        for (int i = 0; i < 3; i++) {
            System.out.println(new Date(delayQueue.take().getTime()));
        }
    }

    static class SampleTask implements Delayed {
        long time;
        public SampleTask(long time) {
            this.time = time;
        }

        public long getTime() {
            return time;
        }

        @Override
        public int compareTo(Delayed o) {
            return Long.compare(this.getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS));
        }

        @Override
        public long getDelay(TimeUnit unit) {
            return unit.convert(time - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }
    }
}

DelayQueue 提供了 put() 和 take() 的阻塞方法,可以向队列中添加对象和取出对象。对象被添加到 DelayQueue 后,会根据 compareTo() 方法进行优先级排序。getDelay() 方法用于计算消息延迟的剩余时间,只有 getDelay <=0 时,该对象才能从 DelayQueue 中取出。

DelayQueue 在日常开发中最常用的场景就是实现重试机制。例如,接口调用失败或者请求超时后,可以将当前请求对象放入 DelayQueue,通过一个异步线程 take() 取出对象然后继续进行重试。如果还是请求失败,继续放回 DelayQueue。为了限制重试的频率,可以设置重试的最大次数以及采用指数退避算法设置对象的 deadline,如 2s、4s、8s、16s ……以此类推。

相比于 Timer,DelayQueue 只实现了任务管理的功能,需要与异步线程配合使用。DelayQueue 使用优先级队列实现任务的优先级排序,新增 Schedule 和取消 Cancel 操作的时间复杂度也是 O(logn)。

 

方案3:ScheduledExecutorService

解决Timer定时器无法并发执行的问题,支持fixedRate和fixedDelay。

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class TestTimerTask {
    public static void main(String[] args) {
        ScheduledExecutorService ses = Executors.newScheduledThreadPool(5);
        //按照固定频率执行,每隔5秒跑一次
        ses.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello fixedRate");
            }
        }, 0, 5, TimeUnit.SECONDS);
        
        //按照固定延时执行,上次执行完后隔3秒再跑
        ses.scheduleWithFixedDelay(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello fixedDelay");
            }
        }, 0, 3, TimeUnit.SECONDS);
    }
    
}
View Code

ScheduledThreadPoolExecutor 继承于 ThreadPoolExecutor,因此它具备线程池异步处理任务的能力。线程池主要负责管理创建和管理线程,并从自身的阻塞队列中不断获取任务执行。

线程池有两个重要的角色,分别是任务和阻塞队列。ScheduledThreadPoolExecutor 在 ThreadPoolExecutor 的基础上,重新设计了任务 ScheduledFutureTask 和阻塞队列 DelayedWorkQueue。

  • ScheduledFutureTask 继承于 FutureTask,并重写了 run() 方法,使其具备周期执行任务的能力。
  • DelayedWorkQueue 内部是优先级队列,deadline 最近的任务在队列头部。对于周期执行的任务,在执行完会重新设置时间,并再次放入队列中。

时间轮

JDK 三种实现定时器的方式。可以说它们的实现思路非常类似,都离不开任务、任务管理、任务调度三个角色。三种定时器新增和取消任务的时间复杂度都是 O(nlog(n)),面对海量任务插入和删除的场景,这三种定时器都会遇到比较严重的性能瓶颈。因此,对于性能要求较高的场景,我们一般都会采用时间轮算法。

如果一个系统中存在着大量的调度任务,而大量的调度任务如果每一个都使用自己的调度器来管理任务的生命周期的话,浪费cpu的资源并且很低效。时间轮是一种高效来利用线程资源来进行批量化调度的一种调度模型。把大批量的调度任务全部都绑定到同一个的调度器上面,使用这一个调度器来进行所有任务的管理(manager),触发(trigger)以及运行(runnable)。能够高效的管理各种延时任务,周期任务,通知任务等等。

时间轮算法的核心是:轮询线程不再负责遍历所有任务,而是仅仅遍历时间刻度。时间轮算法好比指针不断在时钟上旋转、遍历,如果一个发现某一时刻上有任务(任务队列),那么就会将任务队列上的所有任务都执行一遍。

现在,即使有 10k 个任务,轮询线程也不必每轮遍历 10 k 个任务,而仅仅需要遍历 24 个时间刻度。

一个以小时为单位的时间轮算法就这么简单地实现了。不过,小时作为时间单位粒度太大,我们有时候会希望基于分钟作为时间刻度。最直接的方式是增加时间刻度,每一天有 24 * 60 = 1440。

时间轮在 Netty、Akka、Quartz、ZooKeeper 、Kafka等组件中都存在

通过增加时间刻度,我们可以基于更精细的时间单位(分钟)来进行定时任务的执行。但是,这种实现方式有如下的缺陷:

  • 轮询线程遍历效率低问题:当时间刻度增多,而任务数较少时,轮询线程的遍历效率会下降,例如如果只有 50 个时间刻度上有任务,但却需要遍历 1440 个时间刻度。这违背了我们提出时间轮算法的初衷:解决遍历轮询线程遍历效率低的问题;
  • 浪费内存空间问题:在时间刻度密集,任务数少的情况下,大部分时间刻度所占用的内存空间是没有任何意义的。

Kafka有很多延时操作:比如对于耗时的网络请求(比如Produce时等待ISR副本复制成功)会被封装成DelayOperation进行延迟处理操作,防止阻塞Kafka请求处理线程。---基于时间轮实现了延时操作

 

分层时间轮算法

分层的时间轮算法在生活中有对应的模型,那就是水表:

假设我们的任务需要在每天的 7:30:20 秒执行一次。任务首先添加于秒级别时钟轮的第 20 号刻度上,当其轮询线程访问到第 20 号刻度时,就将此任务转移到分钟级别时钟轮的第 30 号刻度上。当分钟级别的时钟轮线程访问到第 30 号刻度,就将此任务转移到小时级别时钟轮的第 7 号刻度上。当小时级别时钟轮线程访问到第 7 号刻度时,最终会将任务交给异步线程负责执行,然后将任务再次注册到秒级别的时间轮中。

分层时间轮中的任务从一个时间轮转移到另一个时间轮,这类似于水表中小单位的表转弯一圈会导致高单位的表前进一个单位一样。


 

二、Spring

 提供了一套轻量级的定时任务工具Spring Task,通过注解可以很方便的配置,支持cron表达式、fixedRate、fixedDelay

   @Scheduled 支持 cron表达式、fixedRate、fixedDelay 

import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@EnableScheduling
public class MyTask {
    
    /**
     * 每分钟的第30秒跑一次
     */
    @Scheduled(cron = "30 * * * * ?")
    public void task1() throws InterruptedException {
        System.out.println("hello cron");
    }
    /**
     * 每隔5秒跑一次
     */
    @Scheduled(fixedRate = 5000)
    public void task2() throws InterruptedException {
        System.out.println("hello fixedRate");
    }
    /**
     * 上次跑完隔3秒再跑
     */
    @Scheduled(fixedDelay = 3000)
    public void task3() throws InterruptedException {
        System.out.println("hello fixedDelay");
    }
}
View Code

 

三、中间件    

 

1、ElasticJob

      一款基于Quartz开发,依赖Zookeeper作为注册中心、轻量级、无中心化的分布式任务调度框架,目前已经通过Apache开源。

  关于分片,失效转移:https://shardingsphere.apache.org/elasticjob/current/cn/features/elastic/

      ElasticJob相对于Quartz来说,从功能上最大的区别就是支持分片,可以将一个任务分片参数分发给不同的机器执行架构上最大的区别就是使用Zookeeper作为注册中心,不同的任务分配给不同的节点调度,不需要抢锁触发,性能上比Quartz上强大很多,架构如下:

     

  springboot配置文件

elasticjob:
  regCenter:
    serverLists: localhost:2181
    namespace: elasticjob-lite-springboot
  jobs:
    simpleJob:
      elasticJobClass: org.apache.shardingsphere.elasticjob.lite.example.job.SpringBootSimpleJob
      cron: 0/5 * * * * ?
      timeZone: GMT+08:00
      shardingTotalCount: 3
      shardingItemParameters: 0=Beijing,1=Shanghai,2=Guangzhou
    scriptJob:
      elasticJobType: SCRIPT
      cron: 0/10 * * * * ?
      shardingTotalCount: 3
      props:
        script.command.line: "echo SCRIPT Job: "
    manualScriptJob:
      elasticJobType: SCRIPT
      jobBootstrapBeanName: manualScriptJobBean
      shardingTotalCount: 9
      props:
        script.command.line: "echo Manual SCRIPT Job: "
View Code

  job实现:

@Component
public class SpringBootShardingJob implements SimpleJob {
    @Override
    public void execute(ShardingContext context) {
        System.out.println("分片总数="+context.getShardingTotalCount() + ", 分片号="+context.getShardingItem()
            + ", 分片参数="+context.getShardingParameter());
    }
}
View Code

  日志打印:

分片总数=3, 分片号=0, 分片参数=Beijing
分片总数=3, 分片号=1, 分片参数=Shanghai
分片总数=3, 分片号=2, 分片参数=Guangzhou

  ElasticJob还提供了一个简单的UI,可以查看任务的列表,同时支持修改、触发、停止、生效、失效操作

2、XXL-JOB

  是一个开箱即用的轻量级分布式任务调度系统,其核心设计目标是开发迅速、学习简单、轻量级、易扩展,在开源社区广泛流行。

  XXL-JOB是Master-Slave架构,Master负责任务的调度,Slave负责任务的执行,架构图如下

  

     XXL-JOB接入也很方便,不同于ElasticJob定义任务实现类,是通过@XxlJob 注解定义JobHandler

@Component
public class SampleXxlJob {
    private static Logger logger = LoggerFactory.getLogger(SampleXxlJob.class);
    /**
     * 1、简单任务示例(Bean模式)
     */
    @XxlJob("demoJobHandler")
    public ReturnT<String> demoJobHandler(String param) throws Exception {
        XxlJobLogger.log("XXL-JOB, Hello World.");
        for (int i = 0; i < 5; i++) {
            XxlJobLogger.log("beat at:" + i);
            TimeUnit.SECONDS.sleep(2);
        }
        return ReturnT.SUCCESS;
    }
    /**
     * 2、分片广播任务
     */
    @XxlJob("shardingJobHandler")
    public ReturnT<String> shardingJobHandler(String param) throws Exception {
        // 分片参数
        ShardingUtil.ShardingVO shardingVO = ShardingUtil.getShardingVo();
        XxlJobLogger.log("分片参数:当前分片序号 = {}, 总分片数 = {}", shardingVO.getIndex(), shardingVO.getTotal());
        // 业务逻辑
        for (int i = 0; i < shardingVO.getTotal(); i++) {
            if (i == shardingVO.getIndex()) {
                XxlJobLogger.log("第 {} 片, 命中分片开始处理", i);
            } else {
                XxlJobLogger.log("第 {} 片, 忽略", i);
            }
        }
        return ReturnT.SUCCESS;
    }
}
View Code

  XXL-JOB相较于ElasticJob,最大的特点就是功能比较丰富,可运维能力比较强,不但支持控制台动态创建任务,还有调度日志、运行报表等功能。

  

   XXL-JOB所有功能都依赖数据库,且调度中心不支持分布式架构,在任务量和调度量比较大的情况下,会有性能瓶颈。不过如果对任务量级、高可用、监控报警、可视化等没有过高要求的话,XXL-JOB基本可以满足定时任务的需求。


posted @ 2022-04-17 22:46  飞翔在天  阅读(166)  评论(0编辑  收藏  举报