我正在参加「掘金·启航计划」
前段时间给系统兼容了一个定时任务系统,选择了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中的调度的基本特性,以及使用中遇到的问题。针对问题提供了两种解决方式,同时希望有更好方案的同学可以多多指导。