← назад к разделу

Три близкие темы про то, как заставить код работать не «прямо сейчас в ответ на запрос», а по расписанию или в фоне. Разберём с нуля: как запускать задачи по таймеру (@Scheduled), как выносить долгую работу в отдельный поток (@Async) и что меняют виртуальные потоки из Java 21.

Зачем запускать код по расписанию

Часто приложению нужно что-то делать само, без запроса от пользователя: раз в сутки чистить старые заказы, раз в минуту публиковать показатели, раз в 15 минут опрашивать внешнюю систему.

Раньше для этого заводили отдельную программу и вешали её на системный планировщик (cron в Linux). Это работает, но появляется второй кусок кода, который надо отдельно собирать, деплоить и следить за ним.

Spring умеет делать это внутри самого приложения. Достаточно включить планировщик и пометить метод аннотацией @Scheduled — Spring сам будет вызывать его по таймеру.

@Configuration
@EnableScheduling          // включаем планировщик
public class AppConfig { }
@Component
public class OrderCleanupJob {

    private final OrderRepository orderRepo;

    OrderCleanupJob(OrderRepository orderRepo) {
        this.orderRepo = orderRepo;
    }

    @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Moscow")  // каждый день в 3 ночи
    public void cleanupAbandoned() {
        orderRepo.deleteAbandonedBefore(Instant.now().minus(30, ChronoUnit.DAYS));
    }
}

Метод с @Scheduled не должен принимать параметры и обычно ничего не возвращает — Spring просто дёргает его в нужный момент.

fixedDelay, fixedRate и cron — три способа задать расписание

Расписание задают одним из трёх атрибутов, и разница между ними — частая причина ошибок.

fixedDelay — пауза между окончанием одного запуска и началом следующего. Если задача длится 50 секунд, а fixedDelay = 60_000 (60 секунд), то следующий запуск будет через 50 + 60 = 110 секунд от старта. Запуски никогда не накладываются друг на друга — это самый безопасный вариант для задач вроде чистки данных.

fixedRate — пауза между началом одного запуска и началом следующего. Если поставить fixedRate = 60_000, Spring старается запускать метод строго раз в минуту. Но если задача однажды затянется дольше минуты, следующий запуск может пойти параллельно. Нужен, когда важна ровная периодичность (например, регулярная отправка показателей).

@Scheduled(fixedDelay = 60_000)   // 60 секунд после завершения предыдущего
public void publishMetrics() { }

@Scheduled(fixedRate = 30_000)    // каждые 30 секунд от старта к старту
public void sampleQueueSize() { }

cron — для расписаний посложнее, чем «каждые N секунд»: «каждый день в 3 ночи», «по будням в 9 утра». Spring использует cron из шести полей: секунда минута час день_месяца месяц день_недели.

@Scheduled(cron = "0 0 3 * * *")        // каждый день в 3:00:00
@Scheduled(cron = "0 */15 * * * *")     // каждые 15 минут
@Scheduled(cron = "0 0 9 * * MON-FRI")  // в 9:00 по будням

Два полезных правила. Во-первых, у cron всегда указывайте zone — иначе расписание считается в часовом поясе сервера, а он может оказаться не тем, что вы ожидаете. Во-вторых, само расписание лучше выносить в настройки, чтобы менять его без пересборки:

@Scheduled(cron = "${app.cleanup.cron}")
public void cleanup() { }

По умолчанию все задачи делят один поток

Неочевидная ловушка: если просто навесить @Scheduled на несколько методов, Spring выполняет их все в одном потоке. Пока одна задача работает, остальные стоят в очереди и ждут. Одна медленная задача задерживает все остальные.

Чтобы задачи могли идти параллельно, заводят пул потоков для планировщика — бин TaskScheduler:

@Configuration
@EnableScheduling
public class SchedulingConfig {

    @Bean
    public TaskScheduler taskScheduler() {
        var scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);                  // до 5 задач параллельно
        scheduler.setThreadNamePrefix("scheduled-"); // понятные имена в логах
        return scheduler;
    }
}

Размер пула — это сколько задач может выполняться одновременно. Понятный префикс в имени потока сильно помогает, когда разбираешь логи и ищешь, кто что тормозит.

Одно приложение в нескольких копиях запустит задачу несколько раз

Когда приложение работает в одном экземпляре, всё просто. Но в реальной эксплуатации его обычно запускают в нескольких копиях для надёжности. И тут вылезает проблема: @Scheduled-задача сработает на каждой копии. Три копии — значит, базу почистили три раза, письмо отправили трижды.

Самое простое и надёжное решение — общий замок (lock) через базу данных. Библиотека ShedLock: перед запуском задачи копия пытается взять замок в общей таблице. Кто успел первым — выполняет задачу, остальные видят, что замок занят, и просто пропускают этот запуск.

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulingConfig {

    @Bean
    public LockProvider lockProvider(DataSource ds) {
        return new JdbcTemplateLockProvider(ds);
    }
}

@Component
public class OrderCleanupJob {

    @Scheduled(cron = "0 0 3 * * *")
    @SchedulerLock(name = "orderCleanup")   // имя замка должно быть уникальным
    public void cleanup() { }
}

Плюс ShedLock — не нужна никакая отдельная инфраструктура, хватает уже имеющейся базы данных. Есть и другие подходы (выбор «главной» копии средствами Kubernetes, или вынос задачи в отдельный CronJob на уровне Kubernetes для тяжёлых редких работ), но для большинства случаев общего замка достаточно.

Зачем выполнять код в фоне через @Async

Представьте обработчик, который оформляет заказ и в конце отправляет письмо-подтверждение. Отправка письма идёт через внешний сервис и может занять секунду-две. Всё это время пользователь ждёт ответ — хотя письмо к самому заказу отношения не имеет.

Логично оформить заказ, сразу ответить пользователю, а письмо отправить в фоне, отдельно. Раньше для этого вручную создавали поток или возились с пулом потоков. Spring закрывает это аннотацией @Async: помеченный метод выполняется в отдельном потоке, а вызывающий код не ждёт его завершения.

@Configuration
@EnableAsync               // включаем поддержку @Async
public class AsyncConfig { }
@Component
public class EmailService {

    @Async
    public void sendConfirmation(String to, String body) {
        // выполнится в другом потоке, вызывающий код не ждёт
    }
}

Что может вернуть @Async-метод

  • void — «запустил и забыл». Вызывающий код не узнаёт, чем кончилось дело, и не увидит ошибку.
  • CompletableFuture<T> — когда результат всё-таки нужен. Вызывающий код может дождаться его или обработать ошибку.
@Async
public CompletableFuture<Report> buildReport() {
    return CompletableFuture.completedFuture(new Report(...));
}

Типичные ловушки @Async

Аннотация работает не магией, а через прокси-обёртку вокруг бина (тот же механизм, что у @Transactional). Из этого следуют две частые ошибки.

Вызов метода из того же класса не сработает. Если метод с @Async вызывают через this, обёртка обходится стороной, и код выполнится в обычном потоке, без всякой асинхронности.

@Service
public class MyService {

    public void process() {
        this.heavyTask();   // НЕ в фоне — вызов через this минует прокси
    }

    @Async
    public void heavyTask() { }
}

Решение — вынести @Async-метод в отдельный бин и вызывать его как зависимость.

Ошибка из void-метода теряется. У обычного метода исключение «всплывает» к вызывающему коду. У @Async void его некому ловить — вызывающий код уже ушёл дальше. Чтобы ошибки не пропадали тихо, задают общий обработчик:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("Async-метод {} упал", method.getName(), ex);
    }
}

Если метод возвращает CompletableFuture, проблемы нет: ошибка попадёт в результат, и вызывающий код увидит её.

Свой пул потоков для @Async

Без настройки @Async использует простой исполнитель, который на каждый вызов создаёт новый поток. Под нагрузкой это плохо: тысяча параллельных вызовов — тысяча потоков, и приложение может задохнуться.

В большинстве случаев заводят ограниченный пул — бин с типом Executor и именем taskExecutor:

@Bean(name = "taskExecutor")
public Executor taskExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);     // постоянно живущих потоков
    executor.setMaxPoolSize(32);     // максимум под пиковой нагрузкой
    executor.setQueueCapacity(100);  // очередь, когда все потоки заняты
    executor.setThreadNamePrefix("async-");
    executor.initialize();
    return executor;
}

Так нагрузка ограничена: больше maxPoolSize потоков не появится, лишние задачи подождут в очереди. Это защищает приложение от перегрузки.

Виртуальные потоки (Java 21)

Обычный поток в Java «весит» довольно много — каждый держит ресурсы операционной системы, поэтому их нельзя завести очень много. Из-за этого и приходится возиться с пулами: пул переиспользует ограниченное число дорогих потоков.

Виртуальные потоки из Java 21 переворачивают ситуацию. Это очень лёгкие потоки: их можно создавать тысячами, и стоят они почти ничего. Когда виртуальный поток упирается в ожидание (запрос к базе, вызов по сети), он не держит «настоящий» поток операционной системы — тот в это время обслуживает другую работу.

Для приложения, которое много ждёт ввод-вывод (а это типичный веб-сервис: сходи в базу, дёрни соседний сервис, верни ответ), это означает простую вещь: можно писать обычный понятный код «сверху вниз», без хитростей, и при этом держать тысячи параллельных запросов.

В Spring Boot включается одной настройкой:

spring.threads.virtual.enabled=true

После этого Spring обслуживает каждый веб-запрос в виртуальном потоке, а @Async и @Scheduled тоже начинают работать на виртуальных потоках — отдельный пул им больше не нужен, потому что создавать виртуальный поток на каждую задачу дёшево.

Чего виртуальные потоки не делают

  • Не ускоряют вычисления. Они помогают там, где код ждёт ввод-вывод. Если задача нагружает процессор расчётами — виртуальные потоки ничего не дадут, упрётесь в число ядер.
  • Не отменяют осторожность с общими данными. Параллельность никуда не делась: общее изменяемое состояние по-прежнему нужно защищать.

Коротко

  • @Scheduled запускает метод по таймеру; включается аннотацией @EnableScheduling.
  • fixedDelay — пауза от конца до начала (запуски не накладываются), fixedRate — от начала до начала (важна ровная периодичность), cron — расписание из шести полей; у cron всегда указывайте zone.
  • По умолчанию все @Scheduled-задачи делят один поток — для параллельности заведите TaskScheduler с пулом.
  • В нескольких копиях приложения задача сработает на каждой — общий замок через ShedLock оставляет один запуск.
  • @Async выполняет метод в фоне; включается аннотацией @EnableAsync. Возврат — void (без результата) или CompletableFuture<T> (с результатом и ошибкой).
  • Ловушки @Async: вызов через this не срабатывает (нужен отдельный бин); ошибка из void-метода теряется (задайте AsyncUncaughtExceptionHandler).
  • Для @Async под нагрузкой заведите ограниченный пул ThreadPoolTaskExecutor (taskExecutor).
  • Виртуальные потоки (Java 21, spring.threads.virtual.enabled=true) делают параллельность дешёвой для задач с ожиданием ввод-вывода; вычисления они не ускоряют.

Что почитать дальше

  • Spring Events — @Async вместе с @EventListener.
  • Spring WebFlux — другой подход к параллельной работе.
  • @Transactional глубоко — тот же механизм прокси и те же ловушки с вызовом через this.