Skip to content

第68课:任务调度

🎯 学习目标

  • 理解定时任务、异步任务、分布式调度和消息延迟任务的区别。
  • 掌握 @Scheduled@Async、线程池配置和 Quartz 基本用法。
  • 能识别任务重复执行、单线程阻塞、集群多实例重复跑、异常吞掉等常见问题。
  • 能根据任务可靠性和分布式要求选择 Spring Scheduling、Quartz、XXL-JOB 或消息队列。
  • 能设计可观测、可重试、可幂等的后台任务。

📖 一、任务类型

常见后台任务:

text
每天凌晨生成报表
每 5 分钟同步第三方数据
订单 30 分钟未支付自动取消
批量发送邮件
定期清理过期数据
异步导出 Excel

这些任务看起来相似,但可靠性要求不同。

text
本地定时任务:简单,但集群下会重复执行。
异步任务:把耗时操作放到线程池,不代表定时。
Quartz:支持持久化和更复杂调度。
分布式任务平台:支持集群、分片、可视化、失败重试。
消息延迟任务:适合按事件触发的延迟处理。

📖 二、@Scheduled

启用:

java
@SpringBootApplication
@EnableScheduling
public class Application {
}

固定延迟:

java
@Component
public class ReportTask {

    @Scheduled(fixedDelay = 5000)
    public void runAfterPreviousFinished() {
        log.info("任务结束 5 秒后再执行下一次");
    }
}

固定速率:

java
@Scheduled(fixedRate = 5000)
public void runEvery5Seconds() {
    log.info("每 5 秒触发一次");
}

Cron:

java
@Scheduled(cron = "0 0 8 * * ?")
public void runAt8AM() {
    log.info("每天 8 点执行");
}

fixedDelay 更适合任务耗时不稳定的场景,避免任务堆叠。


📖 三、调度线程池

默认调度线程可能不足。应显式配置:

java
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(8);
        scheduler.setThreadNamePrefix("scheduled-task-");
        scheduler.setErrorHandler(t -> log.error("scheduled task failed", t));
        scheduler.initialize();
        registrar.setTaskScheduler(scheduler);
    }
}

如果一个任务阻塞默认线程,其他定时任务也可能延迟。


📖 四、@Async 异步任务

启用:

java
@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("async-task-");
        executor.initialize();
        return executor;
    }
}

使用:

java
@Service
public class ExportService {

    @Async("taskExecutor")
    public CompletableFuture<String> export(Long taskId) {
        doExport(taskId);
        return CompletableFuture.completedFuture("done");
    }
}

注意:@Async 也基于代理,同类自调用会失效。


📖 五、Quartz

Quartz 适合更复杂的调度:

java
public class ReportJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) {
        log.info("generate report");
    }
}

配置 JobDetail 和 Trigger:

java
@Bean
public JobDetail reportJobDetail() {
    return JobBuilder.newJob(ReportJob.class)
        .withIdentity("reportJob")
        .storeDurably()
        .build();
}

@Bean
public Trigger reportTrigger() {
    return TriggerBuilder.newTrigger()
        .forJob(reportJobDetail())
        .withIdentity("reportTrigger")
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
        .build();
}

Quartz 支持持久化和集群,但配置和运维比 @Scheduled 复杂。


📖 六、集群重复执行

如果应用部署 3 个实例:

text
app-1 执行定时任务
app-2 也执行定时任务
app-3 也执行定时任务

如果任务不是幂等,就会重复处理。

解决方案:

text
使用分布式锁
使用 Quartz 集群模式
使用 XXL-JOB 等任务平台
使用消息队列让任务只被一个消费者消费

简单分布式锁:

java
@Scheduled(cron = "0 */5 * * * ?")
public void syncData() {
    String lockValue = UUID.randomUUID().toString();
    if (!redisLock.tryLock("job:sync-data", lockValue, Duration.ofMinutes(4))) {
        return;
    }
    try {
        doSync();
    } finally {
        redisLock.unlock("job:sync-data", lockValue);
    }
}

锁 TTL 必须小于任务周期且大于正常执行时间,长任务还要考虑续期。


📖 七、任务可靠性设计

后台任务必须考虑:

text
失败是否重试?
重试多少次?
是否幂等?
是否能断点续跑?
是否有执行日志?
是否有超时?
是否有告警?
是否支持手动补偿?

任务表设计示例:

text
task_id
task_type
status
retry_count
next_retry_time
started_at
finished_at
error_message

不要只在内存里启动一个线程处理重要任务。


⚠️ 八、常见陷阱

1. 定时任务异常后无感知

必须记录错误日志和告警。

2. 集群重复执行

单机定时任务上线到多实例环境,最容易重复跑。

3. 默认线程池阻塞

一个慢任务拖住调度线程,其他任务延迟。

4. @Async 自调用失效

同类内部调用异步方法不会经过代理。

5. 任务不可幂等

重试和重复执行都可能造成重复扣款、重复发券、重复发送通知。


🆚 九、Java vs C 对比

维度C 常见方式Spring 生态
定时cron、线程 sleep@Scheduled、Quartz
异步pthread/thread pool@Async、Executor
分布式调度自研锁/任务表Quartz 集群、XXL-JOB
可靠性手动记录状态任务表、重试、告警

Java 生态提供了很多调度工具,但任务可靠性仍然要靠业务设计保证。


💡 十、最佳实践

  • 简单单机任务用 @Scheduled
  • 多实例部署必须防重复执行。
  • 耗时任务配置独立线程池。
  • 重要任务必须有执行记录和失败重试。
  • 所有任务都应尽量幂等。
  • 定时任务中不要无上限处理全量数据,使用分页或游标。
  • 异步任务异常必须记录。
  • 复杂分布式调度优先使用成熟任务平台。

🔍 十一、自测问题

text
fixedDelay 和 fixedRate 有什么区别?
@Scheduled 默认线程有什么风险?
@Async 为什么可能自调用失效?
多实例部署下定时任务为什么会重复执行?
分布式锁调度有什么风险?
Quartz 相比 @Scheduled 多解决了什么?
重要任务为什么需要任务表?
为什么任务必须设计幂等?

🧭 十二、任务上线检查清单

text
任务是否幂等?
是否会在多实例重复执行?
是否有独立线程池?
是否记录开始、结束、耗时和结果?
失败是否重试?
重试是否有上限?
是否支持手动补偿?
是否有超时控制?
是否有告警?
是否能分页处理大数据?

后台任务没有用户实时盯着,更需要日志、状态和告警。


🧪 十三、实战案例:每日报表

text
每天 02:00 生成昨日订单报表。
任务开始时写 task_run 记录。
分页读取订单数据。
生成文件上传对象存储。
更新 task_run 状态为 SUCCESS。
失败时记录错误并允许重试。

关键设计:

text
使用业务日期作为幂等键。
重复执行同一天报表时覆盖或跳过。
分页处理,避免一次加载全部数据。
执行超时要告警。

📌 十四、学习建议

建议先用 @Scheduled 写单机任务,再启动两个应用实例观察重复执行。随后加入 Redis 分布式锁或 Quartz 集群模式,对比行为。

这个实验能让你真正理解“本地定时任务”和“分布式调度”的区别。


📚 十五、调度方案选择表

场景推荐
单机简单定时@Scheduled
本地异步执行@Async + 线程池
持久化复杂调度Quartz
多实例分布式任务XXL-JOB、Quartz 集群
订单超时取消延迟消息或任务表扫描
大批量数据处理分片任务

任务越重要,越要避免只依赖内存状态。


📌 十六、任务日志建议

每次执行记录:

text
任务名
业务日期
开始时间
结束时间
耗时
处理数量
成功数量
失败数量
错误信息
触发方式

没有执行记录的后台任务,出了问题很难补偿。


🎓 小结

任务调度不是写一个 @Scheduled 就结束。真实项目要考虑线程池、异常、集群重复执行、幂等、重试、补偿和监控。任务越重要,越不能只依赖内存线程和临时日志。