Java 基础体系 · 第 93/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring 调度任务:线程池、Cron、时区、防重入和分布式锁

Spring 调度任务解决的是“在什么时间、由哪个线程、以什么并发方式执行一段代码”。@Scheduled 看起来只是一个注解,但生产问题通常来自以下几层语义混在了一起:

  • 调度器线程池决定任务是否会相互阻塞;
  • Cron 表达式决定触发时刻,而不是业务执行时长;
  • 时区决定 Cron 中的“几点”对应哪个实际时间;
  • 防重入决定同一个业务任务是否允许并发执行;
  • 分布式锁决定多个应用实例之间谁拥有执行权。

这几个概念必须分开。增加线程池大小只能改善调度线程之间的阻塞,不能自动实现分布式防重入;增加分布式锁也不能修正错误的 Cron 时区。

1. Spring 调度模型:触发器、调度器和任务

一次调度至少包含三个对象:

  1. 任务(task):真正执行的 Runnable 或被代理调用的方法;
  2. 触发器(trigger):决定何时提交任务,例如固定频率、固定延迟、Cron;
  3. 调度器(scheduler):根据触发器选择时间,并把任务交给执行线程。

可以把一次执行抽象为:

triggerschedulerworker threadtask\text{trigger} \rightarrow \text{scheduler} \rightarrow \text{worker thread} \rightarrow \text{task}

其中:

  • 触发器只负责计算下一次触发时间;
  • 调度器负责等待和提交;
  • 工作线程负责执行方法;
  • 数据库、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 定律估算并发需求:

LλWL \approx \lambda W

其中:

  • LL 是平均并发执行数;
  • λ\lambda 是每秒开始的任务数;
  • WW 是一次任务平均占用线程的秒数。

例如任务每 10 秒触发一次,平均执行 3 秒,则:

λ=0.1,W=3,L0.3\lambda = 0.1,\quad W = 3,\quad L \approx 0.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. fixedRatefixedDelay 和 Cron

3.1 fixedRate:按开始时间计算间隔

@Scheduled(fixedRate = 60_000)
public void collectMetrics() {
    // 每 60 秒尝试触发一次
}

固定频率的直觉是:

tn+1planned=tnplanned+Pt_{n+1}^{planned} = t_n^{planned} + P

其中 PP 是周期,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 秒
}

固定延迟的计算方式是:

tn+1=tnfinish+Dt_{n+1} = t_n^{finish} + D

若任务在 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() {
}

复杂的日期规则可以使用 LW# 等 Cron 语法,但必须以当前 Spring Framework 版本支持的 CronExpression 规则为准。不要把不同调度器的 Cron 方言混用。

3.4 Cron 不是补偿机制

Cron 只计算“当前时间之后的触发点”。例如任务配置为每天 02:00:

  • 应用在 01:50 启动,通常会等待当天 02:00;
  • 应用在 03:00 启动,通常会等待第二天 02:00;
  • 应用在 02:00 期间宕机,恢复后通常不会自动补跑错过的那次。

因此,“每天执行一次”有两种不同需求:

  1. 触发型需求:每天 02:00 尝试执行,错过不补;
  2. 业务完整性需求:每个业务日期都必须处理一次,允许宕机后补偿。

第二种不能只依靠 @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/ShanghaiUTC
  • 业务日志记录 Instant
  • 数据库存储事件时间时优先使用带时区语义的类型或 UTC;
  • 不要依靠服务器的隐含默认时区表达业务规则。

4.1 夏令时和不存在的本地时间

某些时区存在夏令时切换。例如切换当天,某个本地时间可能不存在,或者某个本地时间出现两次。

因此:

每天 02:30

不一定对应每天恰好一个真实瞬间。遇到时区切换时,调度器需要按照时区规则解析该时间,具体触发行为应以当前 Spring Framework 和 Java Time 的实现规则为准。不能把所有地区都按照固定 UTC 偏移理解。

如果业务要求“每隔 24 小时执行一次”,应使用 fixedRate 或基于 Instant 的时间逻辑;如果业务要求“每个当地自然日的凌晨执行”,才使用带明确 zone 的 Cron。

5. 防重入:同一个任务为什么会重复并发执行

防重入是指:在一次任务尚未完成时,不允许另一次相同业务执行进入关键代码。

设任务执行区间为:

En=[sn,fn)E_n = [s_n, f_n)

若存在:

sn+1<fns_{n+1} < f_n

则两次执行发生重叠,这就是重入或并发执行。

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 分钟后,另一个实例可以获得锁,而第一个实例仍在执行,于是两个实例再次并发。

有三种处理方式:

  1. 把租约设置得明显大于正常最长执行时间;
  2. 任务执行过程中续租;
  3. 让业务操作本身具备幂等性,并接受极端情况下的并发。

续租也有故障路径:

实例 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: 持有者校验后释放锁

正常路径是:

  1. 触发器根据指定时区计算触发时刻;
  2. 调度器把方法提交给某个工作线程;
  3. 实例向共享存储原子抢锁;
  4. 抢锁成功的实例执行任务;
  5. 业务提交成功后释放锁;
  6. 未抢到锁的实例跳过本轮。

常见故障路径包括:

调度线程被阻塞

表现为多个任务同时延迟,线程转储中调度线程停在 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

然后人为暂停持锁实例,观察:

  1. 锁是否在租约到期后可被其他实例获取;
  2. 原实例恢复后是否仍会继续执行;
  3. 原实例的释放动作是否会误删新实例的锁;
  4. 业务数据库是否有幂等约束防止重复写入。

只验证“两个实例同时启动时一个成功”是不够的,因为真正危险的情况往往发生在网络隔离、长 GC、数据库连接中断和进程被强制终止之后。

10. 常见误解和边界

误解一:fixedRate 就是绝对每隔固定时间执行。
它描述计划触发规律,实际开始时间还受线程池、任务执行、应用暂停和调度器状态影响。

误解二:线程池大小为 1 就天然防重入。
它最多提供某个 JVM、某个调度器内的串行执行,不能覆盖多实例、异步入口和手工触发。

误解三:Cron 表达式中的 2 点就是服务器的 2 点。
只有在时区明确且环境一致时,这句话才成立。业务时区应通过 zone 明确表达。

误解四:分布式锁保证任务只执行一次。
它通常只能协调“某个时间窗口内由哪个实例执行”。锁过期、实例暂停、网络隔离或重试都可能导致业务执行两次,因此业务仍需幂等。

误解五:任务失败后 Cron 会自动补跑。
Cron 通常只负责未来触发,不负责业务状态补偿。需要补偿时,应持久化业务日期、执行状态和失败原因。

误解六:Java 25 LTS 自带调度能力。
Java 25 提供平台线程、虚拟线程和并发基础设施,但 @ScheduledTaskScheduler、Cron 解析和 Spring 生命周期属于 Spring 体系。使用 Java 25 LTS 时,还必须选择明确支持该 JDK 的 Spring Framework、Spring Boot 和第三方锁组件版本;Java 版本本身不会改变 Spring 的调度语义。

11. 选择方式的判断依据

可以按业务语义选择:

  • 任务依赖“上一次完成后再等一段时间”:使用 fixedDelay
  • 任务依赖固定周期采样:使用 fixedRate
  • 任务依赖自然日、工作日或某个当地时间:使用带明确 zone 的 Cron;
  • 单实例内部不允许并发:可使用进程内锁,但要明确部署边界;
  • 多实例只能有一个执行者:使用共享存储实现的分布式锁;
  • 错过执行必须补偿:持久化业务计划和状态,不能只依赖 Cron;
  • 即使出现重复执行也不能损坏数据:使用幂等键、条件更新、唯一约束或 fencing token。

最终,一个可靠的 Spring 调度任务不是“加上 @Scheduled”这么简单,而是要同时回答四个问题:

  1. 触发时间按哪个时区计算;
  2. 任务执行需要多少线程和下游容量;
  3. 同一 JVM 以及多个实例是否允许重叠;
  4. 任务失败、锁过期、应用重启和错过触发后,业务状态如何恢复。

只有这四个问题都有明确答案,调度代码才真正具备可预测性。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。