一、概况
调度平台在进行任务调度时,除分片任务外,需要从执行器列表中选择一个来调度,如何选择就需要路由策略。策略实现在xxl-job-admin项目的core.route包中。
抽象类com.xxl.job.admin.core.route.ExecutorRouter仅有一个抽象接口。枚举类ExecutorRouteStrategyEnum声明了支持的策略。
java
// 返回执行器地址即ip:port
public abstract ReturnT<String> route(TriggerParam triggerParam, List<String> addressList);
二、路由策略实现
2.1 RouteFirst、RouteLast和RouteRandom
- RouteFirst返回addressList.get(0)
- RouteLast返回addressList.get(addressList.size()-1)。
- RouteRandom返回addressList.get(new Random().nextInt(addressList.size()));
2.2 RouteRound
轮训算法的常规实现:定义一个计数器即int counter=0,每路由一次counter加1,当counter>Integer.MAX_VALUE时让ounter归零;路由时addressList.get(counter%addressList.size())。
xxl-job中对轮训做了增强:
- counter初始值随机获取100以内的数;
- counter>1000000,或初始赋值后24小时后,主动再次初始化。
2.3 RouteConsistentHash
即一致性hash路由。效果为:
- 每个JOB固定调度执行器列表中一台机器,但是执行器数量发生变化时,调度的节点可能会发生变化;
- 假设有10个job,它们的执行器列表相同;那么这些JOB将均匀散列在不同机器上。
实现中有下面两点优化:
- a、virtual node:解决不均衡问题。每个执行器虚拟为100个hash环节点。
- b、计算hash值时,没有使用String的hashCode(可能重复),使用md5散列计算hash值,将取值范围扩大到2^32。 查找时,找大于jobId的hash值的第一个entry,entry.value就是目标address。 这儿hash环的实现方式,值得借鉴。
2.4 RouteLFU
LFU(Least Frequently Used),使用频率最低的优先被选举。 此处,频率统计使用计数器。
- 对某个JOB的每个执行器,每调用一次,计数器加1;
- 初始化计数器时,取值是new Random().nextInt(addressList.size());当计数大于1000000时,重新初始化。
- 每次调用,需将新增的执行器计数器初始化,将下线的执行器计数器删除。
- 选择时,将内层Map.Entry按照计数顺序排列,获取第一个(即使用次数最小的节点)。
java
// 使用次数的缓存,24小时强制重置
// 外层Map的key即jobId,内存Map的key即执行器address,value即计数器。
private static ConcurrentMap<Integer, HashMap<String, Integer>> jobLfuMap = new ConcurrentHashMap<Integer, HashMap<String, Integer>>();
2.5 RouteLRU
LRU(Least Recently Used),(时间上)最近最久未使用。使用LinkedHashMap实现lru算法。
java
// 缓存
// 外层Map的key即jobId,内存Map的key和value,都是address
private static ConcurrentMap<Integer, LinkedHashMap<String, String>> jobLRUMap = new ConcurrentHashMap<Integer, LinkedHashMap<String, String>>();
java
// lru算法使用LinkedHashMap实现
LinkedHashMap<String, String> lruItem = jobLRUMap.get(jobId);
if (lruItem == null) {
/**
* LinkedHashMap
* a、accessOrder:true=访问顺序排序(get/put时排序);false=插入顺序排期;
* b、removeEldestEntry:新增元素时将会调用,返回true时会删除最老元素;可封装LinkedHashMap并重写该方法,比如定义最大容量,超出时返回true即可实现固定长度的LRU算法;
*/
lruItem = new LinkedHashMap<String, String>(16, 0.75f, true);
jobLRUMap.putIfAbsent(jobId, lruItem);
}
每次被访问的节点,被移到到队尾。选择时,获取链条的第一个节点即可
2.6 RouteBusyover
忙碌转移。该策略在路由时,会遍历执行器列表,依次发起空闲检测,只要有节点返回true,就将向该节点发起调度。(节点处于何种状态时才算空闲?随后介绍)
java
// 简化后代码
for (String address : addressList) {
ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);
// 空闲检查
idleBeatResult = executorBiz.idleBeat(new IdleBeatParam(triggerParam.getJobId()));
// 返回第一个空闲节点
if (idleBeatResult.getCode() == ReturnT.SUCCESS_CODE) {
idleBeatResult.setMsg(idleBeatResultSB.toString());
idleBeatResult.setContent(address);
return idleBeatResult;
}
}
idleBeat由调度平台向执行器发起,是单向的。
2.7 RouteFailover
故障转移。执行列表手动注册时,调度平台不会定期维护如添加新节点、移除下线节点。即使采取主动注册,在心跳间隙,某个节点突然下线,此时正好调度到该节点,本次执行将会失败(如果配置了邮件告警,我们还能及时感知到调度异常)。 该策略在路由时,会遍历执行器列表,依次发起心跳检测。返回第一个响应成功的节点。
java
// 简化后代码
for (String address : addressList) {
// beat
ExecutorBiz executorBiz = XxlJobScheduler.getExecutorBiz(address);
beatResult = executorBiz.beat();
if (beatResult.getCode() == ReturnT.SUCCESS_CODE) {
beatResult.setMsg(beatResultSB.toString());
beatResult.setContent(address);
return beatResult;
}
}
2.8 分片广播
准确来说,分片广播不是路由策略,因为它将调度所有已注册的执行器,而不是从中选择一个。因此,它并不是ExecutorRouter接口的实现类。在com.xxl.job.admin.core.trigger.XxlJobTrigger#trigger中有如下代码:
java
// 分片广播
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 {
// 其他路由策略,分片索引是0,分片总数为1
if (shardingParam == null) {
shardingParam = new int[]{0, 1};
}
processTrigger(group, jobInfo, finalFailRetryCount, triggerType, shardingParam[0], shardingParam[1]);
}
processTrigger()方法最后两个参数,分别是sharding index、sharding total,其中获取address代码如下:
java
// 3、init address
String address = null;
ReturnT<String> routeAddressResult = null;
if (group.getRegistryList()!=null && !group.getRegistryList().isEmpty()) {
// 分片广播
if (ExecutorRouteStrategyEnum.SHARDING_BROADCAST == executorRouteStrategyEnum) {
// 正常不会走else分支
if (index < group.getRegistryList().size()) {
address = group.getRegistryList().get(index);
} else {
address = group.getRegistryList().get(0);
}
} else {
// 路由
routeAddressResult = executorRouteStrategyEnum.getRouter().route(triggerParam, group.getRegistryList());
if (routeAddressResult.getCode() == ReturnT.SUCCESS_CODE) {
address = routeAddressResult.getContent();
}
}
} else {
routeAddressResult = new ReturnT<String>(ReturnT.FAIL_CODE, I18nUtil.getString("jobconf_trigger_address_empty"));
}
三、选择路由策略
- 通常选择随机或轮训即可。
- 如果要保证每次调度成功,可选择故障转移
- 如果要保证任务执行的及时性,可选择忙碌转移
- 分片任务,自然得选分片广播。
- 对于刷新程序内存缓存的定时任务,其实可以用spring的@Schedule来实现。用xxl-job实现的好处在于,支持主动执行来提前刷新缓存。此时,策略应该配置为分片广播,使所有执行器中缓存保持一致。
而博主在工作中曾遇到,同事将刷新项目进程缓存的任务配置为故障转移,导致始终只有一个节点的内存定期刷新,而其他节点没有被调度的机会,从而导致业务异常。