前言
经过两周紧密的开发,JobFlow 第一个版本已经完成。这篇文章用代码说话,展示无锁调度是怎么实现的。
JobFlow 第一版核心功能:
- 无锁调度:Hash 分区 + Owner 判定,避免分布式锁
- 父子分片:回调驱动动态分配,支持大任务并行处理
- 延时队列:复用 JobFlow 基础设施(Nacos、HTTP调用、TraceId等)
- 全链路追踪:TraceId 贯穿调度到执行
- 巡检补偿:超时检测、分片补偿、低频任务漏调检测
这篇文章重点讲无锁调度,其他功能后续文章会详细介绍。
一、为什么要做 JobFlow
这个问题在前两篇文章里已经详细分析过了,这里简单回顾一下核心点。
JobFlow 的定位:轻量级分布式调度框架,专为 Nacos 体系设计
核心理念:复用优于重造
| 维度 | XXL-Job | JobFlow |
|---|---|---|
| 服务发现 | 自建注册中心(手动注册) | 复用 Nacos |
| 配置管理 | Admin 页面配置 | Nacos Config |
| 调度中心 | 独立部署 Admin + Executor | 只需 Scheduler(普通微服务) |
| 管理后台 | 完整的 Web UI | 暂无 UI(配置文件 + 数据库) |
| 监控日志 | 需要单独接入 | 复用现有体系 |
| 运维成本 | 中等(独立平台) | 低(业务团队自己维护) |
| 学习成本 | 中等 | 低(复用 Spring Boot 生态) |
简单说:
- XXL-Job 是功能完整的独立平台,有 Web 管理后台,适合通用场景
- JobFlow 是轻量级框架,复用 Nacos 生态,适合已有 Nacos 的微服务体系
适用场景:
- 如果你的技术栈是 Spring Cloud Alibaba + Nacos,JobFlow 零额外依赖
- 如果你需要 Web 管理后台或更丰富的功能,XXL-Job 更合适
好,回顾完毕,进入正题。
二、无锁调度怎么做到的
2.1 传统方案的问题
先看传统方案是怎么做的。
假设你部署了 3 个调度器实例(高可用),有 50 个任务,每分钟都要执行。
diff
每分钟会发生什么:
- 3 个实例同时醒来
- 每个实例都扫描这 50 个任务
- 每个任务都要去数据库抢锁
- 3 × 50 = 150 次 UPDATE
- 但只有 50 次是有效的
- 另外 100 次白跑了
问题一:数据库压力大
一天下来:100 次/分钟 × 1440 分钟 = 144,000 次无效 UPDATE。
问题二:时间精度差
比如每天凌晨 2 点的对账任务:
makefile
02:00:00.010 实例 A 抢到锁,开始执行
02:00:00.050 实例 B 发现被抢了
02:00:00.100 实例 C 发现被抢了
如果实例 A 执行到一半挂了?
→ 任务丢了,要等明天凌晨 2 点
2.2 JobFlow 的方案
核心思想:每个实例自己算出该不该执行,不需要去抢锁。
思路对比
| 方案 | 传统乐观锁 | JobFlow 无锁设计 |
|---|---|---|
| 判断方式 | 去数据库抢锁 | 本地计算 Hash 分区 |
| 冲突概率 | 100% 冲突(3 实例同时抢) | 极低冲突 |
| 数据库操作 | 每次 3 个 UPDATE | 每次 1 个 INSERT |
| 性能 | 差(大量无效操作) | 好(几乎无浪费) |
| 兜底机制 | 无 | 唯一索引 + 巡检补偿 |
| 漏调风险 | 低(有锁保证) | 有(通过巡检补偿解决) |
工作原理
简单说就是:
markdown
1. 从 Nacos 获取所有调度器实例列表
2. 对实例列表排序(确保所有实例看到的顺序一致)
3. 用任务名算 Hash:hash % 实例数 = 目标索引
4. 如果目标索引是我,就执行;不是我,就跳过
这就是 Hash 分区 + Owner 判定。
举个例子:
diff
实例列表(已排序):
- 索引 0: 192.168.1.10:8080
- 索引 1: 192.168.1.11:8080
- 索引 2: 192.168.1.12:8080
任务列表:
- orderSync
- userClean
- reportGen
Hash 分配:
- "orderSync".hashCode() % 3 = 0 → 192.168.1.10:8080 负责
- "userClean".hashCode() % 3 = 2 → 192.168.1.12:8080 负责
- "reportGen".hashCode() % 3 = 1 → 192.168.1.11:8080 负责
关键点:
- 所有实例对实例列表的排序结果一致
- 同一个任务算出的 Hash 结果一致
- 所以同一个任务在所有实例眼里,owner 是唯一的
核心代码
代码很简单,核心逻辑如下:
java
public boolean isOwner(String jobName) {
// 1. 从 Nacos 获取所有调度器实例
List<ServiceInstance> instances = discoveryClient.getInstances("jobflow-scheduler");
if (instances == null || instances.isEmpty()) {
return false;
}
// 2. 排序(关键!所有实例必须看到相同的顺序)
instances.sort(Comparator.comparing(instance ->
instance.getHost() + ":" + instance.getPort()));
// 3. 找到当前实例在列表中的索引
String currentKey = localHost + ":" + localPort;
int currentIndex = -1;
for (int i = 0; i < instances.size(); i++) {
ServiceInstance inst = instances.get(i);
String key = inst.getHost() + ":" + inst.getPort();
if (key.equals(currentKey)) {
currentIndex = i;
break;
}
}
if (currentIndex == -1) {
log.warn("当前实例不在 Nacos 注册列表中");
return false;
}
// 4. 计算任务应该归谁
int hash = Math.abs(jobName.hashCode());
int targetIndex = hash % instances.size();
// 5. 判断是不是我
return currentIndex == targetIndex;
}
为什么要排序?
如果不排序,Nacos 返回的实例顺序可能是随机的:
css
实例 A 看到:[B, A, C]
实例 B 看到:[C, B, A]
实例 C 看到:[A, C, B]
→ 同一个任务,不同实例算出的 owner 不一样
→ 可能都不执行,也可能重复执行
排序后,所有实例看到的都是 [A, B, C],计算结果就一致了。
2.3 重点说明:这是"无锁设计",不是真的完全无锁
绝大多数情况下,确实无锁:
- 所有实例从 Nacos 拿到相同的实例列表
- 排序后顺序一致
- Hash 计算结果一致
- 同一任务只有一个 owner
- 零冲突,零浪费
极少数的极端情况:
情况 1:网络分区
正常:所有实例都能访问同一个 Nacos
异常:机房间网络中断,实例看到不同的 Nacos 数据
结果:可能有两个实例都认为自己是 owner
情况 2:Nacos 数据延迟
css
实例刚上线或下线,Nacos 的服务列表可能有几秒延迟
实例 A 拉取:没有新实例
实例 B 拉取:有新实例
结果:可能有两个实例都认为自己是 owner
虽然概率很低(实测 < 0.1%),但一旦发生就是事故。所以我们加了兜底机制。
设计取舍:
无锁设计是一种权衡:
- 优势:性能好、响应快、数据库压力小
- 代价:极端情况下可能重复执行(通过数据库唯一索引防护)
这符合 JobFlow 的设计理念:轻量级优先,高性价比容错。
2.4 数据库兜底机制
execution_id 唯一索引
sql
CREATE TABLE job_execution (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
execution_id VARCHAR(200) NOT NULL,
-- ... 其他字段
UNIQUE KEY uk_execution_id (execution_id)
);
execution_id 的格式:
makefile
任务名:触发时间:分片范围
示例:
orderSync:202412211030:0-99
兜底逻辑:
java
try {
// 插入执行记录
jobRepository.insert(execution);
// 成功,继续调度
callExecutor(...);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明其他实例已经在执行了
log.info("任务已被其他实例执行,跳过");
}
工作流程:
ini
极端情况:scheduler-001 和 scheduler-002 都认为自己是 owner
scheduler-001: INSERT execution_id = "orderSync:202412211030:0-99"
→ 成功(第一个到达)
scheduler-002: INSERT execution_id = "orderSync:202412211030:0-99"
→ 失败(唯一索引冲突)
最终:只有 scheduler-001 继续执行
性能对比
虽然加了数据库兜底,但性能仍然比乐观锁好很多:
| 维度 | 乐观锁方案 | JobFlow 方案 |
|---|---|---|
| 数据库操作 | 3 实例 × 50 任务 = 150 次 UPDATE | 50 次 INSERT |
| 有效操作 | 50 次 | 50 次 |
| 无效操作 | 100 次 | 0 次(偶尔有冲突 < 0.1%) |
| 操作类型 | UPDATE(加锁) | INSERT(无锁) |
显著减少数据库压力,CPU 使用率大幅下降。
三、实例变化时怎么办
前面说的是静态场景,那实例上线或下线呢?
3.1 监听 Nacos 事件
调度器启动时,会监听 Nacos 的实例变化事件:
java
@PostConstruct
public void init() {
namingService.subscribe(serviceName, event -> {
log.info("调度器实例发生变化");
// 触发一次立即扫描
jobScheduler.scanNow();
});
}
3.2 变化场景
场景:3 实例 → 停掉一个 → 2 实例
diff
初始状态(3 个实例):
- scheduler-001 负责 orderSync, reportGen
- scheduler-002 负责 paymentSync
- scheduler-003 负责 userClean
停掉 scheduler-002:
- Nacos 检测到心跳丢失(约 15 秒)
- 触发事件通知其他实例
- scheduler-001 和 003 重新计算 Hash
重新分配(2 个实例):
- scheduler-001 负责 orderSync, paymentSync
- scheduler-003 负责 userClean, reportGen
总耗时:15-20 秒完成切换
对比传统方案:
传统方案需要等补偿任务扫描到超时记录,通常 2-3 分钟。
JobFlow 的切换速度快 8-10 倍。
3.3 实测日志
ini
# 停掉 scheduler-002 前
[scheduler-001] 触发任务执行,jobName=orderSync
[scheduler-002] 触发任务执行,jobName=paymentSync
[scheduler-003] 触发任务执行,jobName=userClean
# scheduler-002 停止
[scheduler-001] 调度器实例发生变化,当前实例数:2
[scheduler-003] 调度器实例发生变化,当前实例数:2
# 15 秒后
[scheduler-001] 触发任务执行,jobName=orderSync
[scheduler-001] 触发任务执行,jobName=paymentSync ← 接管了
[scheduler-003] 触发任务执行,jobName=userClean
四、完整的调度流程
用一个简单的图展示核心流程:
关键步骤:
- isOwner 判定:Hash 分区,绝大多数情况下避免冲突
- Cron 判断:时间到了才触发
- 创建父子记录:支持分片并行
- INSERT 执行记录:唯一索引兜底,处理极端情况
- 调用执行器:HTTP 异步调用,回调驱动分片分配
关于分片调度: 这里只展示了调度触发部分。完整的分片调度机制(父子记录、回调驱动、动态分配)请参考第二篇文章。
五、延时队列也用了无锁调度
既然讲到 JobFlow 的无锁调度,这里补充说明一下:延时队列也复用了这套机制。
延时队列的 Owner 判定:
java
// DelayTaskService.java
private void dispatchDelayTask(JobDelayTask task) {
// 1. 无锁 owner 判定(按服务名分区)
if (!clusterInstanceService.isOwner(task.getServiceName())) {
if (log.isDebugEnabled()) {
log.debug("当前实例不是延时任务owner,跳过,serviceName={}",
task.getServiceName());
}
return;
}
// 2. CAS 抢占(数据库乐观锁)
boolean locked = delayTaskRepository.tryMarkSending(
task.getId(), task.getStatus(), task.getRetryCount());
if (!locked) {
log.warn("延时任务CAS抢占失败,可能被其他实例处理");
return;
}
// 3. 发送 HTTP 调用
callExecutor(...);
}
为什么延时队列用 serviceName 而不是 taskId?
- 周期任务:用 jobName 分区(任务固定)
- 延时任务:用 serviceName 分区(任务动态,按目标服务分组)
这样可以让同一个服务的延时任务集中在同一个调度器实例处理,减少网络开销。
延时队列的双重保护:
- Owner 判定:避免多实例重复扫描
- CAS 抢占:避免同一任务被重复调度
这和周期任务的 Owner + 唯一索引是一样的思路。
六、关于"漏调"的问题
有朋友可能会问:每个实例只取自己的任务,那会不会漏调?
确实有这个可能。
比如:
diff
场景:3 个实例,scheduler-001 负责 orderSync
如果 scheduler-001 挂了:
- Nacos 15 秒后检测到
- 其他实例接管任务
- 但这 15 秒内到期的任务,可能漏调
如果 3 个实例同时挂了:
- 所有任务都漏调
- 直到有新实例上线
JobFlow 怎么解决?
答案是:巡检与补偿机制。
具体怎么做,我们留到下一篇文章详细讲。这里先简单说明一下思路:
diff
JobFlow 的三层保障机制:
第一层(正常流程):
- 无锁调度快速触发
- 99.9% 的任务都走这条路
第二层(巡检补偿):
- 分片超时巡检(3分钟一次)
- 检测 PENDING/RUNNING 超时的分片
- 自动重试或标记失败
第三层(漏调检测):
- 低频任务巡检(10分钟一次)
- 专门检测小时级任务的漏调
- 查询时间窗口内是否有执行记录
这是分布式系统的常见做法:正常流程追求效率,异常流程追求可靠性。
七、实际运行效果
7.1 TraceId 全链路追踪
这是一次完整的任务执行日志:
调度器日志:
ini
2025-12-21 10:00:00.123 [traceId=20241221100000-a1b2c3d4]
触发任务执行,jobName=orderSync
2025-12-21 10:00:00.456 [traceId=20241221100000-a1b2c3d4]
调用执行器,url=http://192.168.1.20:8081/internal/job/orderSync
2025-12-21 10:00:05.678 [traceId=20241221100000-a1b2c3d4]
接收到回调,success=true
执行器日志:
ini
2025-12-21 10:00:00.600 [traceId=20241221100000-a1b2c3d4]
接收到任务请求,handler=orderSync
2025-12-21 10:00:01.800 [traceId=20241221100000-a1b2c3d4]
查询订单数据,count=1523
2025-12-21 10:00:05.000 [traceId=20241221100000-a1b2c3d4]
任务执行完成,duration=4300ms
在 ELK 中搜索 traceId:
diff
搜索:traceId="20251221100000-a1b2c3d4"
结果:
- 调度器的 3 条日志
- 执行器的 3 条日志
- 按时间排序,完整还原执行过程
对比传统方案:需要去两个系统分别查日志,靠时间戳对,容易出错。
7.2 性能表现
测试环境:
- 调度器:3 个实例
- 任务数量:100 个
- 执行频率:每分钟执行一次
- 测试时长:1 小时
对比结果:
| 指标 | 乐观锁方案 | JobFlow 方案 | 提升 |
|---|---|---|---|
| 每分钟数据库操作 | 300 次 UPDATE | 100 次 INSERT | 67% ↓ |
| 1 小时操作总数 | 18,000 次 | 6,000 次 | 67% ↓ |
| 数据库 CPU | 较高 | 显著降低 | - |
| 唯一索引冲突 | - | 5 次(0.083%) | - |
核心优势:
- 数据库操作减少 67%
- CPU 使用率显著降低
- 极低的冲突率(< 0.1%)
八、常见问题
Q1:Hash 分区真的能保证不重复吗?
A:绝大多数情况下可以。极端情况下有数据库唯一索引兜底。实测冲突率 < 0.1%。
Q2:数据库唯一索引会不会影响性能?
A:不会,反而比乐观锁方案性能更好。因为:
- 乐观锁:每次 3 个 UPDATE(抢锁)
- JobFlow:每次 1 个 INSERT(记录)
- INSERT 比 UPDATE 更轻量
Q3:实例下线后,正在执行的任务会丢吗?
A:不会。任务已经在执行器上运行了,调度器挂了不影响。如果回调失败,有巡检补偿机制(下一篇文章会讲)。
Q4:支持动态添加任务吗?
A:支持。通过 API 或直接操作数据库添加任务,下一个扫描周期(5 秒后)自动生效,不需要重启调度器。
Q5:和 XXL-Job 比,JobFlow 缺少什么?
A:JobFlow 是轻量级框架,不提供:
- Web 管理后台(通过数据库和配置文件管理,后期考虑补上)
- 可视化监控面板(复用 Prometheus + Grafana)
- 丰富的路由策略(专注于分片并行)
JobFlow 的定位是:在 Nacos 体系下,提供零额外依赖的调度能力。
Q6:巡检频率是多少?会不会漏掉任务?
A:分片巡检 3 分钟一次,低频任务巡检 10 分钟一次。正常流程是秒级响应,巡检只是兜底。实测漏调率 < 0.01%。
九、总结
JobFlow 无锁调度的核心:
- Hash 分区 + Owner 判定:每个实例自己算出该不该执行,绝大多数情况下无冲突
- 数据库兜底:唯一索引保护,处理极端情况
- 快速切换:实例下线 15-20 秒内自动切换,比传统方案快 8-10 倍
- 性能提升:数据库压力降低 67%,CPU 使用率显著降低
设计理念:
JobFlow 的核心理念是:复用优于重造,轻量级优先
- 复用 Nacos 服务发现,不重复造轮子
- 复用 HTTP 调用,无需自定义协议
- 复用监控日志体系,零额外成本
- 数据库兜底,无需引入分布式锁
- 巡检补偿,高性价比容错
重要提醒:
这是"无锁设计",而不是真的完全无锁。我们追求的是:
- 正常情况下高效(Hash 分区)
- 极端情况下可靠(数据库兜底 + 巡检补偿)
遗留问题:
无锁调度解决了重复执行的问题,但带来了新问题:漏调怎么办?
答案是:巡检与补偿机制。
下一篇文章,我们会深入探讨:
- 如何检测超时任务
- 如何智能确认任务状态(不是简单的 timeout = 失败)
- 如何补偿 PENDING 和 RUNNING 的分片
- 如何检测低频任务的漏调(小时级任务)
敬请期待!