Spring Scheduler 与 Quartz:一次触发怎样成为可恢复执行
“每隔 10 秒开始一次”“上次完成后等待 10 秒”“每天业务时区的某个时刻”对应三种不同的时间安排。任务执行变慢时,它们会产生不同的下一次启动时间;应用停止时,进程内安排也随之消失。是否需要把计划存进数据库、让其他节点接管,是选择调度实现的另一个问题。
Spring 定时回调与执行线程
时间安排先于线程数量
Spring 把异步执行和按时执行分成两个接口:TaskExecutor 接收工作并安排线程,TaskScheduler 还接收时刻、间隔或 Trigger。调度器决定何时提交回调,业务代码决定一次回调何时完成。
| 配置 | 下一次启动的依据 | 适合的任务 |
|---|---|---|
fixedRate | 起始计划时刻加上周期的整数倍 | 希望保持采样频率、允许迟到执行的周期任务 |
fixedDelay | 上一次回调完成时刻加上间隔 | 每轮结束后需要冷却时间的轮询 |
cron | 日历表达式、时区和 Trigger 的计算规则 | 按业务日历运行的报表、结算和清理 |
只有 initialDelay | 启动后延迟一次 | 一次性延后执行 |
假设周期 100 ms、一次工作耗时 160 ms。在线程池型调度器中,同一个 scheduleAtFixedRate 注册不会并行执行自身;前一轮完成后,已经迟到的下一轮可以紧接着开始。它的实际起点可能接近 T0、T+160、T+320。改成 fixed-delay 后,下一轮还要再等 100 ms,实际起点接近 T0、T+260、T+520。这些是无其他竞争时的示意时序,操作系统调度和停顿会继续增加延迟。JDK 的周期执行约定保证同一周期任务前后不重叠,没有保证精确准点。
增加线程数可以让不同任务同时执行,同一个固定速率注册仍按原有次序运行。方法若把工作交给另一线程池,情况就变了:回调已经返回,子任务还在处理。fixed-delay 从“提交动作结束”开始计时,下一轮业务便可能与上一轮重叠。
从注解到第一次执行
一个注解回调要经过实际的 Spring Bean 注册:
ApplicationContext
├── @EnableScheduling
│ └── ScheduledAnnotationBeanPostProcessor
│ └── 发现 Bean 上的 @Scheduled 方法
├── ScheduledTaskRegistrar
│ └── 保存任务与触发配置
└── TaskScheduler
└── 等待触发时间 → 调用 Runnable → 业务方法返回普通同步方法不接收参数,依赖通过 Bean 注入,返回值不会成为业务结果存储。Cron 可以写 zone = "Asia/Shanghai",也可以用属性占位符;将表达式配置为 - 可禁用相应 cron 声明。重复注解会创建独立安排,两个 Bean 实例也会各自注册,不能只看方法源代码有一份就推断只运行一份。Spring 调度文档列出了注解、cron、响应式方法及不同调度器的具体行为。
下载 Spring Scheduler 与 Quartz 实验工程,解压后进入含 pom.xml 和 compose.yaml 的目录。以下命令在 Linux Bash 中由有 Docker 使用权限的普通用户执行。Docker daemon 负责创建容器;一次性 Maven 容器映射宿主 UID/GID;后面的应用容器使用 10001,三者身份分别配置。
工程固定 Spring Framework 7.0.9、Quartz 2.5.2,编译目标 Java 17。Maven 3.9.12 的此镜像实际包含 Temurin 17.0.18+8;应用运行镜像另固定为 Temurin 17.0.20+8。不要把构建镜像标签中的主版本当成完整 JDK 构建号。
docker version
docker compose version
mkdir -p .m2
docker run --rm --memory 1g --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/.m2 -v "$PWD:/work" -w /work \
maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/work/.m2 clean verify.m2 和 target 要能由当前用户写入。若目录之前由 root 构建生成,先检查文件所有者,只修正这个实验目录的权限再重试,不要为省事让日常构建一直拥有宿主高权限。
构建完成后,七个测试分别检查注解注册、周期异常、完成后延迟、JobKey 并发和计划管理。测试日志里的预定失败是被断言的异常分支;最终应为 Tests run: 7, Failures: 0, Errors: 0。依赖下载失败时还没进入这些测试,先处理 Maven 仓库或企业代理。
启动三次 Spring 回调:
docker compose -p sched20 run --rm --entrypoint java lab \
-cp 'target/classes:target/dependency/*' example.scheduling.SpringLabSpringLab 使用如下配置;完整类与 import 位于解压工程的 src/main/java/example/scheduling/SpringLab.java。
@Configuration
@EnableScheduling
static class Config {
@Bean
ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(3);
return scheduler;
}
@Bean
Counter counter() { return new Counter(); }
}Counter 的 @Scheduled(initialDelay = 100, fixedDelay = 100) 方法递增计数,主线程等待三次回调后关闭 ApplicationContext:
tick=1 thread=scheduled-1
tick=2 thread=scheduled-1
tick=3 thread=scheduled-1
contextClosed=true线程编号可能变化,关键是回调在 scheduled- 工作线程中执行,计数至少到 3,随后上下文关闭。这里不启动 Web 服务器,也不需要访问 HTTP 端口。
企业内网可以预先在批准的联网构建机解析依赖,把完整 Maven 缓存与相同镜像导入隔离环境,再用 mvn -o 检查离线构建。镜像迁移使用 docker save、文件摘要校验和 docker load;仅有项目 JAR、缺少构建插件或传递依赖时,离线构建仍会失败。镜像、依赖私服与代理应使用组织维护的入口。
异常可能停止周期,也可能只失败这一轮
直接调用 JDK ScheduledThreadPoolExecutor.scheduleAtFixedRate,未捕获异常会使该周期 Future 异常完成,后续轮次被抑制。工程中的原生测试读取 future.get(),精确检查 EXPECTED_PERIODIC_FAILURE,然后确认调用次数为 1。
Spring ThreadPoolTaskScheduler 在任务外面增加错误处理。7.0.9 的默认重复任务处理器记录并抑制异常,因此下一轮仍可执行;默认一次性任务处理器则会继续传播异常。设置自定义 ErrorHandler 后,行为由该处理器是否重新抛出决定。这一分支来自 TaskUtils 的固定版本实现,不能把 JDK 原生周期异常规则直接套到所有 @Scheduled 方法。
工程让第一轮 Spring 回调抛出指定异常,再用闩锁等待第二轮运行。排障也要分两次观察:这一轮如何结束,下一轮是否出现。异常计数停止增长,可能是任务已经不再触发;回调持续出现,也可能每轮都以业务失败结束。
同步方法、响应式方法和异步委派还要分别处理:
- 同步回调返回后,该次执行结束;线程在调用期间被占用。
- 响应式
@Scheduled会按调度周期订阅 Publisher,错误处理和订阅取消走响应式路径。fixed-delay 需要等待这一轮订阅结束。 @Async或自行提交线程池会把业务工作移走。必须明确异步结果怎样被等待、记录和取消,调度线程的空闲程度不再反映业务并发数。
ThreadPoolTaskScheduler 适合需要明确线程池大小的同步任务。SimpleAsyncTaskScheduler 可配合 Java 21+ 虚拟线程,固定速率或 cron 触发能够启动独立执行线程;其 fixed-delay 使用单一调度线程的特点尤其需要考虑。选型应连同数据库连接数、外部接口并发和排队上限一起计算,而不是只把线程开得更多。
Quartz 把执行代码与计划分开保存
JobDetail、Trigger 与 Job 实例
Quartz 中,一个 Job 类可以服务很多独立任务定义。每次执行由 JobFactory 创建 Job 实例,再调用 execute;实例字段不适合保存跨执行进度。需要传给执行的参数放进 JobDataMap,需要可靠保存的业务结果放进业务存储。
Scheduler:调度器实例
├── JobDetail:lab.settlement
│ ├── JobKey:group + name
│ ├── jobClass:执行逻辑
│ ├── JobDataMap:任务参数
│ ├── durable:无 Trigger 时是否仍保留定义
│ └── requestsRecovery:执行节点故障后是否请求重执行
├── Trigger:何时运行某个 JobKey
│ ├── SimpleTrigger:间隔与次数
│ ├── CronTrigger:日历表达式和时区
│ └── Calendar:排除某些可执行时间
└── JobStore:保存定义、触发状态与执行记录JobDetail 和 Trigger 都能带 JobDataMap。context.getMergedJobDataMap() 读取合并后的参数,同名字段由 Trigger 覆盖。固定任务参数和单次触发参数因此可以分开配置。参数需要持久化时,优先保存字符串、业务标识与契约版本;把大型 Java 对象序列化进调度表,会使类名和序列化兼容性成为升级条件。Quartz Job 教程说明了这些对象与执行生命周期。
下面是创建周期 Trigger 的核心写法:
JobDetail job = JobBuilder.newJob(ReportJob.class)
.withIdentity("daily-report", "reports")
.usingJobData("tenant", "tenant-a")
.storeDurably()
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("report-clock", "reports")
.forJob(job)
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")
.inTimeZone(TimeZone.getTimeZone("Asia/Shanghai"))
.withMisfireHandlingInstructionDoNothing())
.build();
scheduler.scheduleJob(job, trigger);
scheduler.start();表达式使用 Quartz 语法,在指定时区的每天 2 点产生候选触发;DoNothing 选择在错过时跳到后续可触发时刻。若每一个漏掉的业务日都必须处理,还需要业务窗口补跑,不能依赖此 Trigger 自动补齐。Spring 与 Quartz 的 cron 字段差异、时区跳变及补跑安排见分片、Misfire 与时区。
并发限制以 JobKey 为单位
@DisallowConcurrentExecution 放在 Job 类上,但约束对象是该类对应的某个 JobDetail。两个 Trigger 指向同一个 JobKey 时会被串行化;两个不同 JobKey 即使使用同一个类,也可以并行执行。
QuartzConcurrencyTest 把线程池设为 2。第一轮进入后先用闩锁阻塞它,再触发同一个 JobKey,此时 TriggerState 为 BLOCKED。释放后两轮依次完成,峰值活动数为 1。换成两个不同 JobKey,两次执行都能进入,峰值便达到 2。
@PersistJobDataAfterExecution 用于执行后更新 JobDetail 的 JobDataMap。需要修改共享参数时通常与不并发注解配合,避免两个执行相互覆盖。它仍属于调度元数据,不能替代业务数据库的事务与唯一约束。
暂停、改计划与删除分别改变什么
这些 API 可以在同一个 Scheduler 上动态管理计划:
TriggerKey triggerKey = new TriggerKey("report-clock", "reports");
JobKey jobKey = new JobKey("daily-report", "reports");
scheduler.pauseTrigger(triggerKey);
Trigger.TriggerState state = scheduler.getTriggerState(triggerKey);
scheduler.resumeTrigger(triggerKey);
scheduler.rescheduleJob(triggerKey, replacementTrigger);
scheduler.unscheduleJob(triggerKey);
scheduler.deleteJob(jobKey);这是各操作的独立调用示例,不应把它们无条件连续执行。暂停阻止后续触发,不取消已经开始的 Job;恢复后可能触发 misfire 处理。改计划替换 Trigger,需明确保持哪个 JobKey 和参数版本。删除 Trigger 后,durable JobDetail 仍可存在;删除 Job 则移除定义及其关联 Trigger。
QuartzManagementTest 实际检查 NORMAL → PAUSED → NORMAL,读取替换后的 nextFireTime,移除 Trigger 后确认 durable Job 仍在,再删除 Job。在线配置平台应该持久保存变更人、原值和新值,避免应用启动配置与人工改动同时抢着覆盖同一条定义。
JDBCJobStore、进程重启与故障接管
哪些记录在数据库里
RAMJobStore 只保存当前进程内存中的计划。JDBCJobStore 将定义、时刻与执行状态存入关系数据库:
| 表 | 可观察内容 | 常见用途 |
|---|---|---|
QRTZ_JOB_DETAILS | Job 类、持久标记、并发限制、恢复标记和参数 | 判断任务定义是否存在及是否兼容当前代码 |
QRTZ_TRIGGERS | 下次/上次时刻、状态、优先级和类型 | 判断暂停、等待、迟到或错误 |
QRTZ_CRON_TRIGGERS | Cron 表达式与时区 | 核对日历计划 |
QRTZ_FIRED_TRIGGERS | 已领取/正在执行的触发及节点 | 定位执行归属和未完成记录 |
QRTZ_SCHEDULER_STATE | 节点心跳 | 参与失效节点检测 |
QRTZ_LOCKS | 数据库锁协调入口 | 分析领取竞争 |
JobStoreTX 自己管理调度存储事务:取得可触发记录、写入执行信息和完成后更新分别经过相应的事务过程。业务代码在 execute 中打开自己的连接时,业务提交与调度记录完成分属两次提交。
集群节点共享同一套表及 scheduler instanceName,各自使用唯一 instanceId,并全部开启 isClustered=true。普通触发由取得数据库锁的节点执行;节点执行途中失效时,其他节点根据持久记录处理恢复。标记 requestsRecovery 的 Job 会重新执行,未标记的 Job 则等待后续关联 Trigger。机器时钟必须同步;非集群实例不能与运行中的集群共用同一组表。Quartz 集群配置对这些前提有明确要求。
空数据库初始化与正常重启
仍在同一个解压目录执行:
docker compose -p sched20 up -d --wait postgres
docker compose -p sched20 ps
docker compose -p sched20 run --rm lab init
docker compose -p sched20 run --rm lab install
docker compose -p sched20 run --rm lab inspectCompose 的 PostgreSQL 18.6 使用独立卷,仅将 5432 发布到宿主 127.0.0.1:18220。健康检查通过容器 TCP 地址 127.0.0.1 连接,避免误把初始化时的临时 Unix socket 服务当作最终就绪。实验账号 scheduling 是此隔离实例的初始化管理账号,口令 local-lab-only 不能用于生产。
应用容器连接 postgres:5432,不是宿主发布端口。其默认变量是:
JDBC_URL=jdbc:postgresql://postgres:5432/scheduling
DB_USER=scheduling
DB_PASSWORD=local-lab-onlyinit 只接受空的 public schema,从 Quartz 依赖 JAR 获取 PostgreSQL 建表脚本,去掉其中 DROP 语句后建表。有表时拒绝执行,不清空已有计划。输出:
empty-schema initialized quartz=2.5.2
job=lab.settlement durable=true requestsRecovery=true
job=lab.settlement durable=true requestsRecovery=true后两行分别来自 install 和 inspect 两个进程:保存 durable Job,正常关闭 Scheduler,再连接数据库读回定义。此时没有执行中的 Job,观察到的是计划持久化。故障恢复需要在执行途中终止进程。
在业务提交后终止执行节点
现在让 Job 提交一条业务记录,然后阻塞在返回之前。命令只针对本地实验容器:
docker compose -p sched20 run -d --name sched20-crash \
-e LAB_MODE=crash lab fire
docker logs -f sched20-crash看到下面一行后用 Ctrl+C 退出日志跟随;Ctrl+C 不会停止这个后台容器:
BUSINESS_COMMITTED operation=settlement-window-A inserted=1 recovering=false该模式最多等待 120 秒,需在等待期间执行故障动作。先读取两边的状态:
docker compose -p sched20 exec -T postgres psql -U scheduling -d scheduling \
-c 'select * from lab_effect' \
-c 'select job_name,state,requests_recovery from qrtz_fired_triggers'业务表已有 settlement-window-A,执行表仍为 settlement / EXECUTING / t。随后终止这个容器,在另一应用进程中启动恢复:
docker kill --signal KILL sched20-crash
docker compose -p sched20 run --rm lab recover恢复需要等待失效检测。实验的 check-in 间隔为 1000 ms,命令最多等待 90 秒;这些是让本地实验容易观察的参数,不能直接拿来制定生产故障接管时限。预期的业务输出为:
BUSINESS_COMMITTED operation=settlement-window-A inserted=0 recovering=true
jobFinished recovering=true恢复进程实际调用了 Job。isRecovering=true 来自 Quartz;inserted=0 来自 PostgreSQL INSERT ... ON CONFLICT DO NOTHING 的影响行数。数据库唯一键吸收了对同一个 operation 的重复插入,Quartz 没有跳过这次执行。
最后再正常触发一次,并读取最终状态:
docker compose -p sched20 run --rm lab fire
docker compose -p sched20 exec -T postgres psql -U scheduling -d scheduling \
-c 'select count(*) as business_rows from lab_effect' \
-c 'select count(*) as executing_records from qrtz_fired_triggers'
docker rm sched20-crash正常再触发应显示 inserted=0 recovering=false,业务行数保持 1,执行记录最终为 0。正式任务应把 operation 定义为业务对象与窗口,例如“某租户的某个结算窗口”;如果把整条长期计划永远用一个键表示,后续合法周期也会被错误吸收。涉及多个业务写入和原结果返回时,使用任务幂等与重试中的事务设计。
重跑完整实验之前先保留需要的数据库观察。确认数据不再需要后,docker compose -p sched20 down -v 会删除本实验容器、网络及数据库卷;卷内数据无法凭容器恢复。不要用这个清理命令处理生产调度数据库。
接入 Spring Boot 与部署管理
Spring Boot 4.1.1 可通过 spring-boot-starter-quartz 创建 SchedulerFactoryBean,并关联 JobDetail、Trigger、Calendar Bean。配置 JDBC store 时应有可用 DataSource:
spring:
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: never
overwrite-existing-jobs: false
wait-for-jobs-to-complete-on-shutdown: true
properties:
org.quartz.scheduler.instanceName: billing-scheduler
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.isClustered: true
org.quartz.jobStore.useProperties: true
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
org.quartz.threadPool.threadCount: 4这是已有数据库和受控建表迁移的配置。Boot 会配置 Spring 的 JDBC JobStore 适配,不要再照搬独立 StdSchedulerFactory 实验的 jobStore.class 或连接提供器。用 @QuartzDataSource 分离调度连接池时,相应建表和事务管理也必须指向它;业务处理仍使用业务连接与事务。
生产启动时不要无条件设置 initialize-schema=always。Quartz 标准脚本包含删表,Boot 文档明确说明它可能在每次重启时删除 Trigger。计划修改也不应默认覆盖数据库中的已有定义。Boot Quartz 文档说明了初始化、独立 DataSource 和覆盖配置。
调度表运行账号通常只需要规定表的读写权限,建表与变更使用单独迁移身份。JobDataMap 保存引用而非数据库密码;详细结果、文件和凭据不要塞进 BLOB。使用 useProperties=true 时参数保存为字符串,需要数字时在处理器中校验并解析。JobStoreTX 配置提供表前缀、misfire、批量领取及数据存储方式的参数说明。
升级应先暂停会受影响的计划,记录旧 JobKey、TriggerKey、cron、zone 和参数版本,再在隔离数据库恢复备份、用新代码读取并运行代表任务。删掉或改名一个仍被持久 JobDetail 引用的类,会在加载/触发时失败;仅回滚应用镜像也恢复不了已经改变的数据库结构。保留旧类适配或完成受控迁移后,才恢复相应计划。
从不触发、迟到和重复找到具体环节
没有回调:先分注册失败与触发等待
Spring 先检查 Bean 是否进入 ApplicationContext、是否启用调度、属性表达式是否变成 -,再检查对应 TaskScheduler。若使用多个 ApplicationContext,确认任务属于哪个容器。启动失败或 Bean 根本不存在时,扩大线程池没有作用。
Quartz 先读 API 的 JobKey/TriggerKey 和 TriggerState,再检查数据库:
docker compose -p sched20 exec -T postgres psql -U scheduling -d scheduling \
-c 'select job_name,job_group,job_class_name from qrtz_job_details' \
-c 'select trigger_name,trigger_state,next_fire_time,misfire_instr from qrtz_triggers'没有 JobDetail 时检查创建计划是否成功提交;有 Job 但无 Trigger 时确认它是否只是 durable 定义;PAUSED 要查暂停来源;BLOCKED 要定位同 JobKey 的在途执行。nextFireTime 为空可能是有限 Trigger 已完成或后续再无匹配时刻,不能统称为调度线程故障。
这里的毫秒时间值属于当前运行数据。需要阅读日历时用 PostgreSQL to_timestamp(next_fire_time / 1000.0) 转换,并同时核对 QRTZ_CRON_TRIGGERS.TIME_ZONE_ID。运维展示采用哪个时区,应该与计划时区区分。
执行迟到:按等待位置观察
记录计划时刻、实际开始和结束,分别计算:
schedule_lag = actual_start - planned_time
run_duration = finished_time - actual_start若还有执行器内部队列,再单独记录入队和取出时间。一次运行的总耗时无法区分触发延迟和业务慢。
线程池只有一个线程且它阻塞在远程调用时,其他回调只能等待。在线程数较多但调度延迟仍增长时,检查数据库连接获取、锁等待和 CPU;运行容器内可用同 UID 的 jcmd <pid> Thread.print 找到工作线程正等待哪个对象。通过 docker stats --no-stream 检查容器 CPU、内存,数据库侧读取等待信息:
docker compose -p sched20 exec -T postgres psql -U scheduling -d scheduling \
-c "select pid,state,wait_event_type,wait_event,pg_blocking_pids(pid) from pg_stat_activity where datname='scheduling'"存在阻塞 PID 时先识别对应事务及 SQL,再决定终止或修复;不要因为调度迟到就直接清空锁表。若每秒到达的任务量长期超过工作线程和下游可完成量,增加调度节点可能放大数据库争用。先限制补跑并发、缩短单次工作和设置外部调用超时,再按完成吞吐调整容量。Quartz 主配置中的批量领取参数也需要与 JobStore 锁设置一起考虑。
Misfire 处理超过允许偏差的触发,应查 nextFireTime、阈值和对应指令。Recovery 处理节点故障后遗留的执行,应查 fired-trigger、节点心跳与恢复标记。日志分别记录这两类处理,才能区分停机后的追赶和故障重执行。
重复执行:先确定重复的是哪种对象
多个 Spring 实例分别注册同一方法、重复注解、手动触发加周期触发,都可能产生多个合法回调。Quartz 的不同 JobKey、独立 schedulerName 或不同数据库表前缀也属于不同计划,即使它们最终调用同一个类。
如果同一业务窗口在故障恢复后再次出现,先查询业务键的结果,再判断是否要重试。实验里的 inserted=0 recovering=true 就是已经提交后的恢复重放。给 Job 增加互斥注解不能消除“第一次已提交、随后才崩溃”的窗口。
排查重复时,日志应关联 JobKey、TriggerKey、Quartz fireInstanceId、isRecovering 和业务 operation。fireInstanceId 用来定位一次执行,业务键则跨重试保持稳定。这些高基数字段进入日志或 Trace;指标标签保留任务类型、结果等受控维度。
停机和停止任务
暂停 Trigger、Scheduler standby、shutdown(true) 的作用不同。standby 暂停该节点的调度,不会让其他集群节点也停止领取;shutdown(true) 等待本节点正在执行的 Job 完成,Job 卡住时等待也可能长期不结束。应用停机预算、网络超时与当前工作块的最长时间要一致。
中断仅是停止请求。处理器需要在允许的批次边界观察信号,完成或回滚当前事务,关闭资源后再记录停止。不要把 execute 中启动的后台 Future 留给 Scheduler 之外的线程,然后让 Quartz 提前认为任务完成。需要检查点、长时间暂停与跨重启继续执行时,使用长任务状态与取消中的批处理模型。
随应用启动和关闭、可从源数据重新计算的轻任务,可以采用进程内回调。计划需要动态修改、保存到数据库或使用日历排除规则时,Quartz 提供了相应对象,也支持满足集群条件后的故障接管。若还要跨应用集中停启、选择执行节点和查询历史,再考虑集中调度平台。
权威资料与规范地址
Spring 与 Java 调度
- Spring 调度接口、注解、Cron 与响应式执行:https://docs.spring.io/spring-framework/reference/integration/scheduling.html
- Spring 7.0.9 周期与一次任务错误处理:https://github.com/spring-projects/spring-framework/blob/v7.0.9/spring-context/src/main/java/org/springframework/scheduling/support/TaskUtils.java
- JDK ScheduledThreadPoolExecutor 周期语义:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ScheduledThreadPoolExecutor.html
- Spring Boot Quartz 自动配置与建表行为:https://docs.spring.io/spring-boot/reference/io/quartz.html
Quartz 对象与持久化
- JobDetail、JobDataMap、并发与恢复标记:https://www.quartz-scheduler.org/documentation/quartz-2.5.x/tutorials/tutorial-lesson-03.html
- JDBCJobStore 集群要求与故障恢复:https://www.quartz-scheduler.org/documentation/quartz-2.5.x/configuration/ConfigJDBCJobStoreClustering.html
- JobStoreTX 参数与数据存储:https://www.quartz-scheduler.org/documentation/quartz-2.5.x/configuration/ConfigJobStoreTX.html
- Scheduler 线程及批量领取参数:https://www.quartz-scheduler.org/documentation/quartz-2.5.x/configuration/ConfigMain.html
