传送门
分布式定时任务系列5:XXL-job中blockingQueue的应用
分布式定时任务系列6:XXL-job触发日志过大引发的CPU告警
分布式定时任务系列12:XXL-job的任务触发为什么是死循环?
从XXL-job的架构设计说起
在《分布式定时任务系列8:XXL-job源码分析之远程调用》章节中通过源码分析了XXL-job的任务调度流程,即XXL-job的定时任务是如何从调度中心触发让客户端执行!
并据此引用了官网的架构图,如下所示:

整体系统架构可以分为两部分,调度中心、执行器:
- 调度模块(调度中心) :
负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码。调度系统与任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务模块;
支持可视化、简单且动态的管理调度信息,包括任务新建,更新,删除,GLUE开发和任务报警等,所有上述操作都会实时生效,同时支持监控调度结果以及执行日志,支持执行器Failover。 - 执行模块(执行器) :
负责接收调度请求并执行任务逻辑。任务模块专注于任务的执行等操作,开发和维护更加简单和高效;
接收"调度中心"的执行请求、终止请求和日志请求等。
而调度中心 与执行器 之间的交互就是通过其中的**自研RPC(xxl-rpc)**模块。一次完整的任务调度通讯流程:
- 1、"调度中心"向"执行器"发送http调度请求: "执行器"中接收请求的服务,实际上是一台内嵌Server,默认端口9999;- 2、"执行器"执行任务逻辑;- 3、"执行器"http回调"调度中心"调度结果: "调度中心"中接收回调的服务,是针对执行器开放一套API服务;
接着又在《分布式定时任务系列10:XXL-job源码分析之路由策略》章节中通过源码分析了XXL-job任务调度的路由策略:
路由策略:当执行器集群部署时,提供丰富的路由策略,包括:
路由策略:当执行器集群部署时,提供丰富的路由策略,包括;FIRST(第一个):固定选择第一个机器;LAST(最后一个):固定选择最后一个机器;ROUND(轮询):;RANDOM(随机):随机选择在线的机器;CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举;FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;SHARDING_BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;- 子任务:每个任务都拥有一个唯一的任务ID(任务ID可以从任务列表获取),当本任务执行结束并且执行成功时,将会触发子任务ID所对应的任务的一次主动调度。
以及由此延伸出来的番外篇《从XXL-job路由策略的"服务容错"说起》,探讨了XXL-job任务调度面临故障的容错设计。不过既然说到路由、容错,那执行器部署自然就是集群部署了,这在官网上也明确提到了:

不同的部署架构
执行器集群部署、调度中心单机部署
在这个架构下面,执行器是集群、调度中心假设为单机,数据源为Mysql:

执行器集群部署、调度中心集群部署
《分布式定时任务系列8:XXL-job源码分析之远程调用》
设计思想
将调度行为抽象形成"调度中心"公共平台,而平台自身并不承担业务逻辑,"调度中心"负责发起调度请求。
将任务抽象成分散的JobHandler,交由"执行器"统一管理,"执行器"负责接收调度请求并执行对应的JobHandler中的业务逻辑。
因此,"调度"和"任务"两部分可以相互解耦,提高系统整体稳定性和扩展性;
**"调度"和"任务"两部分可以相互解耦,提高系统整体稳定性和扩展性,**而执行器的的集群部署则可以提高业务系统的可用性。那么调度中心的集群部署则是提高调度中心的可用性!所以在这里就再来回顾下注册模型。
注册模型
为了观察注册模型的运行过程,先在本地运行2个调度中心实例,然后在将一个任务注册上去。
ER图
xxl_job_group:执行器信息表,维护任务执行器信息;
xxl_job_registry:执行器注册表,维护在线的执行器和调度中心机器地址信息;
sql
-- xxl_job_group:执行器信息表,维护任务执行器信息;
CREATE TABLE `xxl_job_group` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`app_name` varchar(64) NOT NULL COMMENT '执行器AppName',
`title` varchar(12) NOT NULL COMMENT '执行器名称',
`address_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '执行器地址类型:0=自动注册、1=手动录入',
`address_list` text COMMENT '执行器地址列表,多地址逗号分隔',
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- xxl_job_registry:执行器注册表,维护在线的执行器和调度中心机器地址信息;
CREATE TABLE `xxl_job_registry` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`registry_group` varchar(50) NOT NULL,
`registry_key` varchar(255) NOT NULL,
`registry_value` varchar(255) NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `i_g_k_v` (`registry_group`,`registry_key`,`registry_value`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
调度中心集群启动
现在直接在本地通过idea里面启动两个xxl-job-admin实例:端口分别为8080、8081
实例1:端口8080

实例1:端口8081

启动之后,通过两个地址分别访问实例:
http://localhost:8080/xxl-job-admin/,http://localhost:8081/xxl-job-admin/
看到的效果都是一样的:

执行器启动
执行器的启动直接在本地通过idea里面启动xxl-job-executor-sample-springboot实例:注册时的配置就跟之前单机的xxl-job-admin有区别:

这个规则在官网上也有说明:

注册完成之后,再来分别查看xxl-job-admin的控制台,会现在注册上去的执行器列表:

如何看待调度中心的高可用
前面说了调度中心的集群部署是为了高可用,意思就是在集群中的某一个或某一些节点不可用时,整个调度中心还能正常工作。来看看官网的解释:
调度中心支持集群部署,提升调度系统容灾 和可用性。
调度中心集群部署时,几点要求和建议:
- DB配置保持一致;
- 集群机器时钟保持一致(单机集群忽视);
- 建议:推荐通过nginx为调度中心集群做负载均衡,分配域名。调度中心访问、执行器回调配置、调用API服务等操作均通过该域名进行。
Server间数据一致性
由于调度中心的数据存储使用的Mysql,并且根据要求DB配置保持一致,所以所有实例连接的是同一个Mysql库。那么就意味着调度中心的数据一致性就是通过DB来保证的,在这种情况下Server间的是天然一致性:

- 执行器列表会存储在Mysql中,这样调度中心集群每个节点都会获取到相同的执行器(Client)列表
- 由于XXL-job在触发任务时使用了DB锁(悲观锁)来避免重复执行,所以理论上同一时刻只会有一个调度节点来触发任务
- 并且任务触发机制没有本地缓存,这样就保证调度中心获取到相同的执行器(Client)列表,就实现了Server节点的数据一致性
关于一致性的可以看到官网的说明:

具体源码的分析可参考:分布式定时任务系列7:XXL-job源码分析之任务触发
从上面的推论可以得出这样一个结论:只要调度中心集群的节点有一个正常运行(前提是Mysql数据库也正常),那么整个任务就可以正常调度了,这要达到了高可用的目的!
Server与Client的一致性
这里还要说明一点的是,虽然调度中心通过Mysql保证了执行器列表一致,但是细节上有点区别:
- 在任务触发时,获取的执行器列表是读取的表xxl_job_group
- 但是执行器注册时 是更新到xxl_job_registry这张表的
那这张表是什么时候同步的呢,或者说新的执行器注册之后,调度中心怎么感知的呢?这个就在调度中心的监听任务。回顾下com.xxl.job.admin.core.scheduler.XxlJobScheduler这个类:
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.");
}
跟进com.xxl.job.admin.core.thread.JobRegistryHelper这个类:
java
public void start(){
// for registry or remove
registryOrRemoveThreadPool = new ThreadPoolExecutor(
2,
10,
30L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(2000),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "xxl-job, admin JobRegistryMonitorHelper-registryOrRemoveThreadPool-" + r.hashCode());
}
},
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
r.run();
logger.warn(">>>>>>>>>>> xxl-job, registry or remove too fast, match threadpool rejected handler(run now).");
}
});
// for monitor
// 监听线程
registryMonitorThread = new Thread(new Runnable() {
@Override
public void run() {
while (!toStop) {
try {
// auto registry group
List<XxlJobGroup> groupList = XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().findByAddressType(0);
if (groupList!=null && !groupList.isEmpty()) {
// remove dead address (admin/executor)
// 从xxl_job_registry中移除超过"下线"时间的执行器
List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
if (ids!=null && ids.size()>0) {
XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);
}
// fresh online address (admin/executor)
// 从xxl_job_registry中更新"下线"时间内的执行器
HashMap<String, List<String>> appAddressMap = new HashMap<String, List<String>>();
List<XxlJobRegistry> list = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findAll(RegistryConfig.DEAD_TIMEOUT, new Date());
if (list != null) {
for (XxlJobRegistry item: list) {
if (RegistryConfig.RegistType.EXECUTOR.name().equals(item.getRegistryGroup())) {
String appname = item.getRegistryKey();
List<String> registryList = appAddressMap.get(appname);
if (registryList == null) {
registryList = new ArrayList<String>();
}
if (!registryList.contains(item.getRegistryValue())) {
registryList.add(item.getRegistryValue());
}
appAddressMap.put(appname, registryList);
}
}
}
// fresh group address
for (XxlJobGroup group: groupList) {
List<String> registryList = appAddressMap.get(group.getAppname());
String addressListStr = null;
if (registryList!=null && !registryList.isEmpty()) {
Collections.sort(registryList);
StringBuilder addressListSB = new StringBuilder();
for (String item:registryList) {
addressListSB.append(item).append(",");
}
addressListStr = addressListSB.toString();
addressListStr = addressListStr.substring(0, addressListStr.length()-1);
}
group.setAddressList(addressListStr);
group.setUpdateTime(new Date());
XxlJobAdminConfig.getAdminConfig().getXxlJobGroupDao().update(group);
}
}
} catch (Exception e) {
if (!toStop) {
logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:{}", e);
}
}
try {
// 等待心跳时间,默认30s
TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
} catch (InterruptedException e) {
if (!toStop) {
logger.error(">>>>>>>>>>> xxl-job, job registry monitor thread error:{}", e);
}
}
}
logger.info(">>>>>>>>>>> xxl-job, job registry monitor thread stop");
}
});
registryMonitorThread.setDaemon(true);
registryMonitorThread.setName("xxl-job, admin JobRegistryMonitorHelper-registryMonitorThread");
registryMonitorThread.start();
}
而这个Client的心跳是在这个类的方法:
com.xxl.job.core.thread.ExecutorRegistryThread:
java
public void start(final String appname, final String address){
// valid
if (appname==null || appname.trim().length()==0) {
logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, appname is null.");
return;
}
if (XxlJobExecutor.getAdminBizList() == null) {
logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, adminAddresses is null.");
return;
}
registryThread = new Thread(new Runnable() {
@Override
public void run() {
// registry
while (!toStop) {
try {
RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
try {
ReturnT<String> registryResult = adminBiz.registry(registryParam);
if (registryResult!=null && ReturnT.SUCCESS_CODE == registryResult.getCode()) {
registryResult = ReturnT.SUCCESS;
logger.debug(">>>>>>>>>>> xxl-job registry success, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
break;
} else {
logger.info(">>>>>>>>>>> xxl-job registry fail, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
}
} catch (Exception e) {
logger.info(">>>>>>>>>>> xxl-job registry error, registryParam:{}", registryParam, e);
}
}
} catch (Exception e) {
if (!toStop) {
logger.error(e.getMessage(), e);
}
}
try {
if (!toStop) {
// 在线程中,睡眠心跳时间,然后在循环中执行注册(其实就是心跳任务)
TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
}
} catch (InterruptedException e) {
if (!toStop) {
logger.warn(">>>>>>>>>>> xxl-job, executor registry thread interrupted, error msg:{}", e.getMessage());
}
}
}
com.xxl.job.admin.core.thread.JobRegistryHelper#registry
java
public ReturnT<String> registry(RegistryParam registryParam) {
// valid
if (!StringUtils.hasText(registryParam.getRegistryGroup())
|| !StringUtils.hasText(registryParam.getRegistryKey())
|| !StringUtils.hasText(registryParam.getRegistryValue())) {
return new ReturnT<String>(ReturnT.FAIL_CODE, "Illegal Argument.");
}
// async execute
registryOrRemoveThreadPool.execute(new Runnable() {
@Override
public void run() {
// 执行器的注册,其实也是心跳任务,这个可以从调度中心的代码能看出来
int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryUpdate(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
if (ret < 1) {
XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registrySave(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
// fresh
freshGroupRegistryInfo(registryParam);
}
}
});
return ReturnT.SUCCESS;
}
整个流程如下:
