XXL-JOB使用总结

1,314 阅读6分钟

我正在参加「掘金·启航计划」

前段时间给系统兼容了一个定时任务系统,选择了XXL-JOB。发现其足够轻量,使用起来也非常简单。下面主要介绍一下它的一些特点以及使用过程中遇到的问题。

分布式调度

XXL-JOB是中心式的调度平台,并且可保证调度中心高可用。它是如何做到的? 在现有的版本中XXL-JOB使用了自研了调度逻辑。一起来看看吧。

任务的启用

创建完成的任务会保存到xxl_job_info表中。在点击启动用就会计算出当前任务下次需要运行的时间TriggerNextTime。

long nextTriggerTime = 0;
try {
	Date nextValidTime = JobScheduleHelper.generateNextValidTime(xxlJobInfo, new Date(System.currentTimeMillis() + JobScheduleHelper.PRE_READ_MS));
	if (nextValidTime == null) {
		return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")+I18nUtil.getString("system_unvalid")) );
	}
	nextTriggerTime = nextValidTime.getTime();
} catch (Exception e) {
	logger.error(e.getMessage(), e);
	return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")+I18nUtil.getString("system_unvalid")) );
}

xxlJobInfo.setTriggerStatus(1);
xxlJobInfo.setTriggerLastTime(0);
xxlJobInfo.setTriggerNextTime(nextTriggerTime);

调度逻辑

在XXL-JOB的admin启动后就会运行起整个的调度逻辑:

JobScheduleHelper.getInstance().start();

整个逻辑都在这个start方法中,在start方法中首先创建了两个线程,分别来看下

scheduleThread:用于从DB中获取最近5s将要执行的job,判断其是否需要现在执行或者是加入到时间轮中(就是一个map结构,维护了还差几秒中才需要执行的任务)。

这一步涉及到了一个xxl_job_lock表,作用就是用来加锁,防止在高可用情况下多个admin拉取到相同的job,造成不一致的情况。

preparedStatement = conn.prepareStatement(  "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );

锁的使用非常简单就是基于select for update行锁将select语句变为当前读,并对查询的记录加上行锁,当其他节点也在执行此sql查询的时候,因为当前查询语句的事物还没有结束,其他节点的查询就会进行阻塞,直到当前事物提交。

接下来就是从xxl_job_info中获取将要执行的任务,去判断,下面展示一些核心的代码。

//获取job
List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);的

如果需要立刻执行则调用trigger方法更新下次调度的时间

JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);

private void refreshNextValidTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
	Date nextValidTime = generateNextValidTime(jobInfo, fromTime);
	if (nextValidTime != null) {
		jobInfo.setTriggerLastTime(jobInfo.getTriggerNextTime());
		jobInfo.setTriggerNextTime(nextValidTime.getTime());
	} else {
		jobInfo.setTriggerStatus(0);
		jobInfo.setTriggerLastTime(0);
		jobInfo.setTriggerNextTime(0);
		logger.warn(">>>>>>>>>>> xxl-job, refreshNextValidTime fail for job: jobId={}, scheduleType={}, scheduleConf={}",
				jobInfo.getId(), jobInfo.getScheduleType(), jobInfo.getScheduleConf());
	}
}

如果还需要等待几秒中则,就需要将当前job加入到时间轮中

private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

在这个map结构中key就代表了某个时刻,value则存储了当前时刻需要执行的所有job的ID。

int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());

// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));


 private void pushTimeRing(int ringSecond, int jobId){
		// push async ring
		List<Integer> ringItemData = ringData.get(ringSecond);
		if (ringItemData == null) {
			ringItemData = new ArrayList<Integer>();
			ringData.put(ringSecond, ringItemData);
		}
		ringItemData.add(jobId);

		logger.debug(">>>>>>>>>>> xxl-job, schedule push time-ring : " + ringSecond + " = " + Arrays.asList(ringItemData) );
 }

ringThread:用来从时间轮结构中获取每个时刻需要执行的job,并进行trigger。

核心逻辑就是每隔1s获取当前时刻,从时间轮结构中获取到jobId的集合,进行trigger。

try {
	TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
} catch (InterruptedException e) {
	if (!ringThreadToStop) {
		logger.error(e.getMessage(), e);
	}
}

try {
	// second data
	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);
		}
	}

	// ring trigger
	logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
	if (ringItemData.size() > 0) {
		// do trigger
		for (int jobId: ringItemData) {
			// do trigger
			JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
		}
		// clear
		ringItemData.clear();
	}
} catch (Exception e) {
	if (!ringThreadToStop) {
		logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
	}
}
}

关于XXL-JOB中的其他特性就不做过多的介绍了,下面看看我们在使用中遇到的一些问题。

任务并行度限制

并行问题

在XXL-JOB的使用的过程中,我们存在一种任务是基于Doris SQL的(Doris作为一种OLAP计算引擎,对内存要求极高)。在测试过程中会发现几十个任务在相同时间执行的失败率非常高。通过日志发现基本上都是资源不足导致的SQL执行失败。

于是我们将任务执行的时间进行打散,可以解决此问题,但是这种方式并不推荐,任务调度的时间都是由使用者自行配置的。

如何彻底的解决此问题,当时想到了两种实现方式。

源码修改

修改XXL-JOB的源码,这块改造主要涉及到XXL-JOB的executor侧。

先来看一下在XXL-JOB中怎么定义一个执行的Handler:

@XxlJob("demoJobHandler")
public void demoJobHandler() throws Exception {
	XxlJobHelper.log("XXL-JOB, Hello World.");

	for (int i = 0; i < 5; i++) {
		XxlJobHelper.log("beat at:" + i);
		TimeUnit.SECONDS.sleep(2);
	}
	// default success
}

for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
			Method executeMethod = methodXxlJobEntry.getKey();
			XxlJob xxlJob = methodXxlJobEntry.getValue();
			// regist
			registJobHandler(xxlJob, bean, executeMethod);
}

XXL-JOB中会根据@XxlJob注解中的配置来注册当前执行器和真正的Handler实现并保存到map结构中。

private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();

主要改造的地方就是registJobHandler方法中的registJobHandler方法。

registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));

XXL-JOB中统一使用了MethodJobHandler来实现JobHandler。并且每个执行器被注解标注的方法都只会初始化一次。最终job执行的时候调用此类的execute方法,其利用反射的特性去调用真实的实现方法。

@Override
public void execute() throws Exception {
	try {
		Class<?>[] paramTypes = method.getParameterTypes();
		if (paramTypes.length > 0) {
			method.invoke(target, new Object[paramTypes.length]);       // method-param can not be primitive-types
		} else {
			method.invoke(target);
		}
	}catch (Exception e){
		throw e;
	}finally {
	}
}

我们可以借助java中的Semaphore来进行并发的限制,在构造MethodJobHandler实例的时候传入一个并发限制数

public MethodJobHandler(Object target, Method method, Method initMethod, Method destroyMethod, int parallelNum) {
	this.target = target;
	this.method = method;

	this.initMethod = initMethod;
	this.destroyMethod = destroyMethod;
	sp = new Semaphore(parallelNum);
}

@Override
public void execute() throws Exception {
	try {
		sp.acquire();
		Class<?>[] paramTypes = method.getParameterTypes();
		if (paramTypes.length > 0) {
			method.invoke(target, new Object[paramTypes.length]);       // method-param can not be primitive-types
		} else {
			method.invoke(target);
		}
	}catch (Exception e){
		throw e;
	}finally {
		sp.release();
	}
}

通过这种方式,可以比较方便的对并发任务进行限制,但是会对通用的代码进行改造,如果想要其他任务保持原有的逻辑,就需要对其这块的逻辑新增一个分支和一个特定的MethodJobHandler。

统一的OLAP执行层

如果不想对XXL-JOB原有逻辑做修改,那么可以实现一个统一的OLAP执行服务。XXL-JOB只做任务的调度,定时通过调用接口的方式提交任务到这个统一的OLAP服务。所有的任务都由这个服务来执行。

整体逻辑如上,此时我们只需要专注实现一个统一的OLAP执行引擎即可。XXL-JOB只负责定时的调度,并通过简单的http请求将任务信息传递到统一的OLAP执行引擎。

统一的OLAP执行引擎应该如何实现呢?

比如简单点我们可以将收到的请求封装成commend加入到DB中,然后不断的从DB中获取commend。当任务执行成功后就删除此条记录。

private void scheduleSimpleTask() {

	Connection connection = null;
	Boolean connAutoCommit = null;
	PreparedStatement preparedStatement = null;
	try {
		connection = dataSource.getConnection();
		connAutoCommit = connection.getAutoCommit();
		connection.setAutoCommit(false);
		preparedStatement = connection.prepareStatement(
			"select * from simple_task_lock where lock_name = 'simple_lock' for update");
		preparedStatement.execute();

		SimpleOlapTask commend = findCommend();
		if (Objects.isNull(commend)) {
			//indicate that no command ,sleep for 1s
			Thread.sleep(1000);
		} else {
			//提交任务到处理器
			log.info("commendId:{}", commend.getId());
			boolean submit = taskHandler.submitTask(commend);

			if (submit) {
				simpleOlapTaskDao.deleteOne(commend.getId());
			} else {
				log.info("任务排队中...");
				Thread.sleep(1000);
			}
		}

	} catch (Exception e) {
		log.error(" scheduler simple task thread error", e);
		throw new RuntimeException(e);
	} finally {
		if (connection != null) {
			try {
				connection.commit();
			} catch (SQLException e) {
				log.error("commit error:{},{}", e, e.getMessage());
			}
			try {
				connection.setAutoCommit(connAutoCommit);
			} catch (SQLException e) {
				log.error("set auto commit error:{},{}", e, e.getMessage());
			}
			try {
				connection.close();
			} catch (SQLException e) {
				log.error("conn close:{},{}", e, e.getMessage());
			}
		}

		if (null != preparedStatement) {
			try {
				preparedStatement.close();
			} catch (SQLException e) {
				log.error("pst close:{},{}", e, e.getMessage());
			}
		}
	}
}

在真正执行任务的地方做统一并发的限制submitTask

public boolean submitTask(SimpleOlapTask simpleOlapTask) {
	boolean acquire = SimpleTaskCache.SEMAPHORE_MAP.get(getName()).getSemaphore().tryAcquire();
	if (!acquire) {
		return false;
	}
	log.info("submit task to pool");
	//执行任务,到线程池
	threadPoolExecutor.execute(new Runnable() {
		@Override
		public void run() {
			jdbcJobExecutor.execSql(JSONUtils.parseObject(simpleOlapTask.getParam(),
				SqlJobParam.class));
			//TODO 记录失败的任务
			SimpleTaskCache.SEMAPHORE_MAP.get(getName()).getSemaphore().release();
		}
	});
	return true;
}

这里同样使用了Semaphore来进行并发的控制。

Semaphore的不足

Semaphore已经非常好用了,但是并不能做到分布式的,因为我们的服务可能是多个节点部署的。如果想要做到全局的并发控制,就需要借助注册中心的能力。大体的思路就是通过注册中心监控服务的数量,并分配并发数到各个应用,然后结合Semaphore来做到分布式的并发控制。具体的实现就靠大家自己去完成了。

总结

本文简单介绍了XXL-JOB中的调度的基本特性,以及使用中遇到的问题。针对问题提供了两种解决方式,同时希望有更好方案的同学可以多多指导。