JobFlow 实战:无锁调度是怎么做到的

前言

经过两周紧密的开发,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

四、完整的调度流程

用一个简单的图展示核心流程:

graph LR A[定时扫描] --> B{Owner?} B -->|否| F[跳过] B -->|是| C{到期?} C -->|否| F C -->|是| D[INSERT] D -->|成功| E[调度] D -->|冲突| F E --> G[完成]

关键步骤:

  1. isOwner 判定:Hash 分区,绝大多数情况下避免冲突
  2. Cron 判断:时间到了才触发
  3. 创建父子记录:支持分片并行
  4. INSERT 执行记录:唯一索引兜底,处理极端情况
  5. 调用执行器: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 分区(任务动态,按目标服务分组)

这样可以让同一个服务的延时任务集中在同一个调度器实例处理,减少网络开销。

延时队列的双重保护:

  1. Owner 判定:避免多实例重复扫描
  2. 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 无锁调度的核心:

  1. Hash 分区 + Owner 判定:每个实例自己算出该不该执行,绝大多数情况下无冲突
  2. 数据库兜底:唯一索引保护,处理极端情况
  3. 快速切换:实例下线 15-20 秒内自动切换,比传统方案快 8-10 倍
  4. 性能提升:数据库压力降低 67%,CPU 使用率显著降低

设计理念:

JobFlow 的核心理念是:复用优于重造,轻量级优先

  • 复用 Nacos 服务发现,不重复造轮子
  • 复用 HTTP 调用,无需自定义协议
  • 复用监控日志体系,零额外成本
  • 数据库兜底,无需引入分布式锁
  • 巡检补偿,高性价比容错

重要提醒:

这是"无锁设计",而不是真的完全无锁。我们追求的是:

  • 正常情况下高效(Hash 分区)
  • 极端情况下可靠(数据库兜底 + 巡检补偿)

遗留问题:

无锁调度解决了重复执行的问题,但带来了新问题:漏调怎么办?

答案是:巡检与补偿机制

下一篇文章,我们会深入探讨:

  • 如何检测超时任务
  • 如何智能确认任务状态(不是简单的 timeout = 失败)
  • 如何补偿 PENDING 和 RUNNING 的分片
  • 如何检测低频任务的漏调(小时级任务)

敬请期待!

相关推荐
桂花很香,旭很美1 天前
智能体技术架构:从分类、选型到落地
人工智能·架构
有来技术1 天前
Spring Boot 4 + Vue3 企业级多租户 SaaS:从共享 Schema 架构到商业化套餐设计
java·vue.js·spring boot·后端
东东5161 天前
学院个人信息管理系统 (springboot+vue)
vue.js·spring boot·后端·个人开发·毕设
三水不滴1 天前
Redis缓存更新策略
数据库·经验分享·redis·笔记·后端·缓存
sxgzzn1 天前
能源行业智能监测产品与技术架构解析
架构·数字孪生·无人机巡检
xiaoxue..1 天前
React 手写实现的 KeepAlive 组件
前端·javascript·react.js·面试
快乐非自愿1 天前
【面试题】MySQL 的索引类型有哪些?
数据库·mysql·面试
小邓吖1 天前
自己做了一个工具网站
前端·分布式·后端·中间件·架构·golang
南风知我意9571 天前
【前端面试2】基础面试(杂项)
前端·面试·职场和发展
大爱编程♡1 天前
SpringBoot统一功能处理
java·spring boot·后端