🔗 开源地址与系列文章
- 开源地址 :
https://gitee.com/sh_wangwanbao/job-flow - 系列文章:
- 第一篇:基于Nacos的轻量任务调度方案 ------ 从 XXL-Job 的痛点说起
- 第二篇:JobFlow 实现方案:云原生时代的任务调度新思路
- 第三篇:JobFlow 实战:无锁调度是怎么做到的
- 第四篇: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。
装上,配好,跑起来,一切正常。但用了一段时间之后,总觉得哪里别扭。
服务发现] 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 上呢?
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 判定。
算出负责 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%
- 无分布式锁开销
- 实例扩缩容自动生效
这是典型的分布式权衡:不追求完美的一致性,而是用异步补偿换取更好的性能。
周一上午写代码的时候一直在想这个问题。中午吃饭的时候突然想明白了:用巡检补偿。
最小节点负责] --> 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. 继续循环,直到所有分片完成
状态=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
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. 指数退避重试
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,零额外依赖
- 复用监控日志,零运维成本
- 业务团队自己维护,零沟通成本
独立部署] -.调用.-> B[业务服务] end subgraph JobFlow思维 C[调度器
普通微服务] D[业务服务] E[消息中台] F[数据中台] end C --> G[Nacos] D --> G E --> G F --> G
2. 固定分片的简单
不要动态分片,固定分片数量,让回调驱动分配。
这个突破带来的好处:
- 不受实例数影响
- 自动负载均衡
- 状态可查可控
- 断点续传
diff
动态分片的复杂:
- 实例数变了,分片要重新计算
- 计算逻辑复杂
- 状态难以追踪
固定分片的简单:
- 分片数固定
- 回调驱动
- 状态清晰
3. 无锁的代价与补偿
不要追求完美的无锁,而是接受代价,用补偿解决。
这个突破带来的好处:
- 性能好(无锁)
- 可靠性高(补偿)
- 设计清晰(分层)
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 的基础设施,就能很快实现。
创新不一定是发明新东西,把已有的东西组合好,也是创新。
启示五:分布式就是这么回事
回到开头那个问题:分布式到底在搞什么?
做完 JobFlow 之后,我的答案是:
markdown
分布式就是在解决这些问题:
1. 数据怎么复制(日志复制)
2. 故障怎么切换(主从切换、Hash分区)
3. 状态怎么同步(Nacos、数据库)
4. 任务怎么分配(分片、负载均衡)
5. 异常怎么处理(补偿、重试)
xxl-job 也好,RocketMQ 也好,Redis Cluster 也好,本质上都是在解决这些问题。
想通了这些,再去看任何分布式系统,都能看到相同的影子。
写在最后
这两周开发 JobFlow 的经历,对我来说是一次思维突破。
不是说我做出了多么厉害的东西,而是想清楚了一些以前模糊的点:
- 为什么要这么设计
- 设计的边界在哪里
- 什么时候该权衡什么
这些东西,书上很难学到。它来自于实践,来自于思考,来自于一次次的推翻重来。
JobFlow 现在还很简陋:
- 没有 Web 管理后台
- 没有完善的监控面板
- 没有丰富的路由策略
但核心的设计理念是清晰的:
- 复用优于重造
- 轻量级优先
- 高性价比容错
后面会继续完善功能,但这个核心不会变。
如果重来一次
写完之后,回过头看,有些地方我会换个做法:
会保留的设计:
- Hash 分区 + Owner 判定:这个设计经过验证,非常稳定
- 固定分片 + 回调驱动:比动态分片简单太多
- TraceId 全链路追踪:成本低,价值大
会调整的设计:
- 巡检补偿的周期:3 分钟可能太长,应该做成可配置的
- 数据库表结构:父子记录有些字段是冗余的,可以优化
- 超时确认逻辑:现在的"反向询问"实现有点重,可以简化
写早了的代码:
- 低频任务漏调检测:这个功能用的人很少,应该后面再加
- 分片策略抽象:现在只有 MOD_HASH 一种,抽象层有点过度设计
后悔没做的:
- 单元测试:开发太快,测试覆盖不够,后面补测试很痛苦
- 配置校验:很多配置没做合法性检查,上线后才发现问题
- 监控指标:应该一开始就把 Prometheus 指标设计好
这些反思,会在后续的迭代中慢慢调整。
最重要的收获:
分布式系统的设计,本质上是在做权衡:
- 我接受什么代价
- 我换取什么好处
- 我用什么兜底
想清楚这三点,代码自然就清晰了。
最后想问问大家:
你在做架构设计的时候,有没有类似的"突然想通"的时刻?
是什么让你想通的?一个具体的场景?一次失败的教训?还是某个偶然的灵感?
欢迎评论区聊聊。
架构这东西,很多时候不是看书看出来的,是踩坑踩出来的,是想出来的。