XXL-JOB源码(下)--调度器篇

682 阅读6分钟

image.png

一 核心功能

官方把xxljob调度器分成了5块: 任务管理、执行器管理、日志管理、其他、数据中心

从程序的角度可以分为6个模块

image.png

1.1 执行器监控

想要调用不同服务的不同实例,那就需要依据服务名维护该服务的所有实例列表。其实和注册中心一个作用,实现上也一样,采用心跳机制。服务向调度中心定时发送心跳来证明自己还在线,调度中心根据心跳来判断服务是否还活着,调度器需要尽可能的减少误判,提高失效实例被剔除的效率。

public class JobRegistryMonitorHelper {
    // 提供单例对象
   private static JobRegistryMonitorHelper instance = new JobRegistryMonitorHelper();
   public static JobRegistryMonitorHelper getInstance(){
      return instance;
   }
   private Thread registryThread;
   private volatile boolean toStop = false;
   public void start(){
   registryThread = new Thread(()-> {
   while (!toStop) {
       try {
          // 找到需要自动注册的执行器(手动注册的不做处理)
          List<XxlJobGroup> groupList = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().findByAddressType(0);
          if (groupList!=null && !groupList.isEmpty()) {
             /** 
              * 线程每隔多少秒执行一次BEAT_TIMEOUT = 30     DEAD_TIMEOUT = BEAT_TIMEOUT * 3;
              * SELECT t.id FROM xxl_job_registry AS t WHERE t.update_time < (#{nowTime}::timestamp - interval '${timeout}' second)
              * 大致可以理解为找到90s内没有发送心跳的服务实例
              */
             List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
             if (ids!=null && ids.size()>0) {
             // 删除xxl_job_registry表中,刚刚找到的死亡的服务实例  
             XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);}
             // 找到所有存货的服务实例(死亡的已经被剔除)
             List<XxlJobRegistry> list = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findAll(RegistryConfig.DEAD_TIMEOUT, new Date());
              HashMap<String, List<String>> appAddressMap = new HashMap<String, List<String>>();
             // 把XxlJobRegistry 的列表解析成appAddressMap的格式key为appNamevalue为ip地址列表
             if (list != null) {
                for (XxlJobRegistry item: list) {
                   if (RegistryConfig.RegistType.EXECUTOR.name().equals(item.getRegistryGroup())) {
                      String appname = item.getRegistryKey();
                      List<String> registryList = appAddressMap.get(appname);
                      if (registryList == null) {registryList = new ArrayList<String>();}
                      if (!registryList.contains(item.getRegistryValue())) {registryList.add(item.getRegistryValue());}
                      appAddressMap.put(appname, registryList);
                   }
                }
             }
             for (XxlJobGroup group: groupList) {
                 // 把ip列表转成string类型
                List<String> registryList = appAddressMap.get(group.getAppname());
                String addressListStr = null;
                if (registryList!=null && !registryList.isEmpty()) {
                   Collections.sort(registryList);
                   addressListStr = "";
                   for (String item:registryList) {addressListStr += item + ",";}
                   addressListStr = addressListStr.substring(0, addressListStr.length()-1);
                }
                group.setAddressList(addressListStr);
                // 更新到表xxl_job_group
                XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().update(group);
             }
          }
       } catch (Exception e) {}
       // 睡30 秒
       TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
   });
  registryThread.setDaemon(true);
  registryThread.setName("xxl-job, admin JobRegistryMonitorHelper");
  registryThread.start();
  }
}
public class XxlJobGroup {
    private int id;
    private String appname;
    private String title;
    private int addressType;        // 执行器地址类型:0=自动注册、1=手动录入
    private String addressList;     // 执行器地址列表,多地址逗号分隔(手动录入)
    private List<String> registryList; // 执行器ip列表
}

1.2 失败监控 JobFailMonitorHelper

任务失败的补偿机制 1 重试 2 报警

报警模块属于观察者模式,如果需要增加报警方法,可以实现JobAlarm接口,并注册进spring,spring会帮我们注入观察者JobAlarmer中。

monitorThread = new Thread(()-> {
  while (!toStop) {
     try {
        List<Long> failLogIds = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findFailJobLogIds(1000);
        if (failLogIds!=null && !failLogIds.isEmpty()) {
           for (long failLogId: failLogIds) {
              // 查找剩余重试次数
              int lockRet = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateAlarmStatus(failLogId, 0, -1);
              if (lockRet < 1) {continue;}
              XxlJobLog log = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().load(failLogId);
              XxlJobInfo info = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().loadById(log.getJobId());
              // 1、重试任务
              if (log.getExecutorFailRetryCount() > 0) {
                 JobTriggerPoolHelper.trigger(log.getJobId(), TriggerTypeEnum.RETRY, (log.getExecutorFailRetryCount()-1), log.getExecutorShardingParam(), log.getExecutorParam(), null);
                 String retryMsg = "<br><br><span style="color:#F39C12;" > >>>>>>>>>>>"+ I18nUtil.getString("jobconf_trigger_type_retry") +"<<<<<<<<<<< </span><br>";
                 log.setTriggerMsg(log.getTriggerMsg() + retryMsg);
                 XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateTriggerInfo(log);
              }
              // 2、报警alarm
              int newAlarmStatus = 0;       
              // 告警状态:0-默认、-1=锁定状态、1-无需告警、2-告警成功、3-告警失败
              if (info!=null && info.getAlarmEmail()!=null && info.getAlarmEmail().trim().length()>0) {
                 // 报警,观察者模式,可以实现JobAlarm,通过spring注入自定义报警功能
                 // 默认邮件报警
                 boolean alarmResult = XxlJobAdminConfig.getAdminConfig().getJobAlarmer().alarm(info, log);
                 newAlarmStatus = alarmResult?2:3;
              } else {
                 newAlarmStatus = 1;
              }
              XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateAlarmStatus(failLogId, -1, newAlarmStatus); }}
     } catch (Exception e) {}
    // 10秒一次 
    TimeUnit.SECONDS.sleep(10);
  }}});

1.3 日志报告 JobLogReportHelper

统计计算首页的报表,一分钟执行一次

清理日志(默认不清理,可配置保存天数xxl.job.logretentiondays,时间必须大于7),一天一次

1.4 失败监控 JobLosedMonitorHelper

任务结果丢失处理:处于运行状态10分钟并且执行器下线,60s执行一次

monitorThread = new Thread(() -> {
  while (!toStop) {
     try {
        // 任务结果丢失处理:调度记录停留在 "运行中" 状态超过10min,且对应执行器心跳注册失败不在线,则将本地调度主动标记失败;
        Date losedTime = DateUtil.addMinutes(new Date(), -10);
        List<Long> losedJobIds  = XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().findLostJobIds(losedTime);
        if (losedJobIds!=null && losedJobIds.size()>0) {
           for (Long logId: losedJobIds) {
              XxlJobLog jobLog = new XxlJobLog();
              jobLog.setId(logId);
              jobLog.setHandleTime(new Date());
              jobLog.setHandleCode(ReturnT.FAIL_CODE);
              jobLog.setHandleMsg( I18nUtil.getString("joblog_lost_fail") );
              XxlJobAdminConfig.getAdminConfig().getXxlJobLogDao().updateHandleInfo(jobLog);
           }
        }
     } catch (Exception e) {}
     TimeUnit.SECONDS.sleep(60);
  }
});

1.5 任务线程池 JobTriggerPoolHelper

开启两个线程池,一个快线程池,一个慢线程池,用于推送任务给执行器

如果一个任务一分钟触发超过10次就进入慢线程池

public void start(){
     // 快线程池,最大线程数100
    fastTriggerPool = new ThreadPoolExecutor(10,
            XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax(),60L,
            TimeUnit.SECONDS,new LinkedBlockingQueue<Runnable>(1000),
            new ThreadFactory() {
                @Override
                public Thread newThread(Runnable r) {
                    return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-fastTriggerPool-" + r.hashCode());
                }
            });
    // 慢线程池,最大线程数200
    slowTriggerPool = new ThreadPoolExecutor(10,
            XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax(),60L,
            TimeUnit.SECONDS,new LinkedBlockingQueue<Runnable>(2000),
            new ThreadFactory() {
                @Override
                public Thread newThread(Runnable r) {
                    return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-slowTriggerPool-" + r.hashCode());
                }
            });
}

1.6 调度任务 JobScheduleHelper

执行任务使用了时间轮算法,数据结构为 Map<Integer, List<>>,每秒以当前秒数获取map的value就能精确的找到当前秒内需要执行的定时任务。

可以参考一下这篇论文的思想: www.cs.columbia.edu/~nahum/w699…

调度任务分为两个线程 scheduleThread、ringThread

scheduleThread 找到任务,放入时间轮

scheduleThread = new Thread(()-> {
    // 时间对齐,保证整秒执行
    TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
    // 计算预读取数量
    int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
    while (!scheduleThreadToStop) {
        long start = System.currentTimeMillis();
        Connection conn = null;
        Boolean connAutoCommit = null;
        PreparedStatement preparedStatement = null;
        boolean preReadSuc = true;
        try {
            conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
            connAutoCommit = conn.getAutoCommit();
            conn.setAutoCommit(false);
            preparedStatement = conn.prepareStatement(  "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
            preparedStatement.execute();
            // 记录当前时间
            long nowTime = System.currentTimeMillis();
            // 读取5秒内要执行的任务
            List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
            if (scheduleList!=null && scheduleList.size()>0) {
                for (XxlJobInfo jobInfo: scheduleList) {
                    if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
                        // 2.1、当前时间>执行时间+5s,丢弃任务
                        // 更新jobInfo上次和下次执行时间
                        refreshNextValidTime(jobInfo, new Date());
                    } else if (nowTime > jobInfo.getTriggerNextTime()) {
                        // 2.2、如果 当前时间>执行时间
                        // 1、直接执行
                        JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
                        // 2、更新jobInfo上次和下次执行时间
                        refreshNextValidTime(jobInfo, new Date());
                        // 如果下次执行时间在5秒内,直接放入时间轮
                        if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
                            // 1、计算时间轮刻度
                            int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
                            // 2、放入时间轮
                            pushTimeRing(ringSecond, jobInfo.getId());
                            // 3、更新jobInfo上次和下次执行时间
                            refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
                        }
                    } else {
                        // 1、计算时间轮刻度
                        int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
                        // 2、放入时间轮
                        pushTimeRing(ringSecond, jobInfo.getId());
                        // 3、更新jobInfo上次和下次执行时间
                        refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
                    }
                }
                // 3、入库jobInfo的更新
                for (XxlJobInfo jobInfo: scheduleList) {
                    XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
                }
            } else {
                preReadSuc = false;
            }
        } catch (Exception e) {} finally {
        long cost = System.currentTimeMillis()-start;
        // 如果执行时间没超过1秒,需要对齐时间
        if (cost < 1000) {
            TimeUnit.MILLISECONDS.sleep((preReadSuc?1000:PRE_READ_MS) - System.currentTimeMillis()%1000);
        }
    }
}});

ringThread 时间轮线程

每次都时间对齐 确保只能在整秒开始执行,但并不能确保一秒执行一次。假设第一秒对齐结束,继续while循环,如果向线程池推送任务超过了一秒,可能会导致对齐到了第三个整秒。所以找任务时都会找到当前秒和上一秒的要执行的任务。(如果向线程池推送任务超过两秒,事实上还是会出现错过任务)

private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();
// ring thread
ringThread = new Thread(()—> {
    // 首次时间对齐
    TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000 );
    while (!ringThreadToStop) {
        try {
            List<Integer> ringItemData = new ArrayList<>();
            int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   
            // 避免处理耗时太长,跨过刻度,向前校验一个刻度
            for (int i = 0; i < 2; i++) {
                List<Integer> tmpData = ringData.remove( (nowSecond+60-i)%60 );
                if (tmpData != null) {
                    ringItemData.addAll(tmpData);
                }
            }
            if (ringItemData.size() > 0) {
                for (int jobId: ringItemData) {
                    // 向线程池推送任务
                    JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
                }
                ringItemData.clear();
            }
        } catch (Exception e) {}
        // 时间对齐 确保每次都在整秒执行
        TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000);
    }
}});

二 踩坑

记录一下测试环境下踩的坑,本地环境和uat环境都启动调度执行服务,连接同一xxljob数据库,执行器列表找不到调度器ip地址。

我一度怀疑是调度器没有发送心跳,但是查看表xxl_job_registry,执行器发送心跳是正常的,但是没有成为可用执行器并进入表xxl_job_group(执行调度任务只会查看该表维护的ip),如果要成为可用调度器需要满足 90s内发送给心跳。

经过连夜排查,最终定位的问题是时区,本地和uat环境(亚马逊服务器UTC时间)时区相差8小时(本地快8小时),uat环境的调度中心正常收到A服务的心跳,并记录最近心跳的UTC时间记录进表xxl_job_registry,也能正常进入表xxl_job_group。本地的调度中心此刻为UTC+8时间,一看A服务已经8小时没发送心跳了,立马把A服务从表xxl_job_group的可用服务列表中删除。

如果是集群模式的调度中心也一样会出现该问题,所以一定要确保每个调度中心时间时区一致,并且时间不能出现误差。

三 总结

没啥好总结的,核心就是采用时间轮算法精确执行定时任务。