好好好今天想聊聊 Spring Boot 整合 Quartz 这件事。定时任务现在几乎是后端项目标配往小了说是定时清缓存、定时生成报表往大了说是电商的订单超时关闭、支付的自动对账、会员到期提醒底子全是定时调度。Spring Boot 自己带了一个Scheduled注解很多新手项目都在用但一旦任务多起来、要动态调整执行时间、要保证服务重启后任务不丢、要多机部署只跑一次Scheduled就明显顶不住了。这个时候 Quartz 基本是 Java 生态最成熟的答案没有之一。这篇文章我不打算只贴一堆代码然后说看跑通了那没什么意义。我会结合真实项目里踩过的坑把 Spring Boot 整合 Quartz 从基础概念、最小集成、持久化配置、集群方案、动态管理到各种疑难杂症一次讲透。内容可能偏长但对你一定是省时间的。适合正在做 Java 后端、准备在系统里引入定时任务调度能力的开发者也适合已经用了 Quartz 但被各种诡异现象搞到头大的朋友。1. 为什么不用 Scheduled非得上 Quartz很多项目一开始都是从Scheduled起步的写法确实简单Component public class SimpleTask { Scheduled(cron 0 0 2 * * ?) public void run() { System.out.println(每天凌晨两点执行); } }但你不妨先问自己一个问题这个任务的执行时间会不会变这个任务要不要手动触发一次任务挂了之后要不要在管理后台看得到服务重启之后错过了执行时间要不要补跑集群部署的时候同一个任务会不会被两台机器同时跑这些问题里但凡有一个答案是要Scheduled就不够用了。Scheduled本质上是 Spring 容器启动时注册的一个ScheduledTaskRegistrar任务存在内存里没有持久化机制没有线程池隔离没有运行状态记录也没有失败重试策略。它只适合跑那种写死了、没人管、挂了也无所谓的任务比如开发环境定时打印点日志。生产环境的核心任务交给它我不太放心。Quartz 是完全不同的量级。它自带Scheduler调度器、JobDetail任务详情、Trigger触发器三层模型每一个任务都可以独立创建、暂停、恢复、删除和修改。它支持两种存储方式内存里跑跑很简单接上数据库后任务定义和运行状态都能持久化。它还天然支持集群模式多个实例共享同一个任务调度状态保证同一个任务在同一时刻只会被一个实例执行。项目里最典型的一个场景是运营后台需要配置营销活动活动开始时间和结束时间都是运营自己填的。这种由用户触发创建的动态任务用Scheduled根本没法写因为你不知道定时规则是什么更不可能为了一个活动去改一次代码。Quartz 就能把任务信息存进数据库通过管理接口随时新增和调整彻底跟业务系统打通。所以我的建议很简单项目里有几个固定任务Scheduled够用一旦任务数量变多、执行规则动态、需要监控管理直接在项目里上 Quartz越早越好省得后面重构。2. 先把四个核心概念搞明白代码才不会飘Quartz 的代码写起来其实不难坑的是概念没搞懂组合起来就懵了。我先用大白话把四个核心角色讲清楚。2.1 Job你要执行的业务逻辑Job 是接口里面只有一个方法execute(JobExecutionContext context)。你把要跑的活儿写在这个方法里就行。比如你要定时给用户推送消息那推送逻辑就写在 Job 的实现里。public class PushMessageJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { System.out.println(推送消息任务执行 Instant.now()); } }关键点在于Quartz 每次触发任务都会通过反射重新创建这个 Job 的实例。所以不要在 Job 里搞什么成员变量存状态Job 不是单例你存了也可能被多线程并发执行互相踩踏。要传数据走JobDataMap。2.2 JobDetail任务的身份档案Job 是代码JobDetail 是贴在任务上的档案。它记录了这个任务叫什么名字、属于哪个组、要执行哪个 Job 类、带着哪些参数。Quartz 判断两个任务是不是同一个靠的是 JobDetail 的 name 和 group不是靠类名。所以同一个 Job 类完全可以在系统里注册多个不同的 JobDetail执行不同的配置。2.3 Trigger什么时候触发Trigger 是触发规则告诉调度器什么时候该干活。最常用的两种CronTrigger用 Cron 表达式定义触发时间适合固定的、周期性的规则比如每天凌晨 2 点。SimpleTrigger适合指定间隔执行比如每 5 分钟执行一次执行有限次。Trigger 也有自己的 name 和 group同一份 Trigger 可以绑定某个 JobDetail二者之间是多对一的关系。2.4 Scheduler总调度器Scheduler 是 Quartz 的心脏。它负责注册 JobDetail 和 Trigger、启动调度器、维护任务状态。大多数情况下你是围绕 Scheduler 做编程调度器启动之后所有到了触发时间的任务都会被对应的线程池捞出来执行。Spring Boot 的spring-boot-starter-quartz已经帮我们封装了很多东西Scheduler这个 Bean 是自动配置好的直接注入用就行。搞懂这四个概念后再去看 Quartz 的代码就会顺畅很多。下面的所有实操代码都是基于这四个对象的组合。3. 跑通第一版依赖、配置与最小示例3.1 引入依赖Spring Boot 2.x 用spring-boot-starter-quartz是最省事的它不是额外的第三方库而是 Spring 官方封装好的 starter。需要说明的是 Quartz 依赖了 Spring 事务和 JDBC 相关模块所以即使你现在用不到持久化这些依赖也会跟着进来不用慌。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-quartz/artifactId /dependency如果你用的是 Spring Boot 3.x对应的 starter 还是不变但底层 Quartz 版本会升级到 2.3.x 之后需要 JDK 17 以上。这一点在引入依赖之前先确认一下你的运行时环境。3.2 写一个简单的 JobComponent public class SampleJob extends QuartzJobBean { Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { System.out.println(SampleJob 执行当前时间 LocalDateTime.now()); } }如果直接实现 Quartz 的Job接口Spring 是没法自动帮你注入 Bean 的因为 Quartz 内部用反射 new 出对象不走 Spring 容器。当然你可以通过扩展SpringBeanJobFactory来解决这个问题但最简单的方式是继承 Spring Boot 提供的QuartzJobBean。它会把 JobDetail 里的 JobDataMap 数据填充到 Job 的属性中同时你还可以通过Autowired注入 Spring 管理的其他 Bean。3.3 配置 JobDetail 和 Trigger这一步有两种姿势。第一种直接写配置类注册 Bean。适合那些执行规则写死、上线后基本不变的任务。Configuration public class SampleQuartzConfig { Bean public JobDetail sampleJobDetail() { return JobBuilder.newJob(SampleJob.class) .withIdentity(sampleJob) .storeDurably() .build(); } Bean public Trigger sampleTrigger(JobDetail sampleJobDetail) { return TriggerBuilder.newTrigger() .forJob(sampleJobDetail) .withIdentity(sampleTrigger) .withSchedule(CronScheduleBuilder.cronSchedule(0/30 * * * * ?)) .build(); } }这里storeDurably()的意思是即使这个 JobDetail 没有任何 Trigger 绑定它也把它保存下来。如果不加这个Quartz 会认为没有触发器还存着干嘛直接报ObjectAlreadyExistsException或者无法注册。第二种使用SchedulerFactoryBean来配置。这种方式更接近原生 Quartz适合需要精细控制applicationContextSchedulerContextKey、QuartzProperties等高级属性的场景。但在 Spring Boot 里大多数场景不需要走到这一步。3.4 测试验证写好之后启动 Spring Boot 应用观察日志SampleJob 执行当前时间2025-01-12T14:30:00 SampleJob 执行当前时间2025-01-12T14:30:30到这里一个最基础的整合就通了。整个过程不需要自己 newScheduler也不需要调用scheduler.start()因为 Spring Boot 已经做了自动配置。但你会注意到一个问题重启 Spring Boot 应用后任务还在吗如果你没做任何持久化配置答案是不在了。这就要进入下一个话题也是生产环境里真正拉开差距的地方。4. 持久化与集群从进程内任务变成可恢复任务4.1 先理解 RAMJobStore 为什么不靠谱Quartz 默认使用RAMJobStore意思是任务信息全放在内存里。好处自然是快不用连数据库启动零配置。但坏处足够致命任务数据和服务状态绑定重启直接丢失。集群部署时每个节点各自为政同一个任务可能每个节点都执行一遍。没有地方记录任务的历史执行情况、下次触发时间出问题排查困难。生产环境的核心任务是绝对不能接受的因此需要切换到JDBCJobStore。4.2 数据库表从哪里来Quartz 的 JAR 包里面已经自带了建表 SQL路径一般在org/quartz/impl/jdbcjobstore/里面有各种数据库方言的目录比如sql/tables_mysql_innodb.sql、sql/tables_oracle.sql、sql/tables_postgres.sql。你需要找与你数据库对应的文件在当前数据库执行一遍建表语句。以 MySQLInnoDB为例Quartz 核心表大概有 11 张表名作用QRTZ_JOB_DETAILS任务详细信息QRTZ_TRIGGERS触发器信息QRTZ_CRON_TRIGGERSCron 类型触发器QRTZ_SIMPLE_TRIGGERSSimple 类型触发器QRTZ_BLOB_TRIGGERS自定义触发器QRTZ_CALENDARS日历信息QRTZ_PAUSED_TRIGGER_GRPS被暂停的触发器组QRTZ_FIRED_TRIGGERS正在执行的触发器QRTZ_SCHEDULER_STATE调度器实例状态QRTZ_LOCKS悲观锁表QRTZ_SIMPLE_TRIGGERS简单触发器的重复次数信息需要特别注意QRTZ_LOCKS这张表Quartz 集群解决同一个任务不能被多个节点同时执行的核心机制就是靠它。每个调度器在操作触发器等关键资源时会先尝试获取对应行上的数据库锁拿到锁才继续操作。这本身就是一种分布式锁的实现重点在行级锁和事务配合上。4.3 Spring Boot 里的持久化配置spring: quartz: job-store-type: jdbc jdbc: initialize-schema: always properties: org.quartz.scheduler.instanceName: MyQuartzScheduler org.quartz.scheduler.instanceId: AUTO org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.tablePrefix: QRTZ_ org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000 org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool org.quartz.threadPool.threadCount: 10 org.quartz.threadPool.threadPriority: 5逐项说明一下job-store-type: jdbc把任务存储切换到数据库不写的话默认是memory。jdbc.initialize-schema: always每次启动时执行建表 SQL。生产环境建议改成never你自己手动执行一次建表脚本就行避免每次启动都有建表开销更安全。如果你用 Flyway 管理数据库迁移建议把 Quartz 的 SQL 集成到 Flyway 里统一管理。instanceName调度器实例名集群中不同实例可以配置同一个实例名标识它们属于同一个逻辑调度器。如果你配置instanceId: AUTO每个节点会生成唯一的实例 ID用于QRTZ_SCHEDULER_STATE表里区分节点的注册和心跳。isClustered: true打开集群模式。单机也可以打开无副作用还方便以后扩容。clusterCheckinInterval节点心跳检查间隔单位毫秒。默认 15000一般不用改。如果节点挂了另外的节点会在这个间隔之后发现并接手它持有的任务。threadPool.threadCount调度线程池大小默认 10。很多人会忽略这个配置结果发现任务多了不执行了其实不是不执行而是线程池被占满了任务排队排到天荒地老。driverDelegateClass数据库方言代理类。MySQL 用StdJDBCDelegate如果是 Oracle 等特殊数据库需要改成对应的 Delegate。设置完成之后从库里是能看到任务数据被持久化的。手动去QRTZ_JOB_DETAILS和QRTZ_TRIGGERS表里查询能把 JobDataMap 里的参数看得清清楚楚这对排查问题特别有用。4.4 部署新 Job 还是老 Job一个关键细节启用了 JDBC 持久化后有个现象很容易把人搞晕你明明在代码里把 Job 的 Cron 表达式从0 0 2 * * ?改成了0 0 3 * * ?重启应用之后发现还是凌晨 2 点执行。原因是 Quartz 把触发器信息持久化到了数据库重启时先读了数据库里的旧触发规则没有因为你的代码变化就自动覆盖。解决方案有几种在代码里判断数据库中是否存在旧的 JobDetail存在的话用scheduler.addJob(jobDetail, true)强制替换。使用不同的 JobDetail name 和 trigger name让新版本任务和旧版本任务区分开。开发环境可以直接清空 QRTZ_ 相关表重启。最稳的是第一种变更任务配置代码时同时保证调度器里的 JobDataMap 和 Trigger 被显式更新。4.5 集群模式下的分布式锁逻辑把isClustered打开后两个节点 A 和 B 连接同一个数据库。当节点 A 想要触发任务时它先去QRTZ_LOCKS表里尝试获取对应行的行锁。这个行锁是QRTZ_TRIGGER_ACCESS这类固定数据行所有节点抢的是同一行记录谁先抢到谁操作操作完提交事务释放锁。数据库的事务隔离级别一定要用READ_COMMITTEDMySQL 默认Quartz 的集群机制依赖它能读到已提交的数据否则可能死锁或重复调度。这个机制并不是分布式任务调用框架那种抢占后远程通知而是所有节点都在同一个数据库上竞争触发权。所以要求所有节点的时间尽量同步否则可能出现前一个节点的时间比后一个节点慢导致任务触发顺序混乱这在生产环境要特别留意。5. 动态任务管理把调度做成后台功能而不是写死代码前面讲的是写死的任务但现实业务里更多是动态任务运营在后台上传一个活动选择了活动开始时间、结束时间系统就要自动生成对应的定时任务。这在 Quartz 里完全能实现也是它比Scheduled强最多的地方。5.1 动态任务核心 Service先建一个专门操作调度器的 Service把常用操作封装起来Service public class QuartzJobService { Autowired private Scheduler scheduler; /** * 新增或更新任务 */ public void saveOrUpdateJob(String jobName, String jobGroup, String cron, Class? extends Job jobClass, MapString, Object params) throws Exception { JobKey jobKey JobKey.jobKey(jobName, jobGroup); JobDetail jobDetail JobBuilder.newJob(jobClass) .withIdentity(jobKey) .storeDurably() .build(); if (params ! null !params.isEmpty()) { jobDetail.getJobDataMap().putAll(params); } TriggerKey triggerKey TriggerKey.triggerKey(jobName Trigger, jobGroup); CronTrigger trigger TriggerBuilder.newTrigger() .withIdentity(triggerKey) .forJob(jobKey) .withSchedule(CronScheduleBuilder.cronSchedule(cron) .withMisfireHandlingInstructionDoNothing()) .build(); if (scheduler.checkExists(jobKey)) { scheduler.addJob(jobDetail, true); if (scheduler.checkExists(triggerKey)) { scheduler.rescheduleJob(triggerKey, trigger); } else { scheduler.scheduleJob(trigger); } } else { scheduler.scheduleJob(jobDetail, trigger); } } }这里特别注意withMisfireHandlingInstructionDoNothing这个设置。后面会单独讲 Misfire 的问题但你先记住在动态创建任务时建议明确指定 Misfire 策略默认策略很可能不是你想要的行为。5.2 暂停、恢复、删除与立即执行/** 暂停任务暂停后不会再触发但任务元数据还在 */ public void pauseJob(String jobName, String jobGroup) throws Exception { scheduler.pauseJob(JobKey.jobKey(jobName, jobGroup)); } /** 恢复被暂停的任务 */ public void resumeJob(String jobName, String jobGroup) throws Exception { scheduler.resumeJob(JobKey.jobKey(jobName, jobGroup)); } /** 删除任务同时删除关联 Trigger */ public void deleteJob(String jobName, String jobGroup) throws Exception { scheduler.deleteJob(JobKey.jobKey(jobName, jobGroup)); } /** 立即执行一次不受 Cron 规则影响 */ public void triggerNow(String jobName, String jobGroup) throws Exception { scheduler.triggerJob(JobKey.jobKey(jobName, jobGroup)); }删除任务之前最好先检查一下任务是否存在避免误删也可以在同一次事务里把业务单据状态和任务状态一起处理避免业务上活动已删除定时任务还在跑。5.3 查询任务状态任务到底跑没跑Quartz 原生接口查询任务列表不够直观需要自己组装。比较实用的几个方法public ListMapString, Object getAllJobs() throws Exception { ListMapString, Object list new ArrayList(); for (String group : scheduler.getJobGroupNames()) { for (JobKey jobKey : scheduler.getJobKeys(GroupMatcher.groupEquals(group))) { List? extends Trigger triggers scheduler.getTriggersOfJob(jobKey); String status NONE; String cron null; if (!triggers.isEmpty()) { Trigger trigger triggers.get(0); Trigger.TriggerState triggerState scheduler.getTriggerState(trigger.getKey()); status triggerState.name(); if (trigger instanceof CronTrigger cronTrigger) { cron cronTrigger.getCronExpression(); } } MapString, Object item new HashMap(); item.put(jobName, jobKey.getName()); item.put(jobGroup, jobKey.getGroup()); item.put(status, status); item.put(cron, cron); item.put(nextFireTime, triggers.get(0).getNextFireTime()); item.put(prevFireTime, triggers.get(0).getPreviousFireTime()); list.add(item); } } return list; }TriggerState 里比较常见的是NORMAL正常、PAUSED已暂停、COMPLETE已完成不会再触发、BLOCKED被阻塞后面会细讲、ERROR出错。通过一个管理页面把这些状态展示出来运维和运营就都能直观看到任务情况不再抓瞎。6. 一个绕不开的坑任务被 block 住是什么情况在热搜词里看到你搜了 java的quartz一直blocked这个问题我太有感触了几乎每个用 Quartz 的团队都会来一次。Quartz 出现BLOCKED状态意思是这个触发器对应的 Job 上一次还没执行完下一次触发时间已经来了。6.1 为什么会被阻塞Quartz 的线程池大小是有限的默认 10 个线程。假如某个 Job 的执行时间超过了触发间隔新的触发事件到达时发现这个 Job 的旧实例还在跑Quartz 就不会再为它新开一个线程去执行而是等旧实例执行完。在这个等待期间这个触发器在状态表里就显示为BLOCKED。比如你有个数据库同步任务正常应该 10 秒跑完定时配置成每 30 秒一次。某天数据库慢查询导致这个任务跑了 2 分钟那第 2、3、4 次触发都会被阻塞直到第一次执行完后面的触发才被逐步处理。6.2 如何排查遇到 blocked最重要的事情不是立刻手动恢复而是找到根本原因。我的排查链路一般是这样看数据库 QRTZ_FIRED_TRIGGERS 表正在执行的任务记录了 start time、job name。如果一直有记录没有移除说明这个 Job 很长时间没有返回。看日志里 Job 的执行耗时如果平时 1 秒的任务突然要执行几分钟先去查这个任务访问的外部依赖是否有问题比如数据库连接池耗尽、调用的第三方 API 超时。看线程池配置threadPool.threadCount是否过小。假设核心业务有 30 个任务每个任务每 10 秒一次但线程池只有 5 个线程必然会有大量阻塞。看 Job 内部是不是有死循环或者锁等待BLOCKED很可能是 Job 里 synchronized 块没有正常退出或者等待某张表的数据锁等太久。看 Misfire 策略如果任务被错过了几次触发默认策略是马上补跑这样可能导致多个任务瞬间并发执行。6.3 怎么解决第一时间把线程池扩大分析任务并发量和任务时长threadCount 可以通过压测确定。一般单机 20 个线程足够太多线程反而增加数据库连接压力。优化 Job 本身的执行效率能异步处理的就异步处理Job 里只做触发真正耗时逻辑放到消息队列或者线程池去跑。给关键任务配置合理的 Misfire 策略。Cron 触发器的 Misfire 策略主要有这么几种策略说明适用场景withMisfireHandlingInstructionDoNothing错过不补等下次正常触发适合日报、定时同步错过就算了withMisfireHandlingInstructionFireAndProceed立即补跑一次然后继续按计划适合漏跑影响较大的任务withMisfireHandlingInstructionIgnoreMisfires立即执行所有错过的触发谨慎使用可能造成大量任务堆积我个人的经验是绝大多数业务任务选 DoNothing 就够了。很多人害怕漏掉任务选了 IgnoreMisfires结果服务停了半天恢复后几十个错过的触发在一瞬间全部补跑直接把数据库压垮了。真正的核心任务应该做数据补偿机制靠 Misfire 策略兜底不太现实。7. 几件我在项目里觉得务必注意的事7.1 Job 里的异常处理要严格Job 方法内不能把异常全部吞掉。正常情况下Job 抛出的异常会被 Quartz 捕捉任务的状态和日志里会有记录。如果全用 try-catch 包住异常被吃掉从调度系统层面看这个任务永远成功出问题根本发现不了。我的建议是在 Job 里把业务异常包装成JobExecutionException抛出同时把异常信息完整写入日志方便排查。还可以在 Job 里加上失败超过 N 次就发告警通知的逻辑在executeInternal里记录连续失败次数达到阈值就通过消息推送、邮件等方式通知开发人员。7.2 JobDataMap 和参数传递的坑Quartz 在持久化 JobDataMap 时要求里面存放的对象必须能序列化。你往 JobDataMap 里塞一个带ObjectMapper字段的对象反序列化时大概率要出问题。更稳妥的方式是只存基本类型、String、日期、以及 DTOensure DTO implements SerializableBean 之类的重量级对象不要塞进去。如果 JobDetail 里已经存在旧的任务数据再往 JobDataMap 里 put 同名的 Key不会把旧值删掉而是覆盖。如果你想彻底清空重来需要先获取旧的 JobDataMap 并调用clear()方法再 put 新数据最后scheduler.addJob(jobDetail, true)。7.3 Spring Bean 注入问题如果你直接实现Job接口而不是继承QuartzJobBean那 Job 类内部的Autowired字段会一直是 null。Quartz 每次通过反射创建 Job 实例这个实例不受 Spring 管理。解决办法有三个继承QuartzJobBean这是最简单的方案。自定义SpringBeanJobFactory让它从 Spring 容器里获取 Job 实例。在 Job 内部通过ApplicationContextHolder手动获取 Bean。我推荐第一种官方 starters 已经考虑到了这个场景。如果你有特殊需求必须用原生 Quartz API再考虑第二种。7.4 多环境部署时的账号权限与表统一Quartz 表是共用的同一套数据库里如果部署了 dev、test、prod 多个环境千万不要连接同一个库的同一张 QRTZ_ 表。不同环境的调度信息会被互相覆盖严重时会直接出现任务地址指向错误环境。解决办法也简单每个环境一个数据库或者给 Quartz 表加不同的 tablePrefix。配置文件里org.quartz.jobStore.tablePrefix: QRTZ_你可以改成DEV_QRTZ_、TEST_QRTZ_这样同一套库也能物理隔离。7.5 更新任务时 reschedule 和 addJob 的执行顺序动态更新任务时如果你先删除旧的 Trigger 再新增中间会产生一个时间窗任务这段时间不触发极端情况下还会丢失触发次数。正确做法是使用rescheduleJob(triggerKey, newTrigger)方法原子替换或者按照前面 Service 里的写法先 addJob 更新 JobDetail再 reschedule 更新 Trigger。删除 Job 类似不要循环一把梭把所有 Trigger 都删了再删 Job。直接用scheduler.deleteJob(jobKey)就会级联删除关联的 Trigger干净又安全。7.6 任务的幂等性设计Quartz 集群模式下同一个任务不会被多个节点同时执行但这不代表你的业务不用考虑幂等。因为一旦发生网络分区、数据库锁超时、手动补跑等情况任务仍然可能被执行两次。我的习惯是在 Job 执行入口加一个幂等判断比如查一下业务表里当天是否已经生成了对应的记录有就直接返回没有才继续执行。这个习惯在引入 MQ 异步消费之前就要养成因为定时任务和消息通知往往叠加在同一个业务流程上两次投递是常态。最后说点个人的经验用 Quartz 这几年踩过最深的坑基本都集中在两个地方一个是线程池配置不合理导致任务堆成一锅粥另一个是数据库持久化和集群细节没处理好上线后出现莫名其妙的重复执行。现在回头看如果当初在项目开始时就花半天时间把 Quartz 的持久化配置、集群模式和动态管理接口搭好后面能省下几天的排查时间。最后分享一个小技巧在你的配置文件里把 Quartz 的日志级别调到 DEBUG本地调试的时候观察它打印的调度日志真的是个神器。org.quartz.core.JobRunShell、org.quartz.core.QuartzScheduler这两个 Logger 的日志信息量非常大任务的注册、触发、线程池分配、misfire 处理全都能看到。排查问题的时候盯着这两行日志往往比翻业务代码效率高一截。
