← Назад к списку тем

02. Шедулинг

@Scheduled и TaskScheduler, механика ScheduledAnnotationBeanPostProcessor, кастомный планировщик на виртуальных потоках (SimpleAsyncTaskScheduler) в связке с @Async-экзекьютором на virtual threads.

Базовое использование: @EnableScheduling + @Scheduled

Декларативный шедулинг в Spring включается одной аннотацией на конфигурационном классе и размечает методы, которые должны выполняться по расписанию:

@Configuration
@EnableScheduling
class SchedulingConfig {
}

@Component
class OrderReconciliationJob {

    // Следующий запуск стартует через fixedDelay ПОСЛЕ ЗАВЕРШЕНИЯ предыдущего
    @Scheduled(fixedDelay = 30_000)
    void reconcilePendingOrders() {
        orderService.reconcile();
    }

    // Запуск СТРОГО каждые 60 секунд от начала предыдущего запуска,
    // независимо от того, успел ли он завершиться
    @Scheduled(fixedRate = 60_000, initialDelay = 10_000)
    void refreshExchangeRates() {
        rateService.refresh();
    }

    // Cron-выражение: 6 полей (сек мин час день месяц день-недели),
    // выполнить в 02:00 каждый будний день
    @Scheduled(cron = "0 0 2 * * MON-FRI", zone = "Europe/Moscow")
    void nightlySettlement() {
        settlementService.runNightly();
    }
}
АтрибутСемантика
fixedDelayПауза между окончанием одного выполнения и началом следующего — гарантирует, что задачи никогда не пересекаются
fixedRateИнтервал между началами двух последовательных выполнений — если задача выполняется дольше интервала, следующий запуск начинается сразу же, как только предыдущий освободит поток (задачи могут «наезжать» друг на друга по факту очереди в пуле)
initialDelayЗадержка перед самым первым выполнением после старта приложения
cronПолное cron-выражение для сложных расписаний, не сводимых к простому интервалу
⚠️ fixedRate на медленной задаче — частая причина «залипания» шедулера. Если задача с fixedRate = 60_000 стабильно выполняется 90 секунд, а пул шедулера однопоточный (см. следующий раздел) — запуски начинают копиться в очереди и выполняться подряд без пауз, «нагоняя» расписание. Это не баг, а прямое следствие семантики fixedRate при недостаточном параллелизме — почти всегда для потенциально долгих задач нужнее fixedDelay либо пул с достаточным числом потоков.

Механика внутри: ScheduledAnnotationBeanPostProcessor

В отличие от @Retryable/@Async/@Transactional, шедулинг не создаёт AOP-прокси — вызов размеченного метода не нужно перехватывать на каждом обращении, потому что вызывающей стороны как таковой нет: метод вызывается не пользовательским кодом, а самим фреймворком по таймеру. Поэтому механика проще и один раз выполняется на старте контекста:

  1. @EnableScheduling импортирует SchedulingConfiguration, которая регистрирует бин ScheduledAnnotationBeanPostProcessor
  2. На фазе postProcessAfterInitialization этот BeanPostProcessor сканирует методы каждого бина в поисках @Scheduled (и мета-аннотации @Schedules для нескольких расписаний на одном методе)
  3. Для каждого найденного метода атрибуты аннотации конвертируются в объект Trigger: cronCronTrigger, fixedRate/fixedDelayPeriodicTrigger
  4. Метод оборачивается в Runnable (обычный рефлективный вызов — ScheduledMethodRunnable) и регистрируется в ScheduledTaskRegistrar
  5. ScheduledTaskRegistrar передаёт задачи в бин TaskScheduler, который непосредственно ставит их в очередь исполнения (под капотом почти всегда — обёртка вокруг java.util.concurrent.ScheduledExecutorService)
// Упрощённо: что TaskScheduler.schedule(task, trigger) делает при
// каждом срабатывании — вычисляет следующий момент запуска через Trigger
// и заново регистрирует одноразовый ScheduledFuture в ScheduledExecutorService
Instant nextExecution = trigger.nextExecution(triggerContext);
Duration delay = Duration.between(Instant.now(), nextExecution);
executor.schedule(() -> {
    task.run();
    scheduleNext(task, trigger); // пересчитать и запланировать следующий запуск
}, delay.toMillis(), TimeUnit.MILLISECONDS);
🔑 Trigger — единый интерфейс планирования. И CronTrigger, и PeriodicTrigger реализуют один интерфейс org.springframework.scheduling.Trigger с единственным методом nextExecution(TriggerContext). Это значит, что вы можете написать собственный Trigger с произвольной логикой (например, «пропускать выходные и праздники») и передать его в TaskScheduler программно, не привязываясь к синтаксису cron — см. раздел про программный шедулинг ниже.

Дефолтный TaskScheduler: главная ловушка — один поток на все задачи

Если в контексте не объявлен собственный бин TaskScheduler, Spring Boot создаёт дефолтный через TaskSchedulingAutoConfiguration. Классическая, годами известная ловушка: если этот дефолт не настроен явно, он опирается на один поток — а значит все @Scheduled-методы во всём приложении фактически выполняются последовательно, конкурируя за единственный поток. Одна зависшая задача блокирует расписание вообще всех остальных.

# application.yml — минимально необходимая настройка пула
# для дефолтного ThreadPoolTaskScheduler
spring:
  task:
    scheduling:
      pool:
        size: 10
      thread-name-prefix: "scheduling-"
      shutdown:
        await-termination: true
        await-termination-period: 20s

Либо — эквивалентно, но программно, если нужна более тонкая настройка (кастомный ErrorHandler, конкретный ThreadFactory):

@Configuration
@EnableScheduling
class SchedulingConfig {

    @Bean
    TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(10);
        scheduler.setThreadNamePrefix("scheduling-");
        scheduler.setErrorHandler(t -> log.error("Scheduled task failed", t));
        return scheduler;
    }
}
🚨 Необработанное исключение НЕ останавливает будущие запуски, но легко теряется незаметно. Если метод с @Scheduled бросает исключение, конкретное выполнение прерывается, а исключение по умолчанию просто логируется (ERROR уровня, через дефолтный LoggingErrorHandler) — расписание на следующий запуск не отменяется, задача продолжит планироваться и дальше. Опасность в другом: если никто не настроил алертинг по логам ошибок конкретно этого шедулера, задача может «молча» падать при каждом запуске месяцами, а расписание при этом формально «работает». Всегда ставьте кастомный ErrorHandler, который не просто логирует, а инкрементирует метрику (Micrometer counter) — чтобы отказ был виден в мониторинге, а не только в логах.

Кастомный планировщик на виртуальных потоках: SimpleAsyncTaskScheduler

Начиная со Spring Framework 6.1 (и как дефолт-опция в Spring Boot 4 через spring.threads.virtual.enabled=true) появился альтернативный TaskSchedulerSimpleAsyncTaskScheduler. В отличие от ThreadPoolTaskScheduler, который держит фиксированный пул платформенных потоков и переиспользует их, SimpleAsyncTaskScheduler создаёт новый поток на каждое выполнение задачи — и умеет делать это виртуальными потоками через setVirtualThreads(true) (под капотом — Executors.newVirtualThreadPerTaskExecutor()).

@Configuration
@EnableScheduling
class VirtualThreadSchedulingConfig {

    @Bean
    TaskScheduler taskScheduler() {
        SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
        scheduler.setVirtualThreads(true);
        scheduler.setThreadNamePrefix("vt-scheduling-");
        // Опциональный "мягкий" бульхед: не более 50 одновременно
        // ВЫПОЛНЯЮЩИХСЯ задач, несмотря на дешевизну виртуальных потоков —
        // защищает downstream-ресурсы (БД, внешние API), а не сам поток
        scheduler.setConcurrencyLimit(50);
        return scheduler;
    }
}

Или ещё проще — если в проекте достаточно включить единый флаг, Boot 4 сам подставит виртуальные потоки и для шедулера, и для дефолтного @Async-экзекьютора:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

Почему это уместно именно для шедулинга: типичные @Scheduled-задачи (опрос БД, вызов внешнего API, обработка очереди) — это I/O-bound работа с долгими периодами блокировки на ожидании ответа, а не CPU-bound вычисления. Именно такая нагрузка — идеальный кандидат для виртуальных потоков: пока поток заблокирован на сетевом или JDBC-вызове, он не занимает платформенный carrier-поток (при условии, что блокировка честно «отпускает» carrier — см. предостережение про pinning ниже), поэтому сотни параллельно выполняющихся задач обходятся системе почти бесплатно по сравнению с эквивалентным пулом из сотен платформенных потоков.

⚠️ Pinning — виртуальный поток может «залипнуть» на carrier-потоке. Если код внутри synchronized-блока (или нативный вызов через JNI) блокируется — виртуальный поток не освобождает carrier-поток, на котором исполняется, а «пришпиливается» к нему (pinning), временно теряя главное преимущество виртуальных потоков. Старые версии некоторых JDBC-драйверов и легаси-библиотек всё ещё используют synchronized внутри блокирующих вызовов. Перед массовым переходом на SimpleAsyncTaskScheduler с виртуальными потоками для задач, плотно работающих с БД, стоит проверить JDK Flight Recorder (событие jdk.VirtualThreadPinned) на реальную частоту pinning — начиная с JDBC 4.3-совместимых свежих драйверов и JDK 24 (где synchronized перестал пиннить в большинстве случаев) эта проблема значительно менее актуальна, но для legacy-стека её стоит подтвердить эмпирически, а не считать решённой по умолчанию.

Связка с @Async на виртуальных потоках

@Scheduled и @Async решают разные задачи (когда запускать vs как не блокировать вызывающий поток), но часто используются вместе: шедулер запускает лёгкий метод-триггер, а тяжёлая работа делегируется в @Async-метод, чтобы шедулер не держал свой (пусть даже виртуальный) поток на всё время долгой обработки и мог вовремя запланировать следующий тик.

@Configuration
@EnableAsync
class AsyncConfig {

    // Имя бина "applicationTaskExecutor" или "taskExecutor" — Spring
    // Boot использует его как дефолтный Executor и для @Async, и для
    // ThreadPoolTaskExecutorBuilder, если бин объявлен явно
    @Bean("applicationTaskExecutor")
    Executor applicationTaskExecutor() {
        SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("async-vt-");
        executor.setVirtualThreads(true);
        executor.setConcurrencyLimit(100); // защита downstream, не самого пула
        return executor;
    }
}

@Component
class OutboundNotificationJob {

    private final NotificationService notificationService;

    // Шедулер (тоже на виртуальных потоках) вызывает лёгкий метод,
    // который лишь ставит задачи в работу и сразу освобождается
    @Scheduled(fixedDelay = 15_000)
    void dispatchPendingNotifications() {
        notificationService.findPending().forEach(notificationService::sendAsync);
    }
}

@Service
class NotificationService {

    // Каждый вызов уходит в отдельный виртуальный поток из
    // applicationTaskExecutor и не блокирует вызывающую сторону
    @Async
    CompletableFuture<Void> sendAsync(Notification notification) {
        deliveryClient.send(notification); // блокирующий I/O — ок для virtual thread
        return CompletableFuture.completedFuture(null);
    }
}

Здесь работают два независимых Executor на виртуальных потоках с разной ролью: TaskScheduler отвечает за таймер («когда запускать») и не должен занимать поток дольше, чем нужно для диспетчеризации; @Async-Executor отвечает за фактическое выполнение потенциально долгой работы и содержит собственный setConcurrencyLimit — важно понимать, что лимит здесь ограничивает не «потоки» (они почти бесплатны), а реальную нагрузку на downstream-зависимости (пул соединений к БД, rate limit внешнего API), которые остаются дорогим ресурсом независимо от модели потоков.

🔑 Виртуальные потоки не отменяют нужность лимитов конкурентности. Дешевизна виртуальных потоков решает проблему «дорого держать N потоков в ожидании I/O», но не решает проблему «downstream-система не выдержит N одновременных запросов». setConcurrencyLimit на SimpleAsyncTaskExecutor/SimpleAsyncTaskScheduler — это по сути встроенный Bulkhead (см. тему 01), и его стоит настраивать исходя из ёмкости зависимости, а не из «сколько потоков не жалко».
🚨 Один общий Executor на @Scheduled и @Async — риск взаимной блокировки. Если по невнимательности (или «чтобы не плодить бины») TaskScheduler и @Async-Executor — это один и тот же экземпляр с общим пулом/лимитом конкурентности, возникает классический thread/permit-starvation deadlock: шедулерный метод сам вызывает @Async-метод и синхронно ждёт результат через future.get()/join() — поток, исполняющий тик шедулера, занимает слот в общем пуле и блокируется в ожидании, пока освободится слот для самой асинхронной задачи. Если такие тики накопятся и займут все доступные слоты одновременно, свободного слота для выполнения асинхронной работы просто не останется — все ждут друг друга бесконечно. Для ThreadPoolTaskScheduler/ThreadPoolTaskExecutor с фиксированным числом потоков это классика (N блокирующих ожиданий на пуле из N потоков = гарантированный дедлок). Виртуальные потоки сами по себе не спасают: setConcurrencyLimit у SimpleAsyncTaskScheduler/SimpleAsyncTaskExecutor — это семафор с фиксированным числом разрешений, а не бесконечный ресурс, и та же логика блокировки применима к нему один в один. Правило: держать TaskScheduler и @Async-Executor раздельными бинами (как в примере выше) и никогда не делать блокирующий .get()/.join() на результате @Async-вызова изнутри @Scheduled-метода, если он использует общий с шедулером ресурс — либо не ждать результат синхронно вовсе (fire-and-forget/callback), либо ждать через отдельный, независимый пул.

Программный шедулинг и кастомный Trigger

Помимо декларативного @Scheduled, TaskScheduler можно внедрить как обычный бин и использовать императивно — полезно, когда расписание конкретной задачи вычисляется динамически (например, зависит от настроек в БД) или когда логика следующего запуска сложнее, чем выражается cron-строкой:

class BusinessDaysTrigger implements Trigger {

    private final LocalTime runAt;

    BusinessDaysTrigger(LocalTime runAt) {
        this.runAt = runAt;
    }

    @Override
    public Instant nextExecution(TriggerContext context) {
        ZonedDateTime candidate = ZonedDateTime.now().with(runAt);
        if (context.lastActualExecution() != null && !candidate.isAfter(ZonedDateTime.now())) {
            candidate = candidate.plusDays(1);
        }
        while (isWeekendOrHoliday(candidate.toLocalDate())) {
            candidate = candidate.plusDays(1);
        }
        return candidate.toInstant();
    }
}

@Component
class SettlementScheduler {

    SettlementScheduler(TaskScheduler taskScheduler) {
        taskScheduler.schedule(
            this::runSettlement,
            new BusinessDaysTrigger(LocalTime.of(2, 0))
        );
    }

    private void runSettlement() { /* ... */ }
}

taskScheduler.schedule(task, trigger) возвращает ScheduledFuture<?>, который можно сохранить и вызвать на нём cancel(false) для отмены задачи в рантайме — то, что недоступно декларативному @Scheduled, у которого расписание фиксировано на этапе старта контекста.

Множество инстансов: проблема дублирующегося запуска

@Scheduled — механизм уровня одного JVM-процесса: он ничего не знает о других инстансах приложения. При горизонтальном масштабировании (несколько подов/инстансов одного сервиса) каждый инстанс независимо запустит одну и ту же задачу по одному и тому же расписанию — для идемпотентных задач это может быть безобидно (пустая работа), но для неидемпотентных (например, «отправить email-дайджест раз в сутки») это прямой баг дублирования.

Сам Spring эту проблему не решает — для распределённой блокировки задач между инстансами применяют внешние библиотеки (например, ShedLock, которая через аннотацию @SchedulerLock берёт распределённую блокировку в БД/Redis перед выполнением) либо переносят действительно критичные по единственности исполнения задачи на выделенный worker-компонент вместо декларативного @Scheduled в каждом реплицированном инстансе веб-сервиса.

🔑 Практическое правило. Прежде чем полагаться на @Scheduled в сервисе, который будет горизонтально масштабироваться — явно ответьте на вопрос: «что будет, если эта задача выполнится одновременно на трёх инстансах?». Если ответ «ничего страшного» (идемпотентный опрос, чтение статуса) — можно оставить как есть. Если ответ «задвоится отправка/списание» — нужна распределённая блокировка или выделение задачи в отдельный компонент с единственной репликой.

Сравнение вариантов TaskScheduler

КритерийThreadPoolTaskSchedulerSimpleAsyncTaskScheduler (virtual threads)Quartz (spring-boot-starter-quartz)
Модель потоковФиксированный пул платформенных потоковНовый (виртуальный) поток на каждое выполнениеСвой пул воркеров + JDBC job store
Персистентность расписанияНет — только in-memory, теряется при рестарте (задача просто перепланируется при старте контекста)Нет — так же in-memoryДа — можно хранить в БД, переживает рестарт и миграцию на другой инстанс (JobStoreTX)
Кластеризация "из коробки"НетНетДа — встроенная поддержка распределённого исполнения без дублей
Подходит дляНебольшое число задач с предсказуемой, не слишком высокой параллельностьюМного I/O-bound задач (сетевые вызовы, БД), где важна дешёвая высокая параллельностьСложные бизнес-сценарии: динамические расписания, misfire-политики, гарантия единственного исполнения в кластере
Цена сложностиНизкаяНизкая, но требует внимания к pinning на legacy-кодеВысокая — отдельная инфраструктура, схема БД, кривая обучения
✅ Практический ориентир. Для большинства сервисов на Spring Boot 4 с I/O-bound периодическими задачами (опрос БД/очереди/внешнего API) разумный дефолт — SimpleAsyncTaskScheduler с виртуальными потоками и явным setConcurrencyLimit, подобранным под ёмкость downstream-зависимостей. Quartz стоит доставать из коробки только тогда, когда реально нужна персистентность расписаний между рестартами или честная кластерная эксклюзивность выполнения — тащить его ради простого cron-джоба избыточно.