«org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection», «org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection», «java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms»...

Такими ошибками был заполнен наш ELK один-два раза в месяц. Когда это происходило, система полностью переставала работать - падали абсолютно все запросы к БД, независимо от того, какой endpoint вызывался. Подробная инспекция логов не давала никаких зацепок, так как стектрейсы были самые разные, единственной общей чертой была невозможность получить новое соединение из пула. Первым подозреваемым была сама база данных, но со стороны postgres никаких проблем не наблюдалось - pg_stat_activity показывал, что свободные соединения есть.
Единственным рабочим решением была перезагрузка сервисов. После рестарта пулы соединений пересоздавались, и система возвращалась к жизни. Проблема в том, что эти инциденты были непредсказуемы и каждый раз требовали ручного вмешательства.

Используемый стек
  • Java 17

  • Spring Boot 2.7.18

  • Hibernate 5.6.15.Final

  • HikariCP 4.0.3

  • PostgreSQL 14.13

  • datasource-proxy 1.9

Воспроизведение проблемы

После нескольких безуспешных попыток найти причину через анализ стектрейсов, я решил попробовать воспроизвести ситуацию локально. Вооружившись JMeter, я записал наш полный бизнес-флоу с UI через JMeter proxy — он перехватывает браузерный трафик и автоматически генерирует тестовый сценарий. Это позволило воспроизвести все типичные запросы, которые делаются с фронта, начиная с авторизации.

И тут обнаружилось интересное: даже с небольшим количеством одновременных запросов, JMeter тест рано или поздно застревает и всё падает с теми же ошибками о невозможности получить соединение. Главное условие — чтобы количество одновременных запросов было больше или равно количеству соединений в HikariCP пуле.

Отлично, проблема воспроизводится стабильно. Но что её вызывает?

Исследование

Сначала я сузил круг поиска: выяснил, какие именно endpoint'ы в тестовом сценарии приводят к проблеме. Оказалось, что зависают не все запросы — только определённые.

Изучив код этих endpoint'ов, я заметил общий паттерн: в каждом из них запросы делались и через JPA (Spring Data repositories), и через JdbcTemplate, а в некоторых случаях ещё и через чистый JDBC (прямые вызовы dataSource.getConnection()) — всё в рамках одного метода. Причём эти методы не были помечены @Transactional.

Тогда я вспомнил про OSIV (Open Session In View), паттерн, реализация которого в Spring Boot включена по дефолту. Я знал про его существование и что он держит соединение для jpa до конца http запроса, но не понимал деталей. Попробовал отключить:

spring.jpa.open-in-view=false

Запустил те же тесты — ошибки исчезли.

Что такое OSIV

OSIV держит Hibernate сессию (и соединение после первого запроса к БД) открытой на протяжении всего HTTP запроса, до момента рендеринга ответа. Цель паттерна — избежать LazyInitializationException при ленивой загрузке ассоциаций в слое view.

Вот типичный сценарий, для которого нужен OSIV: у вас есть сущность User с lazy коллекцией orders. Вы возвращаете эту сущность из контроллера напрямую, Jackson начинает её сериализовать, пытается достать orders, но hibernate сессия уже закрыта - бум, LazyInitializationException. OSIV решает это тем, что держит сессию открытой до самого конца.

В интернете полно статей о том, что OSIV это антипаттерн. Vlad Mihalcea (один из разработчиков Hibernate) подробно его разбирает в своей статье. Основные претензии: после того как service layer закрывает транзакцию, все дополнительные запросы из UI слоя выполняются в auto-commit режиме. Каждый SQL statement становится отдельной транзакцией, которую нужно flush'ить в transaction log на диск, что создает дополнительную I/O нагрузку на БД. Плюс соединение держится на протяжении всего рендеринга ответа, что ведет к более быстрому истощению пула соединений. А еще OSIV маскирует проблему N+1 — lazy loading «просто работает» в слое view, и можно не заметить, что каждый элемент коллекции генерирует отдельный запрос.

У разработчиков Spring есть контраргументы. Oliver Gierke (член команды Spring) указывает что без OSIV базовые вещи вроде Jackson сериализации или рендеринга Thymeleaf перестают работать «из коробки»: новички сразу получают LazyInitializationException и первым делом будут искать как его отключить (т.е. включить OSIV обратно). Плюс JPA сам по себе уже выбор в пользу удобства над производительностью - иначе использовали бы чистый SQL.

После долгой дискуссии в 2017 году команда Spring Boot решила оставить OSIV включенным по умолчанию, сославшись на обратную совместимость и UX. Был добавлен WARNING в логи при старте, но основной дефолт сохранился.

Диагностика соединений

Можно было бы сразу отключить OSIV, но, прежде чем что-то менять, я хотел точно понять, что происходит. Подключил datasource-proxy — библиотеку, кото��ая логирует все операции с соединениями:

@Bean
public DataSource dataSource(DataSource actualDataSource) {
    return ProxyDataSourceBuilder
        .create(actualDataSource)
        .logQueryBySlf4j(SLF4JLogLevel.INFO)
        .build();
}

Получаем вот такой результат:

Connection:5 - select * from orders where id = ?    // JPA
Connection:8 - SELECT COUNT(*) FROM order_items...  // JdbcTemplate - другое соединение

JPA запрос использует одно соединение, JdbcTemplate — другое. При этом соединение от JPA не возвращается в пул — оно держится до конца HTTP запроса.

Получается, что при включённом OSIV метод с JPA и JdbcTemplate запросами всегда требует два одновременных соединения из пула. Это стало для меня неожиданностью.

Логично: OSIV был сделан специально для JPA, с какой стати JdbcTemplate должен переиспользовать соединение, которое держится для JPA. С другой стороны, появляются две проблемы: все запросы на такие endpoint'ы требуют два одновременных соединения; возможна полная блокировка сервиса

Но почему именно происходит блокировка?

Механизм истощения пула

Давайте разберём это на конкретном примере.

Представим следующую ситуацию:

  • У нас 10 соединений в HikariCP;

  • Приходит 10 одновременных http запросов;

  • Каждый запрос вызывает метод, который сначала делает JPA запрос, потом JdbcTemplate запрос;

  • Этот метод НЕ помечен @Transactional.

Вот упрощенный пример такого кода:

@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepo; // Spring Data JPA

    @Autowired
    private JdbcTemplate jdbcTemplate;

    // нет @Transactional
    public OrderDto getOrderWithStats(Long orderId) {
        // Шаг 1: JPA запрос - берет connection из пула
        Order order = orderRepo.findById(orderId).orElseThrow();

        // Шаг 2: JdbcTemplate запрос - пытается взять ВТОРОЙ connection
        Integer itemCount = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM order_items WHERE order_id = ?",
            Integer.class,
            orderId
        );

        return new OrderDto(order, itemCount);
    }
}

Что происходит:

Шаг 1: Все запросы начинают обрабатываться

  • Запрос #1: JPA query → берет connection #1 из пула

  • Запрос #2: JPA query → берет connection #2 из пула

  • Запрос #3: JPA query → берет connection #3 из пула

  • ...

  • Запрос #10: JPA query → берет connection #10 из пула

Шаг 2: Все соединения заняты
Теперь в hikari pool 0 свободных соединений. Но из-за OSIV эти соединения не отпускаются обратно в пул - они будут держаться до конца обработки http запроса.

Шаг 3: Все запросы пытаются выполнить вторую часть
Каждый из 10 запросов, закончив JPA query, переходит к JdbcTemplate query. JdbcTemplate пытается получить соединение из пула. Но пул пуст - все 10 соединений еще держатся JPA запросами.

Шаг 4: Полная блокировка
Все 10 потоков висят в ожидании освобождения соединений. Но соединения не освободятся пока http запросы не завершатся. А http запросы не завершатся, пока не выполнятся jdbcTemplate запросы. Полное истощение пула.

Единственный выход - connection timeout. По истечении таймаута (по дефолту 30 секунд) HikariCP выбрасывает исключение и запрос падает. Но если в этот момент продолжают приходить новые запросы, система остается в подвешенном состоянии.

Стоит отметить, что смешивание JPA и JdbcTemplate это официально поддерживаемый паттерн в Spring для случаев, когда нужна производительность и JPA неэффективен (например, bulk operations или сложные агрегации). Однако делать это без @Transactional - не рекомендуемая практика. В нашем легаси коде она была использована в нескольких местах.

Без @Transactional каждый вызов к базе получает отдельное соединение из пула. Без OSIV это не проблема - каждый запрос просто использовал бы соединения по очереди: JPA взял, отдал, JdbcTemplate взял, отдал. Да, это два последовательных соединения без транзакционной консистентности, но в нашем случае не критично. С OSIV же соединение, полученное для JPA, держится до конца HTTP запроса. Когда следующий вызов пытается получить своё соединение - первое всё ещё занято.

Решение

Первым решением, пришедшим в голову, было добавить @Transactional на проблемные методы. Для комбинации JPA + JdbcTemplate это работает: JpaTransactionManager позволяет им переиспользовать одно соединение в рамках транзакции.

Но в нашем коде был ещё и чистый JDBC - прямые вызовы dataSource.getConnection(). И тут @Transactional не помогает.

@Transactional
public ReportDto generateReport(Long reportId) {
    Report report = reportRepo.findById(reportId).orElseThrow();

    try (Connection conn = dataSource.getConnection()) {
        // это отдельное соединение, не связанное с транзакцией
        PreparedStatement ps = conn.prepareStatement("SELECT ...");
        // ...
    }

    return new ReportDto(report);
}

Когда стартует транзакция, Spring получает соединение и привязывает его к текущему потоку через TransactionSynchronizationManager. JdbcTemplate знает об этом механизме - внутри он вызывает DataSourceUtils.getConnection(), который сначала проверяет: есть ли уже соединение, привязанное к транзакции? Если есть - переиспользует его.

Но dataSource.getConnection() ничего не знает о Spring. Этот вызов идет напрямую в HikariCP и всегда возвращает новое соединение из пула, игнорируя транзакционный контекст.

То, что соединение под нашим контролем (try-with-resources, явный close) тоже не спасает - проблема в порядке вызовов. Сначала выполняется JPA запрос, и OSIV держит это соединение до конца HTTP запроса. Потом мы пытаемся получить второе соединение для чистого JDBC. При достаточной нагрузке все потоки оказываются в той же ситуации - полная блокировка, все ждут соединений, которые не освободятся.

Правильный способ работать с JDBC в Spring - через DataSourceUtils:

@Transactional
public ReportDto generateReport(Long reportId) {
    Report report = reportRepo.findById(reportId).orElseThrow();

    Connection conn = DataSourceUtils.getConnection(dataSource);
    try {
        PreparedStatement ps = conn.prepareStatement("SELECT ...");
        // используется то же соединение что и у JPA
    } finally {
        DataSourceUtils.releaseConnection(conn, dataSource);
    }

    return new ReportDto(report);
}

DataSourceUtils.getConnection() делает то же, что JdbcTemplate внутри - проверяет TransactionSynchronizationManager на наличие привязанного соединения. Если транзакция активна, вернет то же соединение, что использует JPA.

Альтернатива ручному использованию DataSourceUtils - обернуть DataSource в TransactionAwareDataSourceProxy:

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource transactionAwareDataSource(DataSource actualDataSource) {
        return new TransactionAwareDataSourceProxy(actualDataSource);
    }
}

Однако вместо точечных исправлений мы решили выключить OSIV во всех наших сервисах:

spring.jpa.open-in-view=false

Выключение прошло практически безболезненно. Регрессионное тестирование выявило всего пару LazyInitializationException - на тот момент запросов с использованием JPA было не так много, и большинство из них уже были обернуты в @Transactional с DTO. Эти немногие проблемные места исправлялись добавлением join fetch, что заодно устраняло замаскированные OSIV-ом N+1 запросы. С момента выключения OSIV ошибки с получением соединений больше не появлялись.

Почему это случалось относительно редко на production?

Казалось бы, если я мог с такой легкостью воспроизвести это локально и на тестовом стенде, ошибка должна была проявляться на проде постоянно. Но она возникала всего один-два раза в месяц. Более того, когда я попробовал запустить те же JMeter тесты против production окружения (в техническое окно, разумеется), воспроизвести ее не получилось даже при экстремальных нагрузках.

Причина в совокупности факторов. На production:

  • Запросы обрабатываются значительно быстрее (более мощное железо);

  • Пул соединений значительно больше (50+ соединений вместо 10, несколько инcтансов сервиса);

  • Для возникновения ошибки нужно специфическое условие: все N запросов должны одновременно начать выполнять JPA запрос, исчерпать весь пул, а затем практически одновременно перейти к jdbcTemplate запросу.

Другие сценарии истощения пула с OSIV

Наш случай со смешиванием JPA и JdbcTemplate - не единственный способ получить истощение пула из-за OSIV.

Ещё один паттерн: метод, который делает запрос к базе, а затем выполняет долгую операцию, не требующую соединения — формирование больших документов, вызовы внешних API, тяжелые вычисления, операции с файловой системой. С @Transactional та же проблема — соединение держится до конца метода. Но @Transactional это осознанный выбор. OSIV делает это поведение дефолтным для любого кода, затрагивающего JPA, даже если вы об этом не просили.

Коллеги из другой команды столкнулись именно с таким случаем: после загрузки данных через spring data repository следовало формирование большого документа. Спайк запросов на этот endpoint приводил к истощению пула.

Похожий на наш сценарий описан в этом github issue. Представим ситуацию:

  • 10 соединений в пуле;

  • 10 одновременных HTTP запросов;

  • Каждый запрос сначала делает запрос к БД (например, загружает текущего пользователя), затем запускает асинхронные задачи, которым тоже нужен доступ к БД, и ждёт их завершения.

Что происходит:

  • Запрос #1: JPA query → OSIV берёт соединение #1 → запускает async задачи → ждёт

  • Запрос #2: JPA query → OSIV берёт соединение #2 → запускает async задачи → ждёт

  • ...

  • Запрос #10: JPA query → OSIV берёт соединение #10 → запускает async задачи → ждёт

Все соединения заняты ожидающими HTTP потоками. Асинхронные задачи пытаются получить соединения для своих запросов - пул пуст. HTTP потоки не освободят соединения, пока задачи не завершатся. Механизм тот же: OSIV держит соединение, а для завершения запроса нужно ещё одно.

В ответ на это разработчик Spring замечает: без OSIV можно оказаться в такой же ситуации при слишком большом количестве запросов. Технически верно. Но без OSIV система под нагрузкой деградирует плавно - запросы ждут в очереди за соединениями, но в конечном счете выполняются. С OSIV может встать полностью, как в примере выше.

Заключение

До этого инцидента никто в команде не обращал внимания на OSIV и его влияние на работу системы. Spring Boot выводит предупреждение при старте приложения:

WARN 12345 --- [main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

Но это сообщение легко пропустить среди десятков других строк логов.

OSIV приводит к проблемам с производительностью, может вызвать полное истощение пула соединений, и если код уже полагается на OSIV, его выключение требует масштабного рефакторинга. Как отмечает Vlad Mihalcea: "К тому моменту как разработчики поймут, что сокрытие LazyInitializationException вызывает проблемы производительности, переход от OSIV потребует огромных усилий". К счастью, последнее было не про нас, но легко представить обратную ситуацию, когда код уже сильно полагается на JPA.

Несмотря на всё это, OSIV остается включенным по умолчанию и в Spring Boot 4.0. В преддверии релиза снова поднимался вопрос об изменении дефолта, но команда Spring Boot отложила решение, посчитав, что менять это так близко к релизу рискованно. Основной аргумент в защиту OSIV — удобство для новичков. LazyInitializationException может показаться неожиданным, и проще скрыть его через OSIV, чем объяснять как работает JPA. Но LazyInitializationException — это не баг, а честное сообщение о реальном ограничении. Получив его, разработчик гуглит, узнает про join fetch, entity graphs, projections, и делает осознанный выбор, как решить проблему. Мы сделали свой выбор после месяцев спорадических инцидентов. Наш код далек от идеала, но без OSIV он работал бы безошибочно. Не хочется, чтобы Spring отнимал у меня возможность писать ужасный код без катастрофических последствий.

Честно говоря, я был удивлён, когда не смог найти в интернете людей, столкнувшихся с похожей проблемой. Лишь недавно я натолкнулся на репозиторий, описывающий как раз нашу проблему. Наш случай — это легаси код. Но на эту ошибку можно наткнуться при постепенной миграции с одной технологии доступа к данным на другую (JDBC → JdbcTemplate → Spring Data JPA), или смешивании JPA с JdbcTemplate для перформанса. Так или иначе, стоит понимать, что в таком контексте отсутствие @Transactional в комбинации с OSIV — это бомба замедленного действия.