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

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

время, тиков → 0 3 6 9 12 fixedDelay пауза 3 2 тика пауза считается от конца fixedRate период 3 период считается от старта fixedRate затянулся 5 тиков вместо 2 старты подряд — догоняет расписание, но не параллельно

Одна и та же задача на трёх дорожках. Сверху fixedDelay: пауза отсчитывается от конца предыдущего запуска, поэтому старты уезжают вправо. В середине fixedRate: метки старта стоят на сетке, ритм ровный. Снизу тот же fixedRate, но первый запуск затянулся: пропущенные старты идут подряд, догоняя расписание, — задача не запускается параллельно сама с собой.

Обязательно

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

Часто приложению нужно что-то делать само, без запроса от пользователя: раз в сутки чистить старые заказы, раз в минуту публиковать показатели, раз в 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 не принимает параметров и обычно ничего не возвращает.

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

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

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

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

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

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

Разницу видно и без Spring — на том же ScheduledExecutorService, поверх которого работает планировщик:

живой пример

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class ScheduleDemo {

    static final long TICK = 120;   // один «тик» — 120 мс, чтобы прогон занял пару секунд
    static long start;
    static int run;

    static Runnable task(long... durations) {
        return () -> {
            long tick = Math.round((System.currentTimeMillis() - start) / (double) TICK);
            System.out.println("   запуск на тике " + tick);
            try {
                Thread.sleep(durations[Math.min(run++, durations.length - 1)] * TICK);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };
    }

    static void demo(String title, boolean rate, long... durations) throws Exception {
        System.out.println(title);
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        start = System.currentTimeMillis();
        run = 0;
        Runnable job = task(durations);
        if (rate) {
            scheduler.scheduleAtFixedRate(job, 0, 3 * TICK, TimeUnit.MILLISECONDS);
        } else {
            scheduler.scheduleWithFixedDelay(job, 0, 3 * TICK, TimeUnit.MILLISECONDS);
        }
        Thread.sleep(11 * TICK);
        scheduler.shutdownNow();
    }

    public static void main(String[] args) throws Exception {
        demo("fixedDelay = 3 тика, задача 2 тика:", false, 2);
        demo("fixedRate = 3 тика, задача 2 тика:", true, 2);
        demo("fixedRate = 3 тика, первый запуск затянулся на 5 тиков:", true, 5, 1);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Старты fixedDelay — 0, 5, 10: пауза от конца. fixedRate — 0, 3, 6, 9. В третьем прогоне первый запуск затянулся, старты 5 и 6 идут подряд — планировщик догоняет пропущенное.

Одно различие между этим демо и Spring стоит знать заранее. Если задача в голом ScheduledExecutorService бросит исключение наружу, планировщик снимет её с расписания насовсем — молча, и больше она не запустится ни разу. У @Scheduled не так: Spring оборачивает метод своим обработчиком ошибок, пишет исключение в лог и оставляет задачу в расписании. Следующий запуск будет.

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;
    }
}

Размер пула — сколько задач идёт одновременно; понятный префикс в имени потока помогает искать в логах, кто тормозит.

Заводить бин ради размера пула не обязательно: оба пула настраиваются строками в application.yml, и в большинстве проектов этого достаточно.

spring:
  task:
    scheduling:
      pool:
        size: 5                     # потоков у планировщика
      thread-name-prefix: scheduled-
    execution:
      pool:
        core-size: 8                # постоянные потоки @Async
        max-size: 32                # предел под нагрузкой
        queue-capacity: 100         # очередь, после которой создаются новые потоки
      thread-name-prefix: async-

Свой бин нужен только там, где настроек не хватает: декоратор задач для переноса контекста, нестандартная политика отказа, разные пулы под разные группы задач (тогда имя бина указывают в @Async("reportExecutor")).

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

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

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

строка в shedlock копия 1 тик в 3:00 копия 2 тик в 3:00 копия 3 тик в 3:00 чистка один раз кто успел

Три копии просыпаются на одном и том же тике и идут за одним замком в общей таблице: строка достаётся одной, две другие видят её занятой и пропускают запуск.

@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() { }
}

Одного этого кода мало: замок где-то надо хранить, и JdbcTemplateLockProvider ждёт в базе таблицу с фиксированным именем. Без неё приложение упадёт на первом же тике. Для PostgreSQL таблица такая:

CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP NOT NULL,
    locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));

Второе, о чём легко не подумать, — lockAtMostFor. Это страховка на случай, если копия приложения умрёт, не отпустив замок: по истечении этого времени замок освободится сам. Здесь он задан один на всех (defaultLockAtMostFor = "10m"), но его же можно указать прямо в @SchedulerLock. Важно не поставить его меньше, чем реально длится задача: замок отпустится посреди работы, следующая копия увидит его свободным и запустит вторую параллельную чистку — ровно то, от чего ShedLock и ставили.

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

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

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

Логично оформить заказ, сразу ответить пользователю, а письмо отправить в фоне. Это и делает аннотация @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(...));
}
поток запроса контроллер вызов через прокси ответ клиенту поток пула тело метода исключение только в лог

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

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

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

Вызов метода из того же класса не сработает. Если метод с @Async вызывают через this, обёртка обходится стороной, и код выполнится в обычном потоке. Та же ловушка, что у @Transactional, и как устроена обёртка, показано на чистой Java в статье про AOP. Решение — вынести @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, проблемы нет: ошибка попадёт в результат, и вызывающий код увидит её.

@Scheduled и @Transactional на одном методе работают, и это нормальный способ сделать пакетную обработку: транзакция открывается на каждый запуск задачи. Ловушек две. Длинная задача держит транзакцию всё время работы (см. таймаут и блокировки в статье про @Transactional), поэтому большие пакеты режут на куски со своей транзакцией на каждый. И аннотации на private-методе не будет, как обычно, — планировщик вызывает метод через прокси, значит он должен быть публичным.

@Scheduled и @Async на одном методе тоже сочетаются, но означают не то, что кажется. Планировщик вызовет метод по расписанию, а прокси @Async немедленно отдаст управление обратно и выполнит тело в пуле @Async. В итоге планировщик считает задачу завершённой сразу же, и fixedDelay перестаёт работать как «пауза после окончания»: новый запуск начнётся через заданный интервал независимо от того, закончился предыдущий или нет, — задача может пойти параллельно сама с собой. Если цель была просто не блокировать поток планировщика, правильнее увеличить его пул (spring.task.scheduling.pool.size), а не вешать @Async.

Свой пул потоков для @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 переворачивают ситуацию: они стоят почти ничего, их создают тысячами. Когда виртуальный поток упирается в ожидание (запрос к базе, вызов по сети), он не держит «настоящий» поток операционной системы — тот в это время обслуживает другую работу.

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

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

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

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

spring.threads.virtual.enabled=true

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

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

  • Не ускоряют вычисления. Они помогают там, где код ждёт ввод-вывод. Если задача нагружает процессор расчётами — виртуальные потоки ничего не дадут, упрётесь в число ядер.
  • Не отменяют осторожность с общими данными. Параллельность никуда не делась: общее изменяемое состояние по-прежнему нужно защищать.
  • Прилипают к несущему потоку внутри synchronized. Это главная оговорка Java 21: когда виртуальный поток входит в synchronized-блок и там упирается в ожидание (запрос к базе, вызов по сети), он не может отцепиться от несущего платформенного потока и держит его вместе с собой. Это называют прикалыванием (pinning). Несущих потоков столько же, сколько ядер, поэтому десяток таких мест под нагрузкой останавливает всё приложение, хотя виртуальных потоков заведено тысячи. Диагностируют это флагом -Djdk.tracePinnedThreads=full, который печатает стек каждого прилипания. Лечение — заменить synchronized вокруг блокирующих вызовов на ReentrantLock, который с виртуальными потоками дружит. Библиотеки, где synchronized внутри (старые драйверы, пулы соединений), проверяют по тому же следу. В JDK 24 ограничение сняли, но до перехода на него считать synchronized безопасным нельзя.

Когда @Scheduled уже мало

Планировщик хорош, пока работа описывается словами «раз в столько-то сделать вот это». Границу он переходит в трёх случаях.

Работа рождается по событию, а не по времени: заказ оплачен — нужно отправить чек, файл загружен — нужно разобрать. Опрос базы раз в минуту здесь даёт задержку до минуты и нагрузку на пустом месте.

Работу нужно повторять при сбое и не терять при перезапуске. Упавший тик @Scheduled просто пропущен: в логе строка, в базе ничего. Если задание должно быть выполнено обязательно, оно должно где-то храниться.

Работы много и её надо распределить между экземплярами. ShedLock решает обратную задачу — чтобы задача выполнилась один раз; он не умеет раздавать тысячу заданий десяти копиям.

Ответ на все три — очередь заданий, и начинается она обычно не с брокера, а с таблицы в той же базе. Экземпляры забирают из неё порции запросом с блокировкой, которая не мешает соседям:

SELECT * FROM job_queue
WHERE status = 'NEW' AND run_after <= now()
ORDER BY run_after
LIMIT 20
FOR UPDATE SKIP LOCKED;

FOR UPDATE SKIP LOCKED блокирует выбранные строки и пропускает те, что уже заняты другим экземпляром: десять копий приложения разбирают очередь параллельно и не конфликтуют. Дальше задание помечают выполненным в той же транзакции, что и результат работы, а при сбое возвращают в очередь со счётчиком попыток и отложенным run_after. Такую таблицу естественно совмещают с Outbox: в неё же пишут сообщения, которые надо разослать наружу. Когда объём перерастает и это, берут внешний брокер, но начинают почти всегда с таблицы: она уже транзакционна, уже реплицируется и уже под наблюдением.

Дополнительно: при первом чтении можно пропустить

Глубже: что уезжает в другой поток: MDC, SecurityContext, транзакциярасширенное

Задача ушла в @Async, и в её логах нет traceId, а SecurityContextHolder.getContext().getAuthentication() вернул null. Ничего не сломалось: всё, что Spring и библиотеки держат в ThreadLocal, привязано к потоку, который принял запрос, а задача выполняется в другом.

Что именно не переезжает: MDC для логов (traceId, userId), контекст безопасности, текущая транзакция и открытая сессия JPA, RequestContextHolder с самим запросом. Поэтому @Transactional на @Async-методе открывает новую транзакцию, а не продолжает вызывающую, и откат снаружи не отменит то, что сделано внутри.

Переносить нужно руками, в точке передачи. Для MDC копию берут до отправки и ставят в начале задачи:

Map<String, String> mdc = MDC.getCopyOfContextMap();
executor.submit(() -> {
    MDC.setContextMap(mdc);
    try { work(); } finally { MDC.clear(); }
});

Проще делать это один раз в декораторе задач: ThreadPoolTaskExecutor.setTaskDecorator оборачивает каждую задачу, и @Async получает контекст автоматически. Для контекста безопасности есть DelegatingSecurityContextAsyncTaskExecutor, для трассировки Micrometer ставит свой декоратор сам, если трассировка включена. Транзакцию перенести нельзя по смыслу: соединение с базой принадлежит одному потоку; задаче отдают идентификаторы, а не сущности, и она открывает свою транзакцию.

Глубже: остановка: что будет с @Async и планировщикомрасширенное

Статья про жизненный цикл бина доводит мягкую остановку до HTTP-запросов: сервер перестаёт принимать новые и ждёт текущие. Фоновая работа в это правило сама не входит. По умолчанию ThreadPoolTaskExecutor при остановке контекста задачи не ждёт: пул закрывается, выполняющаяся @Async-задача получает прерывание, а те, что стояли в очереди, теряются.

spring:
  task:
    execution:
      shutdown:
        await-termination: true
        await-termination-period: 30s
    scheduling:
      shutdown:
        await-termination: true
        await-termination-period: 30s

С этими настройками контейнер даст задачам до тридцати секунд закончить и только потом погасит пул. Период должен быть меньше terminationGracePeriodSeconds в Kubernetes, иначе процесс убьют раньше, чем истечёт ожидание. У планировщика ещё одна деталь: тик, который начался во время остановки, доработает, а новые не стартуют; задача, которая бежит дольше периода ожидания, будет прервана, и ей стоит проверять Thread.currentThread().isInterrupted() в длинных циклах. Что делать с работой, которую не успели: самое надёжное не держать её только в памяти, а брать из очереди или таблицы, тогда после перезапуска её подхватит следующий экземпляр.

Коротко

  • @Scheduled запускает метод по таймеру; включается аннотацией @EnableScheduling.
  • fixedDelay — пауза от конца до начала (запуски не накладываются), fixedRate — от начала до начала (важна ровная периодичность), cron — расписание из шести полей; у cron всегда указывайте zone.
  • По умолчанию все @Scheduled-задачи делят один поток — для параллельности хватает spring.task.scheduling.pool.size, свой TaskScheduler нужен только под декоратор или несколько пулов.
  • В нескольких копиях приложения задача сработает на каждой — общий замок через ShedLock оставляет один запуск.
  • @Async выполняет метод в фоне; включается аннотацией @EnableAsync. Возврат — void (без результата) или CompletableFuture<T> (с результатом и ошибкой).
  • Ловушки @Async: вызов через this не срабатывает (нужен отдельный бин); ошибка из void-метода теряется (задайте AsyncUncaughtExceptionHandler).
  • Для @Async под нагрузкой ограничьте пул (spring.task.execution.pool.* или свой ThreadPoolTaskExecutor); @Async поверх @Scheduled ломает fixedDelay, а @Transactional на задаче держит транзакцию весь её прогон.
  • Виртуальные потоки (Java 21, spring.threads.virtual.enabled=true) делают параллельность дешёвой для задач с ожиданием ввод-вывода; вычисления они не ускоряют, а внутри synchronized прилипают к несущему потоку (-Djdk.tracePinnedThreads=full, лечится ReentrantLock).
  • В @Async-поток не переезжают MDC, контекст безопасности и транзакция: переносят декоратором задач, транзакцию открывают заново.
  • Остановка не ждёт фоновые задачи по умолчанию: spring.task.execution.shutdown.await-termination и период короче grace-периода Kubernetes. Работу, которую нельзя терять и надо распределять, переносят из расписания в очередь заданий (таблица с FOR UPDATE SKIP LOCKED).

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