第一次接触SpringBoot定时任务时,我被它的简洁性惊艳到了。相比传统Java中要手动配置线程池和调度器的复杂操作,SpringBoot只需要两个注解就能让任务自动运转起来。我们先从最基础的配置开始,我会带你一步步搭建完整的定时任务骨架。
创建项目时,建议直接用Spring Initializr生成骨架。勾选Web依赖就够了,但如果你需要数据库操作,记得加上Spring Data JPA或MyBatis。我习惯把端口改为8081,避免和本地其他服务冲突:
properties复制server.port=8081
启用定时任务的核心是@EnableScheduling注解。很多新手会直接加在启动类上,这确实能工作,但在实际项目中我更推荐单独创建配置类。这样做有两个好处:一是保持启动类简洁,二是方便集中管理所有定时任务相关配置。这是我的标准做法:
java复制@Configuration
@EnableScheduling
public class SchedulingConfig {
// 后续线程池配置也会放在这里
}
创建定时任务类时要注意,必须将其注册为Spring Bean。我见过有人忘了加@Component注解,然后debug半天找不到原因。下面是一个带完整日志记录的基础模板:
java复制@Component
public class DataSyncJob {
private static final Logger logger = LoggerFactory.getLogger(DataSyncJob.class);
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void syncUserData() {
logger.info("开始执行用户数据同步...");
long start = System.currentTimeMillis();
// 实际业务逻辑
userService.syncFromCRM();
logger.info("数据同步完成,耗时{}ms",
System.currentTimeMillis() - start);
}
}
刚开始写cron表达式时,我经常要查文档。现在经过几十个项目历练,已经能徒手写出各种复杂调度规则。先看标准格式,记住这个六段式结构(秒 分 时 日 月 周):
code复制0 30 9-17 ? * MON-FRI
这个表达式表示"工作日朝九晚五每半小时执行一次"。注意问号的妙用——它专门解决日期和星期冲突问题。当你要指定具体日期或星期时,另一个字段就用问号占位。
特殊字符里最强大的是L和W:
0 0 0 L * ? 每月最后一天执行0 0 0 LW * ? 每月最后一个工作日0 0 0 3W * ? 离3号最近的工作日实际项目中,我总结出几个高频场景的写法:
0 0/15 * * * ?0 15 10 ? * MON-FRI0 0 0 1 * ?调试cron有个小技巧——先用短周期测试。比如设计了一个每月执行的规则,可以先改成每分钟执行验证逻辑正确性:
java复制// 正式环境
@Scheduled(cron = "0 0 0 1 * ?")
// 测试环境
@Scheduled(cron = "0 * * * * ?")
当系统中有多个定时任务时,我发现一个严重问题——所有任务默认都在单线程中串行执行!这会导致长时间任务阻塞后续任务。有次报表生成耗时5分钟,直接导致后续的缓存刷新延迟。
解决方案是配置自定义线程池。在之前的SchedulingConfig中添加:
java复制@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("scheduled-task-");
scheduler.setAwaitTerminationSeconds(60);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setErrorHandler(t ->
logger.error("定时任务执行异常", t));
return scheduler;
}
对于特别耗时的任务(比如Excel导出),我还会加上@Async实现异步执行:
java复制@Async
@Scheduled(fixedRate = 3600000) // 每小时执行
public void generateMonthlyReport() {
// 耗时报表生成逻辑
}
记得在启动类还要加@EnableAsync注解。监控线程池状态也很重要,我通常用Actuator暴露的metrics端点:
yaml复制management:
endpoints:
web:
exposure:
include: health,metrics,scheduledtasks
访问/actuator/scheduledtasks能看到所有任务详情,/actuator/metrics里有线程池活跃线程数等关键指标。
在分布式环境中,定时任务会遇到新挑战。最典型的就是多个实例同时运行导致任务重复执行。我的解决方案是结合Redis分布式锁:
java复制@Scheduled(cron = "0 0 6 * * ?")
public void dailyCleanup() {
String lockKey = "job:dailyCleanup";
try {
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.MINUTES)) {
// 获取锁成功
doCleanup();
}
} finally {
redisTemplate.delete(lockKey);
}
}
错误处理方面,Spring的TaskScheduler已经提供了ErrorHandler接口。我扩展了一个带告警的版本:
java复制public class AlertErrorHandler implements ErrorHandler {
@Override
public void handleError(Throwable t) {
// 发送邮件/短信告警
alertService.send("定时任务执行失败", t.getMessage());
logger.error("任务执行异常", t);
}
}
长时间运行的任务还需要考虑中断恢复。我习惯在数据库记录任务状态:
java复制@Transactional
@Scheduled(cron = "0 0 3 * * ?")
public void dataArchive() {
JobRecord record = jobRepo.findByName("dataArchive");
if (record.getStatus() == RUNNING) {
logger.warn("上次归档任务未完成,跳过本次执行");
return;
}
record.setStatus(RUNNING);
jobRepo.save(record);
try {
archiveService.process();
record.setStatus(SUCCESS);
} catch (Exception e) {
record.setStatus(FAILED);
throw e;
} finally {
record.setEndTime(LocalDateTime.now());
jobRepo.save(record);
}
}
经过多次压测,我总结出几个关键参数经验值:
监控方面,除了Spring Actuator,我还推荐用Micrometer对接Prometheus:
java复制@Bean
public MeterRegistryCustomizer<PrometheusMeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags(
"application", "scheduler-service"
);
}
对于关键任务,可以添加执行时间监控:
java复制@Around("@annotation(scheduled)")
public Object monitor(ProceedingJoinPoint pjp, Scheduled scheduled) {
Timer.Sample sample = Timer.start(registry);
try {
return pjp.proceed();
} finally {
sample.stop(registry.timer("scheduled.tasks",
"name", pjp.getSignature().getName()));
}
}
日志记录要包含足够上下文信息。这是我的日志模板:
java复制logger.info("任务开始|{}|参数:{}", taskName, JSON.toJSONString(params));
try {
Object result = process();
logger.info("任务成功|{}|耗时:{}ms|结果:{}",
taskName, System.currentTimeMillis()-start, result);
return result;
} catch (Exception e) {
logger.error("任务失败|{}|耗时:{}ms|错误:{}",
taskName, System.currentTimeMillis()-start, e.getMessage());
throw e;
}