Фоновые задачи — самое опасное место при остановке приложения. Если прервать задачу посередине, транзакция откатится, но внешний вызов (HTTP, Kafka) уже ушёл — данные разойдутся. Разберём, как Spring управляет тремя типами фоновых задач и что нужно настроить.
Как Spring останавливает @Scheduled-задачи
По умолчанию при получении сигнала SIGTERM Spring просто останавливает планировщик — без ожидания текущей итерации. Это опасно: задача обрывается на середине.
Нужная настройка:
spring:
task:
scheduling:
shutdown:
await-termination: true
await-termination-period: 25s
pool:
size: 4
Что происходит при такой конфигурации:
- Spring получает SIGTERM и публикует событие закрытия контекста.
ThreadPoolTaskSchedulerперестаёт запускать новые итерации.- Текущая итерация дорабатывает до конца.
- Если итерация не завершилась за 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();
}
}
При остановке приложения:
- Spring ждёт завершения текущего вызова
publish(). - Если транзакция зафиксировалась — строки помечены как опубликованные.
- Если транзакция откатилась — строки остались незаблокированными.
- При следующем запуске (или в соседнем поде)
SKIP LOCKEDзахватывает эти строки и обрабатывает.
Ни одно событие не теряется и не дублируется — при условии, что Kafka-отправка идемпотентна или at-least-once допустим по условиям задачи.
Частая ошибка: бесконечный цикл внутри @Scheduled
// Неправильно — Spring не сможет остановить такую задачу
@Scheduled(fixedDelay = 100)
public void relay() {
while (true) {
publish();
}
}
TaskScheduler умеет останавливать между итерациями, но не прерывать бесконечный цикл внутри одной итерации — только если там есть блокирующий вызов, который поймает InterruptedException.
Правильно: каждая итерация @Scheduled-метода обрабатывает один пакет событий и завершается. Планировщик сам контролирует частоту через fixedDelay.
Что не работает и почему
setWaitForTasksToCompleteOnShutdown(false) — самая опасная настройка. При остановке все запущенные @Async-задачи убиваются немедленно. Реальный сценарий потери данных:
@Async-методchargeCustomerвыполняется.- Вызов к платёжной системе прошёл успешно — деньги списаны.
- До сохранения результата в базу — SIGTERM, задача прервана.
- В базе нет записи о платеже, но деньги уже ушли.
Идемпотентность помогает при повторных попытках, но без ожидания завершения каждая остановка — потенциальная потеря данных.
Другие частые ошибки:
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-дрейн: как не обрывать запросы в полёте
- Бюджеты и наблюдаемость