Когда приложение получает сигнал завершения, первый страх — потерять данные. Кто-то в этот момент обращается к базе, транзакция открыта, и если пул соединений закроется не вовремя — запись может откатиться, а внешний вызов уже прошёл. Разберём, как 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 и обработка пакетов.
- Идемпотентность при прерывании — что делать, если транзакция всё же откатилась.