⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7583469866007969827 「踏浪无痕」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

写在前面

XXL-Job 是国内任务调度领域的标杆项目,许雪里老师的设计兼顾了易用性与功能完整性。

但在全面拥抱 Nacos + Spring Cloud Alibaba 的架构中,我们发现了一些摩擦:XXL-Job 有自己的注册中心、配置存储,与 Nacos 体系存在重复。这不是设计缺陷,而是架构演进阶段的自然差异。

于是我们在思考:在云原生时代,中间件应该是独立的”平台”,还是内嵌的”能力模块”?

这就是 JobFlow 这个想法的由来。

在 Nacos 体系下遇到的挑战

当技术栈选择了 Nacos 作为服务发现和配置中心后,使用 XXL-Job 会遇到一些架构上的摩擦。这不是 XXL-Job 的问题,而是两套体系的设计假设不同。

挑战一:两套注册中心导致状态不一致

看看现在的架构:

同一个执行器实例,要向两个注册中心汇报状态。问题来了:这两个注册中心的状态可能不一致。

举个实际场景:

你的某个服务内存占用高,怀疑有内存泄漏,想做 JVM dump 分析。于是你在 Nacos 控制台把这个实例手动下线,避免有流量进来。

你: 在 Nacos 点击”下线”

Nacos: 实例已下线 ✓

你: 放心了,开始 dump

XXL-Job: 调度任务到这个实例

你: ???

为什么?因为 XXL-Job 的注册中心还认为这个实例是在线的。你在 Nacos 的操作,XXL-Job 根本不知道。

再比如:

  • 实例因为网络抖动,在 Nacos 被标记为不健康,但在 XXL-Job 还是健康的
  • 实例重启了,在 Nacos 重新注册成功,但 XXL-Job 还以为它下线了
  • 做灰度发布,在 Nacos 控制权重,但 XXL-Job 还是按原来的比例调度

两套系统各管各的,状态不同步,运维时心里没底。

挑战二:观测性欠缺

调度和执行是割裂的:

调度器触发任务 --> 执行器执行 --> 出问题了
| | |
Admin日志 执行器日志 到底哪里出问题?

想要排查一次任务执行失败,需要:

  • 去 Admin 后台看调度日志
  • 去执行器服务看执行日志
  • 靠时间戳对日志,祈祷两边的时钟同步

没有统一的 TraceId,排查问题全靠猜。

挑战三:分片缺少强约束

XXL-Job 的分片是”建议式”的:

// 执行器拿到分片参数
int shardIndex = XxlJobHelper.getShardIndex(); // 0
int shardTotal = XxlJobHelper.getShardTotal(); // 10

// 然后自己算范围
List<Order> orders = orderDao.findByIdMod(shardIndex, shardTotal);

问题是:没有分布式锁保护,两个实例可能同时处理相同的数据。

实际生产中见过这样的场景:

某个执行器重启,XXL-Job 以为它下线了,把分片分配给其他执行器;结果这个执行器又回来了,还在处理老的分片,导致数据重复处理。

核心思路:中间件即业务

在深入方案之前,先说说设计理念。

从”重中间件”到”轻能力”

传统架构下,中间件是一个外挂:

业务层:订单服务、用户服务...(微服务)
↓ 调用
中间件层:XXL-Job Admin(独立部署、独立运维、独立监控)

这种架构有明显的屏障:

  • 中间件需要单独部署、单独配置
  • 监控告警要单独接入
  • 日志系统要单独打通
  • 配置管理要单独维护
  • 业务团队和中间件团队可能还不是一拨人

但在云原生时代,这些屏障是没有必要的。

JobFlow 的理念是:中间件即业务

业务层:订单服务、用户服务、JobFlow 调度器
↓
都是微服务,都在同一个体系里

调度能力不再是一个独立的”平台”,而是业务能力的一部分:

  • 同样的部署方式(容器化、K8s)
  • 同样的监控告警(Prometheus、Grafana)
  • 同样的配置管理(Nacos Config)
  • 同样的日志收集(ELK/Loki)
  • 同样的团队维护(业务团队自己运维)

中间件不是外挂,而是内嵌的能力模块。

这种理念带来的好处:

  • 架构统一,认知成本低
  • 基础设施复用,运维成本低
  • 状态一致,不会出现”Nacos 下线了但调度还在跑”这种割裂
  • 业务团队自主可控,不依赖中间件团队

这就是 JobFlow 的战略定位:不是要做一个通用的任务调度平台,而是让调度能力融入微服务体系。

具体怎么做:减法和加法

做减法:去掉冗余

既然已经有了 Nacos,那就别再搞一套注册中心了:

  • 去掉自建的注册中心,统一用 Nacos 服务发现
  • MySQL 存任务定义、执行记录和审计日志,不存服务注册信息

做加法:补能力

去掉冗余的同时,把缺失的能力补上:

  • 内置全链路 TraceId,从调度到执行到日志,一条线串起来
  • 真正的分片:带分布式锁,有状态,可恢复
  • 智能重试:指数退避,死信队列
  • 调度器配置云原生化:用 Nacos Config 管理调度器配置(线程池、超时时间等),支持动态调整、多实例共享、版本回滚
  • 共享基础设施:作为微服务部署,天然复用 Actuator、Prometheus、告警系统、日志收集等已有资源
  • 开箱即用的 Prometheus 指标
  • RESTful API,支持手动触发、查询、重试

JobFlow 架构

整体架构

看起来清爽多了,核心就三个东西:

  • Nacos: 统一的服务发现和配置中心
  • JobFlow Scheduler: 一个轻量的调度器
  • MySQL: 存任务定义、执行记录和审计日志

重点是:

JobFlow Scheduler 作为微服务部署,自动复用已有的 Prometheus、Actuator、告警、日志等基础设施,零额外运维成本。

调用流程

重点在于:

  • 第 3 步生成的 traceId,会一路传递到执行器
  • 第 6 步执行器把 traceId 写入 MDC(日志上下文)
  • 第 7 步所有日志自动带上 traceId,在 ELK 里搜这个 traceId,就能看到完整的执行链路

分片调度

不是”建议你处理哪一段”,而是明确告诉你处理哪一段,并且用锁保护。

关键特性详解

特性一:全链路 TraceId

这是最重要的特性。调度器生成一个全局唯一的 traceId,通过 HTTP Header 传给执行器:

// JobFlow Scheduler
String traceId = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.set("X-Trace-Id", traceId);
headers.set("X-Shard-Index", "0");
headers.set("X-Shard-Total", "10");

restTemplate.postForEntity(url, new HttpEntity<>(params, headers), JobResult.class);

执行器收到后,写入 MDC:

// 执行器端
@PostMapping("/internal/job/{jobName}")
public JobResult execute(@RequestHeader("X-Trace-Id") String traceId, ...) {
MDC.put("traceId", traceId);
try {
log.info("开始执行任务"); // 日志自动带 traceId
// 执行业务逻辑
return JobResult.success();
} finally {
MDC.clear();
}
}

这样一来,在 ELK 里搜索 traceId,就能看到:

  • 调度器什么时候触发的
  • 调用了哪个执行器
  • 执行器处理了什么
  • 有没有报错,错在哪里

一个 traceId 贯穿全链路,排查问题效率提升 10 倍。

特性二:真分片

给执行器明确的数据范围,并且用分布式锁保护:

// 调度器计算分片范围
int totalRecords = 1000000;
int shardTotal = 10;
int rangeSize = totalRecords / shardTotal;

for (int i = 0; i < shardTotal; i++) {
long startId = i * rangeSize;
long endId = (i + 1) * rangeSize - 1;

// 生成锁的 key
String lockKey = String.format("lock:job:order-sync:range:%d-%d", startId, endId);

// 调用执行器
JobRequest request = new JobRequest();
request.setTraceId(traceId);
request.setStartId(startId);
request.setEndId(endId);
request.setLockKey(lockKey);

executeAsync(instance, request);
}

执行器拿到范围后,先抢锁:

@PostMapping("/internal/job/order-sync")
public JobResult sync(
@RequestHeader("X-Start-Id") Long startId,
@RequestHeader("X-End-Id") Long endId,
@RequestHeader("X-Lock-Key") String lockKey
) {
// 先抢锁
boolean locked = redisLock.tryLock(lockKey, 60, TimeUnit.SECONDS);
if (!locked) {
log.warn("分片范围 {}-{} 已被其他实例锁定", startId, endId);
return JobResult.skip("已有其他实例处理");
}

try {
// 处理 startId 到 endId 之间的数据
List<Order> orders = orderDao.findByIdBetween(startId, endId);
// ... 业务处理
return JobResult.success();
} finally {
redisLock.unlock(lockKey);
}
}

这样就保证了:

  • 每个分片有明确的数据范围
  • 同一个分片不会被多个实例同时处理
  • 即使执行器重启,分片也不会乱

特性三:智能重试

失败后不是简单重试,而是指数退避:

// 配置
retry:
max: 5
backoff: EXPONENTIAL
initialDelay: 1s
maxDelay: 5m

// 调度器
public void scheduleRetry(JobExecution execution) {
int retryCount = execution.getRetryCount();
if (retryCount >= maxRetry) {
// 超过最大重试次数,进入死信队列
deadLetterQueue.send(execution);
return;
}

// 计算延迟时间:1s, 2s, 4s, 8s, 16s...
long delay = Math.min(
initialDelay * (1 << retryCount),
maxDelay
);

scheduler.schedule(() -> {
retry(execution);
}, delay, TimeUnit.SECONDS);
}

特性四:调度器配置云原生化

JobFlow 自身的配置放在 Nacos Config,享受云原生配置管理的好处:

# Nacos Config: jobflow-scheduler.yaml
jobflow:
scheduler:
thread-pool-size:20 # 调度线程池大小
timeout:300 # 默认超时时间(秒)
max-retry:3 # 默认重试次数

executor:
connect-timeout:5000 # HTTP 连接超时
read-timeout:30000 # HTTP 读取超时

redis:
lock-timeout:60 # 分片锁超时时间(秒)

compensation:
enabled:true
interval:60000 # 补偿任务间隔(毫秒)
stuck-threshold:600000 # 卡住阈值(10分钟)

好处:

1.动态调整不重启

业务高峰期,任务调度压力大

→ 在 Nacos 控制台把 thread-pool-size 从 20 改成 50

→ 配置推送,调度器立刻生效

→ 高峰过后再调回来

2.多实例共享配置

部署 3 个调度器实例

→ 改一次配置,所有实例都生效

→ 不用每个实例都去改 application.yml

3.版本管理和回滚

调整了参数,发现效果不好

→ Nacos 一键回滚到上一个版本

→ 有完整的变更记录和审计日志

注意:这里说的是调度器自身的配置。任务的定义(谁、什么时候、跑什么)仍然存在数据库中,通过 API 或 UI 管理。

特性五:精简的数据库设计

MySQL 存储的内容:

-- 任务定义表
CREATETABLE job_definition (
idBIGINT PRIMARY KEY AUTO_INCREMENT,
job_name VARCHAR(100) UNIQUE,
service_name VARCHAR(100),
handlerVARCHAR(100),
cron VARCHAR(100),
enabled BOOLEANDEFAULTTRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP
);

-- 执行记录表
CREATETABLE job_execution (
idBIGINT PRIMARY KEY AUTO_INCREMENT,
job_name VARCHAR(100) NOTNULL,
trace_id VARCHAR(64) NOTNULLUNIQUE,
trigger_time TIMESTAMPNOTNULL,
finish_time TIMESTAMP,
statusVARCHAR(20) NOTNULL, -- PENDING/RUNNING/SUCCESS/FAILED
retry_count INTDEFAULT0,
result_message TEXT,
INDEX idx_trace (trace_id),
INDEX idx_job_time (job_name, trigger_time)
);

注意这里只存:

  • 任务定义(通过 API 或 UI 管理)
  • 执行状态和元数据
  • 审计日志

不存:

  • 服务注册信息(在 Nacos)
  • 调度器配置(在 Nacos Config)
  • 详细执行日志(靠 traceId 去 ELK 查)

这样数据库职责清晰,压力小,查询快。

常见疑问解答

问题一:Nacos 挂了怎么办?

答:如果 Nacos 挂了,整个微服务体系都挂了,任务调度不是最高优先级。

不过可以做降级:

@Service
publicclass ExecutorDiscovery {

// 本地缓存
private LoadingCache<String, List<String>> cache = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(key -> namingService.getAllInstances(key));

public List<String> getInstances(String serviceName) {
try {
return namingService.getAllInstances(serviceName);
} catch (NacosException e) {
log.warn("Nacos 不可用,使用缓存");
return cache.getIfPresent(serviceName);
}
}
}

这样 Nacos 短暂不可用时,还能用缓存的实例列表继续调度。

问题二:数据库写失败导致状态不一致?

答:采用最终一致性模型。

// 调度时先写 PENDING
jobExecutionDao.insert(new JobExecution()
.setTraceId(traceId)
.setStatus("PENDING")
.setTriggerTime(now));

// 异步调用执行器
CompletableFuture.runAsync(() -> {
try {
JobResult result = executeJob(executor, request);
// 更新为 SUCCESS 或 FAILED
jobExecutionDao.updateStatus(traceId, result.getStatus());
} catch (Exception e) {
jobExecutionDao.updateStatus(traceId, "FAILED");
}
});

// 后台补偿任务
@Scheduled(fixedDelay = 60000)
public void fixStuckExecutions() {
// 查找 PENDING 超过 10 分钟的记录
List<JobExecution> stuck = jobExecutionDao.findStuckExecutions();
for (JobExecution exec : stuck) {
// 通过 traceId 去 ELK 查日志,确认真实状态
// 或者标记为 TIMEOUT
}
}

即使写 DB 失败,也能通过 traceId 在日志系统里找到执行结果。

问题三:没有 UI 怎么运维?

答:初期提供 RESTful API,后期补 UI。

@RestController
@RequestMapping("/api/jobs")
publicclass JobController {

// 手动触发任务
@PostMapping("/{name}/trigger")
public JobResult trigger(@PathVariable String name) {
return jobService.triggerNow(name);
}

// 查询执行历史
@GetMapping("/{name}/executions")
public Page<JobExecution> history(
@PathVariable String name,
@RequestParam int page,
@RequestParam int size
) {
return jobExecutionDao.findByJobName(name, PageRequest.of(page, size));
}

// 根据 traceId 查询详情
@GetMapping("/executions/{traceId}")
public JobExecution detail(@PathVariable String traceId) {
return jobExecutionDao.findByTraceId(traceId);
}

// 重试失败的任务
@PostMapping("/executions/{traceId}/retry")
public JobResult retry(@PathVariable String traceId) {
return jobService.retry(traceId);
}
}

配合 Swagger UI,已经能满足基本的运维需求。等系统稳定了,再用 Vue/React 做一个管理后台。

问题四:调度器怎么保证高可用?

答:调度器无状态,可以部署多个实例,用分布式锁避免重复调度。

@Service
publicclass JobScheduler {

@Scheduled(cron = "${job.cron}")
public void scheduledTrigger() {
List<JobConfig> jobs = getEnabledJobs();

for (JobConfig job : jobs) {
// 每个任务用一把锁
String lockKey = "lock:schedule:" + job.getName();

boolean locked = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS);
if (locked) {
try {
trigger(job);
} finally {
redisLock.unlock(lockKey);
}
}
}
}
}

或者更优雅的方式,用一致性哈希:

// 每个调度器实例只负责部分任务
public boolean isMyResponsibility(String jobName) {
int hash = jobName.hashCode();
List<String> schedulerInstances = getSchedulerInstances();
String responsible = consistentHash.get(schedulerInstances, hash);
return responsible.equals(myInstanceId);
}

if (isMyResponsibility(job.getName())) {
trigger(job);
}

总结

JobFlow 只是一个想法,一个技术探讨。它的核心不是技术细节,而是一个设计理念:中间件即业务。

在云原生时代,调度能力不应该是一个独立部署、独立运维的”平台”,而应该是内嵌在微服务体系中的能力模块。

它不是要替代 XXL-Job,而是在深度使用 Nacos 体系的场景下,提供另一种可能的思路:

更契合 Nacos 生态

  • 复用已有的服务发现,避免维护两套注册中心
  • 调度器配置统一在 Nacos Config 管理,支持动态调整、多实例共享和版本回滚
  • 共享基础设施:无需单独接入 Prometheus、Actuator、告警系统等,作为微服务自动享有
  • 架构一致性更好,运维成本更低

更强的可观测性

  • TraceId 贯穿全链路
  • 日志、调度、执行一条线串起来
  • 排查问题效率更高

更严格的分片约束

  • 明确的数据范围分配
  • 分布式锁保护
  • 支持断点续传

XXL-Job 在通用性、易用性、功能完整性上有巨大优势,适合大多数场景。JobFlow 的思路更适合已经深度绑定 Nacos、对可观测性有较高要求的团队。

这个想法可能有很多问题和不足,欢迎大家提出不同的意见和看法。也许你有更好的解决方案,也许你能指出这个思路的致命缺陷,都很有价值。

技术讨论的意义,不在于一定要实现什么,而在于通过思考和碰撞,让我们对问题的理解更深入一些。

最后,再次感谢许雪里老师和 XXL-Job 社区,正是因为有了这样优秀的开源项目,我们才能站在巨人的肩膀上继续探索。

文章目录
  1. 1. 写在前面
  2. 2. 在 Nacos 体系下遇到的挑战
    1. 2.1. 挑战一:两套注册中心导致状态不一致
    2. 2.2. 挑战二:观测性欠缺
    3. 2.3. 挑战三:分片缺少强约束
  3. 3. 核心思路:中间件即业务
    1. 3.1. 从”重中间件”到”轻能力”
    2. 3.2. 具体怎么做:减法和加法
      1. 3.2.1. 做减法:去掉冗余
      2. 3.2.2. 做加法:补能力
  4. 4. JobFlow 架构
    1. 4.1. 整体架构
    2. 4.2. 调用流程
    3. 4.3. 分片调度
  5. 5. 关键特性详解
    1. 5.1. 特性一:全链路 TraceId
    2. 5.2. 特性二:真分片
    3. 5.3. 特性三:智能重试
    4. 5.4. 特性四:调度器配置云原生化
    5. 5.5. 特性五:精简的数据库设计
  6. 6. 常见疑问解答
    1. 6.1. 问题一:Nacos 挂了怎么办?
    2. 6.2. 问题二:数据库写失败导致状态不一致?
    3. 6.3. 问题三:没有 UI 怎么运维?
    4. 6.4. 问题四:调度器怎么保证高可用?
  7. 7. 总结