Что умеет Spring Scheduler?

Что умеет Spring Scheduler?


Date of publication 24.10.2023



Часто бывает необходимо, чтобы приложение выполняло какие-то действия регулярно, в определённое время. Например каждое утро выгружать актуальные данные из стороннего сервиса, инвалидировать кэш в конце дня и т.п. Для этих целей в Spring существует встроенный планировщик задач, он же Scheduler. В этой статье я расскажу о том, как работать с планировщиком задач в приложении на Spring Boot, как его настроить в своём проекте и какие возможности у него есть.

Для начала я создам новый проект на Spring Boot с помощью уже всем известного Spring Initializr:

 

Как вы могли заметить, я не добавляю сюда каких-либо дополнительных зависимостей - всё что мне нужно, уже есть в Spring.

Структура проекта так же максимально простая, я добавил только 2 новых класса: AppConfiguration и ScheduledTaskService.

Далее необходимо включить поддержку планировщика задач (шедулера) в классе конфигурации AppConfiguration. Для этого необходимо добавить аннотацию @EnableScheduling - без неё планировщики задач работать не будут:

package ru.akutepov.springschedulerexample.configuration;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class AppConfiguration {

}

На этом подготовительные работы заканчиваются и можно переходить к написанию методов, которые будут запускаться шедулером.

Аннотация @Scheduled

Аннотация @Scheduled указывает планировщику задач Spring когда и с какой периодичностью вызывать проаннотированный метод. По сути через эту аннотацию и выполняется основная настройка шедулера. Сами же методы должны соответствовать простым правилам:

  • метод должен иметь возвращаемый тип void (в противном случае возвращаемое значение будет игнорироваться)
  • метод не должен принимать на вход какие-либо параметры
  • метод не должен быть private

Если метод соответствует этим критериям, то его можно проаннотировать аннотацией @Scheduled и настроить расписание, по которому Spring будет данный метод вызывать. Теперь рассмотрим какие возможности есть у данной аннотации!

Выполнение задач с фиксированной задержкой

Аннотация @Scheduled позволяет выполнять задачи с фиксированной задержкой. Время задержки отсчитывается с момента завершения выполнения предыдущей задачи, при этом по умолчанию все задачи выполняются в одном потоке. Каждая последующая задача не выполнится до тех пор, пока предыдущая задача не завершит своё выполнение.

    @Scheduled(fixedDelay = 1000)
    private void scheduleFixedDelayTask() throws InterruptedException {
        LOGGER.info("scheduleFixedDelayTask: begin");
        Thread.sleep(2000);
        LOGGER.info("scheduleFixedDelayTask: end");
    }

В данном примере фиксированная задержка настраивается параметром fixedDelay, в котором задаётся время задержки в миллисекундах. В моём случае это 1000 миллисекунд или 1 секунда. Кроме этого, в теле метода я имитирую работу с помощью Thread.sleep(2000) и вывожу в лог сообщения о начале и завершении работы метода.

Вот так выглядит работа приложения:

Работа метода scheduleFixedDelayTask
По времени в логах видно что сама работа метода (между begin и end) занимает 2 секунды, далее приложение ждёт одну секунду и повторно запускает задачу. Так же по логам видно что вся работа идёт в одном потоке scheduling-1.

Дополнительно есть возможность выставить начальную задержку перед первым выполнением метода. Для этого можно воспользоваться параметром initialDelay:

    @Scheduled(fixedDelay = 1000, initialDelay = 10000)
    public void scheduleFixedRateWithInitialDelayTask() throws InterruptedException {
        LOGGER.info("scheduleFixedRateWithInitialDelayTask: begin");
        Thread.sleep(3000);
        LOGGER.info("scheduleFixedRateWithInitialDelayTask: end");
    }

В данном примере начальная задержка составляет 10000 миллисекунд или 10 секунд, после которой начнётся первое выполнение метода. Последующие срабатывания метода будут с фиксированной задержкой в 1000 миллисекунд, как в предыдущем примере. По логам можно сравнить работу этих двух методов:

Работа метода scheduleFixedRateWithInitialDelayTask

Из логов видно что первое срабатывание метода scheduleFixedRateWithInitialDelayTask происходит только через 10 секунд, так как для него выставлен параметр initialDelay, в то время как метод scheduleFixedDelayTask уже во всю работает.

А дальше происходит ещё одно интересное событие: так как оба метода работают в одном потоке, то они начинают друг другу мешать и зависеть друг от друга. По логам видно что метод scheduleFixedDelayTask ждёт когда закончит работу метод scheduleFixedRateWithInitialDelayTask и срабатывает уже после него, несмотря на то, что задержка уже больше 1 секунды. И наоборот - если в какой-то момент работает метод scheduleFixedDelayTask, то scheduleFixedRateWithInitialDelayTask будет ожидать его завершения. Это не всегда приемлемо и чуть позже я расскажу как настроить многопоточное выполнение задач в проекте.

Выполнение задач с фиксированной скоростью

В данном примере задачи запускаются ровно через определённый промежуток времени и не ожидают завершения друг друга, при условии что планировщик задач их не запускает в одном потоке:

    @Scheduled(fixedRate = 3000)
    public void scheduleFixedRateTask() throws InterruptedException {
        LOGGER.info("scheduleFixedRateTask: begin");
        Thread.sleep(1000);
        LOGGER.info("scheduleFixedRateTask: end");
    }

Время, через которое будет запущена новая задача, проставляется в параметре fixedRate. Обратите внимание на то что в моём примере значение в fixedRate больше чем значение в Thread.sleep, то есть работа метода укладывается в заданный интервал. Поэтому в логах разница между срабатываниями метода (begin и begin) всегда будет составлять 3 секунды, несмотря на то что всё работает в одном потоке:

Работа метода scheduleFixedRateTask

Но если поменять значения местами и выставить fixedRate=1000 и Thread.sleep(3000), то картина в логах поменяется:

    @Scheduled(fixedRate = 1000)
    public void scheduleFixedRateTask() throws InterruptedException {
        LOGGER.info("scheduleFixedRateTask: begin");
        Thread.sleep(3000);
        LOGGER.info("scheduleFixedRateTask: end");
    }

Логи работы метода:

Работа метода scheduleFixedRateTask

Из-за того, что планировщик запускает задачи в одном потоке, а сам метод scheduleFixedRateTask выполняется долго (3000 миллисекунд), уже нет возможности выдерживать интервал между запусками в 1000 миллисекунд, так как приходится ждать завершения предыдущей задачи. Соответственно между begin и begin по прежнему 3000 миллисекунд (должно быть 1000) и между end и begin в логах не наблюдается разница во времени - задача выполняется сразу же после завершения выполнения предыдущей задачи. То есть задачи опять начинают друг другу мешать и требуется многопоточность, про которую я и расскажу в далее.

Параллельное выполнение задач

Рассмотрим как можно запускать задачи параллельно в разных потоках, чтобы они не зависели друг от друга и не мешали друг другу. Для этого нужно сделать 2 вещи:

1. Добавить на класс аннотацию @EnableAsync:

@Service
@EnableAsync
public class ScheduledTaskService {

2. Добавить аннотацию @Async на метод шедулера:

    @Async
    @Scheduled(fixedRate = 1000)
    public void scheduleFixedRateTaskAsync() throws InterruptedException {
        LOGGER.info("scheduleFixedRateTaskAsync: begin");
        Thread.sleep(3000);
        LOGGER.info("scheduleFixedRateTaskAsync: end");
    }

После запуска приложения в логах будет видно что теперь задачи выполняются в разных потоках и не ждут друг друга:

Выполнение метода scheduleFixedRateTaskAsync

Однако, у такого подхода есть существенный недостаток: если каждая задача выполняется слишком долго, то новые задачи будут запускаться быстрее чем старые будут успевать отрабатывать. Таким образом возникает риск утечки памяти и возникновения ошибки OutOfMemory. Поэтому при использовании аннотации @Async нужно быть крайне осторожным, хотя это хороший способ распараллелить одну задачу.

Если же в приложении по шедулеру запускается несколько задач, то удобнее будет настроить пулл потоков, чтобы разные задачи не мешали друг другу. Для этого в application.properties нужно добавить такой параметр:

spring.task.scheduling.pool.size=5

В этом случае Spring начинает использовать многопоточный планировщик задач с фиксированным размером пула. Это позволит распараллелить разные задачи без риска утечки памяти.

Ровно тоже самое можно сделать, если добавить вот такой бин TaskScheduler и настроить в нём размер пула потоков:

@Bean
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
    threadPoolTaskScheduler.setPoolSize(5);
    threadPoolTaskScheduler.setThreadNamePrefix("ThreadPoolTaskScheduler");
    return threadPoolTaskScheduler;
}

Но если вы используете Spring Boot, то создавать бин TaskScheduler не нужно, достаточно только параметра в application.properties.

Теперь я запущу приложение с двумя шедулерами, чтобы убедиться что они больше не мешают друг другу:

Теперь каждый шедулер выполняет задачи в своём темпе и не зависит от выполнения задач другим шедулером. По логам чётко видно что задачи начали работать в разных потоках, количество которых не превышает значения в параметре spring.task.scheduling.pool.size.

Использование выражений Cron

Часто бывает необходимо, чтобы задача запускалась в конкретное время и в конкретный день. Для этого можно использовать в аннотации @Scheduled выражения Cron:

 ┌───────────── second (0-59)
 │ ┌───────────── minute (0 - 59)
 │ │ ┌───────────── hour (0 - 23)
 │ │ │ ┌───────────── day of the month (1 - 31)
 │ │ │ │ ┌───────────── month (1 - 12) (or JAN-DEC)
 │ │ │ │ │ ┌───────────── day of the week (0 - 7)
 │ │ │ │ │ │          (0 or 7 is Sunday, or MON-SUN)
 │ │ │ │ │ │
 * * * * * *

Например выражение 0 10 12 11 * ? означает что задачу необходимо запускать в 12 часов 10 минут 00 секунд, каждого 11-го числа. Такие выражения хорошо знакомы тем, кто ранее работал с cron в Linux.

    @Scheduled(cron = "0 * * * * ?", zone = "Europe/Moscow")
    public void scheduleCronExpressionTask() throws InterruptedException {
        LOGGER.info("scheduleCronExpressionTask: begin");
        Thread.sleep(3000);
        LOGGER.info("scheduleCronExpressionTask: end");
    }

В параметре cron я указал выражение, которое настраивает планировщик таким образом, чтобы он вызывал метод scheduleCronExpressionTask раз в минуту в нулевую секунду. Кроме этого можно указать ещё и таймзону в параметре zone, что я и сделал.

Такой способ позволяет задавать точное расписание работы метода с аннотацией @Scheduled, используя различные выражения cron.

Параметризация настроек шедулеров

В предыдущих примерах для настройки планировщиков задач использовались константные значения. На практике это не всегда удобно, так как иногда требуется гибкая настройка работы шедулеров через файл свойств (конфиг) приложения.

Spring позволяет выносить настройки планировщиков задач в параметры:

    @Scheduled(fixedDelayString = "${task.fixed.delay.millis}")
    public void scheduleFixedDelayParametrizedTask() throws InterruptedException {
        LOGGER.info("scheduleFixedDelayParametrizedTask: begin");
        Thread.sleep(2000);
        LOGGER.info("scheduleFixedDelayParametrizedTask: end");
    }

    @Scheduled(fixedRateString = "${task.fixed.rate.millis}")
    public void scheduleFixedRateParametrizedTask() throws InterruptedException {
        LOGGER.info("scheduleFixedRateParametrizedTask: begin");
        Thread.sleep(2000);
        LOGGER.info("scheduleFixedRateParametrizedTask: end");
    }

    @Scheduled(cron = "${task.cron.expression}")
    public void scheduleCronExpressionParametrizedTask() throws InterruptedException {
        LOGGER.info("scheduleCronExpressionParametrizedTask: begin");
        Thread.sleep(2000);
        LOGGER.info("scheduleCronExpressionParametrizedTask: end");
    }

Остаётся их только прописать в файле свойств - в моём случае это application.properties:

task.fixed.delay.millis=1000
task.fixed.rate.millis=1000
task.cron.expression=0 * * * * ?

Использование параметров позволяет гибко настраивать работу планировщиков задач без внесения исправлений в код и без пересборки приложения!

Резюме

В статье я рассказал об основных возможностях планировщика задач в Spring. Теперь полученные знания вы легко сможете применять в своих приложениях.

Все исходники доступны на GitHub: https://github.com/AlexeyKutepov/spring-scheduler-example

Так же приглашаю всех желающих подписаться на мой Telegram-канал, в котором я делюсь большим количеством полезной информации о разработке программного обеспечения (в том числе и своими новыми статьями): https://t.me/akutepov