不用XXL-JOB!GPL协议下自研分布式定时任务调度系统,从0到1完整实战(附源码)

0 阅读7分钟

不用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 │                │
│  └──────────────────────┘      └──────────────────────┘                │
└─────────────────────────────────────────────────────────────────────────┘

核心流程

  1. 任务注册:业务模块启动时,扫描@ScheduledTask注解,HTTP注册到调度中心
  2. 任务调度:调度中心定时扫描next_execute_time <= now()的任务,生成执行ID,调用业务模块
  3. 任务执行:业务模块收到指令,反射执行方法,返回结果
  4. 结果反馈:调度中心更新执行记录,计算下次执行时间

分布式定时任务调度系统_架构图


三、任务注册:让业务无感接入

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注册时记录完整方法签名,执行时严格匹配
私有方法IllegalAccessExceptionmethod.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%↑
任务注册耗时30ms8ms73%↓
调度延迟(P99)250ms120ms52%↓
接入成本引入依赖仅需注解大幅降低

业务收益

  • 支撑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