传送门
分布式定时任务系列5:XXL-job中blockingQueue的应用
分布式定时任务系列6:XXL-job触发日志过大引发的CPU告警
为何要看源码
DB日志
在上一节,谈到了由于XXL-job触发日志过大引发了CPU告警,主要是由于XXL-job将触发日志记录到了Mysql数据库,xxl_job_log表中:
CREATE TABLE `xxl_job_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`job_group` int(11) NOT NULL COMMENT '执行器主键ID',
`job_id` int(11) NOT NULL COMMENT '任务,主键ID',
`executor_address` varchar(255) DEFAULT NULL COMMENT '执行器地址,本次执行的地址',
`executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler',
`executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数',
`executor_sharding_param` varchar(20) DEFAULT NULL COMMENT '执行器任务分片参数,格式如 1/2',
`executor_fail_retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败重试次数',
`trigger_time` datetime DEFAULT NULL COMMENT '调度-时间',
`trigger_code` int(11) NOT NULL COMMENT '调度-结果',
`trigger_msg` text COMMENT '调度-日志',
`handle_time` datetime DEFAULT NULL COMMENT '执行-时间',
`handle_code` int(11) NOT NULL COMMENT '执行-状态',
`handle_msg` text COMMENT '执行-日志',
`alarm_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '告警状态:0-默认、1-无需告警、2-告警成功、3-告警失败',
PRIMARY KEY (`id`),
KEY `I_trigger_time` (`trigger_time`),
KEY `I_handle_code` (`handle_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
但是理论上,这个设计是有一定的隐患:
- 从时间纬度上:随着程序运行,周期性任务对应的触发日志数据量会不断的增大
- 从数量纬度上:业务可能会不断丰富,增加许多新任务
而众所周知,Mysql对于单表的数据量是有一定限制的,超过Kw数据级性能会严重下降,甚至会影响正常的运行(这个可以参考实操测试章节)。而阿里巴巴开发手册也有Mysql使用规范:
文件日志
除了XXL-job的管理端记录的db触发日志之外,XXL-job在客户端(执行器)也记录了文件日志,并且是自定义的日志系统,不能进行配置,也无法关闭,也可能会导致客户端服务器存储占用过大,具体代码在com.xxl.job.core.thread.JobThread#run:
一路再跟进XxlJobHelper.log方法,可以看到会创建本地文件,而且是触发一次,生成一个:
所以有些问题,碰到了之后都可以通过阅读源码:
- 了解它的设计思想,理念,以及设计模式
- 问题产生的原因,以及框架本身提供解决方案
- 本身设计的不足及局限
- 修改源码实现定制化逻辑及二开,比如修改过nacos默认的认证:接入当前公司的认证体系
源码目录
XXL-job的源码目录在官网有介绍,具体可参考如下:
触发整体时序图
这里,还是以前面的auth模块做例子,进行debug源码跟踪:通过debug可以验证许多猜想及数据流的转换。
在此之前,先从整体上梳理一下任务触发的调用逻辑,通过一个时序图来展现:
上面第3步里面com.xxl.job.admin.core.scheduler.XxlJobScheduler#init,各种资源Helper初始化,包括很多类型:
public void init() throws Exception {
// 国际化
initI18n();
// admin端任务触发线程池启动
JobTriggerPoolHelper.toStart();
// admin端注册监控启动
JobRegistryHelper.getInstance().start();
// admin端失败监控启动,失败进行邮件告警
JobFailMonitorHelper.getInstance().start();
// admin端,任务结果丢失处理:调度记录停留在 "运行中" 状态超过10min,且对应执行器心跳注册失败不在线,则将本地调度主动标记失败;
JobCompleteHelper.getInstance().start();
// admin端日志处理统计启动:日志统计+日志清理
JobLogReportHelper.getInstance().start();
// 定时任务触发启动
JobScheduleHelper.getInstance().start();
logger.info(">>>>>>>>> init xxl-job admin success.");
}
定时任务触发启动
这里面重点看下一下定时任务触发启动这个类,跟进start()方法:
{
// 立即创建一个线程
scheduleThread = new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
} catch (InterruptedException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
logger.info(">>>>>>>>> init xxl-job admin scheduler success.");
// pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
// 不停扫描任务表xxl_job_info,相当于线程的自旋
while (!scheduleThreadToStop) {
// Scan Job
long start = System.currentTimeMillis();
Connection conn = null;
Boolean connAutoCommit = null;
PreparedStatement preparedStatement = null;
boolean preReadSuc = true;
try {
// JDBC操作数据库
conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
connAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
// 加上db悲观锁,防止并发执行
preparedStatement = conn.prepareStatement( "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
preparedStatement.execute();
// tx start
// 1、pre read
long nowTime = System.currentTimeMillis();
// 查询所有任务列表,一次最多6000个
List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
if (scheduleList!=null && scheduleList.size()>0) {
// 2、push time-ring
for (XxlJobInfo jobInfo: scheduleList) {
// time-ring jump
if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
// 2.1、trigger-expire > 5s:pass && make next-trigger-time
logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());
// 1、错过触发时间,根据策略决定,是否立即补尝执行一次
MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
// FIRE_ONCE_NOW 》 trigger
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
}
// 2、更新下次执行相关时间参数
refreshNextValidTime(jobInfo, new Date());
} else if (nowTime > jobInfo.getTriggerNextTime()) {
// 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time
// 1、触发任务
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
// 2、更新下次执行相关时间参数
refreshNextValidTime(jobInfo, new Date());
// next-trigger-time in 5s, pre-read again
if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
} else {
// 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
}
// 3、更新db表中,下次执行相关时间参数
for (XxlJobInfo jobInfo: scheduleList) {
XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
}
} else {
preReadSuc = false;
}
// tx stop
} catch (Exception e) {
if (!scheduleThreadToStop) {
logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:{}", e);
}
} finally {
// commit
if (conn != null) {
try {
conn.commit();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
conn.setAutoCommit(connAutoCommit);
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
conn.close();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
// close PreparedStatement
if (null != preparedStatement) {
try {
preparedStatement.close();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
}
long cost = System.currentTimeMillis()-start;
// Wait seconds, align second
if (cost < 1000) { // scan-overtime, not wait
try {
// pre-read period: success > scan each second; fail > skip this period;
TimeUnit.MILLISECONDS.sleep((preReadSuc?1000:PRE_READ_MS) - System.currentTimeMillis()%1000);
} catch (InterruptedException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
}
logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
}
});
// 线程启动
scheduleThread.setDaemon(true);
scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
scheduleThread.start();
}
DB锁防"并发"
上面的start()方法里面,有一行代码:
preparedStatement = conn.prepareStatement( "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
preparedStatement.execute();
有用过此方法的应该都不陌生,这就是Mysql里面悲观锁的用法:对指定的记录添加X锁(具体是行锁,表锁视隔离级别+索引而定,可参考:MySQL锁系列(3):悲观锁探究)。但是问题是这里为什么要加db锁呢?或者说对admin端来说,为什么要加这样的重量级"分布式"锁呢?来看一下官网的一段话:
- 23、一致性:“调度中心”通过DB锁保证集群分布式调度的一致性, 一次任务调度只会触发一次执行;
从这里,就不难猜到,这行代码是解决在集群部署,形成的分成式环境下,多个定时任务线程同时触发的问题:通过施加DB锁,保证同一时刻,只有一个线程在执行任务触发,从而避免调度任务的重复执行。也可以通过db脚本来看看,xxl_job_lock表的记录:
INSERT INTO `xxl_job_lock` ( `lock_name`) VALUES ( 'schedule_lock');
只有一条记录,就是为这里的定时任务触发服务的。
友情提示
如果要使用db悲观锁,要开启事物,XXL-job的代码里面也是的,看start()方法:
conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
connAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
而最后,也在finally块里面进行了释放:
// commit
if (conn != null) {
try {
conn.commit();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
conn.setAutoCommit(connAutoCommit);
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
conn.close();
} catch (SQLException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
执行时间刷新
上面有一个重要的方法,refreshNextValidTime用来进行更新定时任务的下次执行时间:
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());
}
}
继续跟进这个方法,generateNextValidTime用来获取下次执行时间:
// ---------------------- tools ----------------------
public static Date generateNextValidTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
ScheduleTypeEnum scheduleTypeEnum = ScheduleTypeEnum.match(jobInfo.getScheduleType(), null);
if (ScheduleTypeEnum.CRON == scheduleTypeEnum) {
Date nextValidTime = new CronExpression(jobInfo.getScheduleConf()).getNextValidTimeAfter(fromTime);
return nextValidTime;
} else if (ScheduleTypeEnum.FIX_RATE == scheduleTypeEnum /*|| ScheduleTypeEnum.FIX_DELAY == scheduleTypeEnum*/) {
return new Date(fromTime.getTime() + Integer.valueOf(jobInfo.getScheduleConf())*1000 );
}
return null;
}
看到这里,有没有一种恍然大悟的感觉呢:原来在XXL-job创建执行任务时,选择的调度策略就是在这里进行计算的。
举例说明
具体什么意思呢,以前面创建过的一个任务first_job为例,在界面进行编辑:
可以看到调度配置--》调度类型:
- CRON,表示CRON表达式,后面跟的就表达式配置,十分灵活
- 固定速度,比如每隔30s执行一次,适合固定频率的任务
比如first_job配置的Cron表达式为:0/5 * * * * ?,表示每隔5s执行一次。在db任务中记录为:
INSERT INTO `xxl_job`.`xxl_job_info` (`id`, `job_group`, `job_desc`, `add_time`, `update_time`, `author`, `alarm_email`, `schedule_type`, `schedule_conf`, `misfire_strategy`, `executor_route_strategy`, `executor_handler`, `executor_param`, `executor_block_strategy`, `executor_timeout`, `executor_fail_retry_count`, `glue_type`, `glue_source`, `glue_remark`, `glue_updatetime`, `child_jobid`, `trigger_status`, `trigger_last_time`, `trigger_next_time`) VALUES ('2', '1', 'first_job', '2023-11-25 16:46:20', '2023-11-25 16:46:32', 'kobe', '', 'CRON', '0/5 * * * * ?', 'DO_NOTHING', 'FIRST', 'first_job', '', 'SERIAL_EXECUTION', '0', '0', 'BEAN', '', 'GLUE代码初始化', '2023-11-25 16:46:20', '', '1', '1703686055000', '1703686060000');
启动一下XX-job,打个debug跟进一下里:
而对于固定频率的计算,则更为直接,直接用当前时间+scheduleConf时间(单位为秒)即可:
if (ScheduleTypeEnum.FIX_RATE == scheduleTypeEnum /*|| ScheduleTypeEnum.FIX_DELAY == scheduleTypeEnum*/) {
return new Date(fromTime.getTime() + Integer.valueOf(jobInfo.getScheduleConf())*1000 );
}
过期处理策略
当任务列表被捞起来之后,会循环执行任务触发。如果不符合条件,则会更新对应的执行时间字段。但是在某些异常的情况下,任务可能会错过触发:
- 可能原因:服务重启;调度线程被阻塞,线程被耗尽;上次调度持续阻塞,下次调度被错过;
所以,XXL-job针对这种情况代码里面做了过期处理策略:
- 过期超5s:本次忽略,当前时间开始计算下次触发时间
- 过期5s内:立即触发一次,当前时间开始计算下次触发时间
对应代码如下:
// 过期超5s
if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
// 2.1、trigger-expire > 5s:pass && make next-trigger-time
logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());
// 1、任务调度错过触发时间时的处理策略
MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
// 立即触发一次
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
}
// 如果不执行if里面的trigger,就表示本次忽略
// 2、当前时间开始计算下次触发时间
refreshNextValidTime(jobInfo, new Date());
可以看下过期策略定义的枚举:
public enum MisfireStrategyEnum {
/**
* 本次忽略
*/
DO_NOTHING(I18nUtil.getString("misfire_strategy_do_nothing")),
/**
* 立即触发一次
*/
FIRE_ONCE_NOW(I18nUtil.getString("misfire_strategy_fire_once_now"));
}
任务触发
上面介绍了XXL-job的过期处理策略及对应的代码,其实这仅是任务触发的一种场景:
- 为什么叫做过期,这个超过5s是什么意思?
- 除了过期,是不是还有未过期的:当前的,未来的?
带着这些疑问,再看下触发的代码:
if (scheduleList!=null && scheduleList.size()>0) {
// 2、push time-ring
for (XxlJobInfo jobInfo: scheduleList) {
// time-ring jump:触发时间超过5s
if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
// 2.1、trigger-expire > 5s:pass && make next-trigger-time
logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());
// 1、错过触发时间,根据策略决定,是否立即补尝执行一次
MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
// FIRE_ONCE_NOW 》 trigger
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
}
// 2、更新下次执行相关时间参数
refreshNextValidTime(jobInfo, new Date());
// 触发时间小于当前时间且在5s以内的任务
} else if (nowTime > jobInfo.getTriggerNextTime()) {
// 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time
// 1、触发任务
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
// 2、更新下次执行相关时间参数
refreshNextValidTime(jobInfo, new Date());
// next-trigger-time in 5s, pre-read again:// 触发一次之后,下次执行时间在未来5s以内的任务
if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
} else {
// 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
}
相信初次看到这段代码的时候,不知道有没有像我一样有些摸不着头脑(觉得写的有点乱啊,有没有同感)。
为了解释清楚这段代码,要先定义一下XXL-job的所谓预读与时间环的设计概念!
预读
前面在代码里面,查询任务列表的代码如下:
任务数量
// pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
从这个源码的注释就能看出来,预读数量preReadCount就是6000:triggerPoolFastMax默认200,triggerPoolSlowMax默认为100
preReadCount = (200 + 100) * 20 = 6000!
查询条件
// 1、pre read
long nowTime = System.currentTimeMillis();
List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
其中PRE_READ_MS代码定死了就是5s中:
public static final long PRE_READ_MS = 5000; // pre read
对应的SQL:
SELECT <include refid="Base_Column_List" />
FROM xxl_job_info AS t
WHERE t.trigger_status = 1
and t.trigger_next_time <![CDATA[ <= ]]> #{maxNextTime}
ORDER BY id ASC
LIMIT #{pagesize}
从上面可以看出,任务查询更新都是围绕triggerNextTime字段进行的,这个字段意思就是:
private long triggerNextTime; // 下次调度时间
总结下来就是:XXL-job通过多线程的的方式,每次执行时:批量查询triggerNextTime为当前时间+5s之后的任务列表(最多6000个),这就是所谓的预读!
时序图
这个图上,通过时间轴的变化,人为把它划分为4种类型的任务:
- 超期任务,调度时间在过去5s之前,这种是否触发根据任务配置的过期处理策略来执行,可能是立即补偿一次,也可能是忽略不执行
- 过期任务,调度时间在过去5s以内,这种立即触发调度一次
- 预读任务,调度时间在未来5s以内,这种放入任务环中,由任务环线程来执行调度
- 未来任务,调度时间在未来5s以后,不进行处理。因为这种理论上也是没有被捞取出来的。图上为方便理解只是做个展示说明
任务触发
超期任务
对于超期任务,在前面过期处理策略已经介绍过了,这里就不再赘述,见上面
过期任务
对于过期任务,调度时间在过去5s以内,这种立即触发调度一次:
if (nowTime > jobInfo.getTriggerNextTime()) {
// 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time
// 1、触发任务
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
// 2、更新下次执行相关时间参数
refreshNextValidTime(jobInfo, new Date());
// next-trigger-time in 5s, pre-read again:// 触发一次之后,下次执行时间在未来5s以内的任务
if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
// 1、获取任务环齿轮
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、放入任务环中
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
}
预读任务
预读任务,调度时间在未来5s以内,这种放入时间任务环中,由任务环线程来执行调度:
// 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time
// 1、获取任务环齿轮
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、放入任务环中
pushTimeRing(ringSecond, jobInfo.getId());
// 3、刷新任务执行时间信息
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
所谓的时间任务环,其实就是一个内存Map:
// 时间任务环,key为时间,value为任务列表
private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();
而往里面放入任务的代码如下:
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) );
}
其中的key计算公式为:
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
意思就是根据下次调度时间转换为秒,并与60取模。这样所有的任务计算出来,一定是在60s以内的某个值ringSecond上,这样就ringSecond为key,任务jobId放入到对应的value的集合List中了。面这就形成了XXL-job中的时间环!
时间环
从定时任务触发线程scheduleThread中通过预读生成了预读任务任务列表,如下示意图,
scheduleThread也代表里面的生产线程
而对应的消费线程就是下面要介绍的ringThread
时间环处理线程
先看下代码,还是比较直观的:
// ring thread
ringThread = new Thread(new Runnable() {
@Override
public void run() {
while (!ringThreadToStop) {
// align second:线程随机休眠1s内,用以等待生产线程scheduleThread产生任务
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++) {
// 从时间任务环中,取出待触发调度任务:注意这里remove取出之后,原来的就的map就没有了
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);
}
}
}
logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
}
});
ringThread.setDaemon(true);
ringThread.setName("xxl-job, admin JobScheduleHelper#ringThread");
ringThread.start();