Декларативный шедулинг в 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 = 60_000 стабильно выполняется 90 секунд, а пул шедулера однопоточный (см. следующий раздел) — запуски начинают копиться в очереди и выполняться подряд без пауз, «нагоняя» расписание. Это не баг, а прямое следствие семантики fixedRate при недостаточном параллелизме — почти всегда для потенциально долгих задач нужнее fixedDelay либо пул с достаточным числом потоков.
В отличие от @Retryable/@Async/@Transactional, шедулинг не создаёт AOP-прокси — вызов размеченного метода не нужно перехватывать на каждом обращении, потому что вызывающей стороны как таковой нет: метод вызывается не пользовательским кодом, а самим фреймворком по таймеру. Поэтому механика проще и один раз выполняется на старте контекста:
@EnableScheduling импортирует SchedulingConfiguration, которая регистрирует бин ScheduledAnnotationBeanPostProcessorpostProcessAfterInitialization этот BeanPostProcessor сканирует методы каждого бина в поисках @Scheduled (и мета-аннотации @Schedules для нескольких расписаний на одном методе)Trigger: cron → CronTrigger, fixedRate/fixedDelay → PeriodicTriggerRunnable (обычный рефлективный вызов — ScheduledMethodRunnable) и регистрируется в ScheduledTaskRegistrarScheduledTaskRegistrar передаёт задачи в бин 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);
CronTrigger, и PeriodicTrigger реализуют один интерфейс org.springframework.scheduling.Trigger с единственным методом nextExecution(TriggerContext). Это значит, что вы можете написать собственный Trigger с произвольной логикой (например, «пропускать выходные и праздники») и передать его в TaskScheduler программно, не привязываясь к синтаксису cron — см. раздел про программный шедулинг ниже.
Если в контексте не объявлен собственный бин 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) — чтобы отказ был виден в мониторинге, а не только в логах.
Начиная со Spring Framework 6.1 (и как дефолт-опция в Spring Boot 4 через spring.threads.virtual.enabled=true) появился альтернативный TaskScheduler — SimpleAsyncTaskScheduler. В отличие от 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 ниже), поэтому сотни параллельно выполняющихся задач обходятся системе почти бесплатно по сравнению с эквивалентным пулом из сотен платформенных потоков.
synchronized-блока (или нативный вызов через JNI) блокируется — виртуальный поток не освобождает carrier-поток, на котором исполняется, а «пришпиливается» к нему (pinning), временно теряя главное преимущество виртуальных потоков. Старые версии некоторых JDBC-драйверов и легаси-библиотек всё ещё используют synchronized внутри блокирующих вызовов. Перед массовым переходом на SimpleAsyncTaskScheduler с виртуальными потоками для задач, плотно работающих с БД, стоит проверить JDK Flight Recorder (событие jdk.VirtualThreadPinned) на реальную частоту pinning — начиная с JDBC 4.3-совместимых свежих драйверов и JDK 24 (где synchronized перестал пиннить в большинстве случаев) эта проблема значительно менее актуальна, но для legacy-стека её стоит подтвердить эмпирически, а не считать решённой по умолчанию.
@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), которые остаются дорогим ресурсом независимо от модели потоков.
setConcurrencyLimit на SimpleAsyncTaskExecutor/SimpleAsyncTaskScheduler — это по сути встроенный Bulkhead (см. тему 01), и его стоит настраивать исходя из ёмкости зависимости, а не из «сколько потоков не жалко».
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), либо ждать через отдельный, независимый пул.
Помимо декларативного @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 в сервисе, который будет горизонтально масштабироваться — явно ответьте на вопрос: «что будет, если эта задача выполнится одновременно на трёх инстансах?». Если ответ «ничего страшного» (идемпотентный опрос, чтение статуса) — можно оставить как есть. Если ответ «задвоится отправка/списание» — нужна распределённая блокировка или выделение задачи в отдельный компонент с единственной репликой.
| Критерий | ThreadPoolTaskScheduler | SimpleAsyncTaskScheduler (virtual threads) | Quartz (spring-boot-starter-quartz) |
|---|---|---|---|
| Модель потоков | Фиксированный пул платформенных потоков | Новый (виртуальный) поток на каждое выполнение | Свой пул воркеров + JDBC job store |
| Персистентность расписания | Нет — только in-memory, теряется при рестарте (задача просто перепланируется при старте контекста) | Нет — так же in-memory | Да — можно хранить в БД, переживает рестарт и миграцию на другой инстанс (JobStoreTX) |
| Кластеризация "из коробки" | Нет | Нет | Да — встроенная поддержка распределённого исполнения без дублей |
| Подходит для | Небольшое число задач с предсказуемой, не слишком высокой параллельностью | Много I/O-bound задач (сетевые вызовы, БД), где важна дешёвая высокая параллельность | Сложные бизнес-сценарии: динамические расписания, misfire-политики, гарантия единственного исполнения в кластере |
| Цена сложности | Низкая | Низкая, но требует внимания к pinning на legacy-коде | Высокая — отдельная инфраструктура, схема БД, кривая обучения |
SimpleAsyncTaskScheduler с виртуальными потоками и явным setConcurrencyLimit, подобранным под ёмкость downstream-зависимостей. Quartz стоит доставать из коробки только тогда, когда реально нужна персистентность расписаний между рестартами или честная кластерная эксклюзивность выполнения — тащить его ради простого cron-джоба избыточно.