3. 单线程执行器陷阱:默认线程池配置(SingleThreadScheduledExecutor)导致的阻塞问题

Spring Boot 的 @EnableScheduling 默认使用 SingleThreadScheduledExecutor 作为底层执行器。这意味着所有被 @Scheduled 注解的方法共享同一个后台线程。当其中一个任务发生阻塞(如网络 I/O、数据库慢查询、死循环)时,后续所有定时任务都会排队等待,导致调度延迟甚至完全停止。

3.1 默认执行器的工作原理

Spring 在初始化定时任务时,会创建一个 ThreadPoolTaskScheduler,其核心线程数默认为 1。该执行器内部包装了 java.util.concurrent.ScheduledThreadPoolExecutor,核心池大小固定为 1。所有定时任务提交到同一个工作队列,由唯一的工作线程顺序执行。

关键点: 默认配置下,定时任务之间没有隔离性。一个任务阻塞,全局阻塞。
默认单线程执行器调度模型 @Scheduled 任务 A @Scheduled 任务 B @Scheduled 任务 C 阻塞队列 (无界 LinkedBlockingQueue) 任务 B 等待 任务 C 等待 工作线程(唯一) 正在执行任务 A 任务 A 阻塞中...

3.2 阻塞问题的典型场景

场景 阻塞原因 影响范围
HTTP 远程调用超时 RestTemplate/WebClient 未设置超时,第三方服务挂起 所有定时任务延迟
数据库慢查询 SQL 执行超过调度间隔,锁等待 后续任务排队
文件 I/O 阻塞 大文件读写、网络文件系统挂载 全局调度停止
死循环或无限递归 代码逻辑缺陷,线程永远不释放 永久阻塞

3.3 通过日志验证单线程阻塞

要确认是否因单线程执行器导致阻塞,需要在日志中捕获以下关键信息:

  1. 任务开始/结束时间戳 — 确认任务是否按时完成。
  2. 线程名称 — 默认线程名称为 scheduling-1,所有任务应使用同一线程。
  3. 任务执行耗时 — 超过调度间隔的耗时任务会阻塞后续任务。

3.3.1 配置日志输出

在 application.yml 中开启定时任务相关日志:

logging:
  level:
    org.springframework.scheduling: DEBUG
    com.yourpackage.scheduler: DEBUG

3.3.2 在任务方法中添加日志

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class BlockingTaskDemo {

    @Scheduled(fixedRate = 5000) // 每5秒执行一次
    public void taskA() {
        long start = System.currentTimeMillis();
        String threadName = Thread.currentThread().getName();
        log.info("[{}] 任务A 开始执行", threadName);
        try {
            // 模拟阻塞操作,例如远程调用超时
            Thread.sleep(15000); // 阻塞15秒
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        long elapsed = System.currentTimeMillis() - start;
        log.info("[{}] 任务A 执行完毕,耗时 {} ms", threadName, elapsed);
    }

    @Scheduled(fixedRate = 5000)
    public void taskB() {
        long start = System.currentTimeMillis();
        String threadName = Thread.currentThread().getName();
        log.info("[{}] 任务B 开始执行", threadName);
        long elapsed = System.currentTimeMillis() - start;
        log.info("[{}] 任务B 执行完毕,耗时 {} ms", threadName, elapsed);
    }
}

3.3.3 观察日志输出

运行上述代码,日志输出将呈现明显的阻塞模式:

2025-01-15 10:00:00.001 [scheduling-1] INFO  - [scheduling-1] 任务A 开始执行
2025-01-15 10:00:05.002 [scheduling-1] INFO  - [scheduling-1] 任务B 开始执行  ← 本该5秒执行,但被阻塞
2025-01-15 10:00:15.003 [scheduling-1] INFO  - [scheduling-1] 任务A 执行完毕,耗时 15002 ms
2025-01-15 10:00:15.004 [scheduling-1] INFO  - [scheduling-1] 任务B 执行完毕,耗时 10002 ms  ← 任务B实际耗时10秒,但大部分时间在等待
阻塞特征: 所有任务使用同一线程 scheduling-1;任务B的调度时间点(10:00:05)已到,但实际开始执行时间被推迟到任务A释放线程之后;任务B的“执行耗时”远大于其实际业务逻辑耗时,大部分时间消耗在排队等待上。

3.4 验证阻塞的辅助手段

方法 操作 预期结果
线程 Dump jstack <pid> 查看线程状态 看到 scheduling-1 线程处于 RUNNABLE 或 TIMED_WAITING 状态,但队列中有等待任务
Actuator 端点 访问 /actuator/scheduledtasks 显示任务执行历史,观察 lastExecution 时间戳是否异常
自定义计数器 使用 AtomicInteger 记录任务实际执行次数 预期 5 秒执行一次,实际执行次数远低于预期

3.5 解决方案:配置自定义线程池

通过显式配置 ThreadPoolTaskScheduler,设置合理的核心线程数,避免单线程阻塞:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
public class SchedulerConfig {

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5); // 设置核心线程数为5
        scheduler.setThreadNamePrefix("sched-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return scheduler;
    }
}
建议: 核心线程数应根据定时任务数量、任务类型(CPU 密集型 vs I/O 密集型)以及任务执行耗时来设定。对于 I/O 密集型任务,可适当增加线程数;对于 CPU 密集型任务,线程数不宜超过 CPU 核心数。
自定义线程池调度模型(多线程) 任务 A 任务 B 任务 C 线程池(核心5线程) 线程-1 线程-2 线程-3 线程-4 线程-5 任务并行执行 任务A 在 线程-1 执行 任务B 在 线程-2 执行 任务C 在 线程-3 执行

3.6 小结

  • Spring Boot 默认使用 单线程执行器,所有 @Scheduled 任务共享一个线程。
  • 阻塞问题表现为:任务调度延迟、执行时间异常、线程名称始终为 scheduling-1。
  • 通过日志记录任务开始/结束时间、线程名称和执行耗时,可以快速定位阻塞。
  • 解决方案是自定义 ThreadPoolTaskScheduler,设置合适的线程池大小。