Java 定时任务调度:@Scheduled 与 Quartz

几乎每个后端服务都离不开 Java 定时任务:每天凌晨跑批、定时清理过期订单、整点同步第三方数据。但很多团队一开始用 @Scheduled 随手写,业务一上量就遇到重复执行、任务堆积、实例宕机漏跑等问题。本文从单机到分布式,对比 Spring @Scheduled、Quartz 与 XXL-JOB 三种方案,帮你选对工具、避开生产坑。

一、Spring @Scheduled:单机最快落地

如果服务只有单实例、任务不复杂,Spring 自带的 @Scheduled 是最省心的选择。加一个 @EnableScheduling,再在方法上标注调度规则即可,零额外依赖。它支持三种触发方式:固定速率(fixedRate)、固定延迟(fixedDelay)和 cron 表达式。

@Configuration
@EnableScheduling
public class ScheduleConfig {}

@Component
public class OrderJob {

    // 固定速率:以上次“开始时间”为基准,每 5 秒触发
    @Scheduled(fixedRate = 5000)
    public void syncEvery5s() {
        orderService.sync();
    }

    // 固定延迟:以上次“结束时间”为基准,结束后再等 5 秒
    @Scheduled(fixedDelay = 5000)
    public void syncAfterFinish() {
        orderService.cleanExpired();
    }

    // cron:每天 02:30 跑批(秒 分 时 日 月 周)
    @Scheduled(cron = "0 30 2 * * ?")
    public void dailyBatch() {
        reportService.daily();
    }
}

三种方式的核心差异在于“计时起点”不同,直接决定任务会不会堆叠。下面这张对照表是选型的第一道分水岭。

方式计时基准适合场景主要风险
fixedRate上次开始时间准点采集、心跳上报任务耗时>间隔会堆叠
fixedDelay上次结束时间串行 cleanup、导出耗时越长越滞后
cron绝对时间点日/月批处理时区、宕机漏跑

1.1 单机最大的坑:默认单线程

@Scheduled 默认使用单线程的 TaskScheduler,一个任务卡住,其它任务全部排队。生产环境一定要自定义线程池,或者把耗时逻辑丢到 Spring Boot 异步线程池 @Async 执行,避免阻塞调度线程。任务真正并发执行时,线程池的参数调优可参考 Java 线程池调优:ThreadPoolExecutor 参数配置与监控告警

二、Quartz:需要持久化与集群时

当任务需要“重启不丢、中途可暂停、多实例不重复”时,@Scheduled 就不够了。Quartz 通过 JDBC JobStore 把 Job 和 Trigger 存进数据库,天然支持集群——多台机器抢同一把数据库锁来避免重复触发,还能动态增删改任务。

public class CleanJob implements Job {
    @Override
    public void execute(JobExecutionContext ctx) {
        // 业务逻辑:清理历史日志
        logService.purge(30);
    }
}

// 调度端配置
JobDetail job = JobBuilder.newJob(CleanJob.class)
        .withIdentity("cleanLog", "group1")
        .build();

Trigger trigger = TriggerBuilder.newTrigger()
        .withIdentity("tClean", "group1")
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 * * ?"))
        .build();

scheduler.scheduleJob(job, trigger);

Quartz 的 Job 与 Trigger 解耦设计,让“同一份逻辑、不同周期”可以复用。和 @Scheduled 的选型对照如下。

维度@ScheduledQuartz
任务持久化否(内存)是(JDBC 存储)
集群防重复不支持支持(DB 锁)
动态增删任务需改代码重启运行时 API 操作
学习成本极低中等

三、XXL-JOB:多实例分布式调度

当服务横向扩成多个副本(典型如 Spring Cloud 微服务 架构),再靠 @Scheduled 或 Quartz 单机集群就会很别扭:要么重复跑,要么运维复杂。XXL-JOB 把“调度中心”和“执行器”分离,由调度中心统一触发、执行器只负责干活,天然适配分布式,还自带可视化、失败重试、告警和分片广播。

@XxlJob("demoJobHandler")
public void demoJobHandler() throws Exception {
    XxlJobHelper.log("XXL-JOB, Hello");

    // 分片参数:把大任务拆给多个执行器并行处理
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();

    List<Long> ids = orderMapper.selectByShard(shardIndex, shardTotal);
    for (Long id : ids) {
        orderService.settle(id);
    }
}

分片广播是 XXL-JOB 的杀手锏:一次触发,所有执行器各自处理属于自己的那一片数据,既能水平扩容又能避免重复。配合失败重试和调度日志,基本覆盖了中大型团队的调度诉求。

XXL-JOB 还内置多种路由策略应对不同负载:第一个/最后一个、轮询、一致性哈希、分片广播等。对“全量扫描后再分发”的场景,分片广播最合适;对“只跑一次即可”的场景,用单节点路由避免重复。配合调度中心的失败重试次数与告警邮箱,任务异常能在分钟级触达负责人,而不是等第二天对账才发现。

四、三大方案选型与避坑清单

把三种方案放在一起看,其实是一条清晰的演进路线:从“进程内写个注解”到“独立调度框架管生命周期”,再到“中心化调度平台管多实例”。选型时不要盲目追新,先问清楚自己的服务是单实例还是多副本、任务要不要动态管理、团队有没有精力运维中间件,答案往往就出来了。

方案最适合不适合
@Scheduled单机小任务、原型验证多实例、需动态管理
Quartz需持久化/集群的中型调度超大规模、可视化弱
XXL-JOB分布式、多团队、需监控告警不愿引入额外中间件

4.1 生产环境五大避坑

① 任务重叠:fixedRate 任务耗时超过间隔会堆叠,长任务改用 fixedDelay 或异步执行。
② 多实例重复:单机 @Scheduled 在多副本下必然重复,必须上 Quartz 集群或 XXL-JOB。
③ 时区陷阱:cron 默认取服务器时区,容器常用 UTC,会导致“半夜跑白天”。务必显式指定 zone 或用北京时间基线。
④ 事务边界:定时任务里写库要留意 Spring 事务传播机制,自调用会导致 @Transactional 失效。
⑤ 幂等兜底:任务可能被重试或重复触发,业务逻辑必须幂等(状态机+唯一约束),否则重复跑批会酿成资损。

⑤ 监控不可少:再稳的调度也会翻车。把每次执行的开始/结束/耗时写入日志或埋点,配合告警,才能在第一时间发现“今天批没跑”。

幂等的具体落地通常有两招:一是利用数据库唯一约束,把“任务实例 id + 业务日期”做成唯一键,重复插入直接被忽略;二是用状态机,先查询当前状态再决定动作,配合版本号或乐观锁。凡是涉及金额、库存、积分的批处理,宁可多花一倍代码把幂等做扎实,也别去赌“它只会跑一次”。

五、总结

Java 定时任务 方案时遵循“够用就好”:单实例轻量需求用 @Scheduled,要持久化与集群用 Quartz,多实例分布式统一用 XXL-JOB。无论选哪个,线程池隔离、时区显式、幂等兜底、执行监控这四条都是上线前的必检项。把调度当成一个有状态的组件来对待,才能让半夜的批处理真正“睡得安稳”。

上一篇 浮点数精度踩坑:0.1+0.2 为何不等于 0.3 与正确解法
下一篇 Java Record 与模式匹配实战:写出更简洁的类型安全代码