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

Фоновые задачи — самое опасное место при остановке приложения. Если прервать задачу посередине, транзакция откатится, но внешний вызов (HTTP, Kafka) уже ушёл — данные разойдутся. Разберём, как Spring управляет тремя типами фоновых задач и что нужно настроить.

Как Spring останавливает @Scheduled-задачи

По умолчанию при получении сигнала SIGTERM Spring просто останавливает планировщик — без ожидания текущей итерации. Это опасно: задача обрывается на середине.

Нужная настройка:

spring:
  task:
    scheduling:
      shutdown:
        await-termination: true
        await-termination-period: 25s
      pool:
        size: 4

Что происходит при такой конфигурации:

  1. Spring получает SIGTERM и публикует событие закрытия контекста.
  2. ThreadPoolTaskScheduler перестаёт запускать новые итерации.
  3. Текущая итерация дорабатывает до конца.
  4. Если итерация не завершилась за 25 секунд — планировщик прерывает поток (interrupt).

Пример: outbox-relay отправляет события в Kafka каждые 500 мс:

@Component
@RequiredArgsConstructor
public class OutboxRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() {
        var batch = outboxRepository.fetchUnpublished(50);
        for (var event : batch) {
            kafkaTemplate.send(event.topic(), event.partitionKey(), event.payload()).get();
            outboxRepository.markPublished(event.id());
        }
    }
}

При SIGTERM в момент выполнения publish():

  • Spring ждёт завершения текущего вызова.
  • Если транзакция успела зафиксироваться — все события помечены как опубликованные.
  • Если нет (например, Kafka недоступна) — транзакция откатывается, события остаются необработанными, при следующем старте relay подхватит их снова.

Почему @Async опасен без явной настройки

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

Нужно явно объявить бин исполнителя с правильными настройками:

@Bean(destroyMethod = "shutdown")
public ThreadPoolTaskExecutor asyncExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("async-");

    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(20);

    executor.initialize();
    return executor;
}

Ключевые параметры:

  • setWaitForTasksToCompleteOnShutdown(true) — ждать завершения запущенных задач.
  • setAwaitTerminationSeconds(20) — максимальное время ожидания; после этого потоки прерываются.
  • destroyMethod = "shutdown" — Spring вызовет корректное завершение пула.

Если в @Async-методе есть Thread.sleep или блокирующий Future.get(), они бросят InterruptedException при принудительной остановке. Код должен это обрабатывать:

@Component
@RequiredArgsConstructor
public class AsyncEmailSender {

    private final EmailClient emailClient;

    @Async
    public CompletableFuture<Void> sendAsync(String to, String subject, String body) {
        try {
            emailClient.send(to, subject, body);
            return CompletableFuture.completedFuture(null);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.failedFuture(e);
        } catch (Exception e) {
            return CompletableFuture.failedFuture(e);
        }
    }
}

Если в @Async-методе длинная цепочка вызовов (платёж → база → уведомление), лучше выносить её в outbox + отдельный воркер. Тогда прерывание означает только «продолжим в следующем запуске», а не потерю результата.

Outbox-relay: почему он безопасен при остановке

Outbox-паттерн хорошо сочетается с graceful shutdown именно из-за FOR UPDATE SKIP LOCKED:

@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
    var batch = dsl.selectFrom(OUTBOX_EVENT)
        .where(OUTBOX_EVENT.PUBLISHED_AT.isNull())
        .orderBy(OUTBOX_EVENT.ID)
        .limit(50)
        .forUpdate().skipLocked()
        .fetch();

    for (var row : batch) {
        kafkaTemplate.send(row.getTopic(), row.getPartitionKey(), row.getPayload()).get();
        row.setPublishedAt(Instant.now());
        row.store();
    }
}

При остановке приложения:

  1. Spring ждёт завершения текущего вызова publish().
  2. Если транзакция зафиксировалась — строки помечены как опубликованные.
  3. Если транзакция откатилась — строки остались незаблокированными.
  4. При следующем запуске (или в соседнем поде) SKIP LOCKED захватывает эти строки и обрабатывает.

Ни одно событие не теряется и не дублируется — при условии, что Kafka-отправка идемпотентна или at-least-once допустим по условиям задачи.

Частая ошибка: бесконечный цикл внутри @Scheduled

// Неправильно — Spring не сможет остановить такую задачу
@Scheduled(fixedDelay = 100)
public void relay() {
    while (true) {
        publish();
    }
}

TaskScheduler умеет останавливать между итерациями, но не прерывать бесконечный цикл внутри одной итерации — только если там есть блокирующий вызов, который поймает InterruptedException.

Правильно: каждая итерация @Scheduled-метода обрабатывает один пакет событий и завершается. Планировщик сам контролирует частоту через fixedDelay.

Что не работает и почему

setWaitForTasksToCompleteOnShutdown(false) — самая опасная настройка. При остановке все запущенные @Async-задачи убиваются немедленно. Реальный сценарий потери данных:

  1. @Async-метод chargeCustomer выполняется.
  2. Вызов к платёжной системе прошёл успешно — деньги списаны.
  3. До сохранения результата в базу — SIGTERM, задача прервана.
  4. В базе нет записи о платеже, но деньги уже ушли.

Идемпотентность помогает при повторных попытках, но без ожидания завершения каждая остановка — потенциальная потеря данных.

Другие частые ошибки:

  • await-termination-period больше 30 секунд — не помещается в общий бюджет остановки (60 секунд), останется мало времени на дрейн HTTP и закрытие соединений с базой.
  • @Async без явного бина ThreadPoolTaskExecutor — Spring использует простой SimpleAsyncTaskExecutor, который не поддерживает ожидание завершения задач.
  • Кастомный пул потоков без destroyMethod = "shutdown" — пул не завершится корректно, Spring не знает о нём.

Коротко

  • await-termination: true + await-termination-period: 25s — обязательная настройка для @Scheduled; без неё текущая итерация прерывается.
  • @Async требует явного бина ThreadPoolTaskExecutor с setWaitForTasksToCompleteOnShutdown(true) и setAwaitTerminationSeconds(20).
  • destroyMethod = "shutdown" на бине пула — иначе Spring не вызовет корректное завершение.
  • Outbox-relay безопасен при остановке: FOR UPDATE SKIP LOCKED гарантирует, что незавершённый пакет событий подхватит следующий запуск.
  • Бесконечный while(true) внутри @Scheduled — блокирует остановку; каждая итерация должна завершаться сама.
  • setWaitForTasksToCompleteOnShutdown(false) — потеря данных при любом плановом деплое.

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

  • JVM и Spring: базовая конфигурация graceful shutdown
  • База данных и транзакции при остановке
  • HTTP-дрейн: как не обрывать запросы в полёте
  • Бюджеты и наблюдаемость