开源地址与系列文章
- 开源地址:
https://gitee.com/sh_wangwanbao/job-flow - 系列文章:
前言
上一篇文章《基于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 "基础设施"
N[Nacos<br/>服务发现 + 配置中心]
D[(MySQL<br/>任务定义 & 执行记录)]
end
subgraph "执行层"
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
架构很简单,就三层:
- 调度层:JobFlow Scheduler,一个普通的 Spring Boot 微服务
- 基础设施:Nacos(服务发现和配置)、MySQL(任务定义和执行记录)
- 执行层:各个业务微服务,它们既是任务的执行者,也是普通的 Nacos 服务
关键点是:调度器和执行器都是 Nacos 体系中的普通服务,没有特殊身份,共享同一套基础设施。
依赖最小化:只需要 Nacos 和 MySQL,不依赖 Redis、ZooKeeper 等额外组件,降低运维复杂度。
核心设计:真正的无锁调度
传统方案的问题
先说说传统任务调度的一个常见做法:基于数据库乐观锁的调度。
假设你部署了 3 个调度器实例(为了高可用),每个实例都在跑定时任务。时间一到,3 个实例同时触发,都想调度同一个任务,怎么办?
常见的做法是用数据库乐观锁:3 个实例同时去 UPDATE 同一条记录,谁先更新成功谁就去执行,其他的发现更新失败就不做了。
这个方案的问题:
1. 数据库压力大
假设有 50 个任务,每分钟都要执行,3 个调度器实例:
每分钟:50 个任务 × 3 个实例 = 150 次 UPDATE
但只有 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 "调度器实例"
S1[调度器-1<br/>ID: scheduler-inst-001]
S2[调度器-2<br/>ID: scheduler-inst-002]
S3[调度器-3<br/>ID: scheduler-inst-003]
end
subgraph "任务列表"
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
每个实例只负责自己该负责的任务,不会冲突
好处:
- 零争抢:每个实例自己算,不用去数据库抢锁
- 自动平衡:任务均匀分配到各个实例
- 故障转移:一个实例挂了,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);
}
}
为什么选最小节点?
- 简单可靠:所有实例对实例列表的排序结果一致,最小节点是确定的
- 无需选举:不需要复杂的 Leader 选举算法
- 自动切换:最小节点挂了,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=100,100 % 3 = 1,由实例2 处理
业务高峰期扩容到 5 个实例:
- 实例1 处理 订单ID % 5 == 0 的订单
- 实例2 处理 订单ID % 5 == 1 的订单
- ...
订单 ID=100,100 % 5 = 0,变成实例1 处理了
如果任务还没执行完,就会出现重复处理或遗漏
JobFlow 的固定片数方案
我们的思路是:分片数固定,但分配灵活。
创建任务时,你指定分片数,比如 100。这个数字一旦确定,就不会变。
订单同步任务配置:
- 分片数:100
- 分片策略:MOD_HASH
订单 ID=100,100 % 100 = 0,归属分片 0
订单 ID=255,255 % 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,唯一索引冲突,跳过
好处:
- 绝大多数情况不冲突:一致性哈希已经算好了,冲突概率极低
- 即使冲突,也能正确处理:数据库唯一索引是最终防线
- 性能好:不需要每次都 UPDATE 抢锁,只是 INSERT 记录
- 逻辑清晰:执行记录本来就要有,这个约束是顺便加的
为什么不用 Redis 锁?
- 减少依赖:Redis 挂了不应该影响任务调度
- 避免锁超时问题:任务执行时间不可控,锁续约很复杂
- 一举两得:执行记录本来就要写数据库,用唯一索引做约束更简单
调度流程
完整的调度流程是这样的:
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 面板可以直接看到:
- 每分钟执行次数
- 平均执行耗时
- 失败率
- 按任务名统计
无需单独配置,复用已有的监控体系。
可靠性保证
故障转移
调度器实例挂了怎么办?
因为我们用的是一致性哈希 + Nacos 服务发现 + 监听机制,故障转移是自动的:
- 调度器-2 挂了
- Nacos 检测到心跳失败,摘除这个实例
- 触发监听事件,其他调度器收到通知
- 重新计算一致性哈希,支付对账任务自动分配给调度器-3
- 通常在 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 的核心理念是:中间件即业务。
调度器不再是一个独立的平台,而是融入微服务体系的一个普通服务。这样做的好处是架构更简单、运维成本更低、可观测性更强。
我们计划用一个月左右的时间完成第一个版本,用代码验证这些想法的可行性。如果你对这个方向感兴趣,欢迎关注项目进展,一起探索云原生时代任务调度的新思路。