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

Когда приложение получает сигнал завершения, первый страх — потерять данные. Кто-то в этот момент обращается к базе, транзакция открыта, и если пул соединений закроется не вовремя — запись может откатиться, а внешний вызов уже прошёл. Разберём, как Spring Boot справляется с этим по умолчанию и где можно случайно сломать то, что работало само.

Почему HikariCP закрывается в самом конце

Представьте, что вы закрываете ресторан: сначала перестают пускать новых посетителей, ждут пока все поедят, и только потом гасят свет и запирают двери. Если погасить свет раньше — у гостей в тарелках останется недоеденное.

Spring Boot делает примерно то же самое со своими компонентами при получении SIGTERM:

Шаг 1 — останавливаем «входы»:
  - Tomcat перестаёт принимать новые HTTP-запросы (дожидается текущих)
  - KafkaListenerContainer прекращает забирать новые сообщения
  - TaskScheduler не запускает новые задачи, ждёт текущую
  - ThreadPoolTaskExecutor завершает очередь (если настроен)

Шаг 2 — закрываем контекст:
  - Уничтожаются бины в обратном порядке создания
  - HikariDataSource.close() — самым последним

К моменту, когда HikariCP закрывает соединения, все транзакции уже завершены — либо зафиксированы, либо откачены. Пул закрывает уже свободные соединения.

Это поведение по умолчанию, и оно правильное. Трогать его не нужно.

Что происходит с активными транзакциями

Если в момент SIGTERM у приложения открыта транзакция, судьба этой транзакции зависит от того, в каком контексте она выполняется.

HTTP-запрос в транзакции

@PostMapping("/orders")
@Transactional
public OrderResponse create(@RequestBody CreateOrderRequest req) {
    var order = orderRepository.save(req.toOrder());
    paymentService.charge(order);
    return OrderResponse.from(order);
}

При SIGTERM Tomcat в режиме graceful shutdown даёт текущим обработчикам завершиться до таймаута. Если метод успевает вернуть ответ — транзакция фиксируется. Если таймаут истекает раньше — транзакция откатывается, клиент получает ошибку 5xx, но база остаётся согласованной.

Запланированная задача в транзакции

@Scheduled(fixedDelay = 30_000)
@Transactional
public void processOutbox() {
    var batch = outboxRepository.findUnpublished(50);
    batch.forEach(this::publish);
}

TaskScheduler при завершении ждёт, пока закончится текущая итерация задачи. Транзакция завершается нормально — фиксацией или откатом. Следующая итерация не запустится.

Kafka-слушатель в транзакции

@KafkaListener(topics = "orders.events")
@Transactional
public void onEvent(OrderEvent event) {
    orderRepository.save(fromEvent(event));
}

KafkaListenerContainer дожидается завершения текущего пакета сообщений. После этого транзакция завершается, и контейнер останавливается.

Фоновая задача через @Async

@Async
@Transactional
public void processInBackground(Long orderId) {
    // долгая обработка
}

Это самый непростой случай. ThreadPoolTaskExecutor при остановке должен быть настроен явно:

@Bean
TaskExecutor taskExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(20);
    return executor;
}

Без этих настроек поток прерывается немедленно, транзакция откатывается. Если awaitTermination истекает раньше завершения задачи — поток получает прерывание, и транзакция тоже откатывается.

Liquibase и Flyway — только при старте

Иногда возникает вопрос: нужно ли что-то делать с миграциями при завершении приложения? Нет.

Liquibase и Flyway работают только при старте: читают файлы миграций, применяют новые скрипты, закрывают свои соединения и больше ничего не делают. На shutdown они не вмешиваются и не требуют обслуживания.

Если вы думаете о «SQL-скрипте при завершении» — это неправильный подход. Все изменения схемы делаются миграциями при старте.

Частая ошибка: закрыть пул самостоятельно

Иногда разработчики пишут такой код:

@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class DataSourceCleaner {

    private final DataSource dataSource;

    DataSourceCleaner(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @PreDestroy
    public void cleanup() {
        ((HikariDataSource) dataSource).close(); // не делайте так
    }
}

Намерение понятно: закрыть пул в самом конце. Но это ломает поведение по трём причинам.

Во-первых, Spring Boot уже знает, как и когда закрывать DataSource — дублирование приводит к двойному закрытию и IllegalStateException.

Во-вторых, @Order(LOWEST_PRECEDENCE) не гарантирует, что этот компонент уничтожится последним — другие компоненты с таким же порядком могут оказаться позже.

В-третьих, если пул закроется до того, как завершится запланированная задача с открытой транзакцией, все обращения к базе упадут с SQLException: HikariDataSource closed — и транзакция откатится в неподходящий момент.

Правильное решение — не делать ничего. Spring Boot закроет DataSource в правильный момент без вашей помощи.

Та же логика применима к:

  • dataSource.unwrap(HikariDataSource.class).close() в shutdown hook
  • самодельному BeanDestructionAware для DataSource
  • любому @PreDestroy с SQL-операциями на запись

Коротко

  • Spring Boot закрывает HikariCP после всех других компонентов — к этому моменту транзакции уже завершены.
  • HTTP-запросы, запланированные задачи и Kafka-слушатели имеют свои механизмы дожидания активной транзакции.
  • @Async требует явной настройки: setWaitForTasksToCompleteOnShutdown(true) и setAwaitTerminationSeconds.
  • Liquibase и Flyway работают только при старте, на shutdown ничего не делают.
  • Закрывать пул соединений вручную через @PreDestroy — ошибка: Spring сделает это сам и в правильный момент.

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

  • Запланированные задачи и @Async при завершении — настройка TaskScheduler и ThreadPoolTaskExecutor.
  • HTTP graceful drain — как Tomcat дожидается текущих запросов.
  • Kafka shutdown — завершение listener container и обработка пакетов.
  • Идемпотентность при прерывании — что делать, если транзакция всё же откатилась.