Java 基础体系 · 第 93/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Spring 调度任务:线程池、Cron、时区、防重入和分布式锁
Spring 调度任务解决的是“在什么时间、由哪个线程、以什么并发方式执行一段代码”。@Scheduled 看起来只是一个注解,但生产问题通常来自以下几层语义混在了一起:
- 调度器线程池决定任务是否会相互阻塞;
- Cron 表达式决定触发时刻,而不是业务执行时长;
- 时区决定 Cron 中的“几点”对应哪个实际时间;
- 防重入决定同一个业务任务是否允许并发执行;
- 分布式锁决定多个应用实例之间谁拥有执行权。
这几个概念必须分开。增加线程池大小只能改善调度线程之间的阻塞,不能自动实现分布式防重入;增加分布式锁也不能修正错误的 Cron 时区。
1. Spring 调度模型:触发器、调度器和任务
一次调度至少包含三个对象:
- 任务(task):真正执行的
Runnable或被代理调用的方法; - 触发器(trigger):决定何时提交任务,例如固定频率、固定延迟、Cron;
- 调度器(scheduler):根据触发器选择时间,并把任务交给执行线程。
可以把一次执行抽象为:
其中:
- 触发器只负责计算下一次触发时间;
- 调度器负责等待和提交;
- 工作线程负责执行方法;
- 数据库、HTTP 服务等外部资源不属于调度器,它们的阻塞时间会直接占用工作线程。
启用注解调度需要使用 @EnableScheduling:
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
一个最小任务如下:
package com.example.demo;
import java.time.Instant;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class HeartbeatJob {
@Scheduled(fixedDelay = 10_000)
public void run() {
System.out.println("run at " + Instant.now());
}
}
fixedDelay = 10_000 表示:一次执行结束后,再等待 10 秒开始下一次执行。它不是“每个自然时刻的第 0、10、20 秒执行”。
Spring 会在容器初始化过程中识别这些方法,并把它们注册到 TaskScheduler。因此,这些方法通常由 Spring 容器管理的 Bean 调用;把 @Scheduled 写在自行 new 出来的对象上不会自动生效。
2. 默认线程池与线程阻塞
2.1 默认单线程的后果
如果没有显式配置调度器,Spring 的常见默认行为是使用本地单线程调度器。单线程意味着同一调度器中的任务共享一个工作线程。
例如:
@Component
public class Jobs {
@Scheduled(fixedRate = 1_000)
public void slowJob() throws InterruptedException {
System.out.println("slow start " + System.currentTimeMillis());
Thread.sleep(5_000);
System.out.println("slow end " + System.currentTimeMillis());
}
@Scheduled(fixedRate = 1_000)
public void fastJob() {
System.out.println("fast " + System.currentTimeMillis());
}
}
虽然两个任务都声明为每秒触发,但 slowJob 每次占用线程约 5 秒,fastJob 只能等待。结果不是两个任务都每秒执行,而是它们在一个执行序列中排队。
这不是 Cron 计算错误,而是调度执行资源不足。
2.2 使用 ThreadPoolTaskScheduler
可以显式声明调度器:
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4);
scheduler.setThreadNamePrefix("schedule-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setErrorHandler(throwable ->
System.err.println("scheduled task failed: " + throwable));
scheduler.initialize();
return scheduler;
}
}
这里的 poolSize = 4 表示最多有四个调度执行线程。它能让四个阻塞任务同时运行,但不代表:
- 一个任务一定不会重入;
- 数据库连接池也有四个以上连接;
- HTTP 客户端连接池足够;
- 多个应用实例之间不会重复执行。
线程池大小必须和任务的阻塞特征、调用下游的容量共同考虑。粗略地说,对于以等待为主的任务,可以用 Little 定律估算并发需求:
其中:
- 是平均并发执行数;
- 是每秒开始的任务数;
- 是一次任务平均占用线程的秒数。
例如任务每 10 秒触发一次,平均执行 3 秒,则:
这只说明平均并发不高,不能直接把线程池设置为 1。若任务偶尔执行 60 秒、下游连接池只有 2 个,线程池过大反而会制造更多下游压力。
2.3 Spring Boot 配置方式
在 Spring Boot 中,可以使用自动配置提供的调度线程池:
spring:
task:
scheduling:
pool:
size: 4
thread-name-prefix: schedule-
shutdown:
await-termination: true
await-termination-period: 30s
这依赖对应 Spring Boot 版本提供的配置属性。实际项目应以所使用版本的 Spring Boot Reference 和配置元数据为准,因为属性名称和可配置项属于 Boot,而不是 Spring Framework 核心 API。
如果同时自定义了 TaskScheduler Bean,要确认它是否替代了 Boot 的自动配置,避免“修改了配置但实际使用的是另一个调度器”。
3. fixedRate、fixedDelay 和 Cron
3.1 fixedRate:按开始时间计算间隔
@Scheduled(fixedRate = 60_000)
public void collectMetrics() {
// 每 60 秒尝试触发一次
}
固定频率的直觉是:
其中 是周期,planned 表示计划触发时间。
假设任务在 00:00:00 开始,执行 8 秒,周期为 10 秒,那么计划时刻可能是:
00:00:00 开始,00:00:08 结束
00:00:10 下一次计划触发
00:00:20 再下一次计划触发
它适合周期性采样、刷新缓存等“基于计划时间”的任务。
3.2 fixedDelay:按完成时间计算间隔
@Scheduled(fixedDelay = 60_000)
public void processQueue() {
// 本次结束后等待 60 秒
}
固定延迟的计算方式是:
若任务在 00:00:00 开始,00:00:08 结束,延迟为 10 秒,则下一次在 00:00:18 开始。
它适合轮询队列、扫描数据库等需要“上一轮完成后再等待”的任务。对于这类任务,执行耗时增加会自然推迟下一轮,不会持续追赶固定的自然时间点。
3.3 Cron:按日历时间触发
Spring 的 Cron 表达式通常使用六个字段:
秒 分 时 日 月 星期
例如:
@Scheduled(cron = "0 */5 * * * *")
public void everyFiveMinutes() {
// 每小时的 00、05、10、15……分执行
}
表达式逐字段解释如下:
0 秒:第 0 秒
*/5 分:每 5 分钟
* 时:每个小时
* 日:每天
* 月:每月
* 星期:每星期
与某些 Linux crontab 实现相比,Spring Cron 多一个“秒”字段。把 Linux 的五字段表达式直接复制到 @Scheduled(cron = "..."),通常会导致解析失败或语义错误。
也可以使用宏提高可读性:
@Scheduled(cron = "@daily")
public void dailyJob() {
}
复杂的日期规则可以使用 L、W、# 等 Cron 语法,但必须以当前 Spring Framework 版本支持的 CronExpression 规则为准。不要把不同调度器的 Cron 方言混用。
3.4 Cron 不是补偿机制
Cron 只计算“当前时间之后的触发点”。例如任务配置为每天 02:00:
- 应用在 01:50 启动,通常会等待当天 02:00;
- 应用在 03:00 启动,通常会等待第二天 02:00;
- 应用在 02:00 期间宕机,恢复后通常不会自动补跑错过的那次。
因此,“每天执行一次”有两种不同需求:
- 触发型需求:每天 02:00 尝试执行,错过不补;
- 业务完整性需求:每个业务日期都必须处理一次,允许宕机后补偿。
第二种不能只依靠 @Scheduled(cron = ...),应把业务日期写入任务表或业务状态表,按日期查询未完成记录并补偿。
4. 时区:Cron 中的时间到底是哪一个时间
机器上的时间不是只有一个概念:
Instant表示全球统一的时间线上的一个瞬间;LocalDateTime只是一个没有时区的日期和时间;ZonedDateTime表示“日期时间 + 时区规则”;- Cron 的“每天 02:00”必须结合时区才能转换成
Instant。
例如:
@Scheduled(
cron = "0 0 2 * * *",
zone = "Asia/Shanghai"
)
public void dailySettlement() {
}
这里的含义是:以 Asia/Shanghai 时区计算每天 02:00,而不是使用 JVM 默认时区。
如果省略 zone,实际行为依赖调度器和运行环境使用的默认时区。开发机可能是中国时区,容器镜像或云主机可能是 UTC,于是同一段代码在不同环境触发时刻不同。
可以在启动时打印环境信息:
import java.time.ZoneId;
import java.time.ZonedDateTime;
System.out.println("default zone = " + ZoneId.systemDefault());
System.out.println("now = " + ZonedDateTime.now());
生产环境更稳妥的做法是:
- Cron 任务显式指定 IANA 时区,例如
Asia/Shanghai、UTC; - 业务日志记录
Instant; - 数据库存储事件时间时优先使用带时区语义的类型或 UTC;
- 不要依靠服务器的隐含默认时区表达业务规则。
4.1 夏令时和不存在的本地时间
某些时区存在夏令时切换。例如切换当天,某个本地时间可能不存在,或者某个本地时间出现两次。
因此:
每天 02:30
不一定对应每天恰好一个真实瞬间。遇到时区切换时,调度器需要按照时区规则解析该时间,具体触发行为应以当前 Spring Framework 和 Java Time 的实现规则为准。不能把所有地区都按照固定 UTC 偏移理解。
如果业务要求“每隔 24 小时执行一次”,应使用 fixedRate 或基于 Instant 的时间逻辑;如果业务要求“每个当地自然日的凌晨执行”,才使用带明确 zone 的 Cron。
5. 防重入:同一个任务为什么会重复并发执行
防重入是指:在一次任务尚未完成时,不允许另一次相同业务执行进入关键代码。
设任务执行区间为:
若存在:
则两次执行发生重叠,这就是重入或并发执行。
5.1 单线程不是可靠的防重入方案
单线程调度器可以让同一调度器中的任务串行执行,但它只是一个进程内调度事实,不是业务约束:
- 配置线程池为多个线程后,串行假设消失;
- 使用多个
@Scheduled注解可能产生多个触发源; - 应用启动两个实例时,每个实例都有自己的单线程;
- 其他入口(HTTP、消息监听、手工重跑)仍可调用同一业务方法。
所以,“目前只有一个调度线程”不能作为防重入设计。
5.2 多个触发器会产生多个执行流
下面的写法可能产生两个独立的调度注册:
@Scheduled(cron = "0 0 * * * *")
@Scheduled(cron = "0 30 * * * *")
public void reconcile() {
}
它们分别代表两个触发器。若某次执行时间较长,两个触发器可能在时间上重叠。即使某个具体调度器实现让同一周期任务不并发,也不能把这种行为当作跨实现、跨实例的业务保证。
更清晰的方式是只保留一个调度入口,把不同触发原因统一到一个明确的协调机制中。
5.3 @Async 会改变执行边界
@Async
@Scheduled(fixedRate = 1_000)
public void asyncJob() {
// 实际代码被提交到异步执行器
}
此时调度线程只负责调用并快速返回,真正工作在线程池中运行。若异步线程池中已有上一轮任务,下一轮仍可能被提交,于是:
调度线程:提交 A -> 返回 -> 提交 B -> 返回
异步线程:A 尚未完成,B 已开始
这不是错误,但必须明确承认它引入了并发。@Async 的线程池、队列、拒绝策略和异常处理也要单独配置;调度线程池大小不能代表异步执行池大小。
5.4 进程内互斥锁的边界
对于单实例应用,可以使用一个锁保护关键区:
private final ReentrantLock lock = new ReentrantLock();
@Scheduled(fixedRate = 10_000)
public void job() {
if (!lock.tryLock()) {
return;
}
try {
doBusiness();
} finally {
lock.unlock();
}
}
tryLock() 的语义是拿不到锁就跳过本轮,而不是等待。它能防止同一个 JVM 内的两个执行流同时进入,但无法阻止另一台机器上的实例执行。
synchronized 也只有相同的 JVM 内边界,而且锁对象必须确实是同一个对象;把锁对象创建在方法内部会完全失效:
public void wrong() {
synchronized (new Object()) {
// 每次都是新对象,无法互斥
}
}
6. 分布式锁:把执行权放到共享存储中
当应用部署为多个实例时,必须使用所有实例都能访问的共享协调点,例如数据库、Redis 或 ZooKeeper。分布式锁的核心不是“有一个 lock 字符串”,而是以下状态转换:
不存在/已过期
|
| 原子抢占
v
当前实例持有
|
| 续租或执行完成释放
v
不存在/已过期
一个正确的锁至少需要:
- 唯一锁名;
- 持有者标识(owner token);
- 获取时间或过期时间;
- 原子获取;
- 只有持有者才能释放;
- 过期恢复机制;
- 任务执行失败时的处理策略。
6.1 数据库锁表示例
可以建立如下表:
CREATE TABLE job_lock (
lock_name VARCHAR(128) PRIMARY KEY,
owner_token VARCHAR(128) NOT NULL,
locked_until TIMESTAMP NOT NULL
);
获取锁的条件是:
锁不存在,或者 locked_until <= 当前时间
关键在于“判断并更新”必须是一个原子操作,不能写成两个独立 SQL:
-- 错误思路
SELECT locked_until FROM job_lock WHERE lock_name = ?;
-- 应用层判断后再 UPDATE
UPDATE job_lock SET ... WHERE lock_name = ?;
两个实例可能同时执行 SELECT,都认为锁已过期,然后先后覆盖对方。
一种基于数据库时间的更新语句可以写成:
UPDATE job_lock
SET owner_token = ?,
locked_until = CURRENT_TIMESTAMP + INTERVAL '5 minutes'
WHERE lock_name = ?
AND locked_until <= CURRENT_TIMESTAMP;
若数据库返回更新行数为 1,表示抢锁成功;返回 0 表示当前锁仍由其他实例持有。不同数据库的时间间隔语法不同,上面的 INTERVAL 不是通用 SQL,必须按具体数据库改写。
插入初始记录时,还要处理并发插入。常见做法是先通过迁移脚本预置锁记录,避免第一次抢锁时发生插入竞争:
INSERT INTO job_lock(lock_name, owner_token, locked_until)
VALUES ('daily-settlement', '', TIMESTAMP '1970-01-01 00:00:00');
Java 侧可以封装成:
package com.example.demo;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class DbJobLock {
private final JdbcTemplate jdbcTemplate;
public DbJobLock(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public boolean tryAcquire(String lockName, String ownerToken) {
int updated = jdbcTemplate.update("""
UPDATE job_lock
SET owner_token = ?,
locked_until = CURRENT_TIMESTAMP + INTERVAL '5 minutes'
WHERE lock_name = ?
AND locked_until <= CURRENT_TIMESTAMP
""", ownerToken, lockName);
return updated == 1;
}
@Transactional
public boolean release(String lockName, String ownerToken) {
int updated = jdbcTemplate.update("""
UPDATE job_lock
SET owner_token = '',
locked_until = CURRENT_TIMESTAMP
WHERE lock_name = ?
AND owner_token = ?
""", lockName, ownerToken);
return updated == 1;
}
}
调度任务使用它:
@Component
public class SettlementJob {
private final DbJobLock lock;
public SettlementJob(DbJobLock lock) {
this.lock = lock;
}
@Scheduled(
cron = "0 0 2 * * *",
zone = "Asia/Shanghai"
)
public void run() {
String owner = java.util.UUID.randomUUID().toString();
if (!lock.tryAcquire("daily-settlement", owner)) {
return;
}
try {
doSettlement();
} finally {
lock.release("daily-settlement", owner);
}
}
private void doSettlement() {
// 业务处理
}
}
这段示例的并发过程如下:
实例 A:UPDATE 影响 1 行 -> 获得锁 -> 执行业务
实例 B:UPDATE 影响 0 行 -> 跳过本轮
实例 A:owner_token 匹配 -> 释放锁
owner_token 不能省略。若实例 A 的锁过期后实例 B 获得了同名锁,实例 A 迟到执行 release,没有 owner 校验就可能把 B 的锁释放掉。
6.2 租约、超时和“锁已失效但任务仍在运行”
锁过期时间是租约,不是任务完成时间。假设:
锁租约:5 分钟
任务实际运行:8 分钟
第 5 分钟后,另一个实例可以获得锁,而第一个实例仍在执行,于是两个实例再次并发。
有三种处理方式:
- 把租约设置得明显大于正常最长执行时间;
- 任务执行过程中续租;
- 让业务操作本身具备幂等性,并接受极端情况下的并发。
续租也有故障路径:
实例 A 获锁
实例 A 暂停或网络隔离
续租失败
锁过期
实例 B 获锁并开始执行
实例 A 恢复后继续执行
因此,分布式锁只能降低并发概率或协调正常执行,不能单独证明“旧持有者绝不再继续写数据”。对高风险操作,还需要:
- 数据库唯一约束;
- 幂等业务键;
- 状态机条件更新;
- 版本号或 fencing token;
- 对外部系统的幂等请求。
6.3 使用成熟锁组件
生产项目通常会使用 ShedLock 等专门组件,通过 JDBC、Redis 等存储实现“同一任务在多个实例中只允许一个实例执行”的常见场景。使用此类库时必须确认:
- 锁存储是否真的被所有实例共享;
lockAtMostFor是否覆盖最坏执行时间;lockAtLeastFor是否只是控制最短持有时间,而不是业务幂等;- 时钟、数据库事务和连接异常如何处理;
- 锁失效后旧实例继续执行是否会造成损坏。
这类库解决的是调度层协调,不会自动让任务具备幂等性,也不会自动补偿宕机期间错过的业务日期。
7. 调度任务的完整生命周期和故障路径
一个带 Cron 和分布式锁的任务可以抽象为:
sequenceDiagram
participant S as Spring Scheduler
participant A as 实例 A
participant B as 实例 B
participant D as 共享锁存储
participant DB as 业务数据库
S->>A: 到达 Cron 触发时刻
S->>B: 到达 Cron 触发时刻
A->>D: 原子尝试获取锁
B->>D: 原子尝试获取锁
D-->>A: 成功
D-->>B: 失败
A->>DB: 执行业务写入
DB-->>A: 成功或失败
A->>D: 持有者校验后释放锁
正常路径是:
- 触发器根据指定时区计算触发时刻;
- 调度器把方法提交给某个工作线程;
- 实例向共享存储原子抢锁;
- 抢锁成功的实例执行任务;
- 业务提交成功后释放锁;
- 未抢到锁的实例跳过本轮。
常见故障路径包括:
调度线程被阻塞
表现为多个任务同时延迟,线程转储中调度线程停在 JDBC、HTTP 或锁等待上。解决方向是增加调度线程或把长耗时工作转移到独立执行器,但后者必须重新设计防重入和队列容量。
任务异常
调度方法抛出未处理异常时,异常会交给调度器的 ErrorHandler。不同任务类型和 Spring 版本的后续行为不能靠猜测,生产中应显式配置错误处理并记录:
- 任务名称;
- 计划触发时间;
- 实际开始和结束时间;
- 实例标识;
- 锁获取结果;
- 异常堆栈。
尤其要避免在 finally 之前抛出导致锁不释放;即使使用 finally,租约仍可能在业务执行期间自然过期。
应用关闭
配置优雅关闭后,调度器可以等待正在运行的任务完成。但等待并不等于无限等待:
- 等待时间耗尽后,进程可能被强制终止;
- 外部请求可能没有超时,导致关闭迟迟不结束;
- 分布式锁可能等待自然过期。
任务调用的数据库、HTTP、消息客户端都应配置明确超时,并让业务能够在中断或关闭信号下安全退出。
8. 任务设计:调度入口与业务逻辑分离
不建议把所有业务直接写在 @Scheduled 方法中。更清晰的结构是:
@Component
public class InvoiceSchedule {
private final InvoiceService invoiceService;
public InvoiceSchedule(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
@Scheduled(
cron = "0 */10 * * * *",
zone = "UTC"
)
public void trigger() {
invoiceService.processPendingInvoices();
}
}
调度入口只负责:
- 说明触发规则;
- 获取分布式锁;
- 设置任务上下文;
- 调用业务服务;
- 记录执行结果。
业务服务则应能被调度、HTTP、消息或手工补偿共同调用。这样可以把“是否到点”与“业务如何处理”分开。
如果任务处理的是可重试数据,业务状态应支持类似转换:
PENDING -> PROCESSING -> SUCCESS
|
v
FAILED
更新状态时使用条件:
UPDATE invoice
SET status = 'PROCESSING'
WHERE id = ?
AND status = 'PENDING';
检查影响行数为 1 才表示当前执行者成功取得这条业务记录。这样即使分布式锁失效,数据库条件更新仍能提供第二层并发保护。
9. 可验证的示例与诊断方法
9.1 验证线程池是否生效
在任务中记录线程名:
@Scheduled(fixedRate = 1_000)
public void inspectThread() {
System.out.printf(
"time=%s thread=%s%n",
java.time.Instant.now(),
Thread.currentThread().getName()
);
}
预期输出应包含:
thread=schedule-1
thread=schedule-2
如果仍然只有默认线程名,可能是:
- 自定义的
TaskScheduler没被实际使用; - 任务注册到了另一个调度器;
- Boot 配置没有加载;
- 应用中存在多个调度器而任务选择不明确。
9.2 验证 Cron 时区
不要只在本地等待任务触发。可以先将 Cron 设置为接近当前时间的分钟,并记录:
System.out.println(java.time.ZonedDateTime.now(
java.time.ZoneId.of("Asia/Shanghai")
));
同时查看容器环境变量、JVM 默认时区和任务配置。测试时应覆盖:
- UTC 机器;
- 非 UTC 默认时区机器;
- 夏令时地区;
- 应用重启发生在触发点前后。
9.3 验证分布式锁
启动两个应用实例,让它们连接同一个锁表,并在业务中打印:
instance=pod-a acquired=true
instance=pod-b acquired=false
然后人为暂停持锁实例,观察:
- 锁是否在租约到期后可被其他实例获取;
- 原实例恢复后是否仍会继续执行;
- 原实例的释放动作是否会误删新实例的锁;
- 业务数据库是否有幂等约束防止重复写入。
只验证“两个实例同时启动时一个成功”是不够的,因为真正危险的情况往往发生在网络隔离、长 GC、数据库连接中断和进程被强制终止之后。
10. 常见误解和边界
误解一:fixedRate 就是绝对每隔固定时间执行。
它描述计划触发规律,实际开始时间还受线程池、任务执行、应用暂停和调度器状态影响。
误解二:线程池大小为 1 就天然防重入。
它最多提供某个 JVM、某个调度器内的串行执行,不能覆盖多实例、异步入口和手工触发。
误解三:Cron 表达式中的 2 点就是服务器的 2 点。
只有在时区明确且环境一致时,这句话才成立。业务时区应通过 zone 明确表达。
误解四:分布式锁保证任务只执行一次。
它通常只能协调“某个时间窗口内由哪个实例执行”。锁过期、实例暂停、网络隔离或重试都可能导致业务执行两次,因此业务仍需幂等。
误解五:任务失败后 Cron 会自动补跑。
Cron 通常只负责未来触发,不负责业务状态补偿。需要补偿时,应持久化业务日期、执行状态和失败原因。
误解六:Java 25 LTS 自带调度能力。
Java 25 提供平台线程、虚拟线程和并发基础设施,但 @Scheduled、TaskScheduler、Cron 解析和 Spring 生命周期属于 Spring 体系。使用 Java 25 LTS 时,还必须选择明确支持该 JDK 的 Spring Framework、Spring Boot 和第三方锁组件版本;Java 版本本身不会改变 Spring 的调度语义。
11. 选择方式的判断依据
可以按业务语义选择:
- 任务依赖“上一次完成后再等一段时间”:使用
fixedDelay; - 任务依赖固定周期采样:使用
fixedRate; - 任务依赖自然日、工作日或某个当地时间:使用带明确
zone的 Cron; - 单实例内部不允许并发:可使用进程内锁,但要明确部署边界;
- 多实例只能有一个执行者:使用共享存储实现的分布式锁;
- 错过执行必须补偿:持久化业务计划和状态,不能只依赖 Cron;
- 即使出现重复执行也不能损坏数据:使用幂等键、条件更新、唯一约束或 fencing token。
最终,一个可靠的 Spring 调度任务不是“加上 @Scheduled”这么简单,而是要同时回答四个问题:
- 触发时间按哪个时区计算;
- 任务执行需要多少线程和下游容量;
- 同一 JVM 以及多个实例是否允许重叠;
- 任务失败、锁过期、应用重启和错过触发后,业务状态如何恢复。
只有这四个问题都有明确答案,调度代码才真正具备可预测性。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring Cache:抽象、Key、TTL、穿透、一致性和多级缓存
- 下一篇:Spring Batch:Job、Step、Chunk、Checkpoint、重启和幂等
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论