Когда приложение получает сигнал завершения, первый страх — потерять данные. Кто-то в этот момент обращается к базе, транзакция открыта, и если пул соединений закроется не вовремя — запись может откатиться, а внешний вызов уже прошёл. Разберём, как Spring Boot справляется с этим по умолчанию и где можно случайно сломать то, что работало само.
Что происходит с транзакцией, которая была в работе в момент SIGTERM, и почему пул при этом закрывается последним, видно на одной схеме: сначала общая для всех фаза остановки, потом — порядок уничтожения бинов по умолчанию и с собственным @PreDestroy.
Транзакцию на 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, а если нет, умирает вместе с потоком при выходе процесса.
Десять секунд между мягким выселением и 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 секунд, поэтому в завершении к базе не обращаются или дают короткий таймаут и глушат ошибку.
Что почитать дальше
- Запланированные задачи и @Async при завершении — настройка TaskScheduler и ThreadPoolTaskExecutor.
- HTTP graceful drain — как Tomcat дожидается текущих запросов.
- Kafka shutdown — завершение listener container и обработка пакетов.
- Идемпотентность при прерывании — что делать, если транзакция всё же откатилась.