不用XXL-JOB!GPL协议下自研分布式定时任务调度系统,从0到1完整实战(附源码)
为什么不直接用XXL-JOB?因为GPL协议。这篇文章完整复盘我自研一套分布式定时任务系统的全过程,包含架构设计、核心代码、踩坑记录和落地经验。
先说成果:
- ✅ 支撑8个业务模块、600+个定时任务
- ✅ 日均调度量万次,单机支撑2500+任务
- ✅ 业务方接入时间从2天缩短到2小时
你将获得:
- 一套可运行的源码工程
- 从注册、调度到执行的完整设计思路
- 3个真实踩坑记录 + 解决方案
- 可复用的“自研中间件四步法”
一、背景:为什么要自研?
我们项目需要一个分布式定时任务系统。XXL-JOB很成熟,但因为GPL开源协议的限制——如果引入xxl-job-core依赖并对源码修改,整个项目必须开源。我们项目是公司内部商业项目,不能开源,所以只能自研。
核心要求:
- 业务方无感接入(不用改业务代码)
- 支持分布式调度(多节点高可用)
- 满足日均万级调度的性能要求
二、整体架构:调度与执行分离
我设计的方案是:调度中心独立部署 + 业务模块无感接入 + HTTP通信 + 数据库统一存储。
看这个架构图:
┌─────────────────────────────────────────────────────────────────┐
│ 分布式定时任务调度系统 │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────┐ HTTP/REST ┌──────────────────────┐
│ │ ◄────────────────────────► │ │
│ Scheduler Center │ │ Business Module │
│ (调度中心) │ │ (业务模块) │
│ │ │ │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ 任务管理 │ │ │ │ 任务注册 │ │
│ │ 任务调度 │ │ │ │ 任务执行 │ │
│ │ 状态监控 │ │ │ │ 结果反馈 │ │
│ │ API接口 │ │ │ │ 注解扫描 │ │
│ └────────────────┘ │ │ └────────────────┘ │
│ ↓ │ │ ↓ │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ 数据访问层 │ │ │ │ 反射调用 │ │
│ └────────────────┘ │ │ └────────────────┘ │
└──────────────────────┘ └──────────────────────┘
↓ ↓
┌─────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL/MySQL 数据库 │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 任务定义表 │ │ 执行记录表 │ │
│ │ task_definition │ │ task_execution_record │ │
│ └──────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
核心流程:
- 任务注册:业务模块启动时,扫描
@ScheduledTask注解,HTTP注册到调度中心 - 任务调度:调度中心定时扫描
next_execute_time <= now()的任务,生成执行ID,调用业务模块 - 任务执行:业务模块收到指令,反射执行方法,返回结果
- 结果反馈:调度中心更新执行记录,计算下次执行时间
三、任务注册:让业务无感接入
3.1 自定义注解
业务方只需在方法上加注解,系统自动完成注册:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ScheduledTask {
String taskId(); // 全局唯一
String name(); // 任务名称
String cron(); // Cron表达式
}
3.2 扫描器实现
业务模块启动时,扫描所有@ScheduledTask注解的方法,通过HTTP注册到调度中心:
@Component
public class TaskRegisterScanner {
@PostConstruct
public void scanAndRegister() {
// 获取所有Spring管理的Bean
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class);
for (Object bean : beans.values()) {
for (Method method : bean.getClass().getDeclaredMethods()) {
ScheduledTask task = method.getAnnotation(ScheduledTask.class);
if (task != null) {
// 构建任务定义,HTTP注册
registerTask(buildTaskDefinition(bean, method, task));
}
}
}
}
}
3.3 坑点复盘
| 坑点 | 表现 | 解决方案 |
|---|---|---|
| 重复注册 | 多模块同时启动,同一任务注册多次 | 用taskId做唯一索引,插入前先查询 |
| 注册信息丢失 | HTTP调用失败,任务没注册上 | 记录失败日志,增加重试机制 |
四、任务调度:核心引擎设计
4.1 调度扫描器
调度中心的核心是一个定时扫描器,每5秒扫描一次数据库:
@Component
public class TaskScanner {
@Scheduled(fixedDelay = 5000)
@Transactional
public void scanTasks() {
LocalDateTime now = LocalDateTime.now();
List<TaskDefinition> tasks = taskRepository.findPendingTasks(now);
for (TaskDefinition task : tasks) {
executeTask(task);
}
}
private void executeTask(TaskDefinition task) {
// 1. 生成唯一执行ID(雪花算法)
Long executionId = idGenerator.nextId();
// 2. 创建执行记录(状态:RUNNING)
TaskExecutionRecord record = createRecord(executionId, task);
recordRepository.save(record);
// 3. 调用业务模块执行
TaskExecutionResult result = callBusinessModule(executionId, task);
// 4. 更新执行记录,计算下次执行时间
updateRecord(record, result);
calculateNextExecuteTime(task);
taskRepository.save(task);
}
}
4.2 雪花算法ID生成器
@Component
public class SnowflakeIdGenerator {
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检查
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成ID");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 0xFFF;
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - START_TIMESTAMP) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
}
4.3 坑点复盘
| 坑点 | 表现 | 解决方案 |
|---|---|---|
| 扫描性能 | 任务量大后,全表扫描导致数据库负载高 | next_execute_time加索引,分页查询 |
| 重复执行 | 多节点同时扫描到同一任务 | @Transactional保证原子性 |
| 调用失败 | 业务模块挂了,调用超时 | 增加重试机制 + 失败状态标记 |
五、任务执行:反射调用的艺术
5.1 执行器实现
业务模块收到调度指令后,通过反射调用业务方法:
@RestController
@RequestMapping("/internal/task")
public class TaskExecutor {
private final Map<Long, Boolean> executedCache = new ConcurrentHashMap<>();
@PostMapping("/execute")
public TaskExecutionResult execute(@RequestBody ExecuteRequest request) {
// 1. 幂等性检查(防止重试导致重复执行)
if (executedCache.containsKey(request.getExecutionId())) {
return TaskExecutionResult.success("已执行过", 0L);
}
try {
// 2. 获取Bean
Object bean = springContextUtils.getBeanByTaskId(request.getTaskId());
// 3. 获取方法
Method method = findMethod(bean.getClass(), request.getMethodName());
method.setAccessible(true); // 支持私有方法
// 4. 执行
Object result = method.invoke(bean);
// 5. 记录已执行
executedCache.put(request.getExecutionId(), true);
return TaskExecutionResult.success(result);
} catch (Exception e) {
logger.error("任务执行失败", e);
return TaskExecutionResult.failed(e.getMessage());
}
}
}
5.2 坑点复盘
| 坑点 | 表现 | 解决方案 |
|---|---|---|
| 方法找不到 | NoSuchMethodException | 注册时记录完整方法签名,执行时严格匹配 |
| 私有方法 | IllegalAccessException | method.setAccessible(true) |
| 重复执行 | 调度中心重试,同一executionId重复调用 | 本地缓存幂等检查 |
六、数据库设计
任务定义表
CREATE TABLE `task_definition` (
`task_id` varchar(128) NOT NULL COMMENT '任务ID',
`task_name` varchar(200) NOT NULL COMMENT '任务名称',
`cron_expression` varchar(50) NOT NULL COMMENT 'Cron表达式',
`status` varchar(20) NOT NULL DEFAULT 'ACTIVE',
`next_execute_time` datetime DEFAULT NULL COMMENT '下次执行时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_task_id` (`task_id`),
KEY `idx_next_execute_time` (`next_execute_time`)
);
任务执行记录表
CREATE TABLE `task_execution_record` (
`execution_id` bigint(20) NOT NULL COMMENT '执行ID',
`task_id` varchar(128) NOT NULL,
`status` varchar(20) NOT NULL COMMENT 'RUNNING/SUCCESS/FAILED',
`result` text,
`duration` bigint(20) DEFAULT NULL,
UNIQUE KEY `uk_execution_id` (`execution_id`),
KEY `idx_task_id` (`task_id`)
);
七、落地经验:灰度上线与踩坑
7.1 三级灰度策略
| 层级 | 范围 | 目标 |
|---|---|---|
| 业务级 | 非核心业务(日志清理) | 验证基础功能 |
| 环境级 | 测试 → 预发 → 生产 | 验证稳定性 |
| 任务级 | 低频 → 中频 → 高频 | 验证性能 |
7.2 真实踩坑记录
坑1:上线后注册失败
- 原因:调度中心和业务模块几乎同时启动,HTTP调用超时
- 解决:业务模块启动后延迟5秒,增加重试机制
坑2:凌晨2点数据库连接池打满
- 原因:调度扫描和业务反馈共用连接池
- 解决:读写分离,扫描用只读库,业务用主库
坑3:业务模块重启导致任务丢失
- 原因:调度中心没有重试策略
- 解决:增加失败重试 + 状态标记
八、最终成果
| 指标 | 优化前(XXL-JOB) | 优化后(自研) | 提升 |
|---|---|---|---|
| 单机支撑任务数 | 1500+ | 2500+ | 60%↑ |
| 任务注册耗时 | 30ms | 8ms | 73%↓ |
| 调度延迟(P99) | 250ms | 120ms | 52%↓ |
| 接入成本 | 引入依赖 | 仅需注解 | 大幅降低 |
业务收益:
- 支撑8个业务模块、600+个定时任务
- 日均调度量万次
- 业务方接入时间从2天缩短到2小时
九、总结:可复用的方法论
1. 分布式系统的核心是状态管理
雪花算法保证ID唯一,事务保证原子性,幂等保证重复安全。
2. 性能优化要从索引开始
next_execute_time加索引,分页查询,就能支撑百万级任务。
3. 给业务方最好的体验是无感
用注解驱动,业务方只需要关注业务逻辑,不需要关心调度细节。
自研中间件四步法
| 步骤 | 做什么 | 产出 |
|---|---|---|
| 1. 选型分析 | 调研现有方案,明确为什么不自用 | 方案对比表格 |
| 2. MVP验证 | 最简单的代码跑通核心流程 | 可运行的Demo |
| 3. 灰度打磨 | 找1-2个非核心业务试点 | 接入规范、故障预案 |
| 4. 全面推广 | 完善文档、培训业务方 | 用户手册、SLA承诺 |
作者:麻雀
微信公众号/B站:麻雀聊技术(欢迎关注公众号领取配套资料;B站配套上手视频)
如果觉得有用,点赞、在看、转发就是对我最大的支持。有问题评论区见,我会逐一回复。
【附录】技术栈
- 语言:Java
- 框架:Spring Boot
- 工具:雪花算法、Quartz CronExpression
- 数据库:MySQL/PostgreSQL