1.技术背景
明明已经有成熟的任务调度框架了比如Quartz为什么不直接使用?
- Quartz是一个功能强大的任务调度框架,它提供了丰富的调度方法和灵活的应用方式,支持任务和调度的多种组合方式,以及多种存储方式。Quartz的核心元素包括JobDetail(任务)、Trigger(触发器)和Scheduler(调度器)。它支持集群部署,通过数据库锁来保证任务的高可用性。
- Quartz的不足 :Quartz作为开源任务调度框架中的佼佼者,是任务调度的首选。但是在集群环境中Quartz采用API的方式对任务进行管理,这样存在以下问题:
- 通过调用Api的方式操作任务,不人性化
- 需要持久化业务的QuartzJobBean到底层数据表中,系统侵入性相当严重
- 调度逻辑和QuartzJobBean耦合在同一个项目中,导致调度任务数量逐渐增多,同时调度任务逻辑逐渐加重的情况下,此时调度系统的性能将大大受限于业务。
- Quartz底层以'抢占式'获取Db锁并由抢占成功节点负责运行任务,会导致节点负载悬殊过大,而XXL-JOB通过执行器实现"协同分配式"运行任务,充分发挥集群优势,负载各节点均衡
XXL-JOB弥补了Quartz的上述不足之处,并充分展现他的优势,我们一起来看看
官方介绍
-
XXL---JOB是一个由大众点评员工徐雪里开发的轻量级分布式任务调度框架,其核心设计目标是开发迅速,学习简单,轻量级,简单扩展。XXl-job支持多种任务类型,如内置Java任务。Glue脚本任务等,并且支持在线任务处理和日志白屏化。它使用数据库锁来保证在多台调度段同时工作时,仅有一台机器提供调度工作,但不支持工作流,并且其报警监控相对有限,只支持邮件
-
通俗来说 :xxl-job是一个任务调度框架,通过引入XXL-JOB相关依赖,按照相关格式撰写代码后,可在其可视化界面进行任务的启动,执行,中止以及包含了日志记录 与查询和任务状态监控
如果将XXl------job形容成一个人的话,将每一个引入xxl-job的微服务就相当于一个独立的人(执行器),而按照相关约定格式撰写的Handler为餐桌上的食物,可视化界面则可以决定哪个执行器(人),吃东西或者不吃某个东西(定时任务),在什么时间吃(Corn表达式控制或者执行或终止或即开始)
2.XXl-job底层实现
2.1设计模式
调度中心负责发起调度请求,将任务抽象成分散的JobHandler,交由执行器统一管理,发挥集群的优势
-
调度模块(调度中心)
- 基于Mysql的集群方案,集群分布式并发环境中进行定时任务调度时,会在各个节点上报任务,存在数据库中
- 执行时会从数据库中取出触发器来执行,如果触发器的名称和执行时间相同,则只有一个节点去执行此任务
-
执行模块(执行器)
- 线程池并行,在多线程调度的情况下,调度模块被阻塞的几率很低,大大提升了调度系统的承载量
- 不同任务之间并行调度,并行执行
- 单个任务,针对单个执行器是串行执行的,针对多个执行器是并行运行的
2.2XXL-JOB的原理
2.2.1执行器的注册和发现
- 执行器的注册和发现主要关系两张表:
- XXL_JOB_REGISETRY:执行器的实例表,保存实例信息和心跳信息
- XXL_JOB_GROUP:每个服务注册的实例列表 执行器启动线程每隔30秒向注册表XXL_JOB_REGISETRY请求一次,更新执行器的心跳信息,调度中心启动线程每隔30秒检测一次XXL_JOB_REGISETRY,将超过90秒还没有收到心跳的实例信息从XXL_JOB_REGISETRY删除,并更新XXL_JOB_GROUP服务的实例列表信息
2.2.2 调度中心调用执行器
调度中心通过循环不停的
- 关闭自动提交事务
- 利用Mysql的悲观锁,其他事务无法进入
SQL
Select * from xxl_job_lock where lock_name = 'schedule_lock' for update
- XXlJobScheduler读取数据库中的xxl_job_info:记录定时任务的相关信息,该表中有 trigger_next_time字段表示下一次任务的触发时间。拿到距离当前时间5s内的任务列表,分为三种情况处理:
- 对于当前时间﹣任务的下一次触发时间>5,直接跳过不执行,重置trigger_next_time的时间,(超过5s)
- 对于任务的下一次触发时间<当前时间<任务的下一次触发时间+5的任务(不超过5s的)
- 对于任务的下一次触发时间>当前时间,将其放入时间轮中,根据任务下一次触发时间更新下下一次任务(未到时间的)
- commit提交事务,同时释放排他锁
2.2.3 任务触发器负责分布式调度
JobTriiggerPoolHelper负责将调度中心的任务触发到对应执行器,相当于一个中间件
- 初始化快速任务线程池和慢速任务线程池
- 在1分钟内超时次数超过10次的任务定义为慢任务。这个统计使用的是1分钟的滑动窗口,每次调度大于500ms算超时,使用慢速线程池
- 否则使用快速线程池
java
fastTriggerPool = new ThreadPoolExecutor(
10,
XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax(),
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(2000),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-fastTriggerPool-" + r.hashCode());
}
},
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
logger.error(">>>>>>>>>>> xxl-job, admin JobTriggerPoolHelper-fastTriggerPool execute too fast, Runnable="+r.toString() );
}
});
slowTriggerPool = new ThreadPoolExecutor(
10,
XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax(),
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(5000),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "xxl-job, admin JobTriggerPoolHelper-slowTriggerPool-" + r.hashCode());
}
},
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
logger.error(">>>>>>>>>>> xxl-job, admin JobTriggerPoolHelper-slowTriggerPool execute too fast, Runnable="+r.toString() );
}
});
高可用:通过统计识别任务类型,通过线程池隔离将风险进行隔离,尽量保证快速任务的正常执行
选取路由策略:(分发执行器逻辑)
- FIRST(第一个):固定选择第一个机器执行。
- LAST(最后一个):固定选择最后一个机器执行。
- ROUND(轮询):按照执行器注册地址轮询分配任务。
- RANDOM(随机):随机选择在线的机器执行任务。
- CONSISTENT_HASH(一致性 HASH):每个任务按照 Hash 算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
- 最不经常使用(LEAST_FREQUENTLY_USED):优先选择使用频率最低的那台机器。
- 最近最久未使用(LEAST_RECENTLY_USED):基于机器的使用时间进行选择,优先选择最近最久未使用的机器。
- 故障转移(FAILOVER):当首选机器出现故障时,任务会自动转移到其他可用的机器上执行。
- 忙碌转移(BUSYOVER):当首选机器处于忙碌状态时,任务会转移到其他机器上执行。
- 分片广播:这是一种针对执行时间长的任务的策略,通过将任务分散到各个节点上执行,加快完成速度。
选取阻塞策略:(任务进入执行器后处理逻辑)
- 单机串行(默认): 当调度请求进入单机执行器后,它们会进入 FIFO 队列并以串行方式运行。这种策略按照顺序执行任务,不会并行处理多个任务。
- 丢弃后续调度:当调度请求进入单机执行器时,如果发现执行器已经存在正在运行的调度任务,本次请求将会被丢弃并标记为失败。这种策略会优先处理当前任务,而忽略后续到达的任务。
- 覆盖之前调度:当调度请求进入单机执行器时,如果发现执行器存在运行的调度任务,该策略会终止正在运行的调度任务并清空队列,然后运行新的本地调度任务。
完整任务执行流程
3.调度中心细节
3.1XxlJobScheduler
XxlJobScheduler(定时任务触发器)是 admin sever 初始化的一个bean ,在spring生命周期中的InititlizingBean的 afterPropertiseSet()
方法里初始化, 其中XxlJobScheduler 的init()
方法初始化了一个JobScheduleHelper帮助定时出发在admin页面配置的Job
java
public void init() throws Exception {
// init i18n
initI18n();
// admin trigger pool start
JobTriggerPoolHelper.toStart();
// admin registry monitor run
JobRegistryHelper.getInstance().start();
// admin fail-monitor run
JobFailMonitorHelper.getInstance().start();
// admin lose-monitor run ( depend on JobTriggerPoolHelper )
JobCompleteHelper.getInstance().start();
// admin log report start
JobLogReportHelper.getInstance().start();
// start-schedule ( depend on JobTriggerPoolHelper )
JobScheduleHelper.getInstance().start();
logger.info(">>>>>>>>> init xxl-job admin success.");
}
进入到 JobScheduleHelper的 start()
方法, start()
方法初始化了2个守护线程:
- scheduleThread:读取xxl_job_info的status 为 1 的所有任务并通过
pushTimeRing (int ringSecond, int jobId)
执行时间,也就是2.2.2的刷新逻辑是它执行的。 方法将 JobId 和下次执行时间放入到时间轮里,同时根据 cron 表达式刷新下次执行时间,也就是2.2.2 的刷新逻辑是它执行的。
java
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:轮询时间轮,取出JobId和下次执行时间,触发Trigger
java
// 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();
}
ringData是通过时间戳的取余计算出来的,以一分钟为刻度,每一秒可以作为一个key,如果有相同的key,那么计算出来的值会放在map的value,即List里
3.2 发起任务
- 可以由admin手动发起Http请求给调度中心走trigger流程
java
@RequestMapping("/trigger")
@ResponseBody
public ReturnT<String> triggerJob(HttpServletRequest request,
@RequestParam("id") int id,
@RequestParam("executorParam") String executorParam,
@RequestParam("addressList") String addressList) {
// login user
XxlJobUser loginUser = PermissionInterceptor.getLoginUser(request);
// trigger
return xxlJobService.trigger(loginUser, id, executorParam, addressList);
}
也可以由上述时间环线程发起trigger流程
java
// do trigger
JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
接着进入到JobTriggerPoolHelper的addTrigger()
方法,这里使用了线程池异步执行trigger动作。
java
/**
* add trigger
*/
public void addTrigger(final int jobId,
final TriggerTypeEnum triggerType,
final int failRetryCount,
final String executorShardingParam,
final String executorParam,
final String addressList) {
// choose thread pool
ThreadPoolExecutor triggerPool_ = fastTriggerPool;
AtomicInteger jobTimeoutCount = jobTimeoutCountMap.get(jobId);
if (jobTimeoutCount!=null && jobTimeoutCount.get() > 10) { // job-timeout 10 times in 1 min
triggerPool_ = slowTriggerPool;
}
// trigger
triggerPool_.execute(new Runnable() {
@Override
public void run() {
long start = System.currentTimeMillis();
try {
// do trigger
XxlJobTrigger.trigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
} catch (Throwable e) {
logger.error(e.getMessage(), e);
} finally {
// check timeout-count-map
long minTim_now = System.currentTimeMillis()/60000;
if (minTim != minTim_now) {
minTim = minTim_now;
jobTimeoutCountMap.clear();
}
// incr timeout-count-map
long cost = System.currentTimeMillis()-start;
if (cost > 500) { // ob-timeout threshold 500ms
AtomicInteger timeoutCount = jobTimeoutCountMap.putIfAbsent(jobId, new AtomicInteger(1));
if (timeoutCount != null) {
timeoutCount.incrementAndGet();
}
}
}
}
@Override
public String toString() {
return "Job Runnable, jobId:"+jobId;
}
});
}
处理重试策略,路由策略,执行器地址等参数进入processTrigger
方法
看看processTrigger 主要做了哪些事情吧
1.保存任务ID,任务组,执行时间等日志信息 2. init trigger-param,创建一个 TriggerParam 实例。
- 获取 executor (执行器)的 address,是从
xx1_job_group
表里(根据路由策略)读取出来的一个address,该 address 可自动注册也可在admin 后台手动录入。 - 将TriggerParam 和 address 组合,执行
runExecutor (triggerParam, address)
方法,此时调用执行器,发起Post请求将相关信息请求给执行器
java
@Override
public ReturnT<String> run(TriggerParam triggerParam) {
return XxlJobRemotingUtil.postBody(addressUrl + "run", accessToken, timeout, triggerParam, String.class);
}
- 执行器中实现类是 ExecutorBizImpl 进入其 run()方法,执行jobThread 的实例化, 如果有 JobId对应了旧的Thread,那么需要用新线程去替换。
run()方法里面是怎么执行的?执行器是怎么实现这个 http 请求到任务执行的调度的呢?
4.客户端运行细节
执行器的initEmbedServer
方法中创建了内置服务器 EmbedServer(底层是由Netty实现的),用于接受请求
java
case "/run":
TriggerParam triggerParam = GsonTool.fromJson(requestData, TriggerParam.class);
return executorBiz.run(triggerParam);
分析一下run方法里面做了什么
- 找个这个任务对应的 JobThread,如果没有,则会注册新的 JobThfead
- 找到这个 JobThread 对应的 jobHandler,如果没有,就从 jobHandlerRepository 中获取 jobHandler
- jobThread 线程会一直死循环, 从 triggerQueue 中获取参数,执行我们都任务代码
核心是一个 jobId 对应一个 jobHandle 对应一个 jobThread
4.1 注解方式注册 JobHandler
Job处理器是 XxlJob 中调度的单位,也是最终调用目标的任务的载体,所有的 Job 处理器注册在了一个 ConcurrentHashMap 里,其中 map 的 key 为@XxIJob (value='")的 value 值,map的 value 是由一个IJobHandler 接口的实例实现。
生命周期任务示例:任务初始化与销毁时,支持自定义相关逻辑
java
/**
* 5、生命周期任务示例:任务初始化与销毁时,支持自定义相关逻辑;
*/
@XxlJob(value = "demoJobHandler2", init = "init", destroy = "destroy")
public void demoJobHandler2() throws Exception {
XxlJobHelper.log("XXL-JOB, Hello World.");
}
执行器会维护特定线程扫描注解获取bean,注册到jobHandlerRepository\
java
// ---------------------- job handler repository ----------------------
private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();
public static IJobHandler loadJobHandler(String name){
return jobHandlerRepository.get(name);
}
public static IJobHandler registJobHandler(String name, IJobHandler jobHandler){
logger.info(">>>>>>>>>>> xxl-job register jobhandler success, name:{}, jobHandler:{}", name, jobHandler);
return jobHandlerRepository.put(name, jobHandler);
}
Handler名称 | 描述 |
---|---|
GlueJobHandler | 提供Glue任务的处理器,平台脚本 |
MethodJobHandler | 提供常规Bean模式方法Job处理器,整合Spring工程 |
ScriptJobHandler | 提供脚本处理器 |
核心是MethodJobHandler能完成大部分需求,新版本支持自定义生命周期initMethod
和destroyMethod
方法重写
4.2 注册 JobThread
JobThread是运行job的线程,可以看作Job线程载体,存放在XxlJobExecutor类里的JobThreadResitory,它也是concurrentMap来做并发安全
java
private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();
注册JobThread方法,每次注册时会将JobId和JobHanler作为参数实例化一个JobThread
java
public static JobThread registJobThread(int jobId, IJobHandler handler, String removeOldReason){
JobThread newJobThread = new JobThread(jobId, handler);
newJobThread.start();
logger.info(">>>>>>>>>>> xxl-job regist JobThread success, jobId:{}, handler:{}", new Object[]{jobId, handler});
JobThread oldJobThread = jobThreadRepository.put(jobId, newJobThread); // putIfAbsent | oh my god, map's put method return the old value!!!
if (oldJobThread != null) {
oldJobThread.toStop(removeOldReason);
oldJobThread.interrupt();
}
return newJobThread;
}
避免重复进行:校验 jobThreadRepository 的返回值,如果已经存在便停掉旧线程,这样能始终保证只有一个线程为job服务,避免有些情况下回出现任务重复执行,发生定时错乱问题。
线程注册完了,最终的逻辑是在线程的run方法执行的,再看看JobThread的run方法里面做了什么吧
-
如果注解中有init方法,先执行init方法
-
从triggerQueue 队列中取出任务执行的参数,最多阻塞3s
-
记录任务执行日志
-
执行jobHandler任务逻辑
- 如果任务执行失败,就记录失败信息到日志中
- 最后把执行结果放在TriggerCallBackThread线程的callBackQueue队列中,TriggerCallBackThread线程回调这些结果
-
如果任务停止的话,就调用任务的destory方法
4.3 任务达成机制
核心是xx1_job_1og
- 调度中心(Scheduler):负责触发任务,更新任务的触发状态。当任务满足执行条件时,调度中心会更新trigger_code字段为200,表示任务已经触发。
- 执行器(Executor):当执行器接收到调度中心的任务执行请求时,它会开始执行任务。在任务执行过程中, 执行器会周期性地或在任务执行结束时更新
xx1_job_log
表中的 handle_code字段:- 如果任务执行中,可能会暂时设置handle_code为0。
- 当任务执行成功时,执行器会将handle_code更新为200。
- 如果任务执行失败,执行器会将handle_code 更新为500或其他表示失败的值。
- 回调机制:执行器在任务执行结束后,会向调度中心发送回调信息,告知任务执行的结果。调度中心根据回调信息更新
xx1_job_log
表中的相关字段。 - 监控线程(Monitor Thread):
xxl-job
内部的监控线程会定期检查任务执行情况,如果发现任务长时间未完成或存在异常,可能会更新任务状态或进行相应的处理。 - 日志记录:在任务执行过程中,执行器会记录日志信息。这些日志信息可能包括任务开始、执行进度、异常信息和任务结束等,但实际的字段更新是由执行器和调度中心的交互完成的。
- 失败处理:如果任务执行失败,xxl-job 提供了失败重试机制,重试成功后会更新handle_code为200,否则会保留失败的状态码。
任务执行判断属于系统自动执行,可以通过SQL查询执行失败任务,盘点失败率