xxl-job
前世今生
定时任务的场景
1、信用卡花呗账单通知、手机号余额不足提醒(定时)
2、数据跑批
Quartz的诞生
Quartz在20001年发布,使用 Java 语言编写,它的诞生让任务调度更加简单,开发人员只需要关注业务。
工作模型
优劣势
优点:
- 精确毫秒级别的调度
- 支持集群部署
- 支持事务
- 支持持久化
劣势:
- 调度和任务集成在一起,随着调度任务和逻辑增加,整个调度性能会受到影响
- 集群之间负载结果是随机的,谁抢到数据库锁就由谁去执行任务,可能会出现负载不均衡,发挥不了机器的性能
- 不支持动态调度
- 执行数据统计、监控不完善
xxl-job的发展历史
xxl-job是2015年点评员工 许雪里 业余开源之作,2016年点评内部接入xxl-job,据统计,自2016-01-21接入至2017-12-01期间,该系统已调度约100万次,表现优异。
经过数十个版本的更新,系统的任务模型、UI交互模型以及底层调度通讯模型都有了较大的优化和提升,核心功能更加稳定高效。
目前接入登记使用的企业已经有几百家,算上没有登记的企业,几千家是有的。接入场景:如电商业务,O2O业务和大数据作业等。
版本变更
在早期的xxl-job中,调度依赖是Quartz,直到2019年7月发布的7.27版本才移除Quartz依赖。重构代码虽移除了Quartz,但是仍然少不了 调度器、触发器、任务的设计。
特性
1、调度中心和执行任务解耦
2、调度任务支持多种不同场景的路由策略、容错策略、触发策略
3、事件全异步执行
4、运维更加便捷
架构设计
架构图
架构组成
调度中心
解耦
负责管理任务调度,按照调度配置发出调度请求,不承担任何业务代码。
调度系统和任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务执行。
动态调度
支持动态管理调度信息,包括任务新建、更新、删除,GLUE脚本和任务报警等
可视化运维
支持可视化查看监控调度结果报表和执行日志
执行器
负责接受调度请求并执行任务逻辑,专注于任务的执行操作。
设计思想
调度与执行解耦
调度和执行任务解耦,可以带来提升各自系统的稳定性和扩展性。
全异步化 & 轻量级
全异步化
-
异步调度
调度和任务解耦后,调度触发变成了一个异步的触发,会先把调度请求放入到 ”调度队列“ 里,任务的执行不再消耗调度中心的资源。
-
异步执行
执行器受到调度请求后,会把调度请求放到 执行队列 里异步消费运行,并返回执行结果。
轻量级
XXL-JOB调度中心中每个JOB逻辑非常 “轻”,在全异步化的基础上,单个JOB一次运行平均耗时基本在 “10ms” 之内(基本为一次请求的网络开销);因此,可以保证使用有限的线程支撑大量的JOB并发运行。
得益于上述两点优化,理论上默认配置下的调度中心,单机能够支撑 5000 任务并发运行稳定运行;
实际场景中,由于调度中心与执行器网络ping延迟不同、DB读写耗时不同、任务调度密集程度不同,会导致任务量上限会上下波动。
如若需要支撑更多的任务量,可以通过 “调大调度线程数” 、”降低调度中心与执行器ping延迟” 和 “提升机器配置” 几种方式优化。
均衡调度
调度中心在集群部署时会自动进行任务平均分配,触发组件每次获取与线程池数量(调度中心支持自定义调度线程池大小)相关数量的任务,避免大量任务集中在单个调度中心集群节点。
调度线程池配置:
xxl.job.triggerpool.fast.max=200
xxl.job.triggerpool.slow.max=100
应用
下载
文档地址
源码仓库地址
源码仓库地址 | Release Download |
---|---|
https://github.com/xuxueli/xxl-job | Download |
http://gitee.com/xuxueli0323/xxl-job | Download |
中央仓库地址
<!-- http://repo1.maven.org/maven2/com/xuxueli/xxl-job-core/ -->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>${最新稳定版本}</version>
</dependency>
环境
- Maven3+
- Jdk1.8+
- Mysql5.7+
初始化数据库
“调度数据库初始化SQL脚本” 位置为: /xxl-job/doc/db/tables_xxl_job.sql
调度中心支持集群部署,集群情况下各节点务必连接同一个mysql实例;
如果 Mysql 做主从,调度中心集群节点务必强制走主库。
配置调度中心
调度中心是任务的指挥中心,支持集群部署
/xxl-job/xxl-job-admin/src/main/resources/application.properties
配置数据库、用户密码、token
spring:
datasource:
url: jdbc:mysql://xxx
username: xxx
password: xxx
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.zaxxer.hikari.HikariDataSource
xxl:
job:
login:
username: admin
password: 123456
accessToken: 3d10c87f4be94095b2b0890ea655655e
i18n: zh_CN
triggerpool:
fast:
max: 200
slow:
max: 100
logretentiondays: 30
配置执行器
配置连接的调度中心的地址、通信的token、执行端口、日志存储路径
xxl:
job:
enabled: true
accessToken: 3d10c87f4be94095b2b0890ea655655e
admin-addresses: http://localhost:8080/xxl-job-admin
executor-address:
executor-ip:
executor-port: 9999
log-path: /home/admin/logs/${spring.application.name}/jobhandler
log-retention-days: 30
appname: ${spring.application.name}
添加执行器
调度器和执行器都启动好后,将执行器添加下执行器组
appName 和执行器配置的 xxl.job.appname
要保持一致,如果是自动注册,执行器向调度器注册成功后,调度器会根据配置的AppName判断获取IP地址。在执行定时任务调度时,会根据注册地址和路由策略进行任务调度通信。
编写任务代码
1、加上@Component注解。xxl-job从spring bean容器中扫描带有 @XxlJob 方法的bean实例,存储在一个Map中,key是 @XxlJob 的 value 属性名称,value是当前bean实例。
2、继承 IJobHandler 抽象类
3、execute方法加 @XxlJob ,value是当前JobHandler的名称,init是当前任务的初始化方法,destroy是容器销毁方法,
@Component
public class SimpleJobHandler extends IJobHandler {
@XxlJob(value = "simpleJobHandler")
@Override
public ReturnT<String> execute(String param) throws Exception {
XxlJobLogger.log("Hello XXL-JOB");
return ReturnT.SUCCESS;
}
}
添加定时任务
路由策略
-
FIRST 第一个
固定选择第一个机器
-
LAST 最后一个
固定选择最后一个机器
-
ROUND 轮询
轮询选择在线的机器
-
RANDOM 随机
随机选择在线的机器
-
CONSISTENT_HASH 一致性HASH
每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上
-
LFU 最不经常使用
使用频率最低的机器优先被选举
-
LRU 最近最久未使用
最久未使用的机器优先被选举
-
FAILOVER 故障转移
按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度
-
BUSYOVER 忙碌转移
按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度
-
SHARDING_BROADCAST 分片广播
广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务
分片广播
分片数和执行器机器数量是一致的,编号从 0 开始。这里是数据分片,不是任务分片。
这里就是遍历执行器地址,将分片参数往下传。index是当前执行器下标编号,total是执行器总数。
if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null)
&& group.getRegistryList() != null && !group.getRegistryList().isEmpty()
&& shardingParam == null) {
// 分片广播
for (int i = 0; i < group.getRegistryList().size(); i++) {
processTrigger(group, jobInfo, finalFailRetryCount, triggerType,
// 分片参数:index
i,
// 分片参数:total
group.getRegistryList().size());
}
} else {
if (shardingParam == null) {
shardingParam = new int[]{0, 1};
}
processTrigger(group, jobInfo, finalFailRetryCount, triggerType,
shardingParam[0], shardingParam[1]);
}
调度器会将分片参数发送给执行器,执行器会放在 ThreadLocal 中。
com.xxl.job.core.thread.JobThread#run
ShardingUtil.setShardingVo(new ShardingVO(
triggerParam.getBroadcastIndex(), triggerParam.getBroadcastTotal()));
可以直接在业务 JobHandler 中获取分片参数。
@Component
public class SimpleJobHandler extends BaseJobHandler {
@XxlJob(value = "simpleJobHandler")
@Override
public ReturnT<String> execute(String param) throws Exception {
XxlJobLogger.log("Hello XXL-JOB");
ShardingUtil.ShardingVO shardingVo = ShardingUtil.getShardingVo();
int index = shardingVo.getIndex();
int total = shardingVo.getTotal();
return ReturnT.SUCCESS;
}
}
如何使用分片参数呢?比如每个节点处理指定分片范围的数据,通过 id 取余 total,得出id结果只要等于index,就是当前节点要处理的数据了。
SELECT * FROM content_video WHERE deleted = 0 AND MOD(id, #{total}) = {index} LIMIT 0, 100
故障转移
遍历执行器所有地址,发送心跳检测,如果心跳检测异常,则继续下一个地址。如果心跳检测成功,就使用当前执行器地址。
public class ExecutorRouteFailover extends ExecutorRouter {
@Override
public ReturnT<String> route(TriggerParam triggerParam, List<String> addressList) {
StringBuffer beatResultSB = new StringBuffer();
for (String address : addressList) {
// beat
ReturnT<String> beatResult = null;
try {
ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);
beatResult = executorBiz.beat();
} catch (Exception e) {
logger.error(e.getMessage(), e);
beatResult = new ReturnT<String>(ReturnT.FAIL_CODE, ""+e );
}
beatResultSB.append( (beatResultSB.length()>0)?"<br><br>":"")
.append(I18nUtil.getString("jobconf_beat") + ":")
.append("<br>address:").append(address)
.append("<br>code:").append(beatResult.getCode())
.append("<br>msg:").append(beatResult.getMsg());
// beat success
if (beatResult.getCode() == ReturnT.SUCCESS_CODE) {
beatResult.setMsg(beatResultSB.toString());
beatResult.setContent(address);
return beatResult;
}
}
return new ReturnT<String>(ReturnT.FAIL_CODE, beatResultSB.toString());
}
}
一致性哈希
运行模式
在xxl-job中,不仅支持运行预编译好的类,也支持支持输入脚本代码运行。
BEAN模式
运行Java任务类,在执行器端编写,就是Bean模式。
GLUE模式
运行脚本代码,在调度器控台编写输入,就是GLUE模式。
阻塞处理策略
执行器接收到任务调度请求后,会根据阻塞处理策略和当前任务运行状态,判断调度请求是等待、丢失还是覆盖。
单机串行(默认)
调度请求进入单机执行器后,调度请求进入FIFO队列并以串行方式运行
丢弃后续调度
调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败
覆盖之前调度
调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务
子任务ID
在一些数据跑批场景中,有些任务是相互依赖的,比如需要先下载文件,再解析入库,入库后再进行一些数据分析统计。也就是任务串行执行。
以上可以将每个流程做成一个任务,它的下游流程设置为一个子任务,当前任务执行完毕后,触发它的子任务执行。
任务超时
执行任务在指定时间内返回结果,就不再等待。
任务重试
如果任务执行失败,调度器收到任务执行结果,会写入到任务日志中。
后台监控任务线程,会定时遍历查询执行失败的任务,设置了任务重试次数,会再次发次重试,直到重试次数使用完或执行成功。
原理分析
调度器
启动配置类
com.xxl.job.admin.core.conf.XxlJobAdminConfig
它实现了 InitializingBean 接口,在初始化的时候会调用 afterPropertiesSet 方法
private XxlJobScheduler xxlJobScheduler;
@Override
public void afterPropertiesSet() throws Exception {
adminConfig = this;
xxlJobScheduler = new XxlJobScheduler();
xxlJobScheduler.init();
}
XxlJobScheduler的 init 初始化方法
public void init() throws Exception {
// init i18n
initI18n();
// 1 admin registry monitor run 用来更新执行器注册表
JobRegistryMonitorHelper.getInstance().start();
// 2 admin fail-monitor run 任务执行失败处理
JobFailMonitorHelper.getInstance().start();
// 3 admin lose-monitor run 任务结果丢失处理
JobLosedMonitorHelper.getInstance().start();
// 4 admin trigger pool start 创建了两个调度线程池
JobTriggerPoolHelper.toStart();
// 5 admin log report start 报表线程
JobLogReportHelper.getInstance().start();
// 6 start-schedule 启动调度器
JobScheduleHelper.getInstance().start();
logger.info(">>>>>>>>> init xxl-job admin success.");
}
JobRegistryMonitorHelper
每 30s 执行一次更新注册表,将心跳最后更新时间晚于当前时间 90s 的执行器剔除
JobFailMonitorHelper
每隔 10s 将执行失败的任务触发告警
JobLosedMonitorHelper
每隔 60s 将丢失的任务主动标记为执行失败(执行器心跳注册不在线,调度状态仍是“运行中”)
JobTriggerPoolHelper
创建调度线程池(快慢线程池)
JobLogReportHelper
报表线程池
JobScheduleHelper
启动调度器线程,主要是创建启动调度线程和时间轮线程
调度器线程
随机睡眠4~5秒,尽量避免调度器集群环境下的资源竞争
TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
计算预读取任务数,默认6000
int preReadCount = (getAdminConfig().getTriggerPoolFastMax() + getAdminConfig().getTriggerPoolSlowMax()) * 20;
接下来就是一个 while 循环,也就是调度器重复在做的事情。
// 只要调度线程不停止,就不断地调度
while (!scheduleThreadToStop) {
...
}
获取任务锁
获取数据的排他锁,集群环境下的调度器服务,在同一时间只能有一个调度器能获取任务信息。
如果加锁没有成功,说明有其它调度服务在处理任务了,只能等事务提交或回滚释放锁后,才能继续获取锁。
preparedStatement = conn.prepareStatement(
"select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
preparedStatement.execute();
获取锁后,查询 6000条 下次触发时间在 5秒 内的任务
List<XxlJobInfo> scheduleList = getXxlJobInfoDao()
.scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
调度任务
任务的触发时间分为了三种情况。
比如当前时间是上午 9:00 00,任务每隔5秒执行一次。
第一种情况:触发时间过期5秒以上
触发时间在上午 8:59 55:00 之前的任务
任务触发时间过期5秒以上,这时候就不进行调度处理了,而是刷新它的下次调度时间。
什么场景会出现触发时间超时呢?比如数据库查询非常慢等情况
if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
// 2.1、trigger-expire > 5s:pass && make next-trigger-time
// fresh next
refreshNextValidTime(jobInfo, new Date());
}
第二种情况:触发时间过期5秒内
触发时间在上午 8:59 55:00 到 9:00 之间的任务
已经到了触发时间,但是过期时间在5秒内,正常触发调度。
else if (nowTime > jobInfo.getTriggerNextTime()) {
// 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time
// 1、trigger
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
// 2、fresh next
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()));
}
}
第三种情况:没有到触发时间
触发时间在上午 9:00 之后的任务
对还没有到触发时间的任务,加入到时间轮,并刷新下次触发时间。
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()));
}
任务触发
从 JobTriggerPoolHelper#addTrigger 开始触发调度任务,这里看到会先有一个快慢线程池的选择。
如果当前任务的历史执行耗时超过 500毫秒 ,并且次数大于 10次,那么将调度触发线程池替换为 慢线程池。
这样做将调度效率不同的任务进行线程隔离分组,充分利用线程池资源。
ThreadPoolExecutor triggerPool_ = fastTriggerPool;
AtomicInteger jobTimeoutCount = jobTimeoutCountMap.get(jobId);
if (jobTimeoutCount!=null && jobTimeoutCount.get() > 10) {
// job-timeout 10 times in 1 min
triggerPool_ = slowTriggerPool;
}
执行结束的代码中,进行任务耗时判断,递增耗时次数。
} finally {
// 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();
}
}
}
线程池选择好后,就开始调度触发任务啦!
com.xxl.job.admin.core.trigger.XxlJobTrigger#trigger
1、获取任务服务组
根据任务ID获取任务服务组信息
XxlJobGroup group = XxlJobAdminConfig.getAdminConfig()
.getXxlJobGroupDao().load(jobInfo.getJobGroup());
2、是否广播分片执行
判断是否任务的路由策略是否 广播分片,如果是 广播分片,所有执行器服务都要参与负载,否则就根据策略获取执行器地址。
if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null)
&& group.getRegistryList() != null && !group.getRegistryList().isEmpty()
&& shardingParam == null) {
// 遍历执行器所有地址发起调度请求
for (int i = 0; i < group.getRegistryList().size(); i++) {
processTrigger(group, jobInfo, finalFailRetryCount, triggerType,
i, group.getRegistryList().size());
}
} else {
if (shardingParam == null) {
shardingParam = new int[]{0, 1};
}
processTrigger(group, jobInfo, finalFailRetryCount, triggerType,
shardingParam[0], shardingParam[1]);
}
3、执行器负载均衡
如果任务路由策略不是 广播分片,则根据路由策略获取执行器地址。
routeAddressResult = executorRouteStrategyEnum.getRouter().route(
triggerParam, group.getRegistryList());
if (routeAddressResult.getCode() == ReturnT.SUCCESS_CODE) {
address = routeAddressResult.getContent();
}
发起调度请求
拼装触发请求参数,发起HTTP请求
public static ReturnT<String> runExecutor(TriggerParam triggerParam, String address) {
ReturnT<String> runResult = null;
try {
ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);
runResult = executorBiz.run(triggerParam);
} catch (Exception e) {
logger.error(">>>>>>>>>>> xxl-job trigger error, please check if the executor[{}] is running.", address, e);
runResult = new ReturnT<String>(ReturnT.FAIL_CODE, ThrowableUtil.toString(e));
}
StringBuffer runResultSB = new StringBuffer(I18nUtil.getString("jobconf_trigger_run") + ":");
runResultSB.append("<br>address:").append(address);
runResultSB.append("<br>code:").append(runResult.getCode());
runResultSB.append("<br>msg:").append(runResult.getMsg());
runResult.setMsg(runResultSB.toString());
return runResult;
}
public ReturnT<String> run(TriggerParam triggerParam) {
return XxlJobRemotingUtil.postBody(this.addressUrl + "run", this.accessToken, this.timeout, triggerParam, String.class);
}
以上的总结:如果有任务需要处理,选择执行器调度执行。预读的任务和没有触发时间的任务,加入到时间轮。
执行器
启动
入口 XxlJobSpringExecutor 实现了 SmartInitializingSingleton 接口,在对象初始化的时候,会调用afterSingletonsInstantiated 方法。
public void afterSingletonsInstantiated() {
if (this.isEnabled()) {
// 初始化 JobHandler
this.initJobHandlerMethodRepository(applicationContext);
GlueFactory.refreshInstance(1);
try {
// 启动后台处理线程
super.start();
} catch (Exception var2) {
throw new RuntimeException(var2);
}
}
}
初始化 JobHandler
initJobHandlerMethodRepository
初始化 JobHandler,通过扫描 @XxlJob 注解,将实例存储在一个Map中,key是配置的JobHandler名称,value就是bean实例接口。
当调度请求过来,根据请求的 JobHandler名称 找到对应的实例接口,最后执行execute方法。
初始化后台线程
XxlJobSpringExecutor继承了XxlJobExecutor,在afterSingletonsInstantiated调用了父类的 start 方法
public void start() throws Exception {
// 初始化日志文件目录
XxlJobFileAppender.initLogPath(this.logPath);
// 初始化调度通信客户端
this.initAdminBizList(this.adminAddresses, this.accessToken);
// 初始化日志清理线程
JobLogFileCleanThread.getInstance().start((long)this.logRetentionDays);
// 初始化结果回调线程
TriggerCallbackThread.getInstance().start();
// 初始化执行器服务器(重点)
this.initEmbedServer(this.address, this.ip, this.port, this.appname, this.accessToken);
}
初始化内置服务器
private void initEmbedServer(String address, String ip, int port,
String appname, String accessToken) throws Exception {
...
this.embedServer = new EmbedServer();
this.embedServer.start(address, port, appname, accessToken);
}
初始化业务线程池
该线程池用于处理任务调度、心跳请求
final ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(0, 200, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(2000), ...);
初始化Netty服务
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap();
((ServerBootstrap)bootstrap
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class))
.childHandler(new ChannelInitializer<SocketChannel>() {
public void initChannel(SocketChannel channel) throws Exception {
channel.pipeline()
.addLast(new ChannelHandler[]{new IdleStateHandler(0L, 0L, 90L, TimeUnit.SECONDS)})
.addLast(new ChannelHandler[]{new HttpServerCodec()})
.addLast(new ChannelHandler[]{new HttpObjectAggregator(5242880)})
// 处理调度服务请求的Handler
.addLast(new ChannelHandler[]{new EmbedServer.EmbedHttpServerHandler(
EmbedServer.this.executorBiz, accessToken, bizThreadPool)});
}
})
.childOption(ChannelOption.SO_KEEPALIVE, true);
ChannelFuture future = bootstrap.bind(port).sync();
EmbedServer.this.startRegistry(appname, address);
future.channel().closeFuture().sync();
注册
将执行器的地址注册到调度中心
public void startRegistry(String appname, String address) {
ExecutorRegistryThread.getInstance().start(appname, address);
}
调度中心接口到注册请求后,会将执行器名称和地址保存到 xxl_job_registry。
另外调度中心有 JobRegistryMonitorHelper#registryThread 线程,定时探活执行器服务地址,将不可用的执行器服务删除。
任务处理
执行器的 Netty Server 接收到请求后,会由 EmbedHttpServerHandler 进行业务处理。channelRead0 方法拿到请求数据后,会将请求扔给 业务线程池 异步处理。
public static class EmbedHttpServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private static final Logger logger = LoggerFactory.getLogger(EmbedServer.EmbedHttpServerHandler.class);
private ExecutorBiz executorBiz;
private String accessToken;
private ThreadPoolExecutor bizThreadPool;
public EmbedHttpServerHandler(ExecutorBiz executorBiz, String accessToken, ThreadPoolExecutor bizThreadPool) {
this.executorBiz = executorBiz;
this.accessToken = accessToken;
this.bizThreadPool = bizThreadPool;
}
protected void channelRead0(final ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {
final String requestData = msg.content().toString(CharsetUtil.UTF_8);
final String uri = msg.uri();
final HttpMethod httpMethod = msg.method();
final boolean keepAlive = HttpUtil.isKeepAlive(msg);
final String accessTokenReq = msg.headers().get("XXL-JOB-ACCESS-TOKEN");
this.bizThreadPool.execute(new Runnable() {
public void run() {
Object responseObj = EmbedHttpServerHandler.this.process(httpMethod, uri, requestData, accessTokenReq);
String responseJson = GsonTool.toJson(responseObj);
EmbedHttpServerHandler.this.writeResponse(ctx, keepAlive, responseJson);
}
});
}
private Object process(HttpMethod httpMethod, String uri, String requestData,
String accessTokenReq) {
...
}
}
最终根据请求的 URI 调用不同的处理,调度请求的 URI 是 /run,所以执行 executorBiz#run方法
private Object process(HttpMethod httpMethod, String uri, String requestData,
String accessTokenReq) {
if (HttpMethod.POST != httpMethod) {
return new ReturnT(500, "invalid request, HttpMethod not support.");
} else if (uri != null && uri.trim().length() != 0) {
if (this.accessToken != null && this.accessToken.trim().length() > 0 && !this.accessToken.equals(accessTokenReq)) {
return new ReturnT(500, "The access token is wrong.");
} else {
try {
if ("/beat".equals(uri)) {
return this.executorBiz.beat();
} else if ("/idleBeat".equals(uri)) {
IdleBeatParam idleBeatParam = (IdleBeatParam)GsonTool.fromJson(requestData, IdleBeatParam.class);
return this.executorBiz.idleBeat(idleBeatParam);
} else if ("/run".equals(uri)) {
TriggerParam triggerParam = (TriggerParam)GsonTool.fromJson(requestData, TriggerParam.class);
return this.executorBiz.run(triggerParam);
} else if ("/kill".equals(uri)) {
KillParam killParam = (KillParam)GsonTool.fromJson(requestData, KillParam.class);
return this.executorBiz.kill(killParam);
} else if ("/log".equals(uri)) {
LogParam logParam = (LogParam)GsonTool.fromJson(requestData, LogParam.class);
return this.executorBiz.log(logParam);
} else {
return new ReturnT(500, "invalid request, uri-mapping(" + uri + ") not found.");
}
} catch (Exception var6) {
logger.error(var6.getMessage(), var6);
return new ReturnT(500, "request error:" + ThrowableUtil.toString(var6));
}
}
} else {
return new ReturnT(500, "invalid request, uri-mapping empty.");
}
}
根据调度请求 JobHandler 名称从Map找到对应的 JobHandler 实例,然后包装成一个 JobThread 线程。接着调度请求数据被放入 JobThread 线程的异步队列中,由 JobThread 消费异步队列。
public static JobThread registJobThread(int jobId, IJobHandler handler,
String removeOldReason) {
JobThread newJobThread = new JobThread(jobId, handler);
newJobThread.start();
JobThread oldJobThread = (JobThread)jobThreadRepository.put(jobId, newJobThread);
if (oldJobThread != null) {
oldJobThread.toStop(removeOldReason);
oldJobThread.interrupt();
}
return newJobThread;
}
public class JobThread extends Thread {
private static Logger logger = LoggerFactory.getLogger(JobThread.class);
private int jobId;
private IJobHandler handler;
private LinkedBlockingQueue<TriggerParam> triggerQueue;
private Set<Long> triggerLogIdSet;
private volatile boolean toStop = false;
private String stopReason;
private boolean running = false;
private int idleTimes = 0;
public JobThread(int jobId, IJobHandler handler) {
this.jobId = jobId;
this.handler = handler;
this.triggerQueue = new LinkedBlockingQueue();
this.triggerLogIdSet = Collections.synchronizedSet(new HashSet());
}
public void run() {
while(!this.toStop) {
...
triggerParam = null;
triggerParam = (TriggerParam)this.triggerQueue.poll(3L, TimeUnit.SECONDS);
// 执行 JobHandler 业务代码
executeResult = this.handler.execute(triggerParam.getExecutorParams());
if (triggerParam != null) {
if (!this.toStop) {
// 将执行结果放入回调队列
TriggerCallbackThread.pushCallBack(
new HandleCallbackParam(triggerParam.getLogId(), triggerParam.getLogDateTime(), executeResult));
} else {
stopResult = new ReturnT(500, this.stopReason + " [job running, killed]");
TriggerCallbackThread.pushCallBack(new HandleCallbackParam(triggerParam.getLogId(), triggerParam.getLogDateTime(), stopResult));
}
}
}
}
...
}
结果回调
执行器接收到调度请求后,是放到异步队列进行消费处理的,所以执行结果也是异步返回调度中心的。调度中心接收到请求后,更新任务日志的执行结果。
com.xxl.job.core.thread.TriggerCallbackThread#start
while(!TriggerCallbackThread.this.toStop) {
try {
// 阻塞获取回调队列的数据
HandleCallbackParam callback = (HandleCallbackParam) TriggerCallbackThread.getInstance()
.callBackQueue.take();
if (callback != null) {
// 如果回调队列数据不为空,则将队列数据一次性拿出。
List<HandleCallbackParam> callbackParamListx = new ArrayList();
int drainToNumx = TriggerCallbackThread.getInstance()
.callBackQueue.drainTo(callbackParamListx);
callbackParamListx.add(callback);
if (callbackParamListx != null && callbackParamListx.size() > 0) {
// 向调度中心发起回调请求
TriggerCallbackThread.this.doCallback(callbackParamListx);
}
}
} catch (Exception var4) {
if (!TriggerCallbackThread.this.toStop) {
TriggerCallbackThread.logger.error(var4.getMessage(), var4);
}
}
}
时间轮
什么是时间轮?
先思考一个问题,现在有1000个任务,都是在不同的时间执行,执行时间精确到秒,如何实现对所有的任务调度?
第一种思路(集合)
启动一个线程,每秒种对所有的任务进行遍历,找出执行时间跟当前时间匹配的任务,然后执行。但如果任务数量很大,遍历和比较所有任务会比较浪费时间。
第二种思路(堆)
按照任务执行时间进行排序,执行时间近的放在前面。这种可以用【小顶堆】实现,也就是优先队列。
一个while循环不断拿当前时间和第一个任务的执行时间对比,如果时间到了看这个任务是不是周期性任务,如果是更新下次执行时间,如果不是则将任务从优先队列移除,然后执行任务。
在JDK中就提供了 java.util.Timer 定时任务工具类,核心实现就是优先队列。
public class Timer {
/**
* The timer task queue. This data structure is shared with the timer
* thread. The timer produces tasks, via its various schedule calls,
* and the timer thread consumes, executing timer tasks as appropriate,
* and removing them from the queue when they're obsolete.
*/
private final TaskQueue queue = new TaskQueue();
/**
* The timer thread.
*/
private final TimerThread thread = new TimerThread(queue);
...
}
但 Timer 是单线程的,在很多场景不能满足业务需求。
在 JDK1.5 以后,引入了支持多线程的任务调度工具 ScheduledThreadPoolExecutor 用来替代 Timer。构造函数可以看到是一个延迟队列DelayedWorkQueue
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
但是优先队列的插入和删除时间复杂度是O(logn),当数据量大的时候,频繁的入堆出堆性能不是很好。
第三种思路(数组)
可以考虑将所有任务进行分组,把相同时刻的任务放在一起,精确到1秒种。比如下图,数组的一个下标就代表1秒钟,相同时刻的任务形成链表。这样分组后遍历和比较的时间会减少很多。
但是这样还是有一个问题,如果任务数量很大,而且时间都不同,或者执行时间相差很远,那这个数组长度是不是要非常长?比如有个任务是1个月后执行,按秒为单元的数组得创建多大内存存储?
所以数组的长度肯定不能是无限的,只能是固定长度的。
最终思路(循环数组-时间轮)
比如数组固定长度是60,一个下标代表1秒,一个数组就可以代表60秒了。线程只要遍历数组获取任务执行就可以了。
固定长度的数组怎么用来表示超出最大长度的时间呢?可以使用循环数组。
比如一个循环数组长度60,可以表示60秒,这样时间轮的概念就出来了
多层时间轮
60秒以后的任务怎么执行呢?只要模以60,用得到的余数,放入到对应的数组下标即可。
比如 90 % 60 = 30,就放在数组下标 30 的位置,这里就有了轮次的概念。第 90 秒的任务是第二轮才执行。
在xxl-job中的时间轮是怎么实现的呢?
1、计算当前时间的秒数,ringSecond 是 0~59 的秒数
// 1、make ring second
int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);
// 2、push time ring
pushTimeRing(ringSecond, jobInfo.getId());
2、放入时间轮,key是秒,value是任务id列表
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);
}
放入到 ringData 后,时间轮线程是如何拿出来执行呢?
时间轮线程
初始化对其秒数,休眠时间为:1000 - 当前秒数 % 1000 的余数,也就是等到下一个整秒运行。
TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
进入 while 循环,获取当前时间秒数
// 避免处理耗时太长,跨过刻度,向前校验一个刻度;
int nowSecond = Calendar.getInstance().get(Calendar.SECOND);
获取 当前秒数 和 上一个秒数 的时间轮任务。
(nowSecond + 60 - i) % 60 和 nowSecond - i 的结果是一样的,如果当前秒数是 30 秒,那么这里就是获取 30 秒 和 29 秒的任务。
List<Integer> ringItemData = new ArrayList<>();
for (int i = 0; i < 2; i++) {
List<Integer> tmpData = ringData.remove((nowSecond + 60 - i) % 60);
if (tmpData != null) {
ringItemData.addAll(tmpData);
}
}
将获取到的时间轮任务遍历调度执行
if (ringItemData.size() > 0) {
// do trigger
for (int jobId : ringItemData) {
// do trigger
JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
}
// clear
ringItemData.clear();
}