分布式定时任务从零实现(对话+流程图+代码可运行)
分布式定时任务从零实现(对话+流程图+代码示例)一、日常闹磕:为什么要自己实现定时任务?二、整体架构设计三、任务注册阶段3.1 时序图3.2 对话:任务注册的坑3.3 注册阶段代码示例3.3.1 自定义注解3.3.2 任务定义实体3.3.3 任务注册扫描器四、任务调度阶段4.1 时序图4.2 对话:任务调度的坑4.3 调度阶段代码示例4.3.1 任务执行记录实体4.3.2 调度中心任务扫描器五、任务执行与结果反馈阶段5.1 时序图5.2 对话:结果反馈的坑5.3 执行阶段代码示例5.3.1 业务模块任务执行器5.3.2 Spring上下文工具类六、使用示例6.1 业务模块定义定时任务6.2 Spring配置类6.3 雪花算法实现七、数据库建表SQL八、配置文件示例九、坑点总结十、总结粉丝福利🎥 视频版同步上线💬 技术交流群
一、日常闹磕:为什么要自己实现定时任务?
小兵:麻雀啊,最近我们在做一个新项目,要用到定时任务。以前我用过 XXL-JOB,但现在项目不让引入 xxl-job-core 依赖了(因为GPL开源协议的源码分发),得自己实现一套分布式定时任务。我有点懵,咋整啊?
麻雀:小兵,别慌!自己实现分布式定时任务,其实思路不难。咱们先把功能点拆解开,一步一步来。你今天找我聊,是不是已经有了一些想法?
小兵:是的,我梳理了一下,大概要分成三个阶段:任务注册、任务调度、任务结果反馈。但我不知道每个阶段具体要注意啥,怕踩坑。
麻雀:好!那咱们今天就以这三个阶段为主线,聊聊分布式定时任务的设计思路和踩坑经验。
小兵:对了,我听说你最近写了一套完整的代码,能跑起来的那种?
麻雀:哈哈,被你发现了。确实写了个demo,文末有获取方式,咱们边聊边看代码,这样更容易理解。
二、整体架构设计
小兵:麻雀,咱们先搭个框架吧。分布式定时任务调度系统,整体上应该怎么设计?
麻雀:好,先看架构图,有个整体印象:
┌─────────────────────────────────────────────────────────────────┐
│ 分布式定时任务调度系统 │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────┐ HTTP/REST ┌──────────────────────┐
│ │ ◄────────────────────────► │ │
│ Scheduler Center │ │ Business Module │
│ (调度中心) │ │ (业务模块) │
│ │ │ │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ 任务管理 │ │ │ │ 任务注册 │ │
│ │ 任务调度 │ │ │ │ 任务执行 │ │
│ │ 状态监控 │ │ │ │ 结果反馈 │ │
│ │ API接口 │ │ │ │ 注解扫描 │ │
│ └────────────────┘ │ │ └────────────────┘ │
│ ↓ │ │ ↓ │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ 数据访问层 │ │ │ │ 反射调用 │ │
│ └────────────────┘ │ │ └────────────────┘ │
└──────────────────────┘ └──────────────────────┘
↓ ↓
┌─────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL/MySQL 数据库 │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 任务定义表 │ │ 执行记录表 │ │
│ │ task_definition │ │ task_execution_record │ │
│ └──────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
小兵:哦,我懂了!调度中心负责任务管理,业务模块实际执行任务,中间通过HTTP通信,数据库统一存储。
麻雀:没错!核心就是调度与执行分离。再看一下核心流程:
| 阶段 | 职责 | 技术要点 |
|---|---|---|
| 任务注册 | 业务模块启动时,扫描注解并将任务定义发送到调度中心 | 注解扫描、幂等注册、HTTP异步发送 |
| 任务调度 | 调度中心定时扫描待执行任务,发送执行指令 | Cron解析、分片扫描、HTTP调用 |
| 任务执行 | 业务模块接收指令,反射执行业务逻辑 | 反射调用、异常处理、重试机制 |
| 结果反馈 | 执行完成后将结果发送回调度中心更新状态 | 状态更新、执行记录、补偿机制 |
小兵:分得挺细的。咱们今天就按这个顺序,一个阶段一个阶段地拆解?
麻雀:正合我意!先从任务注册开始。
三、任务注册阶段
3.1 时序图
小兵:任务注册,具体是怎么交互的?
麻雀:看这个时序图就明白了:
业务模块调度中心数据库启动扫描@ScheduledTask构建TaskDefinition发送任务定义(HTTP)检查任务是否已存在插入新任务记录计算下次执行时间返回注册结果业务模块调度中心数据库
3.2 对话:任务注册的坑
小兵:我先说说我理解的任务注册阶段:
- 业务模块启动时,扫描注解配置信息
- 把任务定义信息通过 HTTP 发送到调度中心
- 调度中心存储任务定义,并计算下次触发时间
麻雀:理解得不错。但你知道这里有什么坑吗?
小兵:扫描注解这个,我想到的是:如果多个业务模块同时启动,会不会重复注册?
麻雀:对!这就是第一个坑:重复注册。如果不做幂等性处理,调度中心可能会收到同一个任务的多次注册信息,导致任务重复执行。
小兵:那怎么解决呢?
麻雀:可以用任务唯一标识来判断是否已注册。通常用模块名:类名:方法名作为taskId,调度中心存储时,先查一下,存在就不重复插入。
小兵:明白了。那发送任务定义信息,用 HTTP 够用吗?
麻雀:
- HTTP:简单直接,适合注册量少、对实时性要求不高的场景。咱们这个demo就用HTTP,够用。
- MQ:异步、削峰填谷,适合大规模注册。如果你要支撑上千个任务,可以考虑升级为Kafka。
小兵:懂了,初期HTTP就够了。那计算下次触发时间,有没有坑?
麻雀:有!时间精度和时区问题。如果用 System.currentTimeMillis(),在多节点部署时,服务器时间不一致,会导致任务触发时间错乱。建议用数据库时间或统一的时间服务(如NTP)。
3.3 注册阶段代码示例
3.3.1 自定义注解
小兵:先看看注解怎么定义?
麻雀:好,这是最简单的部分:
package com.example.job.annotation;
import java.lang.annotation.*;
/**
* 分布式任务注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ScheduledTask {
/**
* 任务ID(全局唯一)
*/
String taskId();
/**
* 任务名称
*/
String name();
/**
* 任务描述
*/
String description() default "";
/**
* Cron表达式
*/
String cron();
}
3.3.2 任务定义实体
麻雀:这是任务定义的实体类,对应数据库表结构:
package com.example.job.entity;
import java.time.LocalDateTime;
/**
* 任务定义实体
*/
public class TaskDefinition {
/**
* 任务ID(唯一标识:module:className:methodName)
*/
private String taskId;
/**
* 任务名称
*/
private String taskName;
/**
* 任务描述
*/
private String description;
/**
* 业务模块标识
*/
private String module;
/**
* 全限定类名
*/
private String className;
/**
* 方法名
*/
private String methodName;
/**
* Cron表达式
*/
private String cronExpression;
/**
* 任务状态:ACTIVE-启用 INACTIVE-禁用
*/
private String status;
/**
* 上次执行时间
*/
private LocalDateTime lastExecuteTime;
/**
* 下次执行时间
*/
private LocalDateTime nextExecuteTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
// getters and setters 省略
}
3.3.3 任务注册扫描器
麻雀:最关键的是这个扫描器,业务模块启动时会自动扫描注解并注册:
package com.example.job.scanner;
import com.example.job.annotation.ScheduledTask;
import com.example.job.entity.TaskDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 任务注册扫描器
*
* 坑点1:重复注册问题 -> 使用taskId做幂等
* 坑点2:扫描性能问题 -> 只扫描@Component的Bean
* 坑点3:HTTP调用失败 -> 记录日志,可考虑重试
*/
@Component
public class TaskRegisterScanner {
private static final Logger logger = LoggerFactory.getLogger(TaskRegisterScanner.class);
@Autowired
private ApplicationContext applicationContext;
@Autowired
private RestTemplate restTemplate;
@Value("${scheduler.center.url:http://localhost:8080}")
private String schedulerCenterUrl;
@PostConstruct
public void scanAndRegister() {
logger.info("开始扫描定时任务注解...");
// 获取所有Spring管理的Bean
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class);
List<TaskDefinition> taskList = new ArrayList<>();
for (Object bean : beans.values()) {
// 获取所有方法
Method[] methods = bean.getClass().getDeclaredMethods();
for (Method method : methods) {
ScheduledTask taskAnnotation = method.getAnnotation(ScheduledTask.class);
if (taskAnnotation == null) {
continue;
}
// 构建任务定义
TaskDefinition task = buildTaskDefinition(bean, method, taskAnnotation);
taskList.add(task);
logger.info("扫描到任务: {} - {}", task.getTaskId(), task.getDescription());
}
}
// 批量注册任务
if (!taskList.isEmpty()) {
registerTasks(taskList);
}
}
/**
* 构建任务定义
*/
private TaskDefinition buildTaskDefinition(Object bean, Method method, ScheduledTask annotation) {
TaskDefinition task = new TaskDefinition();
String module = extractModuleName(bean);
String className = bean.getClass().getName();
String methodName = method.getName();
task.setTaskId(annotation.taskId());
task.setTaskName(annotation.name());
task.setDescription(annotation.description());
task.setModule(module);
task.setClassName(className);
task.setMethodName(methodName);
task.setCronExpression(annotation.cron());
task.setStatus("ACTIVE");
task.setCreateTime(LocalDateTime.now());
return task;
}
/**
* 提取模块名(从包名中获取)
*/
private String extractModuleName(Object bean) {
String packageName = bean.getClass().getPackage().getName();
String[] parts = packageName.split("\.");
return parts.length > 2 ? parts[2] : "default";
}
/**
* 注册任务到调度中心
*/
private void registerTasks(List<TaskDefinition> taskList) {
String url = schedulerCenterUrl + "/api/scheduler/tasks/register/batch";
try {
// 使用RestTemplate发送HTTP请求
restTemplate.postForObject(url, taskList, String.class);
logger.info("已注册{}个任务到调度中心", taskList.size());
} catch (Exception e) {
logger.error("任务注册失败", e);
// 这里可以记录到本地表,等待补偿
}
}
}
小兵:代码很清晰!用@PostConstruct在应用启动后自动扫描,然后用RestTemplate调用调度中心接口。
麻雀:对,这个思路简单有效。注意我注释里写的几个坑点,都是实际项目中踩过的。
四、任务调度阶段
4.1 时序图
小兵:接下来是调度阶段,调度中心是怎么工作的?
麻雀:看这个时序图,调度中心就是一个永不停歇的扫描器:
调度中心扫描器数据库业务模块查询待执行任务返回任务列表遍历任务列表创建执行记录更新下次执行时间HTTP调用执行任务返回执行结果更新执行记录状态loop[每5秒]调度中心扫描器数据库业务模块
4.2 对话:任务调度的坑
小兵:这个流程我理解了。但扫描任务,是不是就是定时查数据库?
麻雀:对,但如果你用 select * from task where next_execute_time <= now(),如果任务量很大,每次扫描全表,性能会崩。
小兵:那怎么优化?
麻雀:
- 加索引:
next_execute_time字段建索引 - 分页查询:一次查100条,分批处理
- 分片扫描:按任务ID取模,多个线程分别扫描不同片
小兵:HTTP调用业务模块,如果业务模块挂了,或者网络超时,怎么办?
麻雀:这就是可靠性问题。解决方案:
- 重试机制:配置重试策略,比如重试3次,指数退避
- 状态标记:调用失败后,记录失败状态,由补偿任务处理
- 超时设置:设置合理的连接超时和读取超时
小兵:反射执行任务,如果方法参数不对,或者方法抛出异常,咋办?
麻雀:
- 参数校验:反射前校验方法参数类型、个数
- 异常捕获:执行时用 try-catch 包裹,记录异常日志
- 执行记录:无论成功失败,都要记录执行结果
4.3 调度阶段代码示例
4.3.1 任务执行记录实体
package com.example.scheduler.entity;
import java.time.LocalDateTime;
/**
* 任务执行记录实体
*/
public class TaskExecutionRecord {
/**
* 执行ID(雪花算法生成)
*/
private Long executionId;
/**
* 任务ID
*/
private String taskId;
/**
* 任务名称
*/
private String taskName;
/**
* 执行时间
*/
private LocalDateTime executeTime;
/**
* 完成时间
*/
private LocalDateTime completeTime;
/**
* 执行状态:RUNNING-执行中 SUCCESS-成功 FAILED-失败
*/
private String status;
/**
* 执行结果
*/
private String result;
/**
* 错误信息
*/
private String errorMessage;
/**
* 执行耗时(ms)
*/
private Long duration;
/**
* 创建时间
*/
private LocalDateTime createTime;
// getters and setters 省略
}
4.3.2 调度中心任务扫描器
package com.example.scheduler.scanner;
import com.example.scheduler.entity.TaskDefinition;
import com.example.scheduler.entity.TaskExecutionRecord;
import com.example.scheduler.repository.TaskDefinitionRepository;
import com.example.scheduler.repository.TaskExecutionRecordRepository;
import com.example.scheduler.util.SnowflakeIdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 调度中心-任务扫描器
*
* 坑点1:扫描性能问题 -> 使用索引 + 分页查询
* 坑点2:重复执行问题 -> 事务保证原子性
* 坑点3:调用失败处理 -> 重试机制 + 状态标记
*/
@Component
public class TaskScanner {
private static final Logger logger = LoggerFactory.getLogger(TaskScanner.class);
@Autowired
private TaskDefinitionRepository taskRepository;
@Autowired
private TaskExecutionRecordRepository recordRepository;
@Autowired
private RestTemplate restTemplate;
@Autowired
private SnowflakeIdGenerator idGenerator;
@Value("${job.scanner.batch-size:100}")
private int batchSize;
/**
* 定时扫描任务(每5秒执行一次)
*/
@Scheduled(fixedDelay = 5000)
@Transactional
public void scanTasks() {
logger.debug("开始扫描待执行任务...");
LocalDateTime now = LocalDateTime.now();
// 分页查询待执行任务
int page = 0;
List<TaskDefinition> tasks;
do {
tasks = taskRepository.findPendingTasks(now, page * batchSize, batchSize);
for (TaskDefinition task : tasks) {
try {
executeTask(task);
} catch (Exception e) {
logger.error("执行任务失败: {}", task.getTaskId(), e);
}
}
page++;
} while (tasks.size() == batchSize);
}
/**
* 执行单个任务
*/
private void executeTask(TaskDefinition task) {
logger.info("开始执行任务: {}", task.getTaskId());
// 1. 生成执行ID(雪花算法)
Long executionId = idGenerator.nextId();
// 2. 创建执行记录
TaskExecutionRecord record = createExecutionRecord(executionId, task);
recordRepository.save(record);
// 3. 调用业务模块执行任务
TaskExecutionResult result = callBusinessModule(executionId, task);
// 4. 更新执行记录
updateExecutionRecord(record, result);
// 5. 计算下次执行时间
calculateNextExecuteTime(task);
taskRepository.save(task);
}
/**
* 创建执行记录
*/
private TaskExecutionRecord createExecutionRecord(Long executionId, TaskDefinition task) {
TaskExecutionRecord record = new TaskExecutionRecord();
record.setExecutionId(executionId);
record.setTaskId(task.getTaskId());
record.setTaskName(task.getTaskName());
record.setExecuteTime(LocalDateTime.now());
record.setStatus("RUNNING");
record.setCreateTime(LocalDateTime.now());
return record;
}
/**
* 调用业务模块
*/
private TaskExecutionResult callBusinessModule(Long executionId, TaskDefinition task) {
String url = task.getModuleUrl() + "/internal/task/execute";
ExecuteRequest request = new ExecuteRequest();
request.setExecutionId(executionId);
request.setTaskId(task.getTaskId());
request.setMethodName(task.getMethodName());
try {
// 设置合理的超时时间
return restTemplate.postForObject(url, request, TaskExecutionResult.class);
} catch (Exception e) {
logger.error("调用业务模块失败: {}", task.getTaskId(), e);
return TaskExecutionResult.failed("调用失败: " + e.getMessage());
}
}
/**
* 更新执行记录
*/
private void updateExecutionRecord(TaskExecutionRecord record, TaskExecutionResult result) {
record.setCompleteTime(LocalDateTime.now());
record.setStatus(result.getStatus());
record.setResult(result.getResult());
record.setErrorMessage(result.getErrorMessage());
record.setDuration(result.getDuration());
recordRepository.save(record);
}
/**
* 计算下次执行时间(使用Quartz的CronExpression)
*/
private void calculateNextExecuteTime(TaskDefinition task) {
try {
CronExpression cron = new CronExpression(task.getCronExpression());
Date nextDate = cron.getNextValidTimeAfter(new Date());
if (nextDate != null) {
task.setNextExecuteTime(nextDate.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime());
}
task.setLastExecuteTime(LocalDateTime.now());
} catch (ParseException e) {
logger.error("Cron表达式解析失败: {}", task.getCronExpression(), e);
}
}
/**
* 执行请求
*/
static class ExecuteRequest {
private Long executionId;
private String taskId;
private String methodName;
// getters and setters
}
/**
* 执行结果
*/
static class TaskExecutionResult {
private String status;
private String result;
private String errorMessage;
private Long duration;
public static TaskExecutionResult failed(String message) {
TaskExecutionResult result = new TaskExecutionResult();
result.setStatus("FAILED");
result.setErrorMessage(message);
result.setDuration(0L);
return result;
}
// getters and setters
}
}
小兵:这个扫描器写得真详细!用雪花算法生成执行ID,用事务保证原子性,还有分页查询避免性能问题。
麻雀:对,这些都是实战中总结的经验。特别是雪花算法,可以保证分布式环境下ID唯一且有序。
五、任务执行与结果反馈阶段
5.1 时序图
小兵:最后是业务模块执行任务,这部分怎么设计?
麻雀:看这个时序图,业务模块就像一个"任务执行器":
调度中心业务模块执行器业务方法HTTP调用/execute解析请求反射获取Bean和方法调用业务方法返回执行结果返回执行结果调度中心业务模块执行器业务方法
5.2 对话:结果反馈的坑
小兵:业务模块这边,最需要注意什么?
麻雀:主要是反射调用的稳定性和幂等性。
小兵:反射调用有什么坑?
麻雀:
- 方法找不到:类名、方法名、参数类型要严格匹配
- 私有方法:需要设置
setAccessible(true) - 异常处理:一定要捕获异常,不能影响其他任务
- 性能损耗:反射比直接调用慢,但定时任务场景可以接受
小兵:幂等性呢?
麻雀:如果调度中心重试,业务模块可能收到同一个任务的多次执行指令。这时需要用executionId做幂等检查,已经执行过的直接返回。
小兵:明白了!那执行结果怎么返回给调度中心?
麻雀:咱们这个demo是同步HTTP调用,调度中心等待业务模块返回结果。这种方式简单,但调度中心可能长时间阻塞。
小兵:有没有更好的方式?
麻雀:可以用异步回调:业务模块执行完后,再调用调度中心的接口反馈结果。这样调度中心可以快速释放线程,提高吞吐量。
5.3 执行阶段代码示例
5.3.1 业务模块任务执行器
package com.example.business.executor;
import com.example.business.util.SpringContextUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 任务执行器(业务模块)
*
* 坑点1:反射调用异常 -> try-catch包裹,记录详细日志
* 坑点2:私有方法调用 -> setAccessible(true)
* 坑点3:幂等性问题 -> 使用executionId做幂等检查
*/
@RestController
@RequestMapping("/internal/task")
public class TaskExecutor {
private static final Logger logger = LoggerFactory.getLogger(TaskExecutor.class);
/**
* 本地缓存已执行的ID(简单幂等)
*/
private final Map<Long, Boolean> executedCache = new ConcurrentHashMap<>();
@Autowired
private SpringContextUtils springContextUtils;
@PostMapping("/execute")
public TaskExecutionResult execute(@RequestBody ExecuteRequest request) {
logger.info("收到任务执行请求: executionId={}, taskId={}",
request.getExecutionId(), request.getTaskId());
long startTime = System.currentTimeMillis();
// 1. 幂等性检查
if (executedCache.containsKey(request.getExecutionId())) {
logger.info("任务已执行过,直接返回: {}", request.getExecutionId());
return TaskExecutionResult.success("已执行过", 0L);
}
try {
// 2. 根据taskId获取对应的Bean和方法
// 实际项目中可以从数据库或缓存中查询任务定义
Object bean = springContextUtils.getBeanByTaskId(request.getTaskId());
// 3. 获取方法(这里简化处理,实际需要根据methodName查找)
Method method = findMethod(bean.getClass(), request.getMethodName());
if (method == null) {
throw new NoSuchMethodException("方法不存在: " + request.getMethodName());
}
// 4. 设置可访问(如果是private方法)
method.setAccessible(true);
// 5. 执行方法
Object result = method.invoke(bean);
// 6. 记录已执行
executedCache.put(request.getExecutionId(), true);
long duration = System.currentTimeMillis() - startTime;
logger.info("任务执行成功: {}, 耗时: {}ms", request.getTaskId(), duration);
return TaskExecutionResult.success(
result != null ? result.toString() : "success",
duration);
} catch (Exception e) {
logger.error("任务执行失败: {}", request.getTaskId(), e);
long duration = System.currentTimeMillis() - startTime;
return TaskExecutionResult.failed(e.getMessage(), duration);
}
}
/**
* 查找方法(简化版)
*/
private Method findMethod(Class<?> clazz, String methodName) {
for (Method method : clazz.getDeclaredMethods()) {
if (method.getName().equals(methodName)) {
return method;
}
}
return null;
}
/**
* 执行请求
*/
static class ExecuteRequest {
private Long executionId;
private String taskId;
private String methodName;
// getters and setters
public Long getExecutionId() { return executionId; }
public void setExecutionId(Long executionId) { this.executionId = executionId; }
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getMethodName() { return methodName; }
public void setMethodName(String methodName) { this.methodName = methodName; }
}
/**
* 执行结果
*/
static class TaskExecutionResult {
private String status;
private String result;
private String errorMessage;
private Long duration;
public static TaskExecutionResult success(String result, Long duration) {
TaskExecutionResult res = new TaskExecutionResult();
res.setStatus("SUCCESS");
res.setResult(result);
res.setDuration(duration);
return res;
}
public static TaskExecutionResult failed(String errorMessage, Long duration) {
TaskExecutionResult res = new TaskExecutionResult();
res.setStatus("FAILED");
res.setErrorMessage(errorMessage);
res.setDuration(duration);
return res;
}
// getters and setters
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getResult() { return result; }
public void setResult(String result) { this.result = result; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Long getDuration() { return duration; }
public void setDuration(Long duration) { this.duration = duration; }
}
}
5.3.2 Spring上下文工具类
package com.example.business.util;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* Spring上下文工具类
*/
@Component
public class SpringContextUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {
applicationContext = context;
}
/**
* 根据类型获取Bean
*/
public static <T> T getBean(Class<T> clazz) {
return applicationContext.getBean(clazz);
}
/**
* 根据名称获取Bean
*/
public static Object getBean(String name) {
return applicationContext.getBean(name);
}
/**
* 根据任务ID获取Bean(简化版)
* 实际项目中可以从配置中获取taskId到Bean的映射
*/
public static Object getBeanByTaskId(String taskId) {
// 这里简化处理,实际需要根据taskId找到对应的Bean
// 可以用Map维护taskId到Bean名称的映射
return applicationContext.getBean(taskId.split("-")[0]);
}
}
六、使用示例
小兵:代码写得差不多了,实际用起来是什么样子的?
麻雀:很简单,业务模块只需要定义一个Spring Bean,然后在方法上加注解就行了。
6.1 业务模块定义定时任务
package com.example.business.job;
import com.example.job.annotation.ScheduledTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* 订单模块定时任务
*/
@Component
public class OrderJob {
private static final Logger logger = LoggerFactory.getLogger(OrderJob.class);
/**
* 每天凌晨2点清理过期订单
*/
@ScheduledTask(
taskId = "order:cleanExpiredOrders",
name = "订单清理任务",
description = "每天凌晨2点清理30天前的过期订单",
cron = "0 0 2 * * ?"
)
public void cleanExpiredOrders() {
logger.info("开始清理过期订单...");
// 业务逻辑
// 1. 查询30天前的订单
// 2. 批量更新状态
// 3. 记录清理数量
logger.info("过期订单清理完成");
}
/**
* 每10分钟同步订单状态
*/
@ScheduledTask(
taskId = "order:syncOrderStatus",
name = "订单状态同步",
description = "每10分钟同步第三方平台订单状态",
cron = "0 */10 * * * ?"
)
public void syncOrderStatus() {
logger.info("开始同步订单状态...");
// 业务逻辑
logger.info("订单状态同步完成");
}
}
6.2 Spring配置类
package com.example.job.config;
import com.example.job.util.SnowflakeIdGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* 定时任务配置类
*/
@Configuration
@EnableScheduling
public class JobConfig {
/**
* 配置RestTemplate(设置超时时间)
*/
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder()
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(30))
.build();
}
/**
* 配置雪花算法ID生成器
*/
@Bean
public SnowflakeIdGenerator snowflakeIdGenerator() {
// 数据中心ID和机器ID可从配置文件读取
return new SnowflakeIdGenerator(1L, 1L);
}
/**
* 配置ObjectMapper支持Java8时间
*/
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
return mapper;
}
}
6.3 雪花算法实现
package com.example.job.util;
import org.springframework.stereotype.Component;
/**
* 雪花算法ID生成器
* 用于生成全局唯一的执行ID
*/
@Component
public class SnowflakeIdGenerator {
// 起始时间戳 (2025-01-01 00:00:00)
private static final long START_TIMESTAMP = 1735660800000L;
// 数据中心ID所占位数
private static final long DATACENTER_ID_BITS = 5L;
// 机器ID所占位数
private static final long WORKER_ID_BITS = 5L;
// 序列号所占位数
private static final long SEQUENCE_BITS = 12L;
// 数据中心ID最大值
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS);
// 机器ID最大值
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
// 序列号最大值
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
// 机器ID左移位数
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
// 数据中心ID左移位数
private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
// 时间戳左移位数
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
private final long datacenterId;
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long datacenterId, long workerId) {
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException("数据中心ID超出范围");
}
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException("机器ID超出范围");
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检查
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成ID");
}
// 同一毫秒内
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
// 序列号溢出,等待下一毫秒
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// 生成ID
return ((timestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
七、数据库建表SQL
小兵:数据库表结构也给我看看呗?
麻雀:好,这是MySQL的建表语句:
-- 任务定义表
CREATE TABLE `task_definition` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`task_id` varchar(128) NOT NULL COMMENT '任务ID(唯一标识)',
`task_name` varchar(200) NOT NULL COMMENT '任务名称',
`description` varchar(500) DEFAULT NULL COMMENT '任务描述',
`module` varchar(100) NOT NULL COMMENT '业务模块标识',
`class_name` varchar(500) NOT NULL COMMENT '全限定类名',
`method_name` varchar(200) NOT NULL COMMENT '方法名',
`cron_expression` varchar(50) NOT NULL COMMENT 'Cron表达式',
`status` varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '任务状态:ACTIVE-启用 INACTIVE-禁用',
`last_execute_time` datetime DEFAULT NULL COMMENT '上次执行时间',
`next_execute_time` datetime DEFAULT NULL COMMENT '下次执行时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_task_id` (`task_id`),
KEY `idx_next_execute_time` (`next_execute_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务定义表';
-- 任务执行记录表
CREATE TABLE `task_execution_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`execution_id` bigint(20) NOT NULL COMMENT '执行ID(雪花算法生成)',
`task_id` varchar(128) NOT NULL COMMENT '任务ID',
`task_name` varchar(200) NOT NULL COMMENT '任务名称',
`execute_time` datetime NOT NULL COMMENT '执行开始时间',
`complete_time` datetime DEFAULT NULL COMMENT '完成时间',
`status` varchar(20) NOT NULL COMMENT '执行状态:RUNNING-执行中 SUCCESS-成功 FAILED-失败',
`result` text COMMENT '执行结果',
`error_message` text COMMENT '错误信息',
`duration` bigint(20) DEFAULT NULL COMMENT '执行耗时(ms)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_execution_id` (`execution_id`),
KEY `idx_task_id` (`task_id`),
KEY `idx_execute_time` (`execute_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务执行记录表';
八、配置文件示例
# application.yml
spring:
application:
name: distributed-job-demo
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/job_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
# 调度中心配置
scheduler:
center:
url: http://localhost:8080 # 调度中心地址
# 定时任务配置
job:
scanner:
batch-size: 100 # 每次扫描数量
fixed-delay: 5000 # 扫描间隔(ms)
# 日志配置
logging:
level:
com.example.job: DEBUG
com.example.scheduler: DEBUG
九、坑点总结
小兵:麻雀,今天聊了这么多,把坑点总结一下吧,方便以后查。
麻雀:好,我整理了一个表格:
| 阶段 | 坑点 | 解决方案 | 代码关注点 |
|---|---|---|---|
| 注册阶段 | 重复注册 | 使用taskId做唯一索引,插入前先查询 | TaskRegisterScanner中的幂等处理 |
| 时间精度 | 使用数据库时间或NTP统一时间 | next_execute_time计算 | |
| 注册信息丢失 | HTTP调用失败记录日志,可考虑重试 | registerTasks方法的异常处理 | |
| 调度阶段 | 扫描性能 | 加索引、分页查询 | TaskScanner中的分页查询 |
| 重复执行 | 事务保证原子性 | executeTask方法的@Transactional | |
| 调用失败 | 重试机制 + 状态标记 | callBusinessModule的异常处理 | |
| Cron计算错误 | 使用Quartz的CronExpression | calculateNextExecuteTime方法 | |
| 执行阶段 | 反射异常 | try-catch包裹,记录详细日志 | execute方法的异常处理 |
| 私有方法 | 设置setAccessible(true) | method.setAccessible(true) | |
| 消息重复消费 | 使用executionId幂等检查 | executedCache缓存 | |
| 反馈阶段 | 结果丢失 | 同步等待返回结果 | HTTP同步调用 |
| 状态不一致 | 记录完整执行记录 | TaskExecutionRecord表 |
十、总结
小兵:麻雀,今天聊完,我对分布式定时任务的实现清晰多了!总结一下:
- 任务注册:注意幂等、时区,用注解扫描自动注册
- 任务调度:注意扫描性能、调用失败处理,用雪花算法生成唯一ID
- 任务执行:注意反射异常、幂等性,用executionId做去重
- 结果反馈:同步HTTP返回,记录完整执行日志
麻雀:对,小兵,你总结得很到位!其实分布式定时任务的核心就是:注册、调度、执行,每一步都要考虑分布式环境下的幂等、可靠、高性能。
小兵:谢谢麻雀!这些代码能跑起来吗?我想拿回去试试。
麻雀:当然可以!我已经把完整的源码工程整理好了,包含:
- ✅ 完整的可运行代码(调度中心 + 业务模块)
- ✅ 详细的注释和坑点说明
- ✅ 数据库建表SQL
- ✅ 配置文件示例
- ✅ 雪花算法完整实现
小兵:太好了!怎么获取?
麻雀:
粉丝福利
这篇文章写了整整一周,代码也都跑通验证过。如果你觉得有帮助,欢迎点赞、收藏、评论三连支持~
源码获取方式
微信搜索公众号【麻雀聊技术】并关注,后台回复 “定时任务” 即可获取:
- ✅ 完整可运行的源码工程(调度中心+业务模块)
- ✅ 详细的代码注释和坑点说明
- ✅ 数据库建表SQL
- ✅ 雪花算法完整实现
- ✅ 配置文件示例
🎥 视频版同步上线【催更中】
觉得文字看着累?代码跑不起来?别担心,我专门录制了配套视频教程,在B站同步上线:
视频内容包括:
- ✅ 手把手调试:从零搭建项目,一步步跑通代码
- ✅ 踩坑现场还原:文中的坑点,视频里演示错误现场和排查过程
- ✅ 架构图逐帧讲解:每个时序图、架构图都掰开揉碎了讲
如何观看:
- B站搜索【麻雀聊技术】关注我
- 找到《分布式定时任务从零实现》视频合集
- 一键三连支持一下~
视频与文章搭配食用效果更佳:
- 先看文章理清思路
- 再看视频跟着跑代码
- 最后进群交流踩坑经验
💬 技术交流群
同时会邀请你加入技术交流群,和300+同行一起交流技术、分享经验。
小兵:好嘞,我这就去关注!有问题群里再找你聊。
麻雀:没问题,群里见!
扩展阅读: