JobFlow 实现方案:云原生时代的任务调度新思路

274 阅读17分钟

开源地址与系列文章

前言

上一篇文章《基于Nacos的轻量任务调度方案 —— 从 XXL-Job 的痛点说起》发布后,收到了很多朋友的关注和反馈。大家对这个想法很感兴趣,也提出了不少建设性的意见。

既然有这么多人关注,那就不能只停留在想法阶段。我们计划用一个月左右的时间,完成 JobFlow 的第一个版本,把这些想法用代码落地。

这篇文章就来详细聊聊我们的实现方案,包括:

  • 整体架构怎么设计
  • 无锁调度怎么做
  • 分片机制怎么实现
  • 如何保证可靠性

设计目标

在开始之前,先明确一下我们要做什么:

核心目标:打造一个与 Nacos 生态深度融合的任务调度方案

具体要求

  • 只依赖 Nacos 做服务发现,不再维护独立的注册中心
  • 调度器作为普通微服务部署,复用已有的监控、日志、告警体系
  • 全链路可追踪,从调度到执行一条线串起来
  • 支持分片,但要比现有方案更可靠
  • 轻量化,不追求大而全,够用就好

关于技术选型的聚焦

JobFlow 只支持 Nacos,这是有意为之的聚焦策略,而不是技术限制。

就像 Canal 只支持 MySQL,没有人质疑它为什么不支持 PostgreSQL 一样。聚焦单一技术栈,能够:

  • 把集成做到极致,而不是浅尝辄止
  • 降低维护成本,避免为了通用性而牺牲深度
  • 让使用者明确预期,不会有"支持但不够好"的尴尬

如果你的技术栈是 Nacos,JobFlow 会是一个很好的选择。如果你用的是 Eureka、Consul 等其他注册中心,那 JobFlow 可能不适合你,这没什么不好意思承认的。

术业有专攻,我们宁愿把一件事做到 90 分,也不要把十件事都做到 60 分。

整体架构

先看整体的架构设计:

graph TB
    subgraph "调度层"
        S[JobFlow Scheduler<br/>调度器微服务]
    end
    
    subgraph &#34;基础设施&#34;
        N[Nacos<br/>服务发现 + 配置中心]
        D[(MySQL<br/>任务定义 & 执行记录)]
    end
    
    subgraph &#34;执行层&#34;
        E1[订单服务<br/>order-service]
        E2[用户服务<br/>user-service]
        E3[支付服务<br/>payment-service]
    end
    
    S --> N
    S --> D
    
    N --> E1
    N --> E2
    N --> E3
    
    S -.HTTP.-> E1
    S -.HTTP.-> E2
    S -.HTTP.-> E3

架构很简单,就三层:

  1. 调度层:JobFlow Scheduler,一个普通的 Spring Boot 微服务
  2. 基础设施:Nacos(服务发现和配置)、MySQL(任务定义和执行记录)
  3. 执行层:各个业务微服务,它们既是任务的执行者,也是普通的 Nacos 服务

关键点是:调度器和执行器都是 Nacos 体系中的普通服务,没有特殊身份,共享同一套基础设施。

依赖最小化:只需要 Nacos 和 MySQL,不依赖 Redis、ZooKeeper 等额外组件,降低运维复杂度。

核心设计:真正的无锁调度

传统方案的问题

先说说传统任务调度的一个常见做法:基于数据库乐观锁的调度。

假设你部署了 3 个调度器实例(为了高可用),每个实例都在跑定时任务。时间一到,3 个实例同时触发,都想调度同一个任务,怎么办?

常见的做法是用数据库乐观锁:3 个实例同时去 UPDATE 同一条记录,谁先更新成功谁就去执行,其他的发现更新失败就不做了。

这个方案的问题:

1. 数据库压力大

假设有 50 个任务,每分钟都要执行,3 个调度器实例:

每分钟:50 个任务 × 3 个实例 = 150UPDATE
但只有 50 次是有效的,另外 100 次白跑

数据库压力大,而且大部分都是浪费的。

2. 时间精度问题

比如每天凌晨 2 点跑订单对账任务。3 个实例的时钟可能有细微差异:

实例1:02:00:00.010 最先到达,抢到锁,开始执行
实例2:02:00:00.050 晚了 40 毫秒,发现已经被抢了
实例3:02:00:00.100 晚了 90 毫秒,也没抢到

如果实例1 在执行过程中崩溃了?
这次对账就丢了,要等明天凌晨 2 点才会重试

3. 锁竞争影响性能

高峰期,多个任务同时触发,多个实例同时去 UPDATE,会造成锁等待,影响整体调度性能。

JobFlow 的真无锁方案

我们的思路是:根本不需要抢锁,每个实例自己算出该不该执行,然后直接执行

怎么做?用一致性哈希。

graph TB
    subgraph &#34;调度器实例&#34;
        S1[调度器-1<br/>ID: scheduler-inst-001]
        S2[调度器-2<br/>ID: scheduler-inst-002]
        S3[调度器-3<br/>ID: scheduler-inst-003]
    end
    
    subgraph &#34;任务列表&#34;
        J1[订单对账<br/>hash: 123]
        J2[用户清理<br/>hash: 456]
        J3[支付对账<br/>hash: 789]
        J4[报表生成<br/>hash: 234]
    end
    
    J1 -.分配给.-> S1
    J2 -.分配给.-> S3
    J3 -.分配给.-> S2
    J4 -.分配给.-> S1

核心逻辑很简单:

// 判断这个任务是否由我负责
boolean isMyResponsibility(String jobName) {
    // 1. 从 Nacos 获取所有调度器实例,按 ID 排序
    List<String> instances = nacosDiscovery.getSchedulerInstances();
    instances.sort();
    
    // 2. 用任务名计算哈希,看看该由谁负责
    int hash = jobName.hashCode();
    int index = Math.abs(hash) % instances.size();
    
    // 3. 是我就返回 true
    return instances.get(index).equals(myInstanceId);
}

// 定时扫描
@Scheduled(fixedRate = 60000)
void scan() {
    for (JobDefinition job : getAllEnabledJobs()) {
        if (isMyResponsibility(job.getName())) {
            triggerJob(job);  // 直接执行,不需要抢锁
        }
    }
}

举个例子:

有 3 个调度器实例:scheduler-001, scheduler-002, scheduler-003
有 4 个任务:订单对账、用户清理、支付对账、报表生成

"订单对账"hash % 3 = 0 → 分配给 scheduler-001
"用户清理"hash % 3 = 2 → 分配给 scheduler-003
"支付对账"hash % 3 = 1 → 分配给 scheduler-002
"报表生成"hash % 3 = 0 → 分配给 scheduler-001

每个实例只负责自己该负责的任务,不会冲突

好处

  1. 零争抢:每个实例自己算,不用去数据库抢锁
  2. 自动平衡:任务均匀分配到各个实例
  3. 故障转移:一个实例挂了,Nacos 会摘除它,剩下的实例重新计算,自动接管任务

监听调度器实例变化

这里有个关键点:实例列表变化时,要立即重新计算任务分配

我们用 Nacos 的服务监听机制:

@Component
public class SchedulerInstanceWatcher {
    
    @PostConstruct
    public void init() {
        // 监听调度器服务的实例变化
        nacosNamingService.subscribe("jobflow-scheduler", event -> {
            log.info("调度器实例发生变化,重新计算任务分配");
            
            // 更新本地缓存的实例列表
            refreshInstanceList();
            
            // 触发一次立即扫描,让变化快速生效
            jobScanner.scanNow();
        });
    }
}

这样一来:

初始状态:3 个实例
- scheduler-001 负责:订单对账、报表生成
- scheduler-002 负责:支付对账
- scheduler-003 负责:用户清理

scheduler-002 挂了:
→ Nacos 检测到心跳丢失,摘除实例
→ 触发监听事件
→ 剩余 2 个实例重新计算
→ scheduler-001 负责:订单对账、支付对账
→ scheduler-003 负责:用户清理、报表生成

通常在 10 秒内完成切换

补偿任务:由最小节点负责

虽然有了一致性哈希,但还需要一个补偿机制,处理那些卡住的任务。

比如:调度器触发了订单对账任务,但执行器服务器突然断电了,数据库里的状态一直是 RUNNING,怎么办?

补偿任务就是定期扫描这些异常状态,重新触发或标记为失败。

关键问题:补偿任务本身也是个任务,多个调度器实例都在跑,谁来执行?

答案:只让实例 ID 最小的节点执行。

@Scheduled(fixedDelay = 60000)  // 每分钟执行一次
void compensate() {
    // 1. 从 Nacos 获取所有调度器实例
    List<String> instances = nacosDiscovery.getSchedulerInstances();
    instances.sort();
    
    // 2. 判断我是不是最小的节点
    if (!instances.get(0).equals(myInstanceId)) {
        return;  // 不是我,直接返回
    }
    
    // 3. 我是最小节点,执行补偿
    log.info("我是最小节点,执行补偿任务");
    
    // 查找卡住的任务(状态是 RUNNING,但超过 10 分钟没更新)
    List<JobExecution> stuck = findStuckExecutions();
    
    for (JobExecution exec : stuck) {
        // 根据 traceId 去日志系统查询真实状态
        // 如果确实失败了,标记为 FAILED 并重试
        // 如果日志里也没有,标记为 TIMEOUT
        compensateExecution(exec);
    }
}

为什么选最小节点?

  1. 简单可靠:所有实例对实例列表的排序结果一致,最小节点是确定的
  2. 无需选举:不需要复杂的 Leader 选举算法
  3. 自动切换:最小节点挂了,Nacos 摘除它,下一个节点自动变成最小

举个例子:

初始:scheduler-001, scheduler-002, scheduler-003
→ scheduler-001 是最小节点,它负责补偿任务

scheduler-001 挂了:
→ Nacos 摘除它
→ 剩下:scheduler-002, scheduler-003
→ scheduler-002 变成最小节点,接管补偿任务

通常在几秒内完成切换

这样就保证了补偿任务有且只有一个节点在执行,避免重复补偿。

分片设计:固定片数 + 灵活分配

为什么要固定片数

很多任务调度系统的分片是"按实例数分":有 5 个执行器实例,就分 5 片。这看起来合理,但有个问题:实例数变了,分片就乱了

举个例子:

初始:3 个实例处理订单同步任务
- 实例1 处理 订单ID % 3 == 0 的订单
- 实例2 处理 订单ID % 3 == 1 的订单
- 实例3 处理 订单ID % 3 == 2 的订单

订单 ID=100100 % 3 = 1,由实例2 处理

业务高峰期扩容到 5 个实例:
- 实例1 处理 订单ID % 5 == 0 的订单
- 实例2 处理 订单ID % 5 == 1 的订单
- ...

订单 ID=100100 % 5 = 0,变成实例1 处理了

如果任务还没执行完,就会出现重复处理或遗漏

JobFlow 的固定片数方案

我们的思路是:分片数固定,但分配灵活

创建任务时,你指定分片数,比如 100。这个数字一旦确定,就不会变。

订单同步任务配置:
- 分片数:100
- 分片策略:MOD_HASH

订单 ID=100100 % 100 = 0,归属分片 0
订单 ID=255255 % 100 = 55,归属分片 55

无论有多少个执行器实例,数据归属的分片不会变

然后根据当前的执行器实例数,灵活分配这 100 个分片:

3 个实例:
- 实例1:分片 0-33   (34个分片)
- 实例2:分片 34-66  (33个分片)
- 实例3:分片 67-99  (33个分片)

扩容到 5 个实例:
- 实例1:分片 0-19   (20个分片)
- 实例2:分片 20-39  (20个分片)
- 实例3:分片 40-59  (20个分片)
- 实例4:分片 60-79  (20个分片)
- 实例5:分片 80-99  (20个分片)

订单 ID=100 归属分片 0,一直由实例1 处理,不会变
订单 ID=255 归属分片 55,从实例2 变成实例3,但不会重复处理

关键好处:扩容或缩容时,大部分分片的归属不变,只有少量分片需要调整,减少了数据重复处理的风险。

三种分片策略

1. MOD_HASH(取模哈希)

适合订单、用户等有唯一 ID 的数据。

订单同步任务:
- 100 个分片
- 执行器收到分片 20-39
- 查询:订单ID % 100 在 [20, 39] 之间的订单
- 处理数据

2. RANGE(范围分片)

适合数据 ID 连续的场景。

用户清理任务:
- 100 个分片
- 用户 ID 范围:1 到 10000000
- 每个分片大小:100000
- 执行器收到分片 20-39,处理用户 ID 在 2000000-3999999 之间的用户

3. CUSTOM(自定义)

业务自己决定怎么分片。

地区报表生成任务:
- 100 个分片
- 业务定义:每个地区对应若干分片
- 执行器收到分片 20-39,处理对应的几个地区

分片执行时的保护机制

虽然调度器层面用一致性哈希做到了无锁,但在分片执行层面,我们还是加了一层保护。

为什么?因为有极端情况:

场景1:网络分区

Nacos 多数据中心部署时,可能出现短暂的数据不一致,两个调度器看到的实例列表不同,都认为自己该调度某个分片。

场景2:Nacos 数据延迟

实例刚上线或下线,Nacos 的服务列表可能有几秒的延迟,不同调度器拉取到的列表不一样。

虽然这些情况很少见,但一旦发生,就会导致重复执行。所以我们在分片执行时加了数据库唯一索引保护:

// 调度器在调用执行器前,先记录一下
String executionId = String.format("%s:%s:%d-%d",
    job.getName(),
    getCurrentMinute(),  // 精确到分钟,比如 "202412171030"
    shardStart,
    shardEnd
);

// 插入数据库,executionId 有唯一索引
try {
    jobExecutionDao.insert(execution.setExecutionId(executionId));
    // 成功,继续调用执行器
    callExecutor(instance, job, request);
} catch (DuplicateKeyException e) {
    // 唯一索引冲突,说明别人已经在执行了
    log.info("分片 {}-{} 已被其他调度器执行,跳过", shardStart, shardEnd);
}

数据库表:

CREATE TABLE job_execution (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    execution_id VARCHAR(200) NOT NULL,  -- 任务名:时间:分片范围
    job_name VARCHAR(100) NOT NULL,
    -- ... 其他字段
    
    UNIQUE KEY uk_execution_id (execution_id)  -- 唯一索引
);

这样一来:

  • 调度器1 插入 订单对账:202412171030:0-33,成功
  • 调度器2 也想插入 订单对账:202412171030:0-33,唯一索引冲突,跳过

好处

  1. 绝大多数情况不冲突:一致性哈希已经算好了,冲突概率极低
  2. 即使冲突,也能正确处理:数据库唯一索引是最终防线
  3. 性能好:不需要每次都 UPDATE 抢锁,只是 INSERT 记录
  4. 逻辑清晰:执行记录本来就要有,这个约束是顺便加的

为什么不用 Redis 锁?

  1. 减少依赖:Redis 挂了不应该影响任务调度
  2. 避免锁超时问题:任务执行时间不可控,锁续约很复杂
  3. 一举两得:执行记录本来就要写数据库,用唯一索引做约束更简单

调度流程

完整的调度流程是这样的:

sequenceDiagram
    participant S as Scheduler
    participant N as Nacos
    participant D as MySQL
    participant E as 执行器
    
    Note over S: 定时扫描触发
    
    S->>S: 1. 判断任务是否由我负责<br/>(一致性哈希)
    
    alt 不是我负责
        S->>S: 跳过
    else 是我负责
        S->>D: 2. 查询任务定义
        D-->>S: 返回任务配置
        
        S->>N: 3. 查询执行器实例列表
        N-->>S: 返回实例列表
        
        S->>S: 4. 计算分片分配
        
        loop 每个分片组
            S->>D: 5. 记录执行信息<br/>INSERT execution_id
            
            alt 插入成功
                S->>S: 6. 生成 traceId
                S->>E: 7. HTTP 调用执行器<br/>带上 traceId 和分片参数
                E->>E: 8. 执行任务
                E-->>S: 9. 返回结果
                S->>D: 10. 更新执行状态
            else 唯一索引冲突
                S->>S: 其他调度器已在执行,跳过
            end
        end
    end

关键步骤解释:

步骤 1:每个调度器实例先判断这个任务是否该由自己负责,避免重复调度。绝大多数情况下,到这一步就已经确定了唯一的负责人。

步骤 3-4:从 Nacos 获取执行器实例,计算分片分配。即使实例数变了,分片的数据范围也不会变。

步骤 5:在数据库记录执行信息。用 execution_id(任务名+时间+分片范围)作为唯一索引,防止极端情况下的重复执行。这是最后的防线,通常不会冲突。

步骤 6:生成全局唯一的 traceId,贯穿整个调用链。

步骤 7:通过 HTTP 调用执行器,请求头带上 traceId 和分片参数。

可观测性

全链路追踪

从调度器到执行器,traceId 一路透传:

调度器生成 traceId → HTTP 请求头传递 → 执行器写入日志上下文 → 所有日志自动带 traceId

示例:

// 调度器端:生成 traceId 并传递
String traceId = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.set("X-Trace-Id", traceId);
headers.set("X-Shard-Start", "0");
headers.set("X-Shard-End", "33");

restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), ...);
// 执行器端:接收 traceId 并写入 MDC
@PostMapping("/internal/job/{handler}")
public JobResult execute(@RequestHeader("X-Trace-Id") String traceId, ...) {
    MDC.put("traceId", traceId);
    try {
        log.info("开始执行任务");  // 日志自动带 traceId
        // 业务处理...
        return JobResult.success();
    } finally {
        MDC.clear();
    }
}

这样在 ELK 或 Loki 中搜索 traceId=abc-123,就能看到完整的执行过程:

2024-12-17 10:00:00.123 [traceId=abc-123] 调度器触发任务:订单对账
2024-12-17 10:00:00.456 [traceId=abc-123] 查询订单数据,分片 0-33
2024-12-17 10:00:05.789 [traceId=abc-123] 处理了 1000 条订单
2024-12-17 10:00:10.234 [traceId=abc-123] 任务执行完成

Prometheus 指标

调度器作为普通微服务,自动暴露 Actuator 端点,天然支持 Prometheus:

// 简单的指标记录
meterRegistry.counter("job.execution.total", "job", jobName).increment();
meterRegistry.timer("job.execution.duration", "job", jobName).record(duration, TimeUnit.MILLISECONDS);
if (failed) {
    meterRegistry.counter("job.execution.failures", "job", jobName).increment();
}

Grafana 面板可以直接看到:

- 每分钟执行次数
- 平均执行耗时
- 失败率
- 按任务名统计

无需单独配置,复用已有的监控体系。

可靠性保证

故障转移

调度器实例挂了怎么办?

118.jpg

因为我们用的是一致性哈希 + Nacos 服务发现 + 监听机制,故障转移是自动的:

  1. 调度器-2 挂了
  2. Nacos 检测到心跳失败,摘除这个实例
  3. 触发监听事件,其他调度器收到通知
  4. 重新计算一致性哈希,支付对账任务自动分配给调度器-3
  5. 通常在 10 秒内完成转移

数据模型

任务定义表

CREATE TABLE job_definition (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL UNIQUE COMMENT '任务名称',
    service_name VARCHAR(100) NOT NULL COMMENT '执行器服务名',
    handler VARCHAR(100) NOT NULL COMMENT '处理器名称',
    cron VARCHAR(100) NOT NULL COMMENT 'Cron 表达式',
    enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
    
    -- 分片配置
    shard_count INT NOT NULL DEFAULT 1 COMMENT '分片数',
    shard_strategy VARCHAR(20) NOT NULL DEFAULT 'MOD_HASH' COMMENT '分片策略',
    
    -- 超时和重试
    timeout_seconds INT NOT NULL DEFAULT 300 COMMENT '超时时间(秒)',
    max_retry INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
    
    -- 审计字段
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    created_by VARCHAR(50),
    updated_by VARCHAR(50),
    
    INDEX idx_service (service_name),
    INDEX idx_enabled (enabled)
) COMMENT '任务定义表';

执行记录表

CREATE TABLE job_execution (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    job_name VARCHAR(100) NOT NULL COMMENT '任务名称',
    trace_id VARCHAR(64) NOT NULL UNIQUE COMMENT '追踪ID',
    
    -- 调度信息
    scheduler_instance VARCHAR(100) COMMENT '调度器实例ID',
    executor_instance VARCHAR(100) COMMENT '执行器实例',
    shard_start INT COMMENT '分片起始',
    shard_end INT COMMENT '分片结束',
    
    -- 时间
    trigger_time TIMESTAMP NOT NULL COMMENT '触发时间',
    start_time TIMESTAMP COMMENT '开始执行时间',
    finish_time TIMESTAMP COMMENT '结束时间',
    duration_ms BIGINT COMMENT '执行耗时(毫秒)',
    
    -- 状态
    status VARCHAR(20) NOT NULL COMMENT '状态: PENDING/RUNNING/SUCCESS/FAILED/TIMEOUT',
    retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
    result_message TEXT COMMENT '执行结果消息',
    error_message TEXT COMMENT '错误消息',
    
    INDEX idx_job_time (job_name, trigger_time),
    INDEX idx_trace (trace_id),
    INDEX idx_status (status, trigger_time)
) COMMENT '任务执行记录表';

RESTful API

提供一组 RESTful API,方便运维和集成:

@RestController
@RequestMapping("/api/v1/jobs")
public class JobController {
    
    // 创建任务
    @PostMapping
    public JobDefinition create(@RequestBody JobDefinitionRequest request);
    
    // 更新任务
    @PutMapping("/{name}")
    public JobDefinition update(@PathVariable String name, @RequestBody JobDefinitionRequest request);
    
    // 启用/禁用任务
    @PatchMapping("/{name}/enabled")
    public void setEnabled(@PathVariable String name, @RequestParam boolean enabled);
    
    // 手动触发任务
    @PostMapping("/{name}/trigger")
    public JobExecution trigger(@PathVariable String name);
    
    // 查询任务执行历史
    @GetMapping("/{name}/executions")
    public Page<JobExecution> listExecutions(@PathVariable String name, ...);
    
    // 根据 traceId 查询执行详情
    @GetMapping("/executions/{traceId}")
    public JobExecution getExecution(@PathVariable String traceId);
    
    // 重试失败的任务
    @PostMapping("/executions/{traceId}/retry")
    public JobExecution retry(@PathVariable String traceId);
}

配合 Swagger UI,就有了基本的管理界面。

写在最后

JobFlow 的核心理念是:中间件即业务

调度器不再是一个独立的平台,而是融入微服务体系的一个普通服务。这样做的好处是架构更简单、运维成本更低、可观测性更强。

我们计划用一个月左右的时间完成第一个版本,用代码验证这些想法的可行性。如果你对这个方向感兴趣,欢迎关注项目进展,一起探索云原生时代任务调度的新思路。