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

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

Что происходит с транзакцией, которая была в работе в момент SIGTERM, и почему пул при этом закрывается последним, видно на одной схеме: сначала общая для всех фаза остановки, потом — порядок уничтожения бинов по умолчанию и с собственным @PreDestroy.

SIGTERM · в полёте транзакция processOutbox на 50 строксекунды от SIGTERM0 с5 с10 с15 с Фаза stop: планировщик ждёт текущую итерациюничего настраивать не нужноTX processOutbox · 50 строк COMMIT 50 строк 12 с — COMMIT 50 строк, соединение вернулось в пулпредел ожидания — spring.lifecycle.timeout-per-shutdown-phase, 30 сфаза stop закончилась — дальше уничтожение бинов (порядок шагов, не секунды) По умолчанию: close() — последний шаг уничтожениярепозитории и сервисы@PreDestroy: запись в базуHikariDataSource.close()в пуле 0 занятых соединений — закрывать нечего Ошибка: свой @PreDestroy зовёт close()@Order на порядок уничтожения не влияетрепозитории и сервисыclose() из @PreDestroy@PreDestroy: запись в базуу тех, кого уничтожают после, — SQLException: HikariDataSource closed Пул закрывают последним — и это уже делает SpringСвой close() ставит закрытие в середину — и рвёт тех, кто ещё пишет в базу

Транзакцию на 50 строк Spring не бросает: планировщик дожидается текущей итерации в фазе stop, и только после неё контекст уничтожает бины. В этом порядке пул закрывается последним — занятых соединений уже нет. Свой @PreDestroy с close() встаёт в середину, и те, кого уничтожают после него, получают HikariDataSource closed.

Обязательно

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

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

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

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

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

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

Если же соединение всё-таки осталось занятым, пул не ждёт бесконечно: он в цикле выселяет соединения и обрывает активные, и на это отведено до 10 секунд. Транзакция, которая шла в этот момент, обрывается принудительно. Отсюда, кстати, и берутся «лишние» секунды в конце остановки, когда по расчёту всё должно было уложиться быстрее.

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

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

Если в момент SIGTERM у приложения открыта транзакция, судьба этой транзакции зависит от того, в каком контексте она выполняется. Три первых случая — HTTP-запрос, задача по расписанию, Kafka-слушатель — Spring дожимает сам; опасен только четвёртый, @Async, и к нему стоит присмотреться внимательнее всего.

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

@PostMapping("/orders")
public OrderResponse create(@RequestBody @Valid CreateOrderRequest req) {
    return orderService.create(req);
}
@Transactional
public OrderResponse create(CreateOrderRequest req) {
    var order = orderRepository.save(req.toOrder());
    outboxRepository.append(new ChargeRequested(order.id(), order.total()));
    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 дожидается завершения текущего пакета сообщений — но не бесконечно. При закрытии контекста границу задаёт общий spring.lifecycle.timeout-per-shutdown-phase; собственная настройка контейнера shutdownTimeout тут не работает, она про ручной container.stop(). Уложился listener в это окно — транзакция завершается нормально и контейнер останавливается. Не уложился — Spring перестаёт ждать и идёт закрывать контекст дальше.

Фоновая задача через @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, прерывания не будет: Spring запишет предупреждение в лог и пойдёт закрывать контекст дальше. Задача продолжит работать в непрерванном потоке, пока её не оборвёт закрытый пул соединений или SIGKILL.

А если процесс убили: что станет с транзакцией

Всё описанное выше про мягкую остановку. Бюджет может и не хватить — тогда приходит SIGKILL, и процесс исчезает мгновенно, не выполнив ни одного обработчика. Что происходит с базой в этот момент, стоит знать точно, потому что тут прячется неприятный эффект.

Данные не пострадают. Незафиксированная транзакция остаётся незафиксированной: база откатит её, как только поймёт, что клиент исчез. Целостность не нарушается, половина изменений не «протекает» — это свойство транзакции, и SIGKILL его не отменяет.

А вот блокировки держатся дольше, чем кажется. Проблема в том, когда база узнаёт об исчезновении клиента. Соединение оборвано на уровне процесса, но серверу об этом никто не сообщил: PostgreSQL продолжает ждать следующей команды от сеанса, который уже мёртв. Сеанс остаётся в состоянии idle in transaction, а все взятые им блокировки строк — взятыми. Соседние поды, которым нужны те же строки, ждут.

Сколько ждут: пока операционная система не закроет соединение, а она делает это по проверке живучести (tcp_keepalive), и стандартные настройки Linux дают около двух часов. Для базы это вечность.

Три настройки, которые закрывают этот случай, и все три ставят заранее.

idle_in_transaction_session_timeout в PostgreSQL — самая полезная из всех: сеанс, который открыл транзакцию и ничего не делает дольше заданного времени, обрывается сервером сам. Разумное значение — минуты, не часы (например, 5min); задают его на уровне базы или роли приложения.

tcp_keepalives_idle, tcp_keepalives_interval, tcp_keepalives_count на стороне PostgreSQL — заставляют сервер проверять живость клиента раньше двух часов. Например, tcp_keepalives_idle = 60 означает первую проверку через минуту простоя, и мёртвый сеанс обнаружится за считанные минуты.

statement_timeout — ограничение на одно выражение. Он про другое (защита от долгих запросов), но тоже сокращает время, которое сеанс может удерживать блокировки.

Как это выглядит при разборе инцидента, если настроек нет: соседний под отвечает медленно или падает по таймауту пула, в базе видно ожидание блокировки:

живой пример

SELECT pid, state, wait_event_type, wait_event, query_start, left(query, 60)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY query_start;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Найденный мёртвый сеанс обрывают вручную — SELECT pg_terminate_backend(pid) — и идут ставить настройки, чтобы в следующий раз это произошло само.

Долгие транзакции мешают остановке и соседям

Есть ещё одна связь между остановкой и базой, о которой думают в последний момент. Транзакция, которая держит блокировку, при остановке становится проблемой дважды: она задерживает завершение пода и она блокирует соседей, пока не закончится.

Опаснее всего сочетание «долгая транзакция плюс блокировка строк». Типичный вид:

@Transactional
public void processBatch() {
    List<Order> batch = orderRepository.lockPendingForUpdate(1000);   // SELECT ... FOR UPDATE
    for (Order order : batch) {
        externalClient.notify(order);     // сетевой вызов внутри транзакции
        order.markNotified();
    }
}

Здесь тысяча строк заблокирована на всё время обхода, а внутри ещё и сетевой вызов, длительность которого вам не принадлежит. При остановке пода Spring будет ждать завершения этой задачи, съедая бюджет; если не дождётся — оборвёт, и начнётся история из предыдущего раздела.

Что делают, чтобы этого не было.

Пачки меньше. Сто строк вместо тысячи, и транзакция на пачку, а не на весь прогон. Тогда остановка ждёт секунды, а не минуты, и незаконченный остаток подхватит другой под.

Никаких внешних вызовов внутри транзакции. Это правило само по себе полезно (о нём говорит статья про перехват внутри транзакции), а при остановке оно становится критичным: транзакция не должна зависеть от чужого таймаута.

Проверка флага остановки между пачками. Долгий цикл должен уметь остановиться сам, не дожидаясь обрыва: между пачками проверяют признак завершения (в Spring это SmartLifecycle или переданный в задачу признак) и аккуратно выходят, зафиксировав сделанное.

Выборка с пропуском заблокированного. FOR UPDATE SKIP LOCKED вместо FOR UPDATE означает, что параллельные исполнители не ждут друг друга, а берут свободные строки. Тогда остановка одного пода не тормозит остальных.

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

Когда закрывается EntityManagerFactory

Для приложения на JPA порядок остановки чуть длиннее, чем «Tomcat, потом пул», и стоит знать, где в нём фабрика сущностей.

EntityManagerFactory — обычный бин, и закрывается он при уничтожении контекста, до источника данных, потому что зависит от него (бины уничтожаются в обратном порядке создания). При закрытии он завершает кэш второго уровня и освобождает свои ресурсы; открытые в этот момент сеансы работы с сущностями закрываются вместе с ним.

Отсюда практическое следствие для одной распространённой настройки. spring.jpa.open-in-view (она включена по умолчанию и выводит предупреждение при старте) держит сеанс работы с сущностями открытым на всё время обработки HTTP-запроса, включая отрисовку ответа. То есть при остановке это не «транзакция на секунду», а «сеанс, живущий столько, сколько живёт запрос». Пока дренаж HTTP дожидается запросов, всё в порядке: сеансы закроются вместе с ними. Проблема появляется, когда запрос не уложился в бюджет: сеанс обрывается на середине отрисовки, и клиент получает половину ответа с ошибкой сериализации вместо понятной ошибки.

Это ещё один довод выключить эту настройку (spring.jpa.open-in-view: false) и загружать всё нужное явно в сервисном слое: тогда границы транзакции и сеанса совпадают с границами метода, а не с границами запроса, и остановка становится предсказуемой.

Второе следствие — про ленивую загрузку в асинхронных задачах. Сущность, полученная в одном потоке и переданная в @Async, после закрытия сеанса даст LazyInitializationException, и при остановке это происходит чаще, потому что сеансы закрываются рано. Лечится тем же: из транзакционного метода наружу отдают данные, а не сущности.

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

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

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

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

Оговорка для Kubernetes, и она важнее, чем кажется: «при старте» — это при старте чего? Если миграции выполняет само приложение, то при выкате с тремя репликами их одновременно запускают три пода. Flyway и Liquibase от этого защищены (оба берут блокировку в базе, и остальные ждут), но последствия всё равно есть: два пода стоят на блокировке вместо того, чтобы стартовать, время выката растёт, а при долгой миграции пробы готовности успевают покраснеть и кластер начинает перезапускать поды, которые ничего плохого не делали.

Поэтому в кластере миграции обычно выносят из приложения:

Отдельная задача перед выкатом. Объект Job (или хук pre-upgrade в Helm, или волна PreSync в подходе с репозиторием как источником правды), который выполняется до того, как поднимутся новые поды. Упал — выката не будет, и это правильное поведение. Приложение при этом запускается с отключённой автоматической миграцией (spring.flyway.enabled: false) и только проверяет, что схема ожидаемой версии.

Init-контейнер. Миграция выполняется в контейнере, который стартует до основного, внутри того же пода. Проще в описании, но выполняется на каждом поде: три реплики — три запуска (и снова блокировка). Годится, когда реплика одна или когда миграции быстрые и идемпотентные.

Что выбирают: отдельную задачу для промышленного контура (одно исполнение, понятный отказ), автоматическую миграцию приложением — для стенда и локальной работы, где реплика одна и простота важнее. Разбор объектов — в статье про доставку в Kubernetes.

Если база недоступна в момент остановки

Отдельный случай, который ломает бюджет остановки самым обидным образом: под останавливается, а база в это время недоступна — упала, потеряна сеть, кончились соединения.

Что происходит. Spring уничтожает бины и вызывает у них методы завершения. Любой бин, который в этот момент пытается получить соединение (записать последнее состояние, отметить задачу как отменённую, сохранить счётчик), уходит в ожидание — и ждёт столько, сколько задано в настройке получения соединения из пула (spring.datasource.hikari.connection-timeout, по умолчанию 30 секунд). Один такой бин съедает весь бюджет, и остальные шаги остановки просто не выполняются: приходит принудительное завершение.

Отсюда правила для методов завершения, которые стоит проверить в своём коде.

В завершении не обращаются к базе, если без этого можно обойтись. Всё важное фиксируют в основной транзакции, а не в момент остановки. Метод завершения — плохое место для работы с данными: он выполняется в самый неудачный момент и не имеет права на долгие операции.

Если обращение всё-таки нужно, у него свой короткий таймаут. Не 30 секунд из настроек пула, а одна-две секунды на попытку, и отказ не мешает остановке продолжаться:

@Component
@RequiredArgsConstructor
class ShutdownMarker {

    private final JdbcClient jdbc;

    @PreDestroy
    void markStopped() {
        try {
            jdbc.sql("UPDATE nodes SET stopped_at = now() WHERE id = ?")
                    .param(nodeId)
                    .update();
        } catch (Exception e) {
            log.warn("Не удалось отметить остановку узла, продолжаем", e);
        }
    }
}

Обёртка в try/catch здесь не небрежность, а суть: отказ базы не должен мешать остановке. Само по себе это не решает проблему таймаута, поэтому для таких мест заводят отдельный источник данных с коротким connection-timeout либо ограничивают операцию своим таймаутом.

Порядок важнее полноты. Если бинов с работой при завершении несколько, и один из них может повиснуть, страдают все, кто уничтожается после него. Поэтому у важных шагов задают порядок (SmartLifecycle с фазой или @DependsOn), а необязательные делают безопасными для пропуска.

Проверяется это на стенде за минуту: остановите базу и сразу за ней под, посмотрите, за какое время завершился процесс и что в журнале. Если вместо «остановился за три секунды» получилось «получил принудительное завершение через шестьдесят», в цепочке завершения есть бин, который ждёт соединения.

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

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

@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(); // не делайте так
    }
}

Намерение понятно: закрыть пул в самом конце. Ломает это одно: пул закрывается раньше времени. Бины уничтожаются в порядке, обратном порядку создания, с оглядкой на зависимости между ними, а @Order на порядок уничтожения не влияет вообще — он про сортировку списков бинов. Поставить свой @PreDestroy «в самый конец» аннотацией нельзя, и он срабатывает где-то в середине остановки: запланированная задача с открытой транзакцией ещё работает, а любой, кто попросит соединение, получает SQLException: HikariDataSource ... has been closed. — транзакция откатывается в неподходящий момент. Сам повторный close() при этом безобиден: метод идемпотентен, повторный вызов молча выходит.

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

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

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

Общее у всех трёх одно: порядок закрытия выстраивают руками там, где Spring уже выстроил его по зависимостям между бинами, и соединение исчезает раньше, чем закончил последний, кому оно было нужно.

Дополнительно: при первом чтении можно пропустить

Глубже: что пул делает с транзакцией, которую не дождалисьрасширенное

HikariCP закрывается последним, и статья объясняет почему. Стоит знать, что именно происходит в этот момент с соединением, на котором ещё идёт транзакция, потому что это и механика потери, и десять секунд, которых нет в бюджете.

HikariPool.shutdown() делает три шага. Помечает все соединения на мягкое выселение: свободные закрываются сразу, занятые закроются при возврате в пул. Затем ждёт, пока занятые вернутся, но не дольше десяти секунд, и это число зашито в код, свойства для него нет. Потом всё, что не вернулось, обрывает: Connection.abort() на каждом активном соединении, а это не rollback, а разрыв на уровне сокета. База, потеряв клиента, откатывает незавершённую транзакцию сама; поток приложения, который в этот момент выполнял запрос, получает SQLException про закрытое соединение и, если ему повезло, доходит до своего catch, а если нет, умирает вместе с потоком при выходе процесса.

shutdown() пул начал закрываться softEvict свободные закрыл сразу ждём 10 с занятые не вернулись abort() разрыв сокета rollback база откатила сама

Десять секунд между мягким выселением и abort зашиты в код HikariCP и свойством не меняются, а смотреть тут надо на предпоследний шаг: это единственное место, где пул отнимает транзакцию.

Из этого следует два вывода. Первый про потери: транзакция, которую не дождались, откатывается целиком, включая уже выполненные внутри неё команды, и это безопасно, если всё изменение было в одной транзакции; опасно там, где транзакция уже зафиксирована, а следующее действие (публикация события, запись отметки) не успело, и это территория статьи про идемпотентность. Второй про бюджет: между остановкой последней группы бинов и выходом процесса может пройти до десяти секунд ожидания пула, и в terminationGracePeriodSeconds они обычно не заложены. Если HTTP и слушатели остановились вовремя, активных соединений к этому моменту нет, и пул закрывается мгновенно; если нет, десять секунд это последнее, что приложение сделает перед SIGKILL.

Проверить просто: метрика hikaricp.connections.active в момент остановки должна быть нулём, а строка HikariPool-1 - Shutdown completed в логе идти сразу за Shutdown initiated. Разрыв между ними это транзакции, которых не дождались.

Глубже: две версии кода против одной базы: совместимость N-1расширенное

Во время rolling update старая и новая версии приложения несколько минут работают с одной базой, и откат через rollout undo возвращает старую версию к базе, которую уже поменяла новая. Отсюда правило N-1: каждая миграция совместима с предыдущей версией кода, и каждая версия кода работает со схемой следующей.

Что это запрещает: переименовать колонку, изменить её тип, удалить колонку или таблицу, добавить обязательную колонку без умолчания, ужесточить ограничение. Всё это делают через расширение и сжатие в несколько выкатов, о чём раздел про миграции в статье про деплой в Kubernetes и, подробно, статья про эволюцию схемы. Что это разрешает сразу: добавить необязательную колонку, таблицу, индекс (CONCURRENTLY), ослабить ограничение.

Где это проверяют. Миграция идёт до выката приложения, отдельным Job, и новая версия стартует на уже обновлённой схеме; validate у Liquibase или Flyway при старте подтверждает, что код и схема сошлись. Совместимость старого кода с новой схемой проверяет сборка: в конвейере поднимают базу, применяют миграции новой версии и прогоняют против неё интеграционные тесты предыдущей версии (образ предыдущего тега уже есть в реестре). Тест зелёный, значит откат безопасен и rolling update тоже. Тест красный, значит миграция ломающая и её раскладывают на шаги.

И правило для самой остановки: транзакция старой версии, которая шла во время выката, зафиксируется в схему, которую уже читает новая. Значит, новая версия обязана понимать строки, записанные старой, включая пустые значения в колонках, которых старая не знала, а это тот же терпимый читатель, что и для событий.

Коротко

  • Spring Boot закрывает HikariCP после всех других компонентов — к этому моменту транзакции уже завершены. Пул при закрытии ждёт занятые соединения до десяти секунд, зашитых в код, потом обрывает их через abort, и база откатывает транзакцию; активные соединения к этому моменту должны быть нулём, иначе десять секунд бюджета уходят на ожидание.
  • HTTP-запросы, запланированные задачи и Kafka-слушатели имеют свои механизмы дожидания активной транзакции.
  • @Async требует явной настройки: setWaitForTasksToCompleteOnShutdown(true) и setAwaitTerminationSeconds.
  • Liquibase и Flyway работают только при старте, на shutdown ничего не делают. В кластере миграции выносят из приложения: отдельная задача перед выкатом для прода (одно исполнение, понятный отказ), init-контейнер или автоматическая миграция — для стенда.
  • Закрывать пул соединений вручную через @PreDestroy — ошибка: Spring сделает это сам и в правильный момент.
  • Правило N-1: миграция совместима с предыдущей версией кода и идёт до выката отдельным Job; сборка прогоняет тесты предыдущей версии против новой схемы, ломающие изменения раскладывают на расширение и сжатие.
  • SIGKILL не рвёт целостность (транзакция откатится), но блокировки держатся, пока база не заметит мёртвый сеанс: заранее ставят idle_in_transaction_session_timeout, серверные tcp_keepalives и statement_timeout.
  • Долгая транзакция с блокировкой строк мешает и остановке, и соседям: пачки меньше, никаких внешних вызовов внутри транзакции, проверка признака остановки между пачками и FOR UPDATE SKIP LOCKED.
  • open-in-view растягивает сеанс работы с сущностями на весь запрос: при нехватке бюджета клиент получает половину ответа, поэтому настройку выключают и отдают данные, а не сущности.
  • Недоступная база в момент остановки съедает бюджет: бин в завершении ждёт соединения по 30 секунд, поэтому в завершении к базе не обращаются или дают короткий таймаут и глушат ошибку.

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