
最近帮一个老项目改造定时任务翻代码时发现他们还在用Scheduled硬扛所有调度需求任务一多就开始出问题不是执行时间漂移就是重启后任务状态全丢偶尔两台机器同时跑还会重复处理数据。聊到方案时对方第一反应是“Spring 集成 Quartz感觉配置很复杂”。其实恰恰相反Quartz 的配置方法一旦理顺了反而比在代码里堆Scheduled更容易维护。这篇文章我就把 Spring Quartz 实现定时任务这件事从头到尾拆开讲一遍从核心概念、配置步骤、持久化集群到生产环境里的各种坑一次性说透适合正在选型或准备改造定时任务模块的后端开发者。1. 为什么放弃 Scheduled 选择 Quartz定时任务方案选型1.1 先说清楚 Scheduled 的适用边界Spring 自带的Scheduled确实是好东西开发效率极高。加一个EnableScheduling然后在方法上写Scheduled(cron 0 0 2 * * ?)一个每天凌晨两点执行的任务就出来了。单体应用、任务量少、不需要动态调整的场景下它完全够用。但它的能力边界也很清晰。第一任务状态不持久Scheduled的触发规则写在代码里服务重启后一切从头再来不存在“记录上次执行进度”这回事。第二无法天然支持集群两台实例部署同一个服务同一个Scheduled方法会各执行一次除非自己引入分布式锁否则重复消费问题很难避免。第三运行时的调度控制基本为零你不能在管理页面上暂停一个任务、动态修改它的 cron更不能临时手动触发一次。第四错过触发时间的补偿逻辑完全没有服务停机期间落下的任务重启后不会补齐。这些痛点单独拎出来都还好但几个叠加在一起项目规模稍微上来就非常难受。Quartz 这个老牌 Java 调度框架之所以这么多年没被淘汰正是因为它把这四类问题都做了体系化的解决方案。1.2 Quartz 解决的核心痛点Quartz 的核心设计可以类比成一个“可持久化的操作系统调度器”。它把调度信息抽成了Job、Trigger、Scheduler三组对象调度状态既可以放在内存里也可以存进数据库。存数据库之后服务重启、任务迁移都不怕触发记录、下次执行时间、misfire 补偿标记全都在表里躺着。集群支持也是 Quartz 的招牌能力。多个节点共享同一个数据库通过数据库锁实现“抢任务”机制同一时间只有一个节点会拿到触发权不需要在应用层额外写分布式锁。这一点对比Scheduled是质的提升从“每个实例各跑各的”变成“整个集群只有一个调度大脑”。除此之外Quartz 还提供了 SimpleTrigger、CronTrigger、CalendarIntervalTrigger 多种触发器类型能够表达“每隔 5 分钟执行”“每天 8 点和 18 点执行”“跳过法定节假日执行”这类复杂规则。运行时可以通过 Scheduler API 动态增删改任务也能通过DisallowConcurrentExecution控制同一任务不会叠加执行。1.3 Quartz 和 XXL-Job 这类框架怎么选搜索“java 定时任务框架”的时候大家肯定会看到 XXL-Job、Elastic-Job 这些名词。我的建议是别盲目上分布式调度平台先搞清楚两者的定位差异。Quartz 本质是一个调度库它不做任务管理后台、不做日志可视化、不做分片广播你需要自己封装管理接口。XXL-Job 这类产品是完整的分布式任务调度平台自带 Admin 控制台、执行器、失败告警、分片策略开箱即用。如果公司已经有统一调度平台的需求直接上 XXL-Job 更省事但如果只是几个服务要跑定时任务为了一个控制台引入一堆依赖和运维负担反而得不偿失。我个人在实际项目里的选型标准很简单单体或两三个服务任务数量在几十以内选 Quartz 足够把配置方法学好能稳很多年。任务量大、需要多人协作管理任务、要可视化和告警那再考虑 XXL-Job。下面重点讲 Quartz 的落地配置。2. Quartz 核心概念拆解先把四个角色装进脑子2.1 Job 和 JobDetail任务逻辑与任务描述很多人刚接触 Quartz 时最困惑的就是“为什么有了 Job 还要搞一个 JobDetail”。其实这俩的职责分得很清楚。Job是任务执行的业务逻辑入口你实现org.quartz.Job接口在execute(JobExecutionContext context)方法里写具体代码。JobDetail是任务的“描述信息”它记录了任务叫什么名字、属于哪个分组、绑定的 Job 类是哪个、附带哪些参数。Quartz 在触发任务时会拿着 JobDetail 里的类名通过反射重新new一个 Job 实例然后调用它的 execute 方法。换句话说每次执行都是新实例任务跑完这个对象就被丢弃了不存在一个 Job 实例被多个 Trigger 并发复用的问题。这里有个非常重要的推论Job 实现类必须提供无参构造器而且如果直接在类里写Autowired注入 Spring Bean大概率会注入失败。这个坑我后文专门讲。2.2 Trigger 与 JobKey触发器的身份系统触发器负责决定“什么时候执行”。最常用的 CronTrigger 和 SimpleTrigger 属于org.quartz.Trigger。Trigger 由 TriggerKey名字 分组唯一标识Job 由 JobKey名字 分组唯一标识。给任务和触发器分组的习惯建议从第一天就养成。比如按业务模块分orderGroup、reportGroup或者按环境分prodGroup、testGroup。分组之后可以用scheduler.pauseJobGroup(orderGroup)一键暂停整个业务模块的所有任务排查问题非常方便。触发器和 Job 的关系是一个 JobDetail 可以被多个 Trigger 引用一个 Trigger 只能指向一个 Job。这也是为什么后面要反复强调 JobDetail 必须storeDurably()的原因如果 JobDetail 没有被任何 Trigger 使用Quartz 会认为它是临时对象不允许持久化。2.3 Scheduler把一切串起来的调度器Scheduler 是 Quartz 的门面接口任务的注册、启动、暂停、恢复、删除都通过它操作。在 Spring 环境中Scheduler本身作为一个 Bean 被 Spring 容器管理它的生命周期由 Spring 负责。调度器内部默认维护了一个线程池任务触发后由线程池中的线程来执行任务代码。线程数默认是 10生产环境建议根据任务量和执行耗时来调整。如果任务执行平均耗时较长线程数太小会出现“后续任务排队等线程”的情况。我一般习惯按“并发高峰任务数 3 到 5 个余量”来设置。2.4 JobDataMap给任务传参数的正确姿势JobDataMap 是 Quartz 传参的核心机制它本质上是Map的扩展类。构造 JobDetail 时可以塞参数任务执行时从context.getMergedJobDataMap()里取参数。注意它有两个来源JobDetail 里的数据和 Trigger 里的数据合并后以 Trigger 为准。实践中建议只传简单的字符串、数字、ID 等可序列化数据不要往里塞复杂的业务对象。任务持久化到数据库时JobDataMap 要跟着序列化复杂对象容易出现序列化兼容问题。传参代码很简单JobDetail jobDetail JobBuilder.newJob(MyJob.class) .withIdentity(orderSyncJob, tradeGroup) .usingJobData(orderType, PAID) .usingJobData(maxCount, 1000) .storeDurably() .build();任务执行侧通过context.getMergedJobDataMap().getString(orderType)就能取到。这种参数传递方式也意味着同一份 Job 类可以注册成多个不同参数的 JobDetail实现“一套逻辑多个运行配置”的效果。3. Spring 整合 Quartz 的三种方式从 XML 到 Spring Boot3.1 老项目的 Spring XML 配置方式如果你维护过十年前的老项目大概率见过这种配置方式用SchedulerFactoryBean把 Quartz 的 Scheduler 注册进 Spring 容器配合JobDetailFactoryBean和CronTriggerFactoryBean装配任务。bean idschedulerFactoryBean classorg.springframework.scheduling.quartz.SchedulerFactoryBean property nametriggers list ref beanorderSyncTrigger/ /list /property /bean bean idorderSyncTrigger classorg.springframework.scheduling.quartz.CronTriggerFactoryBean property namejobDetail reforderSyncJobDetail/ property namecronExpression value0 0 2 * * ?/ /bean bean idorderSyncJobDetail classorg.springframework.scheduling.quartz.JobDetailFactoryBean property namejobClass valuecom.example.job.OrderSyncJob/ property namedurability valuetrue/ /bean这段配置的核心是SchedulerFactoryBean它是 Spring 对 Quartz 的适配层负责创建并管理 Scheduler 实例。对于新项目我不推荐再写 XML但如果你接手的是老系统建议先能读懂这段配置改造时可以直接替换成注解式配置类职责对应关系不变。3.2 Spring Boot 2.x 自动化装配最省心的方式Spring Boot 从 2.0 开始提供了spring-boot-starter-quartz这是目前最推荐的方式。它的原理是Spring Boot 自动配置了一个SchedulerFactoryBean你只需要把自定义的 JobDetail 和 Trigger 声明成Bean自动配置类会从容器里找到它们注册到 Scheduler 中。代码里不需要手动 new Scheduler也不需要调 start 方法Spring Boot 准备好一切。核心配置类只需要两段代码一段定义 JobDetail一段定义 Trigger。Configuration public class QuartzConfig { Bean public JobDetail orderSyncJobDetail() { return JobBuilder.newJob(OrderSyncJob.class) .withIdentity(orderSyncJob, tradeGroup) .storeDurably(true) .build(); } Bean public Trigger orderSyncTrigger() { return TriggerBuilder.newTrigger() .forJob(orderSyncJobDetail()) .withIdentity(orderSyncTrigger, tradeGroup) .withSchedule(CronScheduleBuilder.cronSchedule(0 0 2 * * ?)) .build(); } }这里有个细节Trigger的forJob()传的是 JobDetail 对象实例如果你没有显式指定 JobKeyQuartz 会通过 JobDetail 上的 identity 自动关联。不少人在这一步踩坑——两个 Trigger 指向同一个 JobDetail 但不指定forJob()结果任务和触发器根本没绑定上。3.3 Spring Boot 3.x 的差异和注意点Spring Boot 3.x 底层是 Java 17 Jakarta EEQuartz 也升级到了新版本。对于大部分业务代码来说Spring Boot 3 的 Quartz 配置方式和 2.x 几乎一致但如果你的项目从 2.x 直接升上来有三点需要单独验证第一是CronScheduleBuilder的语法解析更严格了个别在老版本能跑通的边缘表达式可能在新版本直接抛ParseException升级后建议把线上所有 cron 表达式统一跑一遍测试用例。第二是依赖里的命名空间从 javax 变成了 jakarta如果自定义了QuartzInitializerListener或实现了ServletContextListener之类的东西要注意包名变化。第三是 Spring Boot 3 默认使用 HikariCP 管理数据源Quartz 直接复用这个数据源时连接保活参数和maxLifetime需要调整否则长时间空闲的连接可能被数据库端断开任务触发时报连接异常。4. 完整配置实操从依赖到调度跑通的最小示例4.1 引入依赖和版本选择Spring Boot 工程引入 Quartz 非常简单一个 starter 就够了dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-quartz/artifactId /dependency如果你用的是 Maven 且继承了spring-boot-starter-parent版本不需要自己声明由 Spring Boot 统一管理。强烈建议不要手动覆盖 Quartz 版本Quartz 和 Spring Boot 之间的兼容性比较微妙手动升级版本容易引入莫名其妙的运行时问题。Gradle 用户对应加一行implementation org.springframework.boot:spring-boot-starter-quartz即可。4.2 编写 Job 实现类Bean 注入问题必须重视Job 类的写法有两种一种是直接实现org.quartz.Job接口另一种是继承 Spring 提供的QuartzJobBean。两者的区别在于QuartzJobBean的executeInternal方法里会从 Spring 容器获取当前 Job 实例因此支持属性注入而原生的Job接口每次由 Quartz 反射创建不在 Spring 容器管理范围内。如果你直接用原生 Job 接口会发现在 execute 方法里Autowired注入的 Service 是 null。这是新手最容易踩的坑原因就是我前面说的Quartz 通过反射 new 出来的 Job 对象没有经过 Spring 的依赖注入流程。解决方案有三种第一种是继承QuartzJobBean它内部会通过ApplicationContext自动装配属性代码可以写如下形式public class OrderSyncJob extends QuartzJobBean { private OrderService orderService; public void setOrderService(OrderService orderService) { this.orderService orderService; } Override protected void executeInternal(JobExecutionContext context) { String orderType context.getMergedJobDataMap().getString(orderType); orderService.syncPaidOrders(orderType); } }第二种是自定义一个 Spring 上下文工具类在 Job 的 execute 方法里手动获取 Bean。这种方式适合不想改动任务类继承结构的场景。第三种是直接用AutowireLazy在某些特殊容器配置下也能生效但原理并不保证我不推荐依赖它。4.3 调度器三件套的标准配置代码把 JobDetail、Trigger、Scheduler 三件套一次性配好参考下面的完整示例Configuration public class QuartzConfig { Bean public JobDetail orderSyncJobDetail() { return JobBuilder.newJob(OrderSyncJob.class) .withIdentity(orderSyncJob, tradeGroup) .requestRecovery(true) .storeDurably(true) .usingJobData(orderType, PAID) .build(); } Bean public Trigger orderSyncTrigger() { CronScheduleBuilder cronSchedule CronScheduleBuilder .cronSchedule(0 0 2 * * ?) .inTimeZone(TimeZone.getTimeZone(Asia/Shanghai)); return TriggerBuilder.newTrigger() .forJob(orderSyncJobDetail()) .withIdentity(orderSyncTrigger, tradeGroup) .withSchedule(cronSchedule) .build(); } }有几个配置项需要详细解释。storeDurably(true)表示即使没有 Trigger 引用这个 JobDetail它也允许被持久化保存。requestRecovery(true)表示集群环境下如果某个节点在任务执行过程中宕机其他节点会重新执行一次该任务这个开关在任务需要“确保一定执行”的场景建议打开。inTimeZone指定 cron 表达式的解析时区避免服务器时区不一致导致执行时间偏差。4.4 Cron 表达式Quartz 和 Linux 的 Cron 不是一回事Quartz 的 Cron 表达式是 6 到 7 位格式是“秒 分 时 日 月 周 [年]”和 Linux 系统定时任务的 5 位格式完全不同。很多人把自己熟悉的 Linux Cron 直接搬过来结果发现第一位被当成了秒执行时间完全不对。下面是我经常用的一组表达式模板执行场景Cron 表达式每 5 分钟执行一次0 0/5 * * * ?每小时整点执行0 0 * * * ?每天凌晨 2 点执行0 0 2 * * ?每天上午 10 点 15 分执行0 15 10 ? * *每周一至周五早上 9 点 30 分执行0 30 9 ? * MON-FRI每月 1 号凌晨 1 点执行0 0 1 1 * ?每年 3 月 15 日 14 点执行0 0 14 15 3 ?注意几个易混点?只能用在“日”和“周”字段上表示不指定值它和*的语义不同。例如0 0 12 ? * MON表示“每周一中午 12 点执行”而0 0 12 * * MON在 Quartz 解析时会报错因为日和周同时指定了互相冲突。需要表达“每月的最后一天”这种复杂规则时用L通配符比如0 0 12 L * ?。4.5 把调度配置外部化到 yml实际项目中cron 表达式经常需要修改让运维改完直接重启就行而不是让开发重新打包。所以建议把 cron 放到配置文件里通过Value或者ConfigurationProperties注入。app: quartz: order-sync-cron: 0 0 2 * * ? report-generate-cron: 0 30 1 * * ?配置类改成这样Component public class QuartzProperties { Value(${app.quartz.order-sync-cron}) private String orderSyncCron; Value(${app.quartz.report-generate-cron}) private String reportGenerateCron; public String getOrderSyncCron() { return orderSyncCron; } public String getReportGenerateCron() { return reportGenerateCron; } }然后在建 Trigger 时用这些属性。修改 cron 后直接改配置重启即可不用动任何 Java 代码。但要注意这种方式的动态性也只到“改配置重启”为止如果产品要求“页面上改 cron 立即生效”那需要走动态任务的 Scheduler API我第 6 章会讲。5. 生产环境必备持久化与集群配置5.1 RAMJobStore 和 JDBCJobStore 的选择Quartz 的调度状态可以存在内存里也可以存在数据库里。默认是内存模式RAMJobStore启动快、性能好但服务一重启所有 JobDetail、Trigger、调度记录全部清零。对于可以接受“重启后重新感知任务”的简单场景内存模式问题不大。但只要任务状态稍微重要比如订单超时自动关单、日报表生成一旦任务丢失会直接影响业务那就必须用JDBCJobStore。Spring Boot 配置只需要一行spring: quartz: job-store-type: jdbc切换到 JDBC 模式后Quartz 会在数据库里建一批表核心的有qrtz_job_details、qrtz_triggers、qrtz_cron_triggers、qrtz_fired_triggers、qrtz_scheduler_state、qrtz_locks。表结构初始化脚本在 Quartz 的 jar 包里路径是org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql找到后直接执行即可。千万记得用 InnoDB 版本的脚本MySQL 表里要支持行级锁集群模式依赖的就是这个。5.2 集群模式配置多实例不重复执行的关键Quartz 集群的原理是用数据库锁模拟分布式锁。每个节点启动时生成一个实例 ID有任务需要触发时节点先尝试获取数据库里的锁拿不到锁的节点自动进入等待状态保证同一时刻只有一个节点在调度同一个任务。配置如下spring: quartz: job-store-type: jdbc properties: org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000 org.quartz.scheduler.instanceId: AUTO org.quartz.scheduler.instanceName: MyQuartzClusterisClustered必须显式设为 trueinstanceId设为 AUTO 让各节点自动生成唯一标识。clusterCheckinInterval默认 15000 毫秒可以理解成节点向数据库报到一次的心跳间隔配合qrtz_scheduler_state表实现故障检测。集群模式下有两个额外约束第一所有节点的服务器时间尽量通过 NTP 同步时间差太大会导致调度乱序第二节点数量不需要太多Quartz 集群不是“越多越强”因为同一任务只有一个节点执行节点多了只是增加冗余度。5.3 别把希望全寄托在调度器上业务幂等设计Quartz 集群解决了“调度不重复”的问题但没解决“业务执行重复”的问题。任务执行过程中网络超时、数据库慢查询、消息中间件重投都可能导致同一条数据被处理两遍。所以真正稳妥的做法是任务逻辑本身要幂等。我的习惯是需要幂等保护的任务在 Job 里先取分布式锁或者利用数据库唯一约束做记录去重。比如订单自动关单任务先往order_close_log表插入一条带order_id唯一键的记录插入成功才继续执行关单逻辑插入失败说明已经处理过直接返回。这样即使集群调度因为异常发生了补偿执行也不会产生重复的关单流水。6. 常见问题与排查技巧实录6.1 “Jobs added with no trigger must be durable” 的报错这个异常几乎每个接触 Quartz 的人都会遇到。原因是某个 JobDetail 没有设置storeDurably(true)而它又没有关联任何 TriggerQuartz 默认认为这种 Job 是临时任务不允许持久化。我在实际开发里习惯给所有 JobDetail 都加上 .storeDurably(true)不管当前有没有 Trigger 绑定这样后续动态给 Job 加 Trigger 时不会踩坑。6.2 Job 里 Spring Bean 注入为 null这是最常见的运行期问题。表现是任务能被触发但一进 execute 方法调用某个 Service 就报 NullPointerException。原因前面已经说过原生 Job 实例由 Quartz 反射创建不经过 Spring 容器。排查思路很简单先看 Job 类继承的是不是QuartzJobBean再看属性注入用的是 setter。如果是原生 Job 接口加Autowired那就必炸。快速解决方案是写一个SpringContextUtils工具类实现ApplicationContextAware然后在 Job 里用它获取 Bean最直接也最好排查。6.3 默认并发执行DisallowConcurrentExecution 必须显式加不少初学 Quartz 的人以为同一个 Job 不会同时跑两次实际上 Quartz 默认是允许并发执行的。假设任务每 5 分钟触发一次但单次执行要跑 20 分钟那么前一次还没结束下一次触发已经开始了多个线程同时执行同一段逻辑数据很容易出问题。解决方法是给 Job 类加DisallowConcurrentExecution注解DisallowConcurrentExecution public class OrderSyncJob extends QuartzJobBean { // ... }这里有个细节必须讲清楚DisallowConcurrentExecution限制的是同一个 JobDetail 内部的并发也就是同一个 JobKey 下不能重叠执行。如果注册了两个不同的 JobDetail 但指向同一个 Job 类那这两个 JobDetail 之间仍然可以并发。这个理解到位了才能正确设计任务注册方式。6.4 Misfire 策略错过触发时间后怎么办当任务触发时间到了但调度器因为线程池繁忙、服务停机等原因没能及时执行就会出现 misfire。Quartz 有三种处理策略MISFIRE_INSTRUCTION_SMART_POLICY默认智能策略、FIRE_ONCE_NOW立即补执行一次、DO_NOTHING跳过这次继续按下一个触发时间。CronTrigger 的智能策略最终表现是FIRE_ONCE_NOW也就是说会立即补跑一次。这在某些场景是灾难服务停机 10 个小时期间本来要执行 10 次的任务恢复后可能一次性补执行多次把系统直接压垮。所以我的经验是绝大多数定时任务都显式设置withMisfireHandlingInstructionDoNothing()让错过的执行直接跳过下一轮按正常节奏来。只有确实需要补偿执行的场景比如支付回调状态对账才用FIRE_ONCE_NOW。6.5 时区不一致导致执行时间偏移Quartz 的 cron 表达式默认按服务器本地时区解析。如果服务器设置的是 UTC而你期望的是北京时间任务就会提前或者推迟 8 小时执行。集群环境下节点时区不一致还会出现两个节点推算出的触发时间不同。最稳妥的做法是在建 Trigger 时显式指定时区CronScheduleBuilder cronSchedule CronScheduleBuilder .cronSchedule(0 0 2 * * ?) .inTimeZone(TimeZone.getTimeZone(Asia/Shanghai));同时数据库连接串里也要带时区参数比如 MySQL 的serverTimezoneAsia/Shanghai避免 JDBC 层的日期时间转换绕一圈又产生偏差。6.6 动态增删改任务的 Scheduler API运营后台要求“动态创建任务、暂停任务、修改 cron”是常见需求。Quartz 的 Scheduler 接口提供了完整支持核心代码如下// 动态添加 Jobreplace 为 true 表示覆盖原有同名 Job jobDetail JobBuilder.newJob(GenericJob.class) .withIdentity(jobName, jobGroup) .storeDurably(true) .build(); scheduler.addJob(jobDetail, true); // 创建 Trigger 并绑定 Job trigger TriggerBuilder.newTrigger() .withIdentity(triggerName, triggerGroup) .forJob(jobName, jobGroup) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); scheduler.scheduleJob(trigger); // 暂停、恢复 scheduler.pauseJob(JobKey.jobKey(jobName, jobGroup)); scheduler.resumeJob(JobKey.jobKey(jobName, jobGroup)); // 删除先解除所有 Trigger再删 Job scheduler.unscheduleJob(TriggerKey.triggerKey(triggerName, triggerGroup)); scheduler.deleteJob(JobKey.jobKey(jobName, jobGroup));需要注意动态创建的任务如果持久化模式是 JDBC那么用addJob(jobDetail, true)时会把任务信息写入数据库重启后任务仍然存在。这是它和Scheduled静态配置最大的区别也是很多运营平台选择 Quartz 作为底层的原因。最后分享两个我自己的习惯。第一Quartz 的配置和原理并不难难的是把 Job 的业务逻辑设计成幂等且可重入的状态模型这比任何框架细节都重要。第二第一次在项目里引入 Quartz不要一上来就集群、持久化、动态任务全上先用内存模式把最小闭环跑通再加入数据库最后开集群。渐进式改造出了问题一眼就能定位是配置问题还是业务问题。