Статья подготовлена в рамках курса «Java разработчик. Экспертный уровень»

JDBC connection pool в Spring Boot — это HikariCP. Стоит из коробки с версии 2.0, в application.properties обычно либо ничего не настроено, либо одна‑две строчки про размер пула. На небольшом трафике этого хватает: сервис работает, в пул никто не лезет, в графиках Grafana пустота, потому что метрики пула в Prometheus никто не вывел.

Пока в пиковую нагрузку в логах не начинает мелькать Connection is not available, request timed out after 30000ms или Connection is closed, и сервис на несколько минут уходит в полупараличное состояние — не падает, но и не работает.

Половина таких инцидентов лечится тем, что в HikariCP оставлены или подкручены интуитивно не те параметры.

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

maximumPoolSize: «больше» не значит «лучше»

Самая частая правка — maximumPoolSize=100 «на всякий случай», обычно с рассуждением: «У нас тысяча пользователей онлайн, надо много коннекшенов». На практике каждый коннекшн к Postgres — это процесс на стороне БД, несколько мегабайт оперативной памяти, контекст транзакций, кэш плана запросов.

Сто параллельных запросов от одного бэкенда на типичный Postgres — это не «параллельность», а очередь, потому что CPU и диск всё равно один: задачи переключаются, и база начинает тратить на переключение контекстов больше времени, чем на сами запросы.

Автор HikariCP давно опубликовал pool sizing guide с формулой:

connections = (core_count * 2) + effective_spindle_count

Для 8 ядер + SSD это даёт 17 коннекшенов как достаточный размер пула на один инстанс. Не сто, и не пятьсот. Половина времени коннекшн ждёт диск или сеть, поэтому потоков может быть в два раза больше ядер — но не в десять.

Отдельный момент — корреляция с числом инстансов сервиса. Если у вас пять реплик Spring Boot, каждая с maximumPoolSize=20, это сто открытых коннекшенов к одной базе только от этого сервиса. У типичного Postgres max_connections равен 100–200, и часть из них уже занята другими сервисами, репликацией, мониторингом, психотерапевтом DBA. Перешагнули — и новый под при старте не сможет подключиться вовсе, в логах будет FATAL: sorry, too many clients already.

Дефолт у HikariCP — maximumPoolSize=10, и для базового веб‑приложения этого обычно хватает. Если упираетесь в пул, прежде чем увеличивать его, посмотрите в pg_stat_activity:

SELECT pid, state, wait_event_type, wait_event, query_start, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;

В девяти случаях из десяти найдёте либо медленные запросы без подходящего индекса, либо забытые транзакции в состоянии idle in transaction, либо локи на таблице. Увеличивать пул бессмысленно, пока эти запросы не починены: больше коннекшенов просто значит больше параллельных медленных запросов, и база встанет ещё сильнее.

Когда честно нужно много коннекшенов (микросервисы плодятся быстрее, чем растёт max_connections), ставится PgBouncer в режиме transaction pooling: он держит небольшой пул реальных коннекшенов к Postgres и мультиплексирует на них тысячи клиентских. HikariCP в этом случае подключается уже к PgBouncer, а не к Postgres напрямую, и его maximumPoolSize можно поставить заметно больше, чем у бэкенда без посредника. Только учтите, что в режиме transaction pooling многие фичи Postgres ломаются: prepared statements, advisory locks, session‑level settings — всё это требует session pooling, который не даёт того же масштаба.

minimumIdle: ставится равным maximumPoolSize, а не половине

В туториалах часто пишут что‑то такое:

spring.datasource.hikari:
  maximum-pool-size: 20
  minimum-idle: 5

Идея — «в холостом режиме держим пять коннекшенов, в пик расширяемся до двадцати, экономим ресурсы базы». Звучит, конечно, круто и идёт ровно вразрез с тем, что рекомендуют сами авторы HikariCP.

Создание коннекшна к Postgres — это TCP‑handshake (один RTT до базы), TLS‑handshake, если включён SSL (ещё один‑два RTT с обменом сертификатами), аутентификация по паролю или сертификату, инициализация сессии (ROLE, search_path, settings). На каждый новый коннекшн уходит 30–100 миллисекунд, и эти миллисекунды добавляются к latency первых запросов, которые попали в пул в момент его расширения.

Если у вас спайк трафика на старте дня и пул в этот момент растёт с пяти до двадцати — пятнадцать запросов получают сто миллисекунд сверху своего обычного отклика, и метрики p99 на дашборде дают красивый кратковременный пик. Часть этих запросов попадает в SLA «больше 500 мс — алерт», и команда идёт разбираться в инциденте, которого фактически нет.

HikariCP изначально проектировался как фиксированный пул именно по этой причине. Дефолтное значение minimumIdle равно maximumPoolSize, и в README автор прямо просит этот параметр не трогать. Пул всегда держит максимум коннекшенов открытыми и не «остывает» даже ночью, поэтому первый запрос после простоя получает уже готовый коннекшн.

Что это даёт на стороне Postgres. Двадцать idle‑коннекшенов — это двадцать процессов, которые ничего особенного не делают, занимают примерно по 5–10 мегабайт памяти каждый (зависит от настроек work_mem и того, что в этом коннекшне делалось последний раз), и не нагружают CPU. Для базы это незаметная нагрузка. Если очень хочется экономить коннекшены на стороне БД, правильнее уменьшить maximumPoolSize, а не разделять его с minimumIdle.

maxLifetime: всегда меньше серверного и сетевого таймаута

По умолчанию maxLifetime = 1800000 миллисекунд, то есть 30 минут. Через это время HikariCP закроет коннекшн и откроет новый, чтобы избежать «старения» — утечек памяти на серверной стороне, накопленных подготовленных запросов, мусора в кэше плана запросов. Идея у этого параметра здравая, но дефолтное значение в большинстве реальных инфраструктур слишком большое.

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

Наши подозреваемые:

  • AWS NLB по умолчанию закрывает idle TCP соединения через 350 секунд. Изменить можно, но мало кто это делает явно.

  • AWS ALB режет idle соединения через 60 секунд, и хотя в цепочке между приложением и базой ALB бывает редко, при работе через Service Discovery или Lambda+RDS Proxy он встречается.

  • Корпоративный фаервол часто рубит idle сессии без предупреждения, обычно через 5–15 минут — без логов, без объяснений, просто FIN или RST в случайный момент.

  • Conntrack в Linux/Kubernetes по умолчанию таймаутит established TCP‑соединение через 5 дней, но idle с keepalive обрывает заметно раньше, особенно если параметры подкручены под высокую нагрузку.

  • В Postgres idle_in_transaction_session_timeout нередко стоит 10 минут, и долгая открытая транзакция получает обрыв.

  • В PgBouncer server_idle_timeout по умолчанию 600 секунд, и pgbouncer закроет реальный коннекшн к Postgres, оставив клиентский висеть.

  • На стороне облачного провайдера (managed Postgres у AWS RDS, GCP Cloud SQL, Yandex Cloud) могут быть свои таймауты на сетевом уровне, которые в документацию попадают редко.

Если внешний элемент закрыл коннекшн раньше, чем HikariCP его состарил, получаете Connection is closed. Лечится тем, что maxLifetime ставится с запасом меньше всего, что может закрыть коннекшн снаружи:

spring.datasource.hikari.max-lifetime: 300000  # 5 минут < 350 секунд NLB

Запас в 30–60 секунд от самого маленького внешнего таймаута — нормальный выбор. Если меньшее звено в инфраструктуре — NLB с его 350 секундами, ставите 300 секунд (5 минут). Если это PgBouncer с 10 минутами — ставите 500–540 секунд. Если внешних таймаутов не видно вовсе, 5 минут — разумный дефолт, который точно меньше всего, что встречается в природе.

keepaliveTime: чтобы коннекшн не протух в idle

Этот параметр часто упускают из виду, хотя в средах с агрессивным conntrack или короткими idle‑таймаутами на файрволе он критичен.

По умолчанию keepaliveTime = 0, то есть отключено: HikariCP не делает ничего с idle‑коннекшенами, пока их кто‑нибудь не запросит. Если между запросами проходит больше времени, чем idle timeout на стороне файрвола или NLB, соединение тихо обрывается, и следующий клиент, который возьмёт этот коннекшн из пула, получит Broken pipe, Connection reset by peer или EOFException на первом же запросе.

Включаете keepaliveTime, и каждые N миллисекунд HikariCP отправляет простой SELECT 1 (или эквивалент для конкретного драйвера) на коннекшены, которые сейчас не используются:

spring.datasource.hikari:
  keepalive-time: 30000      # 30 секунд
  max-lifetime: 300000       # 5 минут — keepaliveTime обязательно меньше

Документация HikariCP требует, чтобы keepaliveTime был меньше maxLifetime — иначе keepalive не успевает отработать до закрытия коннекшена, и смысла в нём нет. Минимальное допустимое значение — 30 000 миллисекунд (30 секунд), всё, что меньше, HikariCP проигнорирует.

Рекомендуемые значения: 30–90 секунд для агрессивных сетевых конфигураций (файрволы с короткими idle‑таймаутами, частые перезагрузки сетевого оборудования), 2–3 минуты для обычных. Если самый короткий внешний idle timeout — 60 секунд, ставьте keepaliveTime 30 секунд, чтобы за минуту успел пройти пинг. Это создаёт небольшой constant traffic к базе — один SELECT 1 на коннекшн раз в 30 секунд, на 20 коннекшенов это 40 пингов в минуту, незаметная нагрузка.

leakDetectionThreshold: ловит код, который забыл вернуть коннекшн

По дефолту leakDetectionThreshold = 0, то есть утечки никак не отслеживаются. Включать этот параметр в проде — почти всегда хорошая идея:

spring.datasource.hikari.leak-detection-threshold: 60000  # 60 секунд

Что это даёт. Если код взял коннекшн через DataSource или transactionTemplate и не вернул в пул за 60 секунд, HikariCP пишет в лог warning со stacktrace того места, где коннекшн был получен:

Connection leak detection triggered for ProxyConnection@1234567,
stack trace follows:
    at com.example.service.OrderService.process(OrderService.java:42)
    at com.example.controller.OrderController.create(OrderController.java:25)
    ...

Дальше вопрос: найти эту строку, понять, почему коннекшн всё ещё занят. Обычно сразу видно, что не так, кто‑то забыл закрыть Connection руками после dataSource.getConnection(), или транзакция случайно охватила долгий вызов.

Самые частые сценарии утечек, которые ловит leak detection:

  • @Transactional + HTTP‑вызов внутри. Метод помечен @Transactional, внутри вызывается клиент к стороннему API, который тормозит на запросах подтверждения. Всё время вызова коннекшн к базе занят и в пул не возвращается. Под нагрузкой за минуту все коннекшены оказываются заняты ожиданием внешнего сервиса, новые запросы валятся по connectionTimeout, сервис встаёт колом, хотя в базе ни одного активного запроса. Лечится либо выносом внешнего вызова за пределы транзакции, либо короткими транзакциями только вокруг работы с БД, либо явным TransactionTemplate с понятными границами.

  • Stream + ленивый запрос Hibernate. Метод возвращает Stream<Entity> от Hibernate, кто‑то наверху потребляет stream долго — например, пишет в файл или передаёт построчно по HTTP. Hibernate держит открытый коннекшн всё это время. Лечится либо .collect(toList()) сразу после запроса (если данные помещаются в память), либо явным управлением жизненным циклом stream через try-with-resources.

  • Connection взят и не закрыт в catch‑блоке. Старомодный код с try/catch без try-with-resources иногда забывает закрыть коннекшн при exception. С try-with-resources ошибка невозможна, но в наследии встречается часто, и leak detection помогает найти такие места.

  • Background‑таск, который держит коннекшн. Какой‑нибудь @Scheduled каждую минуту берёт коннекшн и не возвращает, потому что цикл внутри не выпускает контроль управления. Утечка неочевидна — один таск, один коннекшн в час, но через сутки пул пустой, утром приходит инцидент.

connectionTimeout: ваш SLA

Если пул исчерпан и все коннекшены заняты, getConnection() блокирует поток до connectionTimeout. Дефолт — 30 секунд. Это почти всегда бессмысленно много: если ваше API должно отвечать за 500 миллисекунд, ответа через 30 секунд клиент уже не ждёт — он давно получил 504 от ingress или закрыл соединение по своему таймауту.

А ваш бэкенд тем временем продолжает копить запросы, потоки заняты ожиданием коннекшна, и сервис застревает в этом состоянии надолго, пока пул не разгрузится естественно — что без вмешательства может занять минуты.

Лучше fail fast:

spring.datasource.hikari.connection-timeout: 2000  # 2 секунды

Если пул исчерпан, клиент получает 503 быстро, на сервере не копится очередь, мониторинг видит резкий скачок ошибок и срабатывает алерт. Дальше можно подключить circuit breaker (например, Resilience4j), который при росте ошибок начинает коротко замыкать запросы прямо в контроллере, не пытаясь даже взять коннекшн:

@CircuitBreaker(name = "ordersDb", fallbackMethod = "fallback")
public Order findById(Long id) {
    return orderRepository.findById(id).orElseThrow();
}

private Order fallback(Long id, Throwable t) {
    return Order.empty();   // или 503, или кэш — зависит от семантики
}

Связанный момент — Spring Boot Actuator. Если у вас включён health‑эндпоинт, по умолчанию /actuator/health/db дёргает базу через SELECT 1 или эквивалент. При исчерпанном пуле этот эндпоинт тоже встаёт в очередь и через 30 секунд возвращает DOWN. Kubernetes liveness probe видит таймаут или DOWN, считает под мёртвым, перезапускает, при старте нового пода нагрузка перераспределяется на оставшиеся реплики — и они тоже исчерпывают пул.

Каскадный отказ, который снимается только ручным вмешательством. С connectionTimeout=2s health‑эндпоинт ответит за две секунды (либо UP, либо DOWN), и Kubernetes принимает решение быстрее, а каскад если и случается, то контролируемо. Дополнительно стоит явно настроить liveness и readiness probes по‑разному: liveness не должен трогать базу вообще, иначе любые проблемы с БД будут перезапускать поды, что только усугубит ситуацию.

dataSourceProperties: настройки JDBC‑драйвера, которые часто забывают

HikariCP — это пул, а конкретные настройки протокола работают на уровне JDBC‑драйвера. В Spring Boot их передают через spring.datasource.hikari.data-source-properties. Для Postgres есть как минимум пять параметров, которые стоит выставлять явно:

spring.datasource.hikari:
  data-source-properties:
    socketTimeout: 30          # секунды; обрывает зависший запрос
    loginTimeout: 10           # секунды; не висим при недоступности базы
    tcpKeepAlive: true         # TCP keepalive на уровне ОС
    prepareThreshold: 5        # после 5 вызовов готовится server-side prepare
    ApplicationName: api-prod  # видно в pg_stat_activity
  1. socketTimeout — самое важное. Это таймаут на чтение из сокета на уровне драйвера. Без него зависший запрос к Postgres (например, лок на таблице, который не разрешается, или DDL‑операция, которую кто‑то запустил на проде в рабочее время) будет жить буквально вечно, занимая коннекшн в пуле. socketTimeout=30 значит, что после 30 секунд без ответа от базы драйвер выкинет SocketTimeoutException, HikariCP вернёт коннекшн в пул (точнее, выкинет его и создаст новый), приложение увидит ошибку и решит, что делать.

  2. loginTimeout — таймаут на первоначальное подключение к базе. Без него, если база недоступна (упала, сеть отвалилась, DNS не разрешается, security group закрыла порт), драйвер ждёт TCP‑таймаут операционной системы (одну‑две минуты), и за это время никакие health‑эндпоинты не отвечают. С loginTimeout=10 приложение быстро понимает, что база недоступна, и помечает себя как DOWN.

  3. tcpKeepAlive=true включает TCP keepalive на уровне сокета. Это другой механизм, чем HikariCP keepaliveTime: он работает ниже, на уровне ядра, и обнаруживает мёртвые сокеты быстрее, чем прикладной запрос.

  4. prepareThreshold для Postgres‑драйвера определяет, после какого числа выполнений одного запроса драйвер просит базу подготовить server‑side prepared statement. По умолчанию 5, и обычно это нормально. Если запрос выполняется один раз — server‑side prepare не имеет смысла; если многократно — сервер кэширует план и выполняет быстрее.

  5. ApplicationName устанавливает строку, которая видна в Postgres в pg_stat_activity.application_name. Это даёт возможность фильтровать запросы по сервису‑источнику при отладке (SELECT * FROM pg_stat_activity WHERE application_name='api-prod'), что сильно облегчает жизнь, если на одной базе сидит десяток сервисов. Без неё в application_name будет либо пусто, либо что‑то вроде PostgreSQL JDBC Driver, что одинаково для всех.

Аналогичные параметры есть в драйверах MySQL (socketTimeout, connectTimeout, autoReconnect=false — обязательно false, не путать), Oracle (oracle.net.READ_TIMEOUT, oracle.net.CONNECT_TIMEOUT), MS SQL (socketTimeout, lockTimeout). Конкретные имена смотреть в документации драйвера, но принцип один: должен быть таймаут на чтение, таймаут на подключение и TCP keepalive.

Что держать в Grafana

HikariCP экспортирует базовые метрики в Micrometer автоматически — нужно только добавить micrometer-registry-prometheus в зависимости и подключить Prometheus к /actuator/prometheus. Это даёт сразу шесть метрик:

  • hikaricp_connections_active — сколько коннекшенов сейчас занято запросами. Когда регулярно упирается в maximumPoolSize, пул маловат или код держит коннекшены слишком долго.

  • hikaricp_connections_idle — сколько коннекшенов простаивает в пуле. При правильно выставленном minimumIdle=maximumPoolSize сумма active + idle всегда равна maximumPoolSize; если меньше — значит, коннекшены закрываются и пересоздаются.

  • hikaricp_connections_pending — сколько потоков в данный момент ждёт коннекшн из пула. Если стабильно больше нуля — пул исчерпан, и либо нужно увеличивать пул, либо чинить медленный код.

  • hikaricp_connections_acquire_seconds (с percentile‑метками) — сколько времени потоки проводят в ожидании коннекшна. p50 в норме близко к нулю, p99 показывает реальные задержки. Резкий рост p99 — пул на пределе.

  • hikaricp_connections_usage_seconds — сколько времени коннекшн в среднем занят клиентом перед возвратом в пул. Резкий рост — сигнал медленных запросов или утечки коннекшенов в долгие операции.

  • hikaricp_connections_timeout_total — счётчик таймаутов получения коннекшна. Растёт = есть запросы, которые отваливаются по connectionTimeout, и пользователи получают 503.

Минимальный набор алертов, который имеет смысл настраивать сразу:

  • hikaricp_connections_pending > 0 в течение минуты — пул исчерпан, кто‑то ждёт коннекшн.

  • rate(hikaricp_connections_timeout_total[5m]) > 0 — таймауты идут, пора смотреть.

  • hikaricp_connections_acquire_seconds{quantile="0.99"} > 0.5 — p99 ожидания коннекшна больше полусекунды (величина зависит от вашего SLA).

Итого

Для типового Spring Boot сервиса с Postgres под нагрузкой работают примерно такие настройки:

spring.datasource.hikari:
  maximum-pool-size: 20           # не «100 на всякий», а по нагрузке
  minimum-idle: 20                # = maximum-pool-size, не половине
  max-lifetime: 300000            # 5 минут — меньше любого внешнего таймаута
  keepalive-time: 30000           # 30 секунд, чтобы коннекшн не «протух»
  connection-timeout: 2000        # fail fast, а не 30 секунд молча в стену
  leak-detection-threshold: 60000 # ловим утечки в первый же прогон
  data-source-properties:
    socketTimeout: 30
    loginTimeout: 10
    tcpKeepAlive: true
    prepareThreshold: 5
    ApplicationName: api-prod

Плюс к этому — шесть метрик HikariCP в Grafana с базовыми алертами на pending > 0 и рост timeout. Этого набора достаточно, чтобы покрыть подавляющее большинство инцидентов с пулом.

Дальше идёт тонкая настройка для специфичных случаев — validationTimeout для нестандартных health‑чеков, initializationFailTimeout для контроля поведения при недоступной базе на старте (-1, чтобы сервис стартовал даже без БД, 0 чтобы падал сразу, дефолт — ждёт 1 секунду и падает), dataSourceClassName для подмены драйвера через DataSource вместо JDBC URL. Крутить это имеет смысл, когда базовая конфигурация уже работает.

Если после HikariCP, PgBouncer, таймаутов и health-check’ов появилось ощущение, что highload — это не только «поставить побольше инстансов», можно свериться с базой. Короткий вступительный тест покажет, где всё понятно, а где ещё есть слепые зоны, которые в проде обычно стоят дороже, чем кажется.

И последнее, HikariCP — это просто connection pool. Если в сервисе медленные запросы (SELECT * FROM orders WHERE created_at > NOW() без индекса по created_at), увеличение пула или твики таймаутов не помогут: нужно чинить запросы и добавлять индексы.

Если попытаться решить медленные запросы увеличением пула, в итоге получите больше параллельных медленных запросов, и база встанет ещё быстрее, чем раньше. Сначала смотрите в pg_stat_activity, pg_stat_statements, explain analyze — потом настраивайте пул.


Пулы соединений, таймауты, контейнеры и мониторинг обычно начинают казаться простыми ровно до первого production-инцидента. Чтобы спокойнее разбираться в таких вещах, полезно руками пройти базовые сценарии: собрать приложение, упаковать его в контейнер и задеплоить.

В OTUS скоро пройдут бесплатные уроки по Java-инфраструктуре. На них можно познакомиться с преподавателем, посмотреть формат обучения, задать вопросы и закрыть часть пробелов. Присоединяйтесь:

  • 8 июня в 20:00. «Java в Kubernetes за 40 минут: как задеплоить приложение в Minikube». Записаться

  • 22 июня в 20:00. «Контейнеризация Java-приложений с Docker». Записаться

Больше бесплатных уроков по разработке, инфраструктуре и не только смотрите в дайджесте.

Комментарии (3)


  1. mosinnik
    01.06.2026 21:49

    зачем тут фактически копия "цикла" https://habr.com/ru/articles/1030880/?


    1. tbl
      01.06.2026 21:49

      чтобы отус порекламировать


  1. amarkevich
    01.06.2026 21:49

    насколько данные рекомендации подойдут для случая cloud sql proxy, когда соединение происходит по сути локалько к sidecar контейнеру?