xxl-job老版本是依赖quartz的定时任务触发,在v2.1.0版本开始 移除quartz依赖:一方面是为了精简系统降低冗余依赖,另一方面是为了提供系统的可控度与稳定性。(本文 相应代码版本 2.2.0-SNAPSHOT)
以下是本文的目录大纲:
一.任务触发执行总体流程
二.任务定时触发流程
三.关于这么设计的感悟
一 任务触发执行总体流程
先来看下任务触发和执行的 完整的任务触发执行总体流程图 如下:
上图所示左上角的 第一步:任务触发方式 主要有以下几种类型:
1 根据设置的时间自动触发JobScheduleHelper
2 页面点击操作按钮执行触发
3 父子任务触发
4失败重试触发。
本文重点讲解 第一步:任务触发 的第一种 1 根据设置的时间自动触发,即上图 红色框内标示的部分,具体见JobScheduleHelper这个类。
二 任务定时触发流程
详细的JobScheduleHelperCron定时触发 这个阶段流程图如下:
具体见JobScheduleHelper这个类结合上面流程图来分析,在工程spring启动的时候 触发了JobScheduleHelper类的start()方法
任务定时触发,流程如下:
1 分布式锁
为了保证分布式一致性先上悲观锁:使用select xx for update来实现
1 conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
2 connAutoCommit = conn.getAutoCommit();
3 conn.setAutoCommit(false);
4 preparedStatement = conn.prepareStatement( "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
5 preparedStatement.execute();
2 轮询db,找出trigger_next_time(下次触发时间)在距now 5秒内的任务
1 List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
2 详细sql如下:
3 trigger_status代表触发状态处于启动的任务 trigger_next_time代表 任务下次 执行触发的时间
4 <select id="scheduleJobQuery" parameterType="java.util.HashMap" resultMap="XxlJobInfo">
5 SELECT *
6 FROM xxl_job_info AS t
7 WHERE t.trigger_status = 1
8 and t.trigger_next_time <![CDATA[ <= ]]> #{maxNextTime}
9 ORDER BY id ASC
10 LIMIT #{pagesize}
11 </select>
3 触发算法
拿到了距now 5秒内的任务列表数据:scheduleList,分三种情况处理:for循环遍历scheduleList集合
(1)对到达now时间后的任务:(超出now 5秒外):直接跳过不执行; 重置trigger_next_time;
(2)对到达now时间后的任务:(超出now 5秒内):线程执行触发逻辑; 若任务下一次触发时间是在5秒内, 则放到时间轮内(Map<Integer, List<Integer>> 秒数(1-60) => 任务id列表);
再 重置trigger_next_time
(3)对未到达now时间的任务:直接放到时间轮内;重置trigger_next_time 。
分别对应下面 这个数轴 的 三个阶段
具体参见下面代码:
1 下面对应代码(1)对到达now时间后的任务(超出now 5秒外):直接跳过不执行; 重置trigger_next_time
2 if (nowTime > jobInfo.getTriggerNextTime() + 5000) {
3 // 2.1、trigger-expire > 5s:pass && make next-trigger-time
4 logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());
5 // fresh next
6 refreshNextValidTime(jobInfo, new Date());
7 }
1 下面对应代码(2)对到达now时间后的任务(超出now 5秒内):线程执行触发逻辑; 若任务下一次触发时间是在5秒内,
2
3 则放到时间轮内(Map<Integer, List<Integer>> 秒数(1-60) => 任务id列表);重置trigger_next_time
4
5 else if (nowTime > jobInfo.getTriggerNextTime()) {
6 // 1、trigger
7 JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null);
8 // 2、fresh next
9 refreshNextValidTime(jobInfo, new Date());
10 // next-trigger-time in 5s, pre-read again
11 if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
12 // 1、make ring second
13 int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
14 // 2、push time ring
15 pushTimeRing(ringSecond, jobInfo.getId());
16 // 3、fresh next
17 refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
18 }
19 }
1 下面对应代码(3)对未到达now时间的任务:直接放到时间轮内;重置trigger_next_time
2
3 else {
4 // 1、make ring second
5 int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
6 // 2、push time ring
7 pushTimeRing(ringSecond, jobInfo.getId());
8 // 3、fresh next
9 refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
10 }
上面的refreshNextValidTime方法是 更新任务的 trigger_next_time 下次触发时间,xxl_job_info表是记录定时任务的db表,里面有个trigger_next_time(Long)字段,表示下一次触发的时间点任务时间被修改
每一次任务触发后,可以根据cronb表达式计算下一次触发时间戳:Date nextValidTime = new CronExpression(jobInfo.getJobCron()).getNextValidTimeAfter(new Date())),更新trigger_next_time字段。
4 时间轮触发
接下来讲时间轮,时间轮数据结构: Map<Integer, List<Integer>> key是秒数(1-60) value是任务id列表,具体结构如下图 :
时间轮的执行代码如下:
1 public void run() {
2
3 // align second
4 try {
5 TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000 );
6 } catch (InterruptedException e) {
7 if (!ringThreadToStop) {
8 logger.error(e.getMessage(), e);
9 }
10 }
11
12 while (!ringThreadToStop) {
13
14 try {
15 // second data
16 List<Integer> ringItemData = new ArrayList<>();
17 int nowSecond = Calendar.getInstance().get(Calendar.SECOND); // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
18 for (int i = 0; i < 2; i++) {
19 List<Integer> tmpData = ringData.remove( (nowSecond+60-i)%60 );
20 if (tmpData != null) {
21 ringItemData.addAll(tmpData);
22 }
23 }
24
25 // ring trigger
26 logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
27 if (ringItemData.size() > 0) {
28 // do trigger
29 for (int jobId: ringItemData) {
30 // do trigger
31 JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null);
32 }
33 // clear
34 ringItemData.clear();
35 }
36 } catch (Exception e) {
37 if (!ringThreadToStop) {
38 logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
39 }
40 }
41
42 // next second, align second
43 try {
44 TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000);
45 } catch (InterruptedException e) {
46 if (!ringThreadToStop) {
47 logger.error(e.getMessage(), e);
48 }
49 }
50 }
51 logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
52 }
时间轮数据结构: Map<Integer, List<Integer>> key是hash计算触发时间获得的秒数(1-60),value是任务id列表
入轮:扫描任务触发时 (1)本次任务处理完成,但下一次触发时间是在5秒内(2)本次任务未达到触发时间
出轮:获取当前时间秒数,从时间轮内移出当前秒数前2个秒数的任务id列表, 依次进行触发任务;(避免处理耗时太长,跨过刻度,多向前校验一秒)
增加时间轮的目的是:任务过多可能会延迟,为了保障触发时间尽可能和 任务设置的触发时间尽量一致,把即将要触发的任务提前放到时间轮里,每秒来触发时间轮相应节点的任务
三 关于这么设计的感悟:看似简单的一个任务触发为什么要搞这么复杂呢?
我的答案是: 因为 出于“性能” 和“时效性”这两点 综合来考虑,即“中庸之道”。
就拿每次 “从DB查出 近期 即将要到触发时间任务” 这个场景 来看:
1 如果希望“性能”更好,那肯定每次多查出些数据,但这样就不可避免的造成 因为任务过多,同一批查出来的位置靠后的某些任务 触发就可能会延迟,比如实际触发比设定触发的时间晚几秒。
2 如果希望“时效性”更好,那肯定每次少查出些数据,比如每次只查出来一条或者几条,实际触发时间和设定的触发时间 基本一样,但这样造成了频繁查询数据库,性能下降。
故 通过“时间轮”来达到既“性能”比较好并且每次查出相对尽量多 的数据(目前是取5s内触发的任务),又通过时间轮来保障“时效性”:实际触发时间和设定的触发时间 尽量一样。这就是设计这么复杂的原因。