JobFlow 背后:五个让我豁然开朗的设计瞬间

🔗 开源地址与系列文章

前言

昨天晚上折腾到12点,把 JobFlow 的延时队列功能测试完,看着日志里一条条任务准时执行,突然有种感觉:这个东西,想通了就很简单。

从最开始被 XXL-Job 的两套注册中心搞得头疼,到决定自己写一个调度框架,再到核心功能开发完成,前后不到两周时间。不是因为我写代码有多快,而是想清楚了几个关键问题之后,代码几乎是自己长出来的。

这篇文章不讲具体的代码实现,那些技术细节后面会有专门的文章。今天想聊的是:这两周我是怎么想的,为什么会这么设计。

先说清楚适用边界:

JobFlow 不是要替代 XXL-Job,而是在特定场景下的一个轻量级选择。

适合用 JobFlow 的场景:

  • 技术栈 all-in Spring Cloud Alibaba + Nacos
  • 业务团队自己维护调度系统(不需要中间件团队)
  • 业务型调度 + 延时任务(订单超时、数据同步、定时清理)
  • 中小规模(百万级以下任务量)

不适合用 JobFlow 的场景:

  • 需要 Web 管理后台(JobFlow 没有 UI,通过数据库和配置文件管理)
  • 跨公司、多租户的通用调度平台
  • 强 SLA 的金融核心任务(秒级精度保证)
  • 超大规模 Cron 平台(需要分库分表)

如果你的场景不在上面的"适合"列表里,建议直接用 XXL-Job,功能完整、社区成熟。

好,边界说清楚了,开始正文。

写这篇文章有点冒险,因为不够"专业"。没有严谨的理论推导,没有完整的性能测试报告,就是记录一些想法和感悟。但我觉得这种"想通了"的过程,可能比代码本身更有价值。

痛点:两套注册中心的割裂感

事情的起因很简单:我们的微服务用的是 Spring Cloud Alibaba + Nacos,项目里要加定时任务,自然就想到了 XXL-Job。

装上,配好,跑起来,一切正常。但用了一段时间之后,总觉得哪里别扭。

graph TB subgraph Nacos体系 N[Nacos
服务发现] S1[订单服务] S2[用户服务] S3[支付服务] end subgraph XXL-Job体系 A[XXL-Job Admin
调度中心] E1[订单服务
Executor] E2[用户服务
Executor] end S1 --> N S2 --> N S3 --> N E1 --> A E2 --> A S1 -.同一个应用.-> E1 S2 -.同一个应用.-> E2 style N fill:#90EE90 style A fill:#FFB6C1

问题在哪?

同一个服务,在 Nacos 里注册一次,在 XXL-Job 里又要注册一次。

某天下午,订单服务在 Nacos 里显示下线了,运维同学说机器重启了。但 XXL-Job 的管理后台上,订单服务的执行器还是在线状态,定时任务还在往这个已经挂掉的实例上调度。

排查了半天才发现,XXL-Job 的心跳检测间隔比 Nacos 长,所以摘除得慢。

当时就在想:为什么要维护两套状态?

还有个问题:排查任务执行失败的时候,得先去 XXL-Job 的管理后台查调度日志,然后再去 ELK 查执行器的业务日志。两个日志系统之间没有关联,只能靠时间戳对。

有一次半夜被叫起来排查问题,任务调度时间是凌晨 2:00:01,执行器日志是 2:00:03,中间差了 2 秒。这 2 秒里发生了什么?不知道。调度器到执行器之间的链路断了,查不到。

两套注册中心,两套日志体系,割裂的。

第一个想法:能不能只用 Nacos?

周末在家躺着,脑子里一直在想这个问题。

XXL-Job 为什么要自己搞一套注册中心?因为它要做成通用的任务调度平台,不能绑定在某个具体的服务发现组件上。这个逻辑没问题,但对我们这种已经 all-in Nacos 的项目来说,就是重复建设了。

如果调度器也是一个普通的微服务,直接注册到 Nacos 上呢?

graph TB subgraph Nacos统一体系 N[Nacos] subgraph 调度层 J1[JobFlow
Scheduler-1] J2[JobFlow
Scheduler-2] end subgraph 业务层 S1[订单服务-1] S2[订单服务-2] S3[用户服务-1] end end J1 --> N J2 --> N S1 --> N S2 --> N S3 --> N J1 -.发现服务.-> N J1 --> |HTTP调用| S1 J1 --> |HTTP调用| S2 style N fill:#90EE90

这个想法一出来,后面的设计就顺了。

调度器不是独立的平台,而是业务中台的一部分:

  • 和业务服务部署在同一个 K8s 集群
  • 用同一套 Prometheus 监控
  • 日志输出到同一个 ELK
  • 通过 Nacos 发现业务服务

不需要单独的管理后台,不需要单独的运维团队,业务团队自己就能维护。

这个思路确定下来,很多问题就迎刃而解了。

第二个想法:调度器多实例怎么办?

调度器是个微服务,那肯定要多实例部署(高可用)。问题来了:

复制代码
假设部署 3 个调度器实例
有 50 个定时任务
每分钟都要执行

如果 3 个实例都去调度这 50 个任务
→ 每个任务会被调度 3 次
→ 重复执行

传统方案是用分布式锁:3 个实例去数据库抢锁,抢到的才能调度。

但这样有个问题:

diff 复制代码
每分钟:
- 3 个实例 × 50 个任务 = 150 次数据库 UPDATE
- 但只有 50 次是有效的
- 另外 100 次是浪费

一天下来:
- 144,000 次无效操作

周日晚上吃完饭,在阳台抽烟的时候想到一个点:为什么一定要抢锁?

3 个实例都去抢,本质上是因为它们不知道自己该不该执行这个任务。那如果让每个实例自己算出来呢?

diff 复制代码
调度器实例列表(从 Nacos 拿到):
- scheduler-001
- scheduler-002  
- scheduler-003

任务列表:
- orderSync
- userClean
- reportGen

分配规则:
- "orderSync".hashCode() % 3 = 0 → scheduler-001 负责
- "userClean".hashCode() % 3 = 2 → scheduler-003 负责
- "reportGen".hashCode() % 3 = 1 → scheduler-002 负责

每个实例自己算,算完之后只执行自己负责的任务,不需要去抢锁。

这就是 Hash 分区 + Owner 判定。

graph TB subgraph 传统方案 A1[实例1] --> L[分布式锁] A2[实例2] --> L A3[实例3] --> L L --> T[任务执行] end subgraph JobFlow方案 B1[实例1
算出负责 A,D,G] --> T1[执行 A,D,G] B2[实例2
算出负责 B,E,H] --> T2[执行 B,E,H] B3[实例3
算出负责 C,F,I] --> T3[执行 C,F,I] end

代码写起来也很简单:

java 复制代码
// 从 Nacos 获取所有调度器实例
List<Instance> instances = namingService.getAllInstances(serviceName);

// 排序(关键!所有实例看到的顺序要一致)
instances.sort(Comparator.comparing(i -> i.getIp() + ":" + i.getPort()));

// 计算任务归谁
int hash = Math.abs(jobName.hashCode());
int targetIndex = hash % instances.size();

// 判断是不是我
return currentIndex == targetIndex;

想通这个之后,我知道这事能成了。

第三个想法:会不会漏调?

无锁设计解决了重复执行的问题,但带来了新问题:会不会漏调?

diff 复制代码
场景:3 个调度器实例
- scheduler-001 负责 orderSync
- scheduler-002 负责 userClean
- scheduler-003 负责 reportGen

如果 scheduler-001 挂了:
- Nacos 检测到(约 15 秒)
- scheduler-002 和 003 重新计算 Hash
- scheduler-002 接管 orderSync

但这 15 秒内,orderSync 如果到期了呢?
→ 漏调了

这是无锁设计的代价。

说清楚:我们牺牲了什么

为了追求无锁的性能,我们放弃了一些东西:

放弃的保证:

  • 精确到秒的强保证 → 可能延迟 3-5 分钟(巡检周期)
  • 调度瞬时一致性 → 实例切换期间可能漏调
  • 完全无 DB 依赖 → 唯一索引兜底仍需数据库

换来的好处:

  • 数据库压力降低 67%
  • 无分布式锁开销
  • 实例扩缩容自动生效

这是典型的分布式权衡:不追求完美的一致性,而是用异步补偿换取更好的性能。

周一上午写代码的时候一直在想这个问题。中午吃饭的时候突然想明白了:用巡检补偿。

graph TB subgraph 正常流程 A[Hash分区] --> B[Owner判定] B --> C[执行任务] end subgraph 异常流程 D[巡检任务
最小节点负责] --> E[扫描数据库] E --> F{应该执行
但没执行?} F --> |是| G[补偿触发] F --> |否| H[跳过] end C --> |99%情况| I[任务完成] G --> |1%情况| I

具体怎么做:

markdown 复制代码
最小节点负责巡检(选主规则很简单):
1. 从 Nacos 获取所有实例
2. 排序
3. 最小的那个就是 Leader

巡检任务每 3 分钟执行一次:
1. 查询所有启用的任务
2. 判断:应该执行但没执行
3. 补偿触发

即使漏调了,最多延迟 3 分钟

这是分布式系统的经典做法:正常流程追求效率,异常流程追求可靠性。

周一下午把这块代码写完,测试了一下:停掉一个调度器实例,任务确实在 15 秒左右切换过去了;停掉所有实例再启动,巡检任务会补偿执行。

心里踏实了。

第四个想法:固定分片的妙处

任务调度还有个常见场景:大数据量的批处理。

比如每天凌晨要给 100 万用户发推送,怎么办?

传统做法是动态分片:调度器根据执行器实例数,动态分配任务范围。比如有 10 个执行器实例,就分成 10 片。

但这样有个问题:

diff 复制代码
场景:10 个执行器实例,100 万用户

调度时刻:
- 实例 1 负责 0-10 万
- 实例 2 负责 10-20 万
- ...

如果实例 5 挂了:
- 剩余 9 个实例
- 需要重新分片:0-11.1 万、11.1-22.2 万...
- 但已经在执行的分片怎么办?

周二晚上在家想这个问题,想了很久。

周三早上洗澡的时候突然想通了:固定分片 + 回调驱动。

markdown 复制代码
任务配置:固定 100 个分片(和实例数无关)

执行流程:
1. 调度器创建 100 条分片记录(PENDING 状态)
2. 初始分配:min(分片数, 实例数) 个分片
3. 执行器执行完一个分片,回调调度器
4. 调度器 CAS 更新状态,分配下一个 PENDING 分片
5. 继续循环,直到所有分片完成
sequenceDiagram participant S as 调度器 participant E1 as 执行器1 participant E2 as 执行器2 participant DB as 数据库 S->>DB: 创建100个分片
状态=PENDING S->>DB: CAS更新分片0
PENDING→RUNNING S->>E1: 执行分片0 S->>DB: CAS更新分片1
PENDING→RUNNING S->>E2: 执行分片1 E1->>E1: 执行业务逻辑 E1->>S: 回调:分片0完成 S->>DB: 更新分片0
RUNNING→SUCCESS S->>DB: CAS更新分片2
PENDING→RUNNING S->>E1: 执行分片2 E2->>E2: 执行业务逻辑 E2->>S: 回调:分片1完成 S->>DB: 更新分片1
RUNNING→SUCCESS S->>DB: CAS更新分片3
PENDING→RUNNING S->>E2: 执行分片3

这个设计的好处:

1. 固定分片,不受实例数影响

复制代码
100 个分片,10 个实例:每个实例平均 10 个分片
100 个分片,5 个实例:每个实例平均 20 个分片
100 个分片,20 个实例:每个实例平均 5 个分片

分片数是固定的,实例数可以动态变化

2. 回调驱动,动态负载均衡

复制代码
快的实例多执行几个分片
慢的实例少执行几个分片
自动负载均衡,不需要预先计算

3. 状态可查,进度透明

diff 复制代码
数据库里有每个分片的状态:
- PENDING:等待执行
- RUNNING:执行中
- SUCCESS:成功
- FAILED:失败

随时可以看到任务进度

4. 断点续传,失败重试

diff 复制代码
如果某个分片失败了:
- 状态标记为 FAILED
- 巡检任务会重试
- 重试次数可控

不会因为一个分片失败,整个任务重来

这个设计想通了之后,代码写起来特别顺。父子记录结构、CAS 乐观锁、回调接口,一天就写完了。

第五个想法:TraceId 的威力

前面说了,排查问题的时候,调度器和执行器的日志是割裂的。这个问题怎么解决?

周四上午在写日志的时候,突然想到一个点:能不能让 TraceId 从调度器贯穿到执行器?

TraceId 的设计很简单:

diff 复制代码
格式:yyyyMMddHHmmss-shortuuid-shardindex

示例:
- 父记录:20241221100000-a1b2c3d4
- 分片 0:20241221100000-a1b2c3d4-0
- 分片 1:20241221100000-a1b2c3d4-1
graph LR A[调度器创建TraceId
20241221100000-a1b2c3d4] --> B[HTTP Header传递] B --> C[执行器接收TraceId] C --> D[MDC.put] D --> E[业务日志自动带上TraceId] E --> F[ELK收集] F --> G[搜索TraceId
看到完整链路]

实际效果:

ini 复制代码
# 调度器日志
2024-12-21 10:00:00.123 [traceId=20241221100000-a1b2c3d4] 
触发任务执行,jobName=orderSync

2024-12-21 10:00:00.456 [traceId=20241221100000-a1b2c3d4-0] 
调用执行器,url=http://order-service:8081/internal/job/orderSync

# 执行器日志
2024-12-21 10:00:00.600 [traceId=20241221100000-a1b2c3d4-0] 
接收到任务请求,handler=orderSync

2024-12-21 10:00:01.800 [traceId=20241221100000-a1b2c3d4-0] 
查询订单数据,count=1523

2024-12-21 10:00:05.000 [traceId=20241221100000-a1b2c3d4-0] 
任务执行完成,duration=4300ms

在 ELK 里搜 traceId="20241221100000-a1b2c3d4*",调度器和执行器的日志全出来了,按时间排序,完整链路一目了然。

这个设计成本几乎为零(就是在 HTTP Header 里加个字段),但价值巨大。

日志打通了,排查问题的效率至少提升 10 倍。

延时队列:复用的艺术

周五上午,正在写文档的时候,看到一篇博客讲延时队列的实现。突然想到:我们的架构可以很快融入延时队列功能。

为什么?因为 JobFlow 已经有了所有需要的基础设施:

markdown 复制代码
延时队列需要什么:
1. 定时扫描机制 → 已经有了(周期任务扫描)
2. 服务发现 → 已经有了(Nacos)
3. HTTP 调用 → 已经有了(调用执行器)
4. TraceId 追踪 → 已经有了(全链路日志)
5. 数据库存储 → 已经有了(MySQL)

新增什么:
1. 独立的 job_delay_task 表
2. 一次性执行的逻辑
3. JSON Payload 透传
4. 指数退避重试
graph TB subgraph 延时队列 A[业务代码] --> B[提交延时任务
bizUuid + executeTime] B --> C[(job_delay_task表)] end subgraph JobFlow基础设施 D[定时扫描
每5秒] --> C C --> E{到期?} E --> |是| F[Nacos发现服务] F --> G[HTTP调用执行器] G --> H[TraceId追踪] H --> I[执行器处理] I --> J[回调更新状态] end J --> K{成功?} K --> |是| L[删除任务] K --> |否| M[指数退避重试]

周五下午花了 3 个小时,把延时队列写完了。测试了一下:

java 复制代码
// 业务代码
delayTaskService.submit(DelayTask.builder()
    .bizUuid("order_timeout_" + orderId)
    .serviceName("order-service")
    .handler("orderTimeoutHandler")
    .executeTime(LocalDateTime.now().plusMinutes(30))
    .payloadJson(JSON.toJSONString(Map.of("orderId", orderId)))
    .build());

// 30 分钟后
// 执行器自动收到调用
@JobHandler("orderTimeoutHandler")
public JobResult orderTimeout(JobContext context) {
    Map<String, Object> payload = JSON.parseObject(
        context.getPayloadJson(), Map.class);
    Long orderId = (Long) payload.get("orderId");
    
    // 取消订单
    orderService.cancel(orderId);
    
    return JobResult.success();
}

完美。

这就是复用的威力:基础设施搭好了,新功能几乎是自己长出来的。

写代码的节奏

回顾一下这两周的开发过程:

objectivec 复制代码
第一周(思考为主):
周末:想清楚核心设计(Nacos + Hash分区)
周一:写 Owner 判定 + 定时扫描
周二:写固定分片 + 回调驱动
周三:写 TraceId + HTTP 调用
周四:写巡检补偿
周五:测试 + 修 Bug

第二周(完善功能):
周一:优化 CAS 逻辑
周二:完善超时确认
周三:写低频任务漏调检测
周四:写文档
周五:延时队列(意外之喜)

代码量其实不大,核心功能 3000 行左右。但设计想清楚之后,代码写起来特别快。

很多时候不是代码难写,而是没想清楚。想清楚了,代码几乎是一气呵成的。

几个关键突破

回过头看,这两周有几个关键的思维突破:

1. 调度器即业务

不要把调度器当成独立的平台,而是当成业务中台的一部分。

这个突破带来的好处:

  • 复用 Nacos,零额外依赖
  • 复用监控日志,零运维成本
  • 业务团队自己维护,零沟通成本
graph TB subgraph 传统思维 A[调度平台
独立部署] -.调用.-> B[业务服务] end subgraph JobFlow思维 C[调度器
普通微服务] D[业务服务] E[消息中台] F[数据中台] end C --> G[Nacos] D --> G E --> G F --> G

2. 固定分片的简单

不要动态分片,固定分片数量,让回调驱动分配。

这个突破带来的好处:

  • 不受实例数影响
  • 自动负载均衡
  • 状态可查可控
  • 断点续传
diff 复制代码
动态分片的复杂:
- 实例数变了,分片要重新计算
- 计算逻辑复杂
- 状态难以追踪

固定分片的简单:
- 分片数固定
- 回调驱动
- 状态清晰

3. 无锁的代价与补偿

不要追求完美的无锁,而是接受代价,用补偿解决。

这个突破带来的好处:

  • 性能好(无锁)
  • 可靠性高(补偿)
  • 设计清晰(分层)
graph LR A[正常流程
Hash分区
99%情况] --> C[任务执行] B[异常流程
巡检补偿
1%情况] --> C style A fill:#90EE90 style B fill:#FFB6C1

4. 复用的威力

不要重复造轮子,能复用就复用。

这个突破带来的好处:

  • Nacos 服务发现 → 复用
  • HTTP 调用 → 复用
  • TraceId 追踪 → 复用
  • 监控日志 → 复用

延时队列为什么能 3 小时写完?因为 90% 的代码都是复用的。

一些启示

启示一:想清楚比写代码重要

这两周,真正写代码的时间可能只有 30%,70% 的时间在想设计。

但正是这 70% 的思考,让 30% 的编码变得简单。

架构设计不是拍脑袋,是一个持续思考、推翻重来、逐步清晰的过程。

启示二:简单的设计更可靠

最开始想过很多复杂的方案:

  • 用 Raft 协议选主
  • 用 Redisson 分布式锁
  • 用 ZooKeeper 协调

后来发现,这些都不需要:

  • 选主?最小节点就是 Leader
  • 分布式锁?Hash 分区 + 数据库兜底
  • 协调?Nacos 服务发现就够了

复杂的方案不一定更好,简单的方案往往更可靠。

启示三:分布式的本质是权衡

无锁设计很快,但可能漏调 → 用巡检补偿 固定分片很简单,但可能不均匀 → 用回调驱动负载均衡 数据库兜底很可靠,但有性能损耗 → 99% 的场景无损耗

没有完美的方案,只有合适的权衡。

diff 复制代码
分布式系统的权衡:
- 性能 vs 一致性
- 简单 vs 完美
- 效率 vs 可靠

关键是:知道自己在权衡什么,为什么这么权衡

启示四:复用是最大的创新

延时队列不是新功能,但复用 JobFlow 的基础设施,就能很快实现。

创新不一定是发明新东西,把已有的东西组合好,也是创新。

graph TB A[基础设施] --> B[周期任务] A --> C[延时队列] A --> D[分片调度] A --> E[巡检补偿] A --> |Nacos| F[服务发现] A --> |HTTP| G[远程调用] A --> |TraceId| H[链路追踪] A --> |MySQL| I[状态存储]

启示五:分布式就是这么回事

回到开头那个问题:分布式到底在搞什么?

做完 JobFlow 之后,我的答案是:

markdown 复制代码
分布式就是在解决这些问题:
1. 数据怎么复制(日志复制)
2. 故障怎么切换(主从切换、Hash分区)
3. 状态怎么同步(Nacos、数据库)
4. 任务怎么分配(分片、负载均衡)
5. 异常怎么处理(补偿、重试)

xxl-job 也好,RocketMQ 也好,Redis Cluster 也好,本质上都是在解决这些问题。

想通了这些,再去看任何分布式系统,都能看到相同的影子。

写在最后

这两周开发 JobFlow 的经历,对我来说是一次思维突破。

不是说我做出了多么厉害的东西,而是想清楚了一些以前模糊的点:

  • 为什么要这么设计
  • 设计的边界在哪里
  • 什么时候该权衡什么

这些东西,书上很难学到。它来自于实践,来自于思考,来自于一次次的推翻重来。

JobFlow 现在还很简陋:

  • 没有 Web 管理后台
  • 没有完善的监控面板
  • 没有丰富的路由策略

但核心的设计理念是清晰的:

  • 复用优于重造
  • 轻量级优先
  • 高性价比容错

后面会继续完善功能,但这个核心不会变。

如果重来一次

写完之后,回过头看,有些地方我会换个做法:

会保留的设计:

  • Hash 分区 + Owner 判定:这个设计经过验证,非常稳定
  • 固定分片 + 回调驱动:比动态分片简单太多
  • TraceId 全链路追踪:成本低,价值大

会调整的设计:

  • 巡检补偿的周期:3 分钟可能太长,应该做成可配置的
  • 数据库表结构:父子记录有些字段是冗余的,可以优化
  • 超时确认逻辑:现在的"反向询问"实现有点重,可以简化

写早了的代码:

  • 低频任务漏调检测:这个功能用的人很少,应该后面再加
  • 分片策略抽象:现在只有 MOD_HASH 一种,抽象层有点过度设计

后悔没做的:

  • 单元测试:开发太快,测试覆盖不够,后面补测试很痛苦
  • 配置校验:很多配置没做合法性检查,上线后才发现问题
  • 监控指标:应该一开始就把 Prometheus 指标设计好

这些反思,会在后续的迭代中慢慢调整。

最重要的收获:

分布式系统的设计,本质上是在做权衡:

  • 我接受什么代价
  • 我换取什么好处
  • 我用什么兜底

想清楚这三点,代码自然就清晰了。


最后想问问大家:

你在做架构设计的时候,有没有类似的"突然想通"的时刻?

是什么让你想通的?一个具体的场景?一次失败的教训?还是某个偶然的灵感?

欢迎评论区聊聊。

架构这东西,很多时候不是看书看出来的,是踩坑踩出来的,是想出来的。

相关推荐
北邮刘老师2 小时前
马斯克的梦想与棋盘:空天地一体的智能体互联网
数据库·人工智能·架构·大模型·智能体·智能体互联网
七夜zippoe2 小时前
使用OpenLLM管理轻量级大模型服务
架构·langchain·大模型·kv·轻量
黄俊懿2 小时前
【深入理解SpringCloud微服务】Gateway简介与模拟Gateway手写一个微服务网关
spring boot·后端·spring·spring cloud·微服务·gateway·架构师
用户2190326527352 小时前
别再到处try-catch了!SpringBoot全局异常处理这样设计
java·spring boot·后端
梁同学与Android2 小时前
Android ---【经验篇】阿里云 CentOS 服务器环境搭建 + SpringBoot项目部署(二)
android·spring boot·后端
我是小妖怪,潇洒又自在2 小时前
springcloud alibaba(十)分布式事务
分布式·spring cloud·wpf
Q8762239652 小时前
基于S7 - 200 PLC和组态王的大小球颜色大小材质分拣系统探索
分布式
用户2190326527352 小时前
SpringBoot自动配置:为什么你的应用能“开箱即用
java·spring boot·后端