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 的分片
  • 如何检测低频任务的漏调(小时级任务)

敬请期待!

相关推荐
一 乐13 小时前
婚纱摄影网站|基于ssm + vue婚纱摄影网站系统(源码+数据库+文档)
前端·javascript·数据库·vue.js·spring boot·后端
码事漫谈14 小时前
Protocol Buffers 编码原理深度解析
后端
码事漫谈14 小时前
gRPC源码剖析:高性能RPC的实现原理与工程实践
后端
ITFLY814 小时前
架构很简单:系统拆分与组合
架构
踏浪无痕16 小时前
AI 时代架构师如何有效成长?
人工智能·后端·架构
程序员小假16 小时前
我们来说一下无锁队列 Disruptor 的原理
java·后端
辞砚技术录17 小时前
MySQL面试题——联合索引
数据库·面试
anyup17 小时前
2026第一站:分享我在高德大赛现场学到的技术、产品与心得
前端·架构·harmonyos
小L~~~17 小时前
绿盟校招C++研发工程师一面复盘
c++·面试
武子康17 小时前
大数据-209 深度理解逻辑回归(Logistic Regression)与梯度下降优化算法
大数据·后端·机器学习