JobFlow 负载感知调度:把任务分给最闲的机器

前言

这一周工作很忙,周末终于可以来还债了。

看到有读者提 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

- 负载数据集中,不会分散
- 每个调度器负责一部分服务
- 和任务调度逻辑一致
graph LR subgraph 执行器 E1[order-service-1] E2[order-service-2] E3[user-service-1] E4[user-service-2] end subgraph 调度器 S1[Scheduler-1
负责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;
    }
}

职责:根据负载信息选择最优执行器。

完整流程

把各个模块串起来:

sequenceDiagram participant E as 执行器 participant N as Nacos participant S as 调度器 Note over E: 定时收集负载 E->>E: 收集CPU、内存、线程池 Note over E: 选择负责调度器 E->>N: 查询所有调度器实例 N-->>E: 返回调度器列表 E->>E: Hash(serviceName) % count Note over E: 上报负载 E->>S: POST /internal/load/report S->>S: 存储到内存Map S-->>E: 200 OK Note over S: 任务触发时选择执行器 S->>S: 获取负载信息 S->>S: 计算权重,选择最优 S->>E: 调用执行器

分两个阶段:

阶段 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

总结

负载感知调度功能已经实现并验证,核心特点:

  1. 轻量级:无外部依赖,只用 Java 标准 API
  2. 简单:HTTP 直接上报,调度器内存存储
  3. 可靠:多层降级,功能退化但系统不挂
  4. 灵活:权重可调整,适应不同场景

这个功能解决了执行器负载不均衡的问题,提高了系统资源利用率。

最重要的是,它延续了 JobFlow 的设计理念:用最简单的方案,解决实际问题。


代码已经提交到 Gitee:https://gitee.com/sh_wangwanbao/job-flow

如果你有更好的想法或遇到问题,欢迎提 issue 讨论。

相关推荐
UrbanJazzerati16 小时前
Python自动化统计工具实战:Python批量分析Salesforce DML操作与错误处理
后端·面试
Van_Moonlight16 小时前
RN for OpenHarmony 实战 TodoList 项目:任务完成进度条
javascript·开源·harmonyos
编程点滴16 小时前
高并发与分布式系统中的幂等处理
架构
我爱娃哈哈16 小时前
SpringBoot + Seata + Nacos:分布式事务落地实战,订单-库存一致性全解析
spring boot·分布式·后端
Van_Moonlight16 小时前
RN for OpenHarmony 实战 TodoList 项目:深色浅色主题切换
javascript·开源·harmonyos
nil16 小时前
记录protoc生成代码将optional改成omitepty问题
后端·go·protobuf
FIT2CLOUD飞致云16 小时前
应用升级为智能体,模板中心上线,MaxKB开源企业级智能体平台v2.5.0版本发布
人工智能·ai·开源·1panel·maxkb
Van_captain17 小时前
rn_for_openharmony常用组件_Chip纸片
javascript·开源·harmonyos
JZC_xiaozhong17 小时前
主数据同步失效引发的业务风险与集成架构治理
大数据·架构·数据一致性·mdm·主数据管理·数据孤岛解决方案·数据集成与应用集成