Опирается на правила: R-SHUT-DB-1R-SHUT-DB-3 и R-SHUT-DB-X1 из Graceful Shutdown Style Guide → раздел 4. БД и persistence.

Важно знать

  • HikariCP закрывается после Spring shutdown phases — дефолт Spring Boot правильный.
  • Не переопределять @PreDestroy на DataSource — может закрыть pool до завершения scheduled-tasks.
  • Активные транзакции дожимаются graceful-механизмами своих контекстов: HTTP, @Scheduled, @KafkaListener, @Async.
  • Liquibase/Flyway работают на startup, не на shutdown — миф «что-то почистить» неверен.
  • pool.shutdown() в @Order(LOWEST_PRECEDENCE) — закроет pool до scheduled tasks → SQLException.
  • Главный принцип: trust Spring lifecycle ordering, не переопределяй.

DB-pool — критический ресурс. Закрыть его слишком рано = @Scheduled-job не сможет committed его последнюю транзакцию, side-effect-вызов внешней системы прошёл, а DB-запись откатилась → inconsistency. UCP формулирует одно правило: не вмешиваться в lifecycle.

HikariCP закрывается в последнюю очередь

R-SHUT-DB-1: Spring Boot делает правильно по дефолту.

Spring lifecycle на shutdown (упрощённо):

Phase 1: SmartLifecycle stop в reverse order
         - Web container (Tomcat) graceful drain
         - KafkaListenerContainer.stop()
         - TaskScheduler.shutdown()
         - @Async ThreadPoolTaskExecutor.shutdown()

Phase 2: ApplicationContext.close()
         - Beans destroyed in reverse order
         - HikariDataSource.close() в самом конце

Это означает: к моменту закрытия pool все active transactions уже завершены (либо committed, либо rolled back в своих контейнерах). Pool закрывает unused connections.

Не переопределяй порядок. Не пиши @PreDestroy на DataSource. Не вмешивайся в BeanDestructionAware.

Активные транзакции дожимаются

R-SHUT-DB-2: каждый контекст имеет свой механизм.

HTTP-handler в transaction

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

При SIGTERM:

  • Graceful HTTP (см. HTTP drain) даёт handler завершиться.
  • @Transactional commit при exit метода (если успел в timeout-per-shutdown-phase).
  • Если timeout — transaction rollback → клиент получает 5xx, но DB consistent.

@Scheduled в transaction

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

При SIGTERM:

  • Spring TaskScheduler.shutdown() ждёт текущую итерацию (см. Scheduled / Async / outbox).
  • Текущая @Transactional либо commit, либо rollback.

@KafkaListener в transaction

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

При SIGTERM:

  • Kafka listener container ждёт текущий batch (см. Kafka shutdown).
  • @Transactional завершается с commit/rollback.

@Async в transaction

@Async
@Transactional
public void processInBackground(Long orderId) {
    ...
}

При SIGTERM:

  • ThreadPoolTaskExecutor.shutdown() с setWaitForTasksToCompleteOnShutdown(true).
  • awaitTermination(20) — даём 20 секунд.
  • Если не успел — interrupt(), InterruptedException, transaction rollback.

@Async — самый сложный случай, см. Scheduled / Async / outbox.

Liquibase/Flyway — только startup

R-SHUT-DB-3: миф «миграции при выходе» не существует.

Liquibase / Flyway:

  • Запускаются при старте приложения.
  • Применяют новые migration scripts.
  • После — закрывают свои internal connections.

На shutdown — ничего не делают. Не пиши @PreDestroy для «cleanup миграций» — нечего чистить.

Если используется shutdown-script (раздать SQL на shutdown) — это анти-паттерн. Все миграции — на startup, без исключений.

Что запрещено

pool.shutdown() в @PreDestroy

R-SHUT-DB-X1:

// КАТАСТРОФА
@Component
@RequiredArgsConstructor
@Order(Ordered.LOWEST_PRECEDENCE)
public class DataSourceCleaner {

    private final DataSource dataSource;

    @PreDestroy
    public void cleanup() {
        ((HikariDataSource) dataSource).close();
    }
}

Проблемы:

  • Spring уже знает, как закрыть DataSource. Дублирование = двойной close (IllegalStateException).
  • @Order(LOWEST_PRECEDENCE) не гарантирует, что выполнится последним — другие компоненты с тем же @Order могут выполниться после.
  • Закрытие pool до завершения scheduled tasks — все pending DB operations падают с SQLException: HikariDataSource closed.

Корректно — ничего не делать. Spring Boot закрывает DataSource в правильную фазу.

Аналогично:

  • Не пиши dataSource.unwrap(HikariDataSource.class).close() в shutdown hook.
  • Не используй custom BeanDestructionAware для DataSource.
  • Не настраивай lazyInitialization через @PreDestroy.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
DataSource.close() в @PreDestroyR-SHUT-DB-X1trust Spring lifecycle
pool.shutdown() в @PreDestroy с @OrderR-SHUT-DB-X1ничего, Spring сам
Кастомный BeanDestructionAware для DataSourceR-SHUT-DB-1дефолт Spring
Long-running queries без timeoutR-SHUT-DB-2statement_timeout на DB session
JdbcTemplate.execute("...") для shutdown cleanupR-SHUT-DB-3startup migrations
@PreDestroy с DB-операциейR-SHUT-DB-X1shutdown hook = closing, не writing
Connection.close() руками без try-with-resourcesR-SHUT-DB-1Spring/jOOQ управляет
Pre-stop SQL script для cleanupR-SHUT-DB-3startup migrations only

Куда дальше

  • Graceful Shutdown → раздел 4. БД и persistence — нормативные формулировки.
  • JVM/Spring конфигурация — общий graceful Spring.
  • Scheduled / Async / outbox — TaskScheduler shutdown.
  • HTTP drain — handler transactions.
  • Kafka shutdown — listener transactions.
  • Идемпотентность in-flight — partial commits.
  • PG runtime → optimistic lock — interrupted transactions.