从零搭建xxl-job(五):查询待执行任务逻辑优化

当前的程序还存在很多问题,比如每次扫描数据库都查询了所有的定时任务信息,那么应该查询哪些定时任务信息呢?怎么保证查询的定时任务准时触发?如果数据库中没有定时任务信息了,或者定时任务信息比较少了,scheduleThread线程仍然要无限循环吗?这些遗留的问题将在本章得到解决。

先代领大家思考第一个问题,还是请大家温习一下上节课的代码。请看下面的代码段。

public class JobScheduleHelper {

    // 调度定时任务的线程
    private Thread scheduleThread;

    // 创建当前类的对象
    private static JobScheduleHelper instance = new JobScheduleHelper();

    // 把当前类的对象暴露出去
    public static JobScheduleHelper getInstance(){
        return instance;
    }

    // 启动调度线程工作的方法
    public void start(){
        scheduleThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    // 从数据库中查询所有定时任务信息
                    List<YyJobInfo> yyJobInfoList =  YyJobAdminConfig.getAdminConfig().getYyJobInfoDao().findAll();
                    // 得到当前时间
                    long time = System.currentTimeMillis();
                    // 遍历所有定时任务信息
                    for (YyJobInfo yyJobInfo : yyJobInfoList) {
                        if (time > yyJobInfo.getTriggerNextTime()){
                            // 如果大于就执行定时任务,就调用下面这个方法,开始远程通知定时任务程序
                            // 执行定时任务
                            // 注意,这里引入了一个新的类,JobTriggerPoolHelper
                            JobTriggerPoolHelper.trigger(yyJobInfo);
                            // 计算定时任务下一次的执行时间
                            Date nextTime = null;
                            try {
                                 nextTime = new CronExpression(yyJobInfo.getScheduleConf()).getNextValidTimeAfter(new Date());
                            } catch (ParseException e) {
                                e.printStackTrace();
                            }
                            // 下面就是更新数据库中定时任务的操作
                            YyJobInfo job = new YyJobInfo();
                            job.setTriggerNextTime(nextTime.getTime());
                            System.out.println("保存job信息");
                        }
                    }
                }
            }
        });
        scheduleThread.start();
    }

在上面的代码段中,每次进入循环后,就会通过findAll()方法找出所有存储在数据库中的定时任务,然后一次判断每个定时任务是否可以执行了。当然,查询数据库中的方法都定义在YyJobInfoDao类中。请看下面的代码块。

@Mapper
public interface YyJobInfoDao {

    // 从数据库查询所有定时任务信息
    List<YyJobInfo> findAll();
    
    // 保存定时任务信息
    int save(YyJobInfo info);
    
}

经过上面的分析,现在我当前不希望每次都查询出数据库中的所有定时任务的信息了。那我具体该怎么办呢?其实只要查询某一个时间段的定时任务信息就可以了。当然,查询的时间短肯定是以当前时间为起点的。比如说,我要查询10秒内可以执行的定时任务,肯定是以当前时间为起点,查询当前时间10秒后,这一段时间内可以执行的任务信息。如果当前时间是5秒,查询10秒可以执行的,那查询的时间段肯定就是5-15秒。如果是这样,YyJobInfoDao就应该定一个新的方法了,就是根据执行时间来查询定时任务信息的方法,并且,由这个方法查询出来的定时任务都是可以执行的定时任务,这一点一定要梳理清楚。既然我们都已经是用时间来查询了,查询到的就是某个时间段内,所有可以执行的定时任务。请看下面的代码块。

@Mapper
public interface YyJobInfoDao {

    // 从数据库查询所有定时任务信息
    List<YyJobInfo> findAll();

    // 保存定时任务信息
    int save(YyJobInfo info);

    // 根据执行时间查询定时任务信息的方法,这里查询的依据就是
    // 定时任务下一次的执行时间。比如当前时间是0秒,要查询10秒以内的可以执行的定时任务
    // 那么就判断定时任务下一次的执行时间只要是小于10秒的,都返回给用户
    // 这些定时任务都是在10秒内可以执行的
    List<YyJobInfo> scheduleJobQuery(@Param("maxNextTime") long maxNextTime);

}

好了,现在方法定义好了,那么下一个问题就来了,我该查询当前时间多久之内的定时任务信息呢?我以当前时间为起点,查询一秒之内的?还是5秒之内的?还是10秒,甚至是一分钟之内的?这就成了目前最棘手的问题。请大家仔细思考思考,如果我一次从数据库中查询很少的任务,比如,我只查询当前时间到下1秒之内要执行的任务,这样每次查询出来的任务确实很少,并且时间的精度比较高,可以说是等任务真正该执行的时候我才去把它从数据库中查找出来。就比如说有一个任务要在1秒之后执行了,我才在1秒前把它从数据库中找出来然后判断是否应该执行了。这么做看似很好,但是,让我们换一种角度来思考,这么做对调度中心的性能来说无疑是一种拖累。每次只查询下一秒的定时任务,然后再去让线程池执行,但假如说,在查询这些任务,访问数据库,或者其他的方面线程阻塞了呢?每次只查询一秒然后判断,这么一来,可能后面紧接着的任务都会延后执行,似乎精度又没有那么准确了。再说,频繁地访问数据库,本身对行难呢过就是一种拖累,显然我刚才的提议并不值得采纳。那这是不是就意味着一次查出来很多很多任务就是最好的呢?比如一次查询出10秒内腰执行的定时任务,甚至一直查询出1分钟内要执行的定时任务,这样一来,查询出的任务势必会有很多。那会发生什么情况呢?先不说任务调度会不会拖累后续任务的远程调用,也不考虑scheduleThread线程和数据库打交道时的耗时异常状况,就只考虑最实际的问题,如果一次取出一分钟以内要执行的定时任务,但是当前时间可能才是第一分钟,现在取出了1-2分钟之内要执行的定时任务,如果有定时任务是在一分一秒就要执行,那直接调度就行了,可是一分50秒要执行的任务该怎么办呢?scheduleThread线程可是不会休息的,难道没到时间的就再次跳过不理它,显然,这种决策也是行不通的。

并且,让我再来指出一点,现在scheduleThread扫描定时任务要和数据库打交道,这是个不确定的因素。而在真正的XXL-JOB源码中,一旦调度中心形成集群,就要防止定时任务被重复调度。这时候,就要使用分布式锁了,在XXL-JOB中,分布式锁是用数据库实现的,马上就会讲解到。所以,scheduleThread每次扫描定时任务之前,就要先和数据库打一次交道争抢分布式锁。如此看来,scheduleThread线程的性能本身就被这两点拖累了。

但是,让我再来换一种思路,如果我只让scheduleThread线程负责从数据库中扫描出下一阶段可以执行的定时任务的信息,然后把这些定时任务信息缓存在一些数据结构中,然后让另一个线程到点之后就直接调度它们,也就是把这些定时任务按照时间顺序提交给线程池去真正的远程调度。这样的话,scheduleThread线程就只负责从数据库中扫描可以执行的定时任务,然后根据执行时间把它们缓存在某些容器中。而新的线程就根据这些定时任务的执行时间到点去调度这些定时任务。这样就做到了尽可能精确的触发定时任务了。讲到这里,大家应该也意识到了,我终于要为自己的程序引入时间轮了。但是在引入时间轮之前,还有一点需要最终确定,那就是究竟让scheduleThread线程扫描多少秒以内可以执行的定时任务信息呢?这里我就不卖关子了,源码中设定的是5秒,也就是查找出当前时间+5秒之内所有的可以执行的定时任务信息,所以我就直接按照5秒来重构自己的的调度中心了。

接下来,就请大家看看我重构之后的JobScheduleHelper类。

public class JobScheduleHelper {

    private static Logger logger = LoggerFactory.getLogger(JobScheduleHelper.class);

    // 调度定时任务的线程
    private Thread scheduleThread;

    // 这个就是时间轮线程
    // 这个时间轮线程主要就是用来向触发器线程池提交触发任务的
    // 它提交的任务是从Map中获得的,而Map中的任务是由上面的调度线程添加的,具体逻辑会在下面的代码中讲解
    private Thread ringThread;

    //下面这两个是纯粹的标记,就是用来判断线程是否停止的
    private volatile boolean scheduleThreadToStop = false;
    private volatile boolean ringThreadToStop = false;

    // 这个就是时间轮的容器,该容器中的数据是由scheduleThread线程添加的
    // 但是移除是由ringThread线程移除的
    // Map的key为时间轮中任务的执行时间,也就是在时间轮中的刻度,value是需要执行的定时任务的集合,这个集合中的数据就是需要执行的定时任务的Id
    // 意思就是在这个时间,有这么多定时任务要被提交给调度线程池
    private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

    // 创建当前类的对象
    private static JobScheduleHelper instance = new JobScheduleHelper();

    // 把当前类的对象暴露出去
    public static JobScheduleHelper getInstance() {
        return instance;
    }

    // 这里定义了5000毫秒,查询数据库的时候会用到,查询的就是当前时间5秒之内的可以执行的定时任务信息
    public static final long PRE_READ_MS = 5000;

    // 启动调度线程工作的方法
    public void start() {
        scheduleThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    // 得到当前时间
                    long nowTime = System.currentTimeMillis();
                    // 从数据库根据执行时间查询定时任务的方法
                    List<YyJobInfo> yyJobInfoList = YyJobAdminConfig.getAdminConfig().getYyJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS);
                    // 遍历所有定时任务信息
                    for (YyJobInfo yyJobInfo : yyJobInfoList) {
                        // 注意,这里的判断条件改成了小于等于了,别忘了,我已经从数据库中查出来所有的5秒内可执行的定时任务了,这些任务都是可以执行的
                        // 如果当前时间小于定时任务的下一次执行时间,说明还没有到定时任务的执行时间呢
                        // 下面我就要计算定时任务在时间轮中的精确执行时间,然后把定时任务放到时间轮中
                        if (nowTime > yyJobInfo.getTriggerNextTime()) {
                            // 接下来就把5秒内可以执行,但是还不到执行时间的定时任务放到时间轮中
                            // 计算该任务要放在时间轮的刻度,也就是在时间轮中的执行时间,注意,千万不要被这里的取余给搞迷惑了
                            // 这里的余数计算结果是0-59,单位是秒,意味着时间轮有60个刻度,一个代表一秒
                            // 所以,这里就计算出来该定时任务在时间轮中的哪个刻度。
                            int ringSecond = (int) ((yyJobInfo.getTriggerNextTime() / 1000) % 60);
                            // 把定时任务的信息,就是它的id放进时间轮
                            pushTimeRing(ringSecond, yyJobInfo.getId());
                            // 计算定时任务下一次的执行时间,这里就不再使用当前时间为计算标志了,使用的是定时任务这一次的执行时间为计算标志
                            // 比如说定时任务是在第一秒执行了,如果每两秒执行一次,那下一次的计算时间肯定是在第一秒之后,所以用1秒这个时间作为计算标志


                            // 如果大于就执行定时任务,就调用下面这个方法,开始远程通知定时任务程序
                            // 执行定时任务
                            // 注意,这里引入了一个新的类,JobTriggerPoolHelper
                            JobTriggerPoolHelper.trigger(yyJobInfo);
                            // 计算定时任务下一次的执行时间
                            Date nextTime = null;
                            try {
                                nextTime = new CronExpression(yyJobInfo.getScheduleConf()).getNextValidTimeAfter(new Date(yyJobInfo.getTriggerNextTime()));
                            } catch (ParseException e) {
                                e.printStackTrace();
                            }
                            // 下面就是更新数据库中定时任务的操作
                            YyJobInfo job = new YyJobInfo();
                            job.setTriggerNextTime(nextTime.getTime());
                            YyJobAdminConfig.getAdminConfig().getYyJobInfoDao().save(job);
                        }
                    }
                }
            }
        });
        scheduleThread.start();

        // 在这里创建时间轮线程,并且启动
        ringThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!ringThreadToStop) {
                    try {
                        // 这里让线程睡一会,作用还是比较明确的,因为该线程是时间轮线程,时间轮执行任务是按照时间刻度来执行的
                        // 如果这一秒内所有任务都调度完了,但是耗时只还用了500毫秒,剩下的500毫秒就只好睡过去,等待下一个整秒的到来再继续开始工作
                        // System.currentTimeMillis()%1000 计算出来的结果如果是500毫秒,1000-500 = 500,线程就继续睡500毫秒
                        // 如果System.currentTimeMillis()%1000 计算出来的结果如果是0,说明现在是整秒,那就睡一秒,等到下个工作时间开始工作
                        TimeUnit.MILLISECONDS.sleep(1000 - (System.currentTimeMillis() % 1000));
                    } catch (InterruptedException e) {
                        if (!ringThreadToStop) {
                            logger.error(e.getMessage(), e);
                        }
                    }
                    try {
                        // 先定义一个集合变量,时间轮是一个Map容器,Map的key是定时任务要执行的时间,value是定时任务的JobId的集合
                        // 到了固定时间,要把对应时刻的定时任务从集合中取出来,所以自然也要用集合来存放这些定时任务的Id
                        List<Integer> ringItemData = new ArrayList<>();
                        // 获取当前时间的秒数
                        int nowSecond = Calendar.getInstance().get(Calendar.SECOND);
                        // 下面这里很有意思,如果我们计算出来的是第三秒,时间轮线程会把第2秒和第三秒的定时任务都取出来,一起执行
        
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值