Timeliness Issues with Scheduled Tasks in Spring Boot

This article discusses the built-in @EnableScheduling and @Scheduled annotations in Spring Boot for implementing scheduled tasks.

1. Setting Up the Basic Environment

Basic dependencies:

<parent>
    <artifactId>spring-boot-parent</artifactId>
    <groupId>org.springframework.boot</groupId>
    <version>2.7.2</version>
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

Create a startup class and a scheduled task:

@SpringBootApplication
public class ScheduleApplication {

    public static void main(String[] args) {
        SpringApplication.run(ScheduleApplication.class, args);
    }
}
@Slf4j
@Component
@EnableScheduling
public class TaskService {

    @Scheduled(cron = "0/5 * * * * ?")
    public void runTask() {
        log.info("Current thread ID: {}", Thread.currentThread().getId());
    }
}

2. Issues: Execution Delay and Single-Thread Execution

With the given cron expression @Scheduled(cron = "0/5 * * * * ?"), the task should execute every 5 seconds. Ideally, the last five executions would be:

2024-07-06 00:21:10
2024-07-06 00:21:15
2024-07-06 00:21:20
2024-07-06 00:21:25
2024-07-06 00:21:30

If the task executes very quickly, there is no noticeable delay.

Actual output for the simple task:

2024-07-06 19:42:10.018  INFO 24496 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:42:15.015  INFO 24496 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:42:20.001  INFO 24496 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:42:25.005  INFO 24496 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:42:30.007  INFO 24496 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64

However, real business logic often takes longer. Let's simulate a 10-second delay:

@Scheduled(cron = "0/5 * * * * ?")
public void runTask() {
    try {
        Thread.sleep(10000);
        log.info("Current thread ID: {}", Thread.currentThread().getId());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

Output:

2024-07-06 19:46:50.019  INFO 27236 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:47:05.024  INFO 27236 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:47:20.016  INFO 27236 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:47:35.005  INFO 27236 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 19:47:50.006  INFO 27236 --- [scheduling-1] c.e.service.TaskService : Current thread ID: 64

Two problems emerge:

  • Execution Delay: The task no longer runs every 5 seconds; it is significantly delayed.
  • Single-Thread Execution: Only one thread executes all tasks, causing blocking.

3. Why Do These Issues Occur?

Root cause: Tasks are executed in a blocking manner with too few threads.

The @EnableScheduling annotation triggers auto-configuration via TaskSchedulingAutoConfiguration. Let's examine its code:

@ConditionalOnClass(ThreadPoolTaskScheduler.class)
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(TaskSchedulingProperties.class)
@AutoConfigureAfter(TaskExecutionAutoConfiguration.class)
public class TaskSchedulingAutoConfiguration {

    @Bean
    @ConditionalOnBean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
    @ConditionalOnMissingBean({ SchedulingConfigurer.class, TaskScheduler.class, ScheduledExecutorService.class })
    public ThreadPoolTaskScheduler taskScheduler(TaskSchedulerBuilder builder) {
        return builder.build();
    }
    // ...
}

The build() method creates a ThreadPoolTaskScheduler:

public ThreadPoolTaskScheduler build() {
    return configure(new ThreadPoolTaskScheduler());
}

Inside ThreadPoolTaskScheduler, the default pool size is 1:

private volatile int poolSize = 1;

Default pool size

The executor creation only uses these three parameters, relying on ScheduledExecutorService defaults:

protected ScheduledExecutorService createExecutor(
    int poolSize, ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler)

These defaults are problematic for production. The Alibaba Java Development Guide explicitly states to manually create thread pools with appropriate parameters. Why? Because the default thread pool allows Integer.MAX_VALUE for both max pool size and work queue capacity.

Default parameters

If something goes wrong, the system will inevitab crash.

4. Solutions

  1. Configure properties via TaskSchedulingProperties: Allows setting core pool size and prefix, but cannot set max pool size or queue capacity. This is insufficient.
  2. Manually use asynchronous execution with a custom thread pool.
  3. Add @Async to the scheduled method and define a custom thread pool.

4.1 Configuration File

You can configure the following:

spring:
  task:
    scheduling:
      thread-name-prefix: custom-schedule-
      pool:
        size: 10

Output:

2024-07-06 20:49:15.015  INFO 7852 --- [custom-schedule-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 20:49:30.004  INFO 7852 --- [custom-schedule-2] c.e.service.TaskService : Current thread ID: 66
2024-07-06 20:49:45.024  INFO 7852 --- [custom-schedule-1] c.e.service.TaskService : Current thread ID: 64
2024-07-06 20:50:00.025  INFO 7852 --- [custom-schedule-3] c.e.service.TaskService : Current thread ID: 67
2024-07-06 20:50:15.023  INFO 7852 --- [custom-schedule-2] c.e.service.TaskService : Current thread ID: 66
2024-07-06 20:50:30.008  INFO 7852 --- [custom-schedule-4] c.e.service.TaskService : Current thread ID: 68

Note: This configuration may not always take effect; the success rate varies.

Now multiple threads are used.

4.2 Asynchronous Execution via CompletableFuture

First, inject a custom thread pool:

@Configuration
public class ThreadPoolConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("custom-schedule-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        return executor;
    }
}

Then use it in the task:

@Slf4j
@Component
@EnableScheduling
public class TaskService {

    @Autowired
    private TaskExecutor taskExecutor;

    @Scheduled(cron = "0/5 * * * * ?")
    public void runTask() {
        CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(10000);
                log.info("Current thread ID: {}", Thread.currentThread().getId());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, taskExecutor);
    }
}

Output:

2024-07-06 21:00:00.019  INFO 18356 --- [custom-schedule-1] c.e.service.TaskService : Current thread ID: 66
2024-07-06 21:00:05.022  INFO 18356 --- [custom-schedule-2] c.e.service.TaskService : Current thread ID: 67
2024-07-06 21:00:10.013  INFO 18356 --- [custom-schedule-3] c.e.service.TaskService : Current thread ID: 68
2024-07-06 21:00:15.020  INFO 18356 --- [custom-schedule-4] c.e.service.TaskService : Current thread ID: 69
2024-07-06 21:00:20.026  INFO 18356 --- [custom-schedule-5] c.e.service.TaskService : Current thread ID: 70

The task now runs every 5 seconds without delay.

4.3 Asynchronous Scheduled Task with @Async

Add @EnableAsync and @Async to the class and method:

@Slf4j
@Component
@EnableAsync
@EnableScheduling
public class TaskService {

    @Autowired
    private TaskExecutor taskExecutor;

    @Async(value = "taskExecutor")
    @Scheduled(cron = "0/5 * * * * ?")
    public void runTask() {
        try {
            Thread.sleep(10000);
            log.info("Current thread ID: {}", Thread.currentThread().getId());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Output:

2024-07-06 21:10:15.022  INFO 22760 --- [custom-schedule-1] c.e.service.TaskService : Current thread ID: 66
2024-07-06 21:10:20.021  INFO 22760 --- [custom-schedule-2] c.e.service.TaskService : Current thread ID: 67
2024-07-06 21:10:25.007  INFO 22760 --- [custom-schedule-3] c.e.service.TaskService : Current thread ID: 68
2024-07-06 21:10:30.020  INFO 22760 --- [custom-schedule-4] c.e.service.TaskService : Current thread ID: 69
2024-07-06 21:10:35.007  INFO 22760 --- [custom-schedule-5] c.e.service.TaskService : Current thread ID: 70

This approach also works. Note that @EnableAsync activates TaskExecutionAutoConfiguration, which has its own properties (TaskExecutionProperties). The default core pool size is 8, but max pool size and queue capacity default to Integer.MAX_VALUE, again not recommended.

4.4 Summary

Scheduled Tasks

  • @EnableScheduling: enables scheduling
  • @Scheduled: marks a method as scheduled
  • Auto-configuration: TaskSchedulingAutoConfiguration

Asynchronous Tasks

  • @EnableAsync: enables async execution
  • @Async: marks a method as asynchronous
  • Auto-configuration: TaskExecutionAutoConfiguration

5. Distributed Considerations

The above solutions work for single-node applications. In a distributed environment, running the same scheduled task on multiple nodes can cause issues, such as duplicate messages or data corruption.

Using Redisson Distributed Lock

Add dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.17.6</version>
</dependency>

Configure RedissonClient:

@Configuration
public class RedissonConfig {

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient() throws IOException {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://xxx.xxx.xxx.xxx:6379")
            .setPassword("yourpassword"); // omit if no password
        return Redisson.create(config);
    }
}

Modify the scheduled task to use the lock:

@Slf4j
@Component
@EnableAsync
@EnableScheduling
public class TaskService {

    @Autowired
    private TaskExecutor taskExecutor;

    @Autowired
    private RedissonClient redissonClient;

    private static final String SCHEDULE_LOCK = "schedule:lock";

    @Async(value = "taskExecutor")
    @Scheduled(cron = "0/5 * * * * ?")
    public void runTask() {
        RLock lock = redissonClient.getLock(SCHEDULE_LOCK);
        try {
            // Lock with 10-second lease time; disables Redisson's watchdog
            lock.lock(10, TimeUnit.SECONDS);
            Thread.sleep(10000);
            log.info("Current thread ID: {}", Thread.currentThread().getId());
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

This is a basic implementation. Optimization ideas include using a flag to skip execution if another node already holds the lock.

Posted on Wed, 02 Sep 2026 16:11:29 +0000 by acac