
凌晨两点报警群突然炸了。某运营系统的日报任务在那个时间点跑了三次短信通道连着发出去三轮数据库里多了两批重复数据。排查下来原因很简单——系统部署了三台服务器每台服务器上都有同一个 Quartz 调度器到点了大家各跑各的谁也没通知谁。这个场景我相信很多做 Java 后端的朋友不陌生单机下跑得好好的定时任务一旦为了高可用做成多节点部署就变成了定时炸弹。这篇内容我不打算只给配置而是把“为什么多服务器部署会让 Quartz 定时任务重复执行”这件事讲透再给出完整的集群改造方案包括底层原理、建表、配置、代码、验证方法以及我在实际项目中踩过的坑。适合正在维护 Spring Boot Quartz 存量系统、或者准备给定时任务做高可用改造的开发者参考。1. 一台机器运行正常两台机器就乱套重复执行到底是怎么发生的1.1 一次“三份报表”事故的完整现场还原先说那次事故。项目背景是某内部运营平台每天凌晨需要生成前一天的统计报表推送结果并触发短信提醒。单机时代这功能稳如老狗后来因为服务器要扩容、要平滑发布团队把应用部署到了三台机器上用负载均衡对外提供服务。结果上线后第一个凌晨就炸了。查日志时发现三台机器的应用日志里都有 Quartz 的执行记录生成报表、发短信的动作各自完整执行了一遍。有人第一反应是定时任务重复配置了但检查 cron 表达式确实只有一份有人怀疑是发布器重复部署了两次检查后也不是。真正的原因写出来其实非常朴素三台服务器上的应用程序各自创建了自己的 Quartz 调度器实例每个调度器独立计算触发时间到了点各自触发各自的 Job。这不是配置写错了而是 Quartz 默认的运行模式决定的。1.2 Quartz 的内存 JobStore 从设计上就不懂“协调”大多数人在 Spring Boot 里集成 Quartz默认的 JobStore 是RAMJobStore。名字已经说明了一切——Job 和 Trigger 的元数据全部存在当前 JVM 的内存里。每个应用进程只知道自己肚子里的 JobDetail、Trigger、Calendar完全不知道隔壁进程里还住着同样的一家人。从这个角度理解三台机器其实是三个完全孤立的调度器。它们互不通信、互不感知、没有共同的数据源自然也就不存在“协商谁跑谁不跑”的机制。到点之后各自调度器按照本地内存里的 Trigger 状态完成任务触发。RAMJobStore 本身没有错它的优点是快、零依赖、不需要数据库缺点就是无状态信息共享、无法跨进程协作。如果你只有一台服务器这方案完全够用一旦拆成多节点它就成了重复执行的温床。所以问题的第一个关键点不是 Quartz 的代码而是部署形态从单机走向集群时JobStore 没有跟着换掉。1.3 为什么临时加 Redis 锁很难根治有人会问那我不用数据库 JobStore直接在 Job 执行入口加一个 Redis 分布式锁抢到锁的人才执行行不行能做但这是另一种选型思路不是 Quartz 本身的解法。用锁方案最大的问题是锁的粒度、锁超时时间、业务执行时长三者之间的关系很难拿捏。锁设短了任务没跑完锁就过期了另一台机器抢到锁又跑一遍锁设长了持有锁的进程挂了锁要等自然过期才能被其他人获取任务恢复周期变得不可控。你要给每个任务单独设计锁 Key还要处理锁续期复杂度很快就会超过 Quartz 集群方案。更关键的是加锁只能解决“同时刻多点触发”的问题不能解决 Quartz 在调度层面的种种细节——比如错过触发时间的补偿策略、持久化 Trigger 状态、调度器故障后的任务接管。如果你都自己造轮子了其实就是在重复实现 Quartz 已有的功能。2. 集群模式的拳头机制数据库锁与心跳2.1 关键切换从 RAMJobStore 到 JDBCJobStore要让多个 Quarztz 调度器进程变成“一个集群”最根本的一条是让所有调度器共享同一份 JobDetail 和 Trigger 状态。能承担这个共享角色的最好用的就是数据库。切换到数据库存储后Quartz 用的是JobStoreTX或JobStoreCMT这两个类名里的 JDBCJobStore 家族。所有的 JobDetail、Trigger、Calendar、锁、调度器状态统一落在数据库的若干张表里。每个调度器进程启动时都去读同一个库里的同一份元数据。它们仿佛拿到了同一张值日表谁先抢到任务谁执行。这一步如果没做后面的集群配置全都是空谈。isClusteredtrue只是告诉 Quartz 启用集群协作逻辑但它要求底层的 Store 本身是 JDBC 集群 JobStore协作逻辑才能生效。RAMJobStore 配了集群参数也不会起作用因为它根本不读数据库。2.2 行锁抢活同一时刻只有一个节点能“扣下扳机”把状态放进数据库后最重要的就是如何避免多个调度器同时选中同一个 Trigger。Quartz 集群机制中这里用的就是数据库行锁。具体流程可以简化成四步。第一步调度器进入调度循环需要获取将要触发的 Trigger 列表第二步所有节点都尝试访问 QRTZ_LOCKS 表中名为 TRIGGER_ACCESS 的锁记录用类似SELECT ... FOR UPDATE的方式锁住这一行第三步成功获取到行锁的节点才去 QRTZ_TRIGGERS 表执行带FOR UPDATE的查询把当前时间窗口内满足条件的 Trigger 捞出来第四步把选取到的 Trigger 状态从 WAITING 更新为 ACQUIRED然后提交事务并释放锁。这套动作的结果非常关键在一段毫秒级的时间内只有拿到了 TRIGGER_ACCESS 行锁的调度器能执行“选任务”的动作其他节点只能在外排队。等它们拿到锁时再去查满足条件的 Trigger状态已经不是 WAITING 了自然就退出本次轮询。我拿生活中的场景打个比方有一张桌面值日表几个同学轮流去看表但看表的人要把表拿到手上别人才看不到。第一个同学看完把需要擦的黑板标记成“已被认领”后面几个同学再看时发现黑板已经有人干了就各回各位。Quartz 的数据库锁就是那个“拿在手上的值日表”。2.3 心跳注册与故障摘除QRTZ_SCHEDULER_STATE 在背后怎么工作行锁解决了“同一时刻不重复跑”但还缺一个能力如果某台服务器突然宕机了它之前锁住的任务怎么办它还能不能继续参与集群调度Quartz 的答案是 QRTZ_SCHEDULER_STATE 表。这张表记录的是每个调度器实例的在线状态有几个比较关键的列INSTANCE_NAME 表示调度器实例名LAST_CHECKIN_TIME 表示最后一次心跳时间CHECKIN_INTERVAL 表示心跳间隔。每个集群模式的调度器进程启动后都会往这张表里注册一行记录然后每隔一个clusterCheckinInterval周期回写一次 LAST_CHECKIN_TIME。默认情况下心跳间隔是 15000 毫秒。如果某个实例超过clusterCheckinInterval的若干倍都没有更新心跳其他正常运行的调度器就会认为它已经失联这个实例在集群中被判死。被摘除后它对 Trigger 的占用并没有直接强制清除——Quartz 是配合 misfire 机制来处理这些“半路没跑完”的任务的。调度器会去查找处于 ACQUIRED 状态但所属实例已经失效的 Trigger根据约定的 misfire 策略决定是否重新调度执行。这就回答了很多人关于故障转移的疑问Quartz 集群模式确实支持故障接管但它不是立刻完成接管接管节奏由心跳检测周期和 misfire 补偿策略共同决定。3. 落地集群配置从建表到 Spring Boot 接入的完整过程3.1 先建好十一张表一个都不能少所有集群相关的数据结构都在 Quartz 的建表脚本里。不用自己手工设计官方包里有现成的 SQL 文件按数据库选对文件执行即可。以 MySQL InnoDB 为例常见的是tables_mysql_innodb.sql注意别拿 MyISAM 版本跑在 InnoDB 上行锁要求表引擎支持事务。建表完成后数据库里会出现一组以 QRTZ_ 开头的表。这里挑几张贴出来说明它们各自的作用方便你建完表后检查核心状态表QRTZ_JOB_DETAILS存 Job 描述信息QRTZ_TRIGGERS存 Trigger 的主体信息包含 Trigger 状态、下一次触发时间等QRTZ_CRON_TRIGGERS存 Cron 表达式相关字段QRTZ_SIMPLE_TRIGGERS存简单重复触发的时间间隔QRTZ_FIRED_TRIGGERS存已经触发了但尚未执行完毕的 TriggerQRTZ_SCHEDULER_STATE存调度器实例在线状态QRTZ_LOCKS存集群锁记录。另外还有几张辅助表如QRTZ_CALENDARS、QRTZ_PAUSED_TRIGGER_GRPS、QRTZ_SIMPROP_TRIGGERS、QRTZ_BLOB_TRIGGERS。总共十一张官方脚本会一次性建好。建表脚本执行时最容易犯的错是把脚本中的表前缀给改了表前缀必须和后续配置里的tablePrefix保持一致默认是QRTZ_。如果改前缀两边的值必须同步。另外建议把建表脚本纳入项目的数据库迁移流程不要人肉在生产库上执行否则容易漏表。3.2 quartz.properties 里真正影响集群行为的配置项用 Spring Boot 的spring-boot-starter-quartz时有两种配置方式一种是直接写官方 Quartz 的quartz.properties文件一种是把配置项挂在 Spring Boot 的 application.yml 里。我个人习惯用 application.yml 方案这样整个项目的配置都在一处管理。先看必须保证的几项org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000 org.quartz.scheduler.instanceName: MyClusteredScheduler org.quartz.scheduler.instanceId: AUTO org.quartz.jobStore.tablePrefix: QRTZ_ org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.dataSource: quartzDataSource org.quartz.dataSource.quartzDataSource.provider: hikaricp org.quartz.dataSource.quartzDataSource.url: jdbc:mysql://xxx这里逐个解释。isClustered表示启用集群模式所有部署实例的这一项都必须为 true否则有的节点独立运行、有的节点集群运作相当于两个队伍不在一个频道。clusterCheckinInterval是心跳间隔默认 15000 毫秒。间隔越长对数据库心跳写压力越小但故障发现越慢隔得越短故障切换越快却会放大数据库写请求。一般线上 15 到 20 秒是比较常见的取值。instanceName是集群的名字它必须保持一致。Quartz 以schedName instanceId作为调度器的唯一标识只要 instanceName 相同实例才被认为属于同一个集群。instanceId一般配为AUTO让 Quartz 自动生成不重复的实例名最终表现为主机名时间戳这种格式。在多节点手动部署时千万别把所有实例都配成同一个固定值否则数据库的主键会打架。driverDelegateClass根据数据库种类选择MySQL 用 StdJDBCDelegate 一般够用如果是 PostgreSQL 或 Oracle需要换成对应的 Delegate 类。dataSource指向 Quartz 自己的数据源配置它和应用业务数据源是两个东西。如果 Spring Boot 配置里已经有主数据源Quartz 的数据源要单独配避免它去复用业务连接池把业务库和定时任务状态库混淆。3.3 Spring Boot 集成示例与完整 Job 代码Spring Boot 下引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-quartz/artifactId /dependency配置项这么写spring: quartz: job-store-type: jdbc scheduler-name: MyClusteredScheduler auto-startup: true properties: org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000 org.quartz.jobStore.tablePrefix: QRTZ_ org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.misfireThreshold: 60000 org.quartz.scheduler.instanceId: AUTO org.quartz.threadPool.threadCount: 10这里的spring.quartz.job-store-type: jdbc是 Spring Boot 封装层的开关它让 SchedulerFactoryBean 使用 JdbcJobStore 而不是默认的 RAM。spring.quartz.properties底下则是原生 Quartz 配置项的透传。Job 代码没什么特别的但有两个细节要讲清楚。第一Job 类必须有无参构造并且最好使用持久化友好的写法比如把执行参数放进 JobDataMap不要依赖 Spring 注入的重型 Bean避免集群节点反序列化时找不到类。第二对同一份 JobDetail 的并发执行要显式限制。下面是一个简单但完整的例子DisallowConcurrentExecution public class ReportGenerationJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { JobDataMap dataMap context.getJobDetail().getJobDataMap(); String reportName dataMap.getString(reportName); System.out.println(开始生成报表 reportName); long start System.currentTimeMillis(); // 这里放真正的业务逻辑 generateReport(reportName); System.out.println(报表生成完成耗时 (System.currentTimeMillis() - start)); } private void generateReport(String reportName) { // 业务代码 } }调度注册方式Component public class QuartzSchedulerInitializer implements ApplicationRunner { private final Scheduler scheduler; public QuartzSchedulerInitializer(Scheduler scheduler) { this.scheduler scheduler; } Override public void run(ApplicationArguments args) throws Exception { JobDetail jobDetail JobBuilder.newJob(ReportGenerationJob.class) .withIdentity(reportJob, reportGroup) .usingJobData(reportName, daily_report) .storeDurably() .build(); CronTrigger trigger TriggerBuilder.newTrigger() .withIdentity(reportTrigger, reportGroup) .withSchedule(CronScheduleBuilder.cronSchedule(0 0 2 * * ?) .withMisfireHandlingInstructionFireAndProceed()) .build(); scheduler.scheduleJob(jobDetail, trigger); } }这里要重点说明DisallowConcurrentExecution这个注解。在集群模式下它有两层含义一是阻止同一个 JobDetail 在一个节点内并发执行二是堵住跨节点并发的可能。虽然数据库行锁已经保证了 Trigger 不会被两个节点同时 acquire但任务执行时间过长、或者 misfire 补偿逻辑触发时仍然可能出现 Trigger 在一个节点还没执行完、另一个节点已经开始执行同类 Job 的情况。加上这个注解等于给了双重保险。我在实战中几乎对所有写操作型 Job 都会加上。4. 上线验证与故障演练怎么证明它真的不会重复跑4.1 三处关键日志与状态表一眼看出集群是否生效配置完成并启动后第一件事不是直接等凌晨两点而是要主动验证集群模式确实生效了。最直观的验证点是 QRTZ_SCHEDULER_STATE 表。启动几台应用就应看到几行实例记录。比如启动两台节点这张表里会看到两个不同的 INSTANCE_NAMELAST_CHECKIN_TIME 不断刷新说明心跳注册正常。如果只有一个实例注册另一个节点没进来多半是两台机器的 instanceName 不一致或数据库连接没通。第二个验证点是调度日志。开启 Quartz INFO 级别日志后正常请求调度时会看到类似于QuartzScheduler.signaled JobStoreTX.acquireNextTrigger TRIGGER_ACCESS锁获取失败也会留下记录比如出现锁等待的日志。注意观察有没有多个节点同时获取到同一个 Trigger。第三个验证点是 QRTZ_FIRED_TRIGGERS 表。Trigger 被某个节点发起执行后会以 FIRED 状态出现在这张表里同时记录 INSTANCE_NAME。如果同一时刻同一 Trigger 出现在多行且来自不同节点说明集群隔离失效了正常情况下只会有一行。4.2 模拟节点宕机摘掉一个节点看另一个如何接管验证接管能力时我的习惯是挑一个非关键时间窗口做演练。具体做法是把一台节点的进程直接kill -9杀掉然后盯着剩下节点的日志和数据库状态。这里要把节奏说清楚节点被杀后并不像切换代码那样立即完成接管。QRTZ_SCHEDULER_STATE 里那个死节点的 LAST_CHECKIN_TIME 会停留在最后时刻其他节点在心跳周期内发现它超时未更新才会真正接管它的任务。按照默认 clusterCheckinInterval 15 秒算最快也要十几秒后才有接管动作。如果你发现宕机后任务迟迟没有恢复第一排查点是 misfire 策略。对于 CronTrigger默认的 SMART_POLICY 在集群恢复场景下不一定补跑。建议对“必须每天跑、漏了不如不跑”的任务使用withMisfireHandlingInstructionDoNothing()对“漏了一定要补跑”的任务使用withMisfireHandlingInstructionFireAndProceed()这个选择完全取决于业务能否接受补数据。4.3 并发竞争下的 misfire 策略别忽略misfire 这个词直译是“错过开火”。意思很简单到点了结果调度器因为某种原因没有触发任务超过一定阈值misfireThreshold默认 60 秒后这个触发就被标记为错失。在单机模式下misfire 主要影响的是当前节点错过了任务。在集群模式下misfire 的发生频率和影响范围会明显上升。因为节点时钟偏差、数据库锁竞争、任务执行超时、节点宕机恢复都会催生 misfire。我见过最典型的问题是某任务凌晨 2 点触发A 节点 acquire 到了 Trigger但任务执行到一半 JVM 卡死。B 节点在几十秒后通过集群状态判断 A 失联发现这个 Trigger 还处于 ACQUIRED 状态。此时 B 节点怎么处理这个“名花有主”的 Trigger完全取决于 misfire 策略。默认策略下Quartz 会把错过的触发尝试补执行这本身是好事可如果业务不幂等补执行就会产生脏数据。所以集群改造时每个 Trigger 的 misfire 策略都应该被当成业务需求来评审而不是随意使用默认值。5. 集群定时任务最容易踩的坑与我的处理经验5.1 节点时钟不一致会让“守护者”误判写集群应用的人都知道要保证各节点时钟一致但 Quartz 这里特别敏感。它的 Trigger 下一次触发时间是基于本机时间计算的调度器执行时也以本机时间作为“当前时间”去扫描数据库中的 Trigger。如果 A 和 B 的时钟差个几十秒在行锁竞争场景下可能出现某节点还在等待锁的时候另一个节点的本地时间已经越过触发点触发了 misfire 判定。处理方式只有一条全集群节点必须依赖同一时间源比如统一的 NTP 服务并且校验时区。部署容器时常见的一个小坑是容器镜像里的时区是 UTC本机时区是 CSTQuartz 按容器的 UTC 时间判断和预期触发时间整整差 8 小时。我自己在 Docker 部署脚本里一定会固定设置TZAsia/Shanghai同时校验/etc/localtime是否指向正确时区。5.2 长任务和锁释放你不加注解会怎样如果一个 Job 的执行时间超过了触发间隔或者达到了秒级、分钟级的长任务集群下会暴露两类问题第一类是同类 Job 的跨节点重复。虽然 acquire 环节有锁保护但 Quartz 的锁只保护“选中 Trigger”的那一瞬不保护“Job 执行整个过程”。挂掉的任务在触发恢复后会重新进入调度队列。所以我在前面的代码示例中特意加了DisallowConcurrentExecution这一点在集群里不是可选项而是必选项。第二类是锁超时。如果节点 A 持有了数据库行锁但业务线程在锁保护的临界区内执行时间过长比如去调了外部接口、查询大报表数据库的事务超时和连接池等待可能会把节点 B 的锁请求卡死。锁超时后会抛出锁获取异常调度器纪录一条错误日志然后继续跑。结果就是任务没有重复执行但 B 节点会像“局外人”一样在旁边光看不干活。碰到这类问题要检查的不是 Quartz 配置而是业务代码里有没有在调度临界区做慢操作。5.3 “伪集群”instanceName 不相同的后果这个坑比较隐蔽。两个节点明明连了同一个数据库也开了 isClustered但任务还是会重复。查到最后发现instanceName不一样。Quartz 集群的归属判断依据是schedName instanceId。如果 instanceName 不同Quartz 会把它们当成两套完全不同的调度服务只是恰好共享了一个数据库。共享数据库只能保证 Trigger 状态不冲突却不能保证这两个调度器之间做真正的锁协作。所以在所有节点的配置里spring.quartz.scheduler-name和org.quartz.scheduler.instanceName必须完全一致。5.4 数据库连接池参数与锁超时被忽略的一对搭档集群模式下每个节点都需要从数据库连接池中获取连接来读写表。线程池大小、连接池容量、事务隔离级别这三者要配合好。org.quartz.threadPool.threadCount决定了调度器内部可以并发的线程数默认是 10。数据库连接池的最大连接数至少要大于 threadCount不然调度器在高峰期拿不到连接。我一般把 HikariCP 的 maximum-pool-size 设为 15 到 20并为 Quartz 单独建立一个池子降低和业务连接池互相挤兑的风险。还有一个常见的 MySQL 参数innodb_lock_wait_timeout默认 50 秒。在多节点竞争非常激烈的场景下如果锁等待时间超过这个限制Quartz 的任务获取流程会直接抛异常。有些团队为了不看到一堆 ERROR 日志把这个参数调大到 120 秒。我不太建议这么干锁等待时间越长只会让调度延迟越大正确做法是缩短临界区耗时必要时把 clusterCheckinInterval 调大一点降低心跳带来的锁竞争频率。6. 有了集群还得有兜底幂等设计和轻量替代思路6.1 任务幂等是最后的保险丝我必须把话说直白集群配置能解决 99% 的重复执行问题剩下的 1% 要靠任务自身的幂等来兜底。数据库锁不是神它解决的是调度层的并发业务层的重复提交、重复执行它管不到。所谓幂等是指同一条业务消息执行一次和执行十次最终结果一致。落地形态多种多样比如报表任务执行前先检查目标文件在不在短信任务执行前先查询发送日志有没有这条 jobId积分任务用数据库唯一索引挡住重复入账。我曾经处理过一个问题集群切换期间任务在两个节点各执行了一次业务代码里没有幂等处理结果积分余额错乱。后来在数据库加了一条唯一业务键问题才彻底消失。所以做集群改造时建议同时做两件事调度层交给 Quartz 集群业务层给关键写操作加幂等保护。双保险。6.2 有中间件的团队可以怎么权衡如果团队已经有 Redis 这类缓存服务有人会倾向用 Redis 分布式锁来代替数据库存储方案。我不能说你错但我要提醒的是Quartz 集群模式给出的是一整套调度器协作方案而 Redis 锁只是给“某个 Job 互斥执行”做了一层拦截。当你的任务数量少、逻辑简单Redis 锁确实够用当任务数量膨胀到几十上百个每个都要设计锁 Key、续期机制、异常释放方案维护成本会反超 Quartz 集群。反过来如果团队已经在用消息队列或专门的分布式任务调度平台那就没必要再刻意引入 Quartz 集群。你有更轻的选项把定时任务统一收编到消息队列的延迟队列里或者直接用专业调度平台的集群机制。Quartz 集群的价值就在于它不需要引入额外中间件仅靠数据库就能完成多节点协作适合不想改底座的存量系统。6.3 我推荐的最小改动方案给一个最稳妥的改造路径。存量系统如果是单机 Quartz改造多节点部署时按“先建表、后改配置、再验证演练”这个顺序走不要跳步。建表脚本纳入版本管理配置项里 instanceName 和集群开关保持一致Job 类加DisallowConcurrentExecution每个 Trigger 根据业务定 misfire 策略最后做一次完整的宕机演练。这个方案的收益是很明确的不用把定时任务迁到别的中间件不用大改业务代码就能把多服务器部署造成的重复执行问题按在萌芽状态。我前后用这个方案处理过好几个项目的集群改造整体改动量通常在一到两天核心时间都花在梳理任务清单和定 misfire 策略上而不是配置本身。最后补一个实际操作中的小经验集群模式上线后的第一周最好每天早晨看一眼 QRTZ_SCHEDULER_STATE 表的记录数和 QRTZ_TRIGGERS 表的状态分布。如果某个 Trigger 长期停留在 ERROR 状态或者是 ACQUIRED 状态一直没有被执行多半是节点失联或者 misfire 策略设置有问题。把这两张表的状态当成定时任务本身的“体检指标”比盯着业务日志更早发现问题。