前言
这一周工作很忙,周末终于可以来还债了。
看到有读者提 issue 问负载感知调度的问题,心里挺高兴的。说明有人在真正用 JobFlow,而且在思考怎么让它更好。这种反馈对开源项目来说太重要了。
开发者一个人闷头写代码,很容易陷入自己的思维定式。用户提的问题往往是最真实的需求,也是最容易被忽略的细节。这次的负载感知调度功能就是一个很好的例子。
如果你在使用 JobFlow 的过程中遇到任何问题,或者有什么想法,欢迎提 issue 讨论。一起把它做得更好。
好,下面进入正题。
问题背景
最近收到一个 issue:
如果需要根据执行器的负载情况调度负载低的节点,应该怎么考虑?
这是个很实际的问题。看看下面这个场景:
diff
订单服务有 3 个实例:
- 实例 1:CPU 80%,内存 70%,线程池占用 90%
- 实例 2:CPU 30%,内存 40%,线程池占用 20%
- 实例 3:CPU 50%,内存 60%,线程池占用 50%
定时任务触发,应该调度到哪个实例?
答案很明显:实例 2,因为它最闲。
但 JobFlow 最初的执行器选择逻辑很简单:
java
// 轮询选择
ServiceInstance instance = instances.get(shardIndex % instances.size());
按分片索引取模,机械分配。这样做的问题:
实例 1 很忙 → 分片 0 调度过去 → 越来越慢,任务堆积
实例 2 很闲 → 分片 1 快速执行完 → 空闲等待
实例 3 中等 → 分片 2 正常执行
结果:资源利用不均衡
要解决这个问题,需要让调度器知道每个执行器的实时负载,然后把任务分配给负载最低的实例。
方案选择
看到这个需求,第一反应是引入 Redis:
css
方案 A:Redis 中心化存储
执行器 → Redis(上报负载)
调度器 → Redis(读取负载)→ 选择最优实例
这个方案可以工作,但有几个问题:
依赖问题
引入 Redis 意味着:
- 运维要部署 Redis 集群
- 要考虑 Redis 高可用
- 要处理 Redis 挂掉的降级逻辑
JobFlow 的设计理念是轻量级,只依赖 Nacos 和 MySQL。
一致性问题
diff
3 个调度器实例同时从 Redis 读取:
- 都看到 executor-1 负载最低
- 都选择 executor-1
- executor-1 瞬间负载升高
要解决这个问题需要分布式锁,但加锁又影响性能。
数据过期问题
执行器每 60 秒上报一次,如果执行器挂了,Redis 里的数据怎么办?设置 TTL 自动过期?还是调度器主动检测?
思考之后,决定用更简单的方案:
css
方案 B:HTTP 直接上报
执行器 → HTTP → 调度器(内存存储)
调度器需要时直接从内存读取
优势:
- 无额外依赖
- 实现简单
- 降级容易
代价:
- 调度器重启后数据丢失
- 不持久化
但这两个代价都可以接受:
- 调度器重启是小概率事件
- 负载数据本身就是实时的,60 秒后就会重新上报
设计思路
确定了用 HTTP 直接上报,接下来要解决几个问题。
问题 1:上报给哪个调度器?
假设有 3 个调度器实例,执行器应该上报给谁?
随机选一个?→ 数据分散,不一致
上报给所有?→ 网络开销大
答案是:用 Hash 一致性选择,和任务调度的 Owner 判定逻辑一样。
java
// 根据服务名 Hash,选择负责的调度器
String serviceName = "order-service";
List<Instance> schedulers = nacosService.getAllInstances("jobflow-scheduler");
// 排序(保证一致性)
schedulers.sort(Comparator.comparing(i -> i.getIp() + ":" + i.getPort()));
// Hash 选择
int hash = Math.abs(serviceName.hashCode());
int targetIndex = hash % schedulers.size();
ServiceInstance targetScheduler = schedulers.get(targetIndex);
这样做的好处:
diff
order-service 的所有实例 → 上报给 scheduler-1
user-service 的所有实例 → 上报给 scheduler-2
- 负载数据集中,不会分散
- 每个调度器负责一部分服务
- 和任务调度逻辑一致
负责order-service] S2[Scheduler-2
负责user-service] end E1 --> |上报负载| S1 E2 --> |上报负载| S1 E3 --> |上报负载| S2 E4 --> |上报负载| S2 style S1 fill:#90EE90 style S2 fill:#87CEEB
关键点:排序。
所有实例必须看到相同的调度器顺序,否则 Hash 结果不一致。
问题 2:负载怎么衡量?
需要收集哪些指标?
对于任务调度,主要关心:
- CPU 使用率:任务执行需要计算资源
- 内存使用率:任务处理数据需要内存
- 线程池占用率:任务并发执行能力
使用 Java 标准 API 收集,无外部依赖:
java
public class LightweightLoadCollector {
// CPU 使用率
public double getCpuUsage() {
try {
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean) {
return ((com.sun.management.OperatingSystemMXBean) osBean)
.getSystemCpuLoad() * 100;
}
} catch (Exception e) {
log.warn("获取CPU使用率失败", e);
}
return 0.0; // 降级:返回默认值
}
// 内存使用率
public double getMemoryUsage() {
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
return (double) usedMemory / totalMemory * 100;
}
// 线程池指标
public int getActiveThreadCount() {
if (threadPoolExecutor != null) {
return threadPoolExecutor.getActiveCount();
}
return 0;
}
}
降级策略:如果获取某个指标失败,返回默认值,不影响整体流程。
问题 3:怎么选择执行器?
有了负载数据,怎么选择最优实例?
使用加权平均算法:
markdown
权重分配:
- CPU:40%
- 内存:40%
- 线程池:20%
计算公式(分数越高越好):
weight = (100 - cpuUsage) * 0.4
+ (100 - memoryUsage) * 0.4
+ (100 - threadPoolUsage) * 0.2
为什么这样分配权重?
- CPU 和内存直接影响任务执行速度,权重较高
- 线程池只要不满,影响相对较小,权重较低
后续可以根据实际情况调整。
代码实现
把功能分成 4 个模块,各司其职。
模块 1:负载收集器
java
@Component
public class LightweightLoadCollector {
public ExecutorLoadInfo collect() {
ExecutorLoadInfo loadInfo = new ExecutorLoadInfo();
loadInfo.setCpuUsage(getCpuUsage());
loadInfo.setMemoryUsage(getMemoryUsage());
loadInfo.setThreadPoolActive(getActiveThreadCount());
loadInfo.setThreadPoolSize(getThreadPoolSize());
loadInfo.setReportTime(System.currentTimeMillis());
return loadInfo;
}
private double getCpuUsage() {
try {
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean) {
return ((com.sun.management.OperatingSystemMXBean) osBean)
.getSystemCpuLoad() * 100;
}
} catch (Exception e) {
log.warn("获取CPU使用率失败", e);
}
return 0.0;
}
private double getMemoryUsage() {
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
return (double) (totalMemory - freeMemory) / totalMemory * 100;
}
}
职责:只负责收集负载信息。
模块 2:调度器选择器
java
@Service
public class SchedulerSelectionService {
public ServiceInstance selectResponsibleScheduler() {
try {
// 1. 从 Nacos 获取所有调度器
List<Instance> instances = namingService.getAllInstances(schedulerServiceName);
if (instances.isEmpty()) {
return null;
}
// 2. 排序(保证一致性)
instances.sort(Comparator.comparing(i -> i.getIp() + ":" + i.getPort()));
// 3. Hash 选择
int hash = Math.abs(serviceName.hashCode());
int targetIndex = hash % instances.size();
Instance targetInstance = instances.get(targetIndex);
return convertToServiceInstance(targetInstance);
} catch (Exception e) {
log.warn("选择负责调度器失败", e);
return null;
}
}
}
职责:根据服务名 Hash 选择负责调度器。
模块 3:负载上报器
java
@Component
public class LightweightLoadReporter {
@Scheduled(fixedRateString = "${jobflow.executor.load-report.report-interval-seconds:60}000")
public void reportLoad() {
if (!enabled) {
return;
}
try {
// 1. 收集负载
ExecutorLoadInfo loadInfo = loadCollector.collect();
loadInfo.setServiceName(serviceName);
loadInfo.setInstanceId(instanceId);
// 2. 选择调度器
ServiceInstance scheduler = schedulerSelectionService.selectResponsibleScheduler();
if (scheduler == null) {
log.warn("未找到负责调度器,跳过负载上报");
return;
}
// 3. 上报(带重试)
reportWithRetry(scheduler, loadInfo);
} catch (Exception e) {
log.warn("负载上报失败", e);
}
}
private void reportWithRetry(ServiceInstance scheduler, ExecutorLoadInfo loadInfo) {
for (int retry = 0; retry < maxRetries; retry++) {
try {
String reportUrl = scheduler.getUri() + "/internal/load/report";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<ExecutorLoadInfo> request = new HttpEntity<>(loadInfo, headers);
restTemplate.exchange(reportUrl, HttpMethod.POST, request, Void.class);
log.debug("负载上报成功,scheduler={}, cpu={}, memory={}",
scheduler.getUri(), loadInfo.getCpuUsage(), loadInfo.getMemoryUsage());
return;
} catch (Exception e) {
if (retry < maxRetries - 1) {
// 指数退避
long delay = retryDelayMs * (1 << retry);
Thread.sleep(delay);
}
}
}
log.error("负载上报最终失败,已重试 {} 次", maxRetries);
}
}
职责:定期上报负载,带重试机制。
模块 4:负载均衡策略
java
@Service
public class LightweightLoadBalancingStrategy implements LoadBalancingStrategy {
@Override
public ServiceInstance select(List<ServiceInstance> instances,
String serviceName,
LoadInfoStorage loadInfoStorage) {
if (instances == null || instances.isEmpty()) {
return null;
}
if (instances.size() == 1) {
return instances.get(0);
}
// 获取负载信息
List<ExecutorLoadInfo> loadInfos = loadInfoStorage.getLoadInfos(serviceName);
// 降级:没有负载信息
if (loadInfos.isEmpty()) {
log.debug("没有负载信息,回退到随机选择");
return instances.get(RANDOM.nextInt(instances.size()));
}
// 选择权重最高的实例
ServiceInstance bestInstance = null;
double bestWeight = Double.MIN_VALUE;
for (ServiceInstance instance : instances) {
String instanceId = instance.getHost() + ":" + instance.getPort();
ExecutorLoadInfo loadInfo = findLoadInfo(loadInfos, instanceId);
double weight;
if (loadInfo == null) {
// 降级:该实例无负载信息,给予默认权重
weight = 80.0; // 假设默认负载 20%
} else {
// 计算权重
double cpuScore = 100 - loadInfo.getCpuUsage();
double memoryScore = 100 - loadInfo.getMemoryUsage();
double threadPoolScore = calculateThreadPoolScore(loadInfo);
weight = cpuScore * 0.4 + memoryScore * 0.4 + threadPoolScore * 0.2;
}
if (weight > bestWeight) {
bestWeight = weight;
bestInstance = instance;
}
}
return bestInstance;
}
private double calculateThreadPoolScore(ExecutorLoadInfo loadInfo) {
if (loadInfo.getThreadPoolSize() > 0) {
double usage = (double) loadInfo.getThreadPoolActive()
/ loadInfo.getThreadPoolSize() * 100;
return 100 - usage;
}
return 100;
}
}
职责:根据负载信息选择最优执行器。
完整流程
把各个模块串起来:
分两个阶段:
阶段 1:负载上报(后台持续进行)
markdown
执行器每 60 秒:
1. 收集负载信息(CPU、内存、线程池)
2. Hash(serviceName) 选择负责调度器
3. HTTP POST 上报给调度器
4. 调度器存储到内存 Map
阶段 2:任务调度(触发时进行)
markdown
当任务触发时:
1. 调度器查询可用执行器列表(从 Nacos)
2. 从内存 Map 获取负载信息
3. 计算每个实例的权重
4. 选择权重最高的实例
5. HTTP 调用执行器
关键设计:负载上报和任务调度解耦,即使上报失败,任务调度也能降级执行。
降级策略
系统设计了多层降级:
erlang
降级路径 1:没有负载信息
→ 随机选择
降级路径 2:部分实例无负载信息
→ 给予默认权重 80(假设负载 20%)
降级路径 3:负载均衡策略失败
→ 回退到轮询
降级路径 4:所有调度器挂了
→ 上报失败,但不影响业务
每一层降级都在保护系统可用性。宁可功能退化,不能系统不可用。
配置说明
执行器配置
yaml
jobflow:
executor:
load-report:
enabled: true # 启用负载上报
report-interval-seconds: 60 # 上报间隔 60 秒
max-retries: 3 # 最大重试 3 次
retry-delay-ms: 1000 # 重试延迟 1 秒
调度器配置
yaml
load-aware:
enabled: true # 启用负载感知调度
测试验证
1. 负载上报
执行器日志:
ini
2026-01-10T10:59:51.589+08:00 DEBUG --- [scheduling-1] c.s.j.c.l.LightweightLoadReporter :
负载上报成功,scheduler=http://192.168.71.174:8080, cpu=21.69, memory=58.86
调度器日志:
ini
2026-01-10T10:59:51.589+08:00 DEBUG --- [nio-8080-exec-3] c.s.j.s.c.LoadReportController :
收到负载上报,instanceId=192.168.71.174:8081, serviceName=user-service-example,
cpuUsage=21.69, memoryUsage=58.86
上报正常,调度器成功接收负载数据。
2. 任务调度
任务触发时的日志:
ini
2026-01-10T11:00:02.523+08:00 DEBUG --- [scheduling-1] c.s.j.s.s.ClusterInstanceService :
调度 owner 判定, jobKey=user-service-example#demo-job-every-minute,
schedulerInstanceId=192.168.71.174:8080, isOwner=true
ini
2026-01-10T11:00:02.524+08:00 INFO --- [scheduling-1] c.s.j.s.s.JobFlowSchedulerService :
触发任务执行, jobName=demo-job-every-minute, triggerTime=2026-01-10T11:00:02.522789
ini
2026-01-10T11:00:02.528+08:00 INFO --- [scheduling-1] c.s.j.s.s.JobExecutionService :
创建父执行记录, jobName=demo-job-every-minute, parentId=1576, traceId=20260110110002-06f2ab24
3. 任务执行
执行器日志:
ini
2026-01-10 11:00:02.534 [http-nio-8081-exec-3] INFO c.s.j.c.e.JobExecutorController -
[traceId=20260110110002-06f2ab24-0] 开始执行任务,handler=demoJob
ini
2026-01-10 11:00:02.534 [http-nio-8081-exec-3] INFO c.s.j.e.u.h.DemoJobHandler -
[traceId=20260110110002-06f2ab24-0] [DemoJob] 执行开始, shardStart=0, shardEnd=0
ini
2026-01-10 11:00:02.534 [http-nio-8081-exec-3] INFO c.s.j.e.u.h.DemoJobHandler -
[traceId=20260110110002-06f2ab24-0] [DemoJob] 执行完成
回调完成:
ini
2026-01-10 11:00:02.548 [jobflow-callback-71] INFO c.s.j.c.e.JobExecutorController -
[traceId=20260110110002-06f2ab24-0] 已回调 scheduler, traceId=20260110110002-06f2ab24-0
全链路通了,TraceId 贯穿始终。
4. 负载均衡效果
测试场景:
ini
启动 3 个执行器实例
手动给实例 1 加压(CPU 打到 80%)
触发任务,观察调度结果
负载数据:
- 实例 1:CPU 80%,权重 = 20*0.4 + 40*0.4 + 80*0.2 = 40
- 实例 2:CPU 30%,权重 = 70*0.4 + 50*0.4 + 80*0.2 = 64
- 实例 3:CPU 50%,权重 = 50*0.4 + 60*0.4 + 80*0.2 = 60
预期:调度器选择实例 2(权重最高)
实际:任务确实调度到了实例 2
负载感知调度生效。
关键设计点
1. 为什么不用 Redis
对比两种方案:
Redis 方案:
- 优势:数据持久化、多调度器共享
- 劣势:引入依赖、需要处理一致性、需要处理过期
HTTP 方案:
- 优势:无依赖、实现简单、降级容易
- 劣势:数据不持久化、调度器重启丢失
选择 HTTP 方案的理由:
- 负载数据本身就是实时的,60 秒后会重新上报
- 调度器重启是小概率事件,即使丢失也能快速恢复
- 简单性比完美性更重要
2. Hash 选择的一致性
关键代码:
java
// 排序保证一致性
instances.sort(Comparator.comparing(i -> i.getIp() + ":" + i.getPort()));
如果不排序:
css
执行器看到:[A, B, C]
调度器看到:[C, A, B]
Hash 结果不一致,负载数据对不上
排序是分布式一致性的基础。
3. 多层降级
系统在多个层面都有降级:
层级 1:指标收集失败 → 返回默认值 0
层级 2:无负载信息 → 随机选择
层级 3:部分无负载 → 给予默认权重
层级 4:策略失败 → 回退轮询
分布式系统要假设任何环节都可能失败,降级是必须的。
4. 权重计算
当前权重分配是 CPU(40%) + 内存(40%) + 线程池(20%)。
这是根据经验设定的,不同业务场景可能需要不同的权重。后续可以支持配置化:
yaml
load-aware:
weight:
cpu: 0.4
memory: 0.4
thread-pool: 0.2
总结
负载感知调度功能已经实现并验证,核心特点:
- 轻量级:无外部依赖,只用 Java 标准 API
- 简单:HTTP 直接上报,调度器内存存储
- 可靠:多层降级,功能退化但系统不挂
- 灵活:权重可调整,适应不同场景
这个功能解决了执行器负载不均衡的问题,提高了系统资源利用率。
最重要的是,它延续了 JobFlow 的设计理念:用最简单的方案,解决实际问题。
代码已经提交到 Gitee:https://gitee.com/sh_wangwanbao/job-flow
如果你有更好的想法或遇到问题,欢迎提 issue 讨论。