场景
在 Spring Boot 应用中,@Scheduled 是实现定时任务的利器,但你是否知道,默认情况下所有 @Scheduled 方法都挤在同一条线程里?
一旦某个任务长时间阻塞,其他任务就会"饿死"。
本文结合摄像头重连的实际场景,拆解这个经典问题,并给出多种解决方案。
问题场景复现
某系统需要同时做两件事:
MODBUS 数据采集(getData),每 5 秒执行一次,将实时数据写入 Redis,TTL 设为 5 秒;
海康摄像头心跳检测与断线重连(heartbeatCheck),每 30 秒检测一次,若发现设备离线则自动重连,
重连内部有 3 次重试,每次间隔 10 秒,最长可能阻塞 30 秒。
当某台摄像头真的断开时,heartbeatCheck 中的重连逻辑会占据调度线程长达 30 秒。
在此期间,getData 无法执行,Redis 数据在 5 秒后过期,下游服务立刻认为设备离线,引发一连串误报。
即使摄像头很快恢复,也要等重连结束,MODBUS 数据才能重新上线,故障被无谓放大。
这就是 Spring @Scheduled 默认单线程导致的典型"任务阻塞风暴"。
根因:单线程的 TaskScheduler
Spring Boot 在没有自定义 TaskScheduler 时,会自动创建 ThreadPoolTaskScheduler,
其内部线程池核心线程数默认为 1(源码见 TaskSchedulingAutoConfiguration)。
所有 @Scheduled 方法都提交到这个唯一的线程上执行,一次只能跑一个任务。
其调度模型类似:
Trigger Time ──> 放入队列 ──> 单线程取出执行
当某个任务耗时很长时,后续任务即使时间已到,也只能在队列中等待。
尤为隐蔽的是,Java 的 ScheduledThreadPoolExecutor 对同一个定时任务(fixedRate/fixedDelay)的多次触发不会为它们创建新线程,
即使线程空闲(其实根本不会空闲),任务也会排队。
所以增大 poolSize 能让不同任务并行,却无法让同一个阻塞任务自己和自己并行(这往往是期望的行为,避免数据混乱)。
我们的重连为什么会阻塞 30 秒?
重连逻辑 reconnectDevice 的大致结构如下(简化示意):
private void reconnectDevice(String ip) {
int retryCount = 0;
while (retryCount < 3) {
// 各种 SDK 登录、布防操作 ...
if (成功) break;
retryCount++;
Thread.sleep(10_000); // 每次重试间隔 10 秒
}
}
三次重试 × 每次睡眠 10 秒 = 30 秒,这期间当前线程被完全占用,无法执行其他任务。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
方案一:增加 TaskScheduler 线程池大小(最简)
自定义一个线程池大小,让不同任务拥有独立的线程
@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4); // 根据任务数量设定
scheduler.setThreadNamePrefix("scheduled-");
scheduler.initialize();
taskRegistrar.setTaskScheduler(scheduler);
}
}
这样一来,heartbeatCheck 和 getData 可以由不同线程执行,重连不会阻塞数据采集。
但如果重连真的需要 30 秒,那个线程依然会被占用,只是不影响其他任务了。若要更彻底解耦,请看方案二。
方案二:耗时操作异步化(推荐)
用 @Async 将重连操作扔到另一个线程池中,使 heartbeatCheck 快速返回:
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-reconnect-");
executor.initialize();
return executor;
}
}
在心跳方法中异步调用重连:
@Async
public void asyncReconnect(String ip) {
reconnectDevice(ip); // 该方法内包含 Thread.sleep 等阻塞操作
}
@Scheduled(fixedRate = 30000)
public void heartbeatCheck() {
for (Map.Entry<String, Integer> entry : userHandles.entrySet()) {
if (!isDeviceOnline(entry.getValue())) {
asyncReconnect(entry.getKey()); // 立即返回,不占用调度线程
}
}
}
需要注意并发控制:同一设备可能被多次异步重连,可通过 ReentrantLock 或状态标记避免重入。
方案三:分离调度器
创建两个不同的 TaskScheduler 分别管理核心任务和辅助任务。但配置较为繁琐,通常方案一、二已足够应对。
方案四:改造重连逻辑,消除长时间阻塞
将重连设计为非阻塞的异步版本,或使用海康 SDK 自带的重连回调,彻底避免 Thread.sleep 占线。
例如,先尝试重连,失败后记录状态,等下次心跳再试,不在一次心跳里循环重试。但这可能需要业务逻辑上的调整。
重要数据可考虑由线程直接写入,不依赖 Redis 键的自动过期作为唯一状态依据。