一 核心功能
官方把xxljob调度器分成了5块: 任务管理、执行器管理、日志管理、其他、数据中心
从程序的角度可以分为6个模块
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的可用服务列表中删除。
如果是集群模式的调度中心也一样会出现该问题,所以一定要确保每个调度中心时间时区一致,并且时间不能出现误差。
三 总结
没啥好总结的,核心就是采用时间轮算法精确执行定时任务。