基于 Spring ThreadPoolTaskScheduler + CronTrigger 实现的动态定时任务调度系统

动态定时任务调度系统技术实现文档

一、系统概述

基于 Spring ThreadPoolTaskScheduler + CronTrigger 实现的动态定时任务调度系统,支持通过数据库配置动态管理定时任务的启停、暂停、恢复和立即执行。

二、核心架构

2.1 组件结构

复制代码
skill-manage/
├── scheduler/
│   ├── DynamicTaskScheduler.java      # 调度管理器(核心)
│   └── TaskStartupLoader.java         # 应用启动加载器
├── task/
│   └── BrowseLogSimulateExecutor.java # 任务执行器
└── service/
    └── BrowseLogSimulateConfigService.java # 业务服务层

2.2 数据表设计

表名 : t_browse_log_simulate_config

字段 类型 说明
id bigint 主键ID
config_name varchar(100) 配置名称
cron_expression varchar(50) Cron表达式
task_status tinyint 任务状态(0=停止,1=运行中,2=暂停)
enabled tinyint 是否启用(0=禁用,1=启用)
participation_rate_range varchar(50) 参与率范围(JSON格式)
logs_per_user_range varchar(50) 浏览次数范围(JSON格式)
work_time_range varchar(50) 工作时间范围(JSON格式)
last_execute_time datetime 最后执行时间
next_execute_time datetime 下次执行时间
execute_count bigint 累计执行次数
total_generated_logs bigint 累计生成记录数

三、核心技术实现

3.1 调度管理器 - DynamicTaskScheduler

3.1.1 初始化配置
java 复制代码
@Component
public class DynamicTaskScheduler {
    
    private static final int CORE_POOL_SIZE = 10;
    
    private ThreadPoolTaskScheduler taskScheduler;
    private final ConcurrentHashMap<Long, ScheduledFuture<?>> taskMap = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<Long, AtomicBoolean> executingTasks = new ConcurrentHashMap<>();
    
    @PostConstruct
    public void init() {
        taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(CORE_POOL_SIZE);
        taskScheduler.setThreadNamePrefix("dynamic-task-scheduler-");
        taskScheduler.setAwaitTerminationSeconds(60); // 优雅关闭等待60秒
        taskScheduler.setWaitForTasksToCompleteOnShutdown(true); // 等待任务完成
        taskScheduler.setErrorHandler(t -> log.error("任务执行异常", t));
        taskScheduler.initialize();
    }
}

关键配置说明

  • setPoolSize(10): 固定线程池大小
  • setAwaitTerminationSeconds(60): 关闭时最多等待60秒
  • setWaitForTasksToCompleteOnShutdown(true): 确保任务执行完毕再关闭
  • ConcurrentHashMap: 线程安全的任务映射表
  • AtomicBoolean: 防止任务并发执行
3.1.2 注册定时任务
java 复制代码
public void scheduleTask(Long configId, String cronExpression, Runnable runnable) {
    // 1. 取消已存在的任务
    cancelTask(configId);
    
    // 2. 校验 Cron 表达式
    validateCronExpression(cronExpression);
    
    // 3. 初始化执行状态
    executingTasks.put(configId, new AtomicBoolean(false));
    
    // 4. 创建 CronTrigger
    CronTrigger trigger = new CronTrigger(cronExpression);
    
    // 5. 注册任务(带并发控制)
    ScheduledFuture<?> future = taskScheduler.schedule(() -> {
        AtomicBoolean isExecuting = executingTasks.get(configId);
        
        // 防止并发执行
        if (isExecuting != null && isExecuting.get()) {
            log.warn("任务正在执行中,跳过本次调度,configId: {}", configId);
            return;
        }
        
        long startTime = System.currentTimeMillis();
        try {
            if (isExecuting != null) {
                isExecuting.set(true); // 标记为执行中
            }
            
            runnable.run(); // 执行任务
            
            long costTime = System.currentTimeMillis() - startTime;
            log.debug("定时任务执行完成,configId: {}, 耗时: {}ms", configId, costTime);
        } catch (Exception e) {
            long costTime = System.currentTimeMillis() - startTime;
            log.error("定时任务执行异常,configId: {}, 耗时: {}ms", configId, costTime, e);
        } finally {
            if (isExecuting != null) {
                isExecuting.set(false); // 标记为执行完成
            }
        }
    }, trigger);
    
    taskMap.put(configId, future);
    
    // 6. 计算并记录下次执行时间
    Date nextExecuteTime = getNextExecutionTime(cronExpression);
    log.info("注册定时任务成功,configId: {}, cron: {}, 下次执行: {}", 
        configId, cronExpression, nextExecuteTime);
}

核心特性

  1. 真正的 Cron 调度 : 使用 CronTrigger 解析 Cron 表达式
  2. 并发控制 : 使用 AtomicBoolean 防止同一任务并发执行
  3. 执行监控: 记录每次执行耗时
  4. 异常隔离: 单个任务异常不影响其他任务
3.1.3 Cron 表达式校验
java 复制代码
private void validateCronExpression(String cronExpression) {
    try {
        new CronSequenceGenerator(cronExpression);
    } catch (Exception e) {
        throw new IllegalArgumentException("无效的 Cron 表达式: " + cronExpression);
    }
}
3.1.4 计算下次执行时间
java 复制代码
public Date getNextExecutionTime(String cronExpression) {
    try {
        CronTrigger trigger = new CronTrigger(cronExpression);
        // 必须传入非 null 的 TriggerContext
        return trigger.nextExecutionTime(new TriggerContext() {
            @Override
            public Date lastScheduledExecutionTime() {
                return new Date();
            }
            @Override
            public Date lastActualExecutionTime() {
                return new Date();
            }
            @Override
            public Date lastCompletionTime() {
                return new Date();
            }
        });
    } catch (Exception e) {
        log.error("计算下次执行时间失败,cron: {}", cronExpression, e);
        return null;
    }
}

注意 : CronTrigger.nextExecutionTime() 不能传 null,否则抛出 NPE。

3.1.5 任务控制方法
java 复制代码
// 取消任务
public void cancelTask(Long configId) {
    ScheduledFuture<?> future = taskMap.remove(configId);
    if (future != null && !future.isCancelled()) {
        future.cancel(false);
    }
    executingTasks.remove(configId); // 清理执行状态
}

// 暂停任务(等同于取消)
public void pauseTask(Long configId) {
    cancelTask(configId);
}

// 恢复任务(重新注册)
public void resumeTask(Long configId, String cronExpression, Runnable runnable) {
    scheduleTask(configId, cronExpression, runnable);
}

// 立即执行一次
public void executeOnce(Long configId, Runnable runnable) {
    taskScheduler.submit(() -> {
        try {
            runnable.run();
        } catch (Exception e) {
            log.error("立即执行任务异常,configId: {}", configId, e);
        }
    });
}

// 检查任务是否运行中
public boolean isRunning(Long configId) {
    ScheduledFuture<?> future = taskMap.get(configId);
    return future != null && !future.isCancelled() && !future.isDone();
}
3.1.6 优雅关闭
java 复制代码
@PreDestroy
public void destroy() {
    if (taskScheduler != null) {
        taskScheduler.shutdown();
        log.info("动态任务调度器已关闭");
    }
}

3.2 应用启动加载器 - TaskStartupLoader

java 复制代码
@Component
public class TaskStartupLoader implements CommandLineRunner {
    
    @Resource
    private BrowseLogSimulateConfigMapper configMapper;
    
    @Resource
    private DynamicTaskScheduler taskScheduler;
    
    @Resource
    private BrowseLogSimulateExecutor taskExecutor;
    
    @Override
    public void run(String... args) {
        log.info("========== 开始加载定时任务 ==========");
        
        // 查询所有启用且运行中的任务
        List<BrowseLogSimulateConfig> runningConfigs = configMapper.selectList(
            new LambdaQueryWrapper<BrowseLogSimulateConfig>()
                .eq(BrowseLogSimulateConfig::getEnabled, 1)
                .eq(BrowseLogSimulateConfig::getTaskStatus, 1)
        );
        
        if (runningConfigs == null || runningConfigs.isEmpty()) {
            log.info("没有需要加载的定时任务");
            return;
        }
        
        log.info("发现 {} 个运行中的定时任务,开始注册...", runningConfigs.size());
        
        int successCount = 0;
        int failCount = 0;
        
        for (BrowseLogSimulateConfig config : runningConfigs) {
            try {
                // 注册到调度器
                taskScheduler.scheduleTask(
                    config.getId(),
                    config.getCronExpression(),
                    () -> taskExecutor.execute(config.getId())
                );
                
                // 更新下次执行时间
                Date nextExecuteTime = taskScheduler.getNextExecutionTime(config.getCronExpression());
                if (nextExecuteTime != null) {
                    configMapper.updateNextExecuteTime(config.getId(), nextExecuteTime);
                }
                
                log.info("✓ 任务注册成功 | ID:{} | 名称:{} | Cron:{} | 下次执行:{}",
                    config.getId(), config.getConfigName(), 
                    config.getCronExpression(), nextExecuteTime);
                
                successCount++;
            } catch (Exception e) {
                log.error("✗ 任务注册失败 | ID:{} | 名称:{}", 
                    config.getId(), config.getConfigName(), e);
                
                // 更新任务状态为停止
                config.setTaskStatus(0);
                configMapper.updateById(config);
                
                failCount++;
            }
        }
        
        log.info("========== 定时任务加载完成 | 总数:{} | 成功:{} | 失败:{} ==========", 
            runningConfigs.size(), successCount, failCount);
    }
}

关键特性

  • 使用 CommandLineRunner 在 Spring 容器启动完成后执行
  • 自动加载数据库中所有运行中的任务
  • 异常隔离:单个任务注册失败不影响其他任务
  • 失败任务自动更新状态为停止

3.3 任务执行器 - BrowseLogSimulateExecutor

3.3.1 执行流程
java 复制代码
public void execute(Long configId) {
    long startTime = System.currentTimeMillis();
    log.info("开始执行浏览记录模拟任务,configId: {}", configId);
    
    try {
        // 1. 加载配置
        BrowseLogSimulateConfig config = configMapper.selectById(configId);
        
        // 2. 解析配置参数(JSON格式)
        SimulateParams params = parseConfig(config);
        
        // 3. 获取有效用户(根据机构/学科/运营商过滤)
        List<Customer> validCustomers = getValidCustomers(config);
        
        // 4. 获取课程资源
        List<CommonResource> courseList = getAllCourses();
        
        // 5. 生成浏览记录(并行处理)
        List<SysUserBrowseLog> allBrowseLogs = generateBrowseLogs(...);
        
        // 6. 批量插入数据库
        insertBatchBrowseLogs(allBrowseLogs, config.getBatchSize());
        
        // 7. 更新统计信息
        updateStatistics(configId, allBrowseLogs.size());
        
        // 8. 更新下次执行时间
        updateNextExecuteTime(configId, config.getCronExpression());
        
        long costTime = System.currentTimeMillis() - startTime;
        log.info("完成 | configId:{} | 浏览记录:{} | 耗时:{}ms", 
            configId, allBrowseLogs.size(), costTime);
            
    } catch (Exception e) {
        log.error("浏览记录模拟任务执行异常,configId: {}", configId, e);
    }
}
3.3.2 JSON 参数解析
java 复制代码
private SimulateParams parseConfig(BrowseLogSimulateConfig config) {
    SimulateParams params = new SimulateParams();
    
    // 解析参与率范围(兼容 BigDecimal、String 等类型)
    if (StringUtils.isNotEmpty(config.getParticipationRateRange())) {
        Map<String, Object> rateMap = JSONUtil.toBean(
            config.getParticipationRateRange(), Map.class);
        params.setMinParticipationRate(parseDouble(rateMap.get("min"), 0.2));
        params.setMaxParticipationRate(parseDouble(rateMap.get("max"), 0.6));
    }
    
    // 解析浏览次数范围
    if (StringUtils.isNotEmpty(config.getLogsPerUserRange())) {
        Map<String, Object> logsMap = JSONUtil.toBean(
            config.getLogsPerUserRange(), Map.class);
        params.setMinLogsPerUser(parseInt(logsMap.get("min"), 5));
        params.setMaxLogsPerUser(parseInt(logsMap.get("max"), 10));
    }
    
    return params;
}

// 安全解析 Double(兼容 Number、String、null)
private double parseDouble(Object value, double defaultValue) {
    if (value == null) return defaultValue;
    if (value instanceof Number) return ((Number) value).doubleValue();
    try {
        return Double.parseDouble(value.toString());
    } catch (NumberFormatException e) {
        return defaultValue;
    }
}
3.3.3 统计信息更新
java 复制代码
// Mapper 层 - 使用 SQL 累加(避免并发问题)
@Update("UPDATE t_browse_log_simulate_config SET " +
        "last_execute_time = NOW(), " +
        "execute_count = execute_count + 1, " +
        "total_generated_logs = total_generated_logs + #{generatedCount}, " +
        "update_time = NOW() " +
        "WHERE id = #{configId}")
void updateStatistics(@Param("configId") Long configId, 
                      @Param("generatedCount") Integer generatedCount);

// 更新下次执行时间
@Update("UPDATE t_browse_log_simulate_config SET " +
        "next_execute_time = #{nextExecuteTime}, " +
        "update_time = NOW() " +
        "WHERE id = #{configId}")
void updateNextExecuteTime(@Param("configId") Long configId, 
                           @Param("nextExecuteTime") Date nextExecuteTime);

四、业务流程

4.1 启动任务流程

复制代码
Controller.start(id)
    ↓
Service.startTask(id)
    ↓
1. 查询配置
2. 校验状态(是否已在运行、是否启用)
3. 调用 Scheduler.scheduleTask()
   ├─ 校验 Cron 表达式
   ├─ 创建 CronTrigger
   ├─ 注册到 ThreadPoolTaskScheduler
   └─ 计算下次执行时间
4. 更新数据库 task_status = 1
5. 更新 next_execute_time

4.2 定时触发流程

复制代码
ThreadPoolTaskScheduler (按 Cron 表达式触发)
    ↓
DynamicTaskScheduler.schedule() 中的 Runnable
    ↓
1. 检查并发状态(AtomicBoolean)
   ├─ 如果正在执行 → 跳过本次
   └─ 如果空闲 → 标记为执行中
2. 执行 BrowseLogSimulateExecutor.execute()
   ├─ 加载配置
   ├─ 生成浏览记录
   ├─ 批量插入数据库
   ├─ 更新统计信息(execute_count + 1)
   └─ 更新下次执行时间
3. 标记为执行完成

4.3 应用重启流程

复制代码
Spring Boot 启动
    ↓
@PostConstruct - DynamicTaskScheduler.init()
    ↓ 初始化线程池
CommandLineRunner - TaskStartupLoader.run()
    ↓
1. 查询 task_status=1 且 enabled=1 的任务
2. 逐个注册到调度器
3. 更新 next_execute_time
4. 输出加载日志(成功/失败统计)

五、关键技术点

5.1 防止任务并发执行

问题: 如果任务执行时间超过调度间隔,可能导致多个实例同时运行。

解决方案 : 使用 AtomicBoolean 标记执行状态

java 复制代码
AtomicBoolean isExecuting = executingTasks.get(configId);
if (isExecuting != null && isExecuting.get()) {
    log.warn("任务正在执行中,跳过本次调度");
    return;
}

try {
    isExecuting.set(true);
    runnable.run();
} finally {
    isExecuting.set(false);
}

5.2 JSON 数字类型转换

问题 : Hutool 解析 JSON 时,数字默认是 BigDecimal 类型,直接 cast 会报错。

解决方案 : 先转为 Number,再调用 .doubleValue().intValue()

java 复制代码
Map<String, Object> map = JSONUtil.toBean(json, Map.class);
double value = ((Number) map.get("key")).doubleValue();

5.3 CronTrigger 空指针问题

问题 : CronTrigger.nextExecutionTime(null) 会抛出 NPE。

解决方案 : 传入有效的 TriggerContext

java 复制代码
trigger.nextExecutionTime(new TriggerContext() {
    @Override
    public Date lastScheduledExecutionTime() {
        return new Date();
    }
    // ... 其他方法
});

5.4 统计字段并发更新

问题: 多线程同时更新计数器可能丢失数据。

解决方案: 使用 SQL 原子操作

sql 复制代码
UPDATE table SET 
    execute_count = execute_count + 1,
    total_generated_logs = total_generated_logs + #{count}
WHERE id = #{id}

六、监控与日志

6.1 关键日志

复制代码
// 启动加载
========== 开始加载定时任务 ==========
发现 3 个运行中的定时任务,开始注册...
✓ 任务注册成功 | ID:1 | 名称:默认配置 | Cron:0 */5 * * * ? | 下次执行:2026-05-09 14:00:00
========== 定时任务加载完成 | 总数:3 | 成功:3 | 失败:0 ==========

// 任务执行
开始执行浏览记录模拟任务,configId: 1
完成 | configId:1 | 总用户:1000 | 参与用户:350 | 浏览记录:2100 | 人均:6.00次 | 耗时:15234ms
更新执行统计成功,configId: 1, 本次生成: 2100
更新下次执行时间成功,configId: 1, 下次执行: 2026-05-09 14:05:00

6.2 监控指标

java 复制代码
// 获取运行中的任务数量
public int getRunningTaskCount() {
    return (int) taskMap.values().stream()
        .filter(future -> !future.isCancelled() && !future.isDone())
        .count();
}

// 检查任务状态
public boolean isRunning(Long configId) {
    ScheduledFuture<?> future = taskMap.get(configId);
    return future != null && !future.isCancelled() && !future.isDone();
}

七、扩展性设计

7.1 新增任务类型

只需创建新的 Executor,无需修改调度器:

java 复制代码
@Component
public class NewTaskExecutor {
    public void execute(Long configId) {
        // 新任务逻辑
    }
}

// 注册时传入新的 Executor
taskScheduler.scheduleTask(id, cron, () -> newTaskExecutor.execute(id));

7.2 配置字段扩展

使用 JSON 格式存储范围配置,新增字段无需修改表结构:

json 复制代码
{
  "participationRateRange": {"min": 0.2, "max": 0.6},
  "logsPerUserRange": {"min": 5, "max": 10},
  "workTimeRange": {"start": 8, "end": 22}
}

八、注意事项

  1. Cron 表达式校验: 注册前必须验证表达式合法性
  2. 线程池大小: 根据任务数量和执行时长合理设置
  3. 优雅关闭: 确保应用关闭时等待任务完成
  4. 异常隔离: 单个任务异常不应影响其他任务
  5. 并发控制: 长时间任务必须防止并发执行
  6. 资源清理: 取消任务时清理执行状态标记
  7. JSON 解析: 注意数字类型转换问题
  8. 统计更新: 使用 SQL 原子操作避免并发问题

九、总结

本调度系统基于 Spring 原生能力实现,具有以下优势:

简洁 : 仅3个核心类,代码量少

可靠 : 真正的 Cron 调度,支持复杂表达式

健壮 : 并发控制、异常隔离、优雅关闭

可扩展 : 新增任务类型无需修改调度器

可监控 : 详细的执行日志和统计信息

易维护: 配置化管理,支持动态启停

相关推荐
北秋,1 小时前
PostgreSQL(Postgres)数据库基础用法 + 数字型 + 字符型 完整联合注入实战
数据库·postgresql·开源
m0_596749092 小时前
JavaScript中手动实现一个new操作符的底层逻辑
jvm·数据库·python
多加点辣也没关系2 小时前
Redis 的安装(详细教程)
数据库·redis·缓存
好家伙VCC2 小时前
【无标题】
java
数据库小学妹3 小时前
数据库连接池避坑指南:告别“连接超时”与“资源耗尽”,让系统跑得更快!
数据库·redis·sql·mysql·缓存·dba
dishugj3 小时前
HANA 数据库备份与恢复
数据库·oracle
前进的李工3 小时前
EXPLAIN输出格式全解析:JSON、TREE与可视化
开发语言·数据库·mysql·性能优化·explain
小碗羊肉3 小时前
【JavaWeb | 第十一篇】文件上传(本地&阿里云OSS)
java·阿里云·servlet
吾疾唯君医3 小时前
Java SpringBoot集成积木报表实操记录
java·spring boot·spring·导出excel·积木报表·数据文件下载