Главная › Блог › Пул соединений с БД: как устроен HikariCP и почему он быстрее всех

Пул соединений с БД: как устроен HikariCP и почему он быстрее всех

Зачем нужен пул соединений

Открытие TCP-соединения с PostgreSQL или MySQL — это не бесплатно: хэндшейк, аутентификация, инициализация сессии на стороне базы. На типичной конфигурации это 5-30 мс. Если открывать соединение на каждый запрос и закрывать после, при нагрузке 1000 RPS вы будете тратить на одно это больше времени, чем на сам запрос к данным.

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

Пул соединений — это как такси у вокзала, а не вызов через приложение каждый раз. Машины уже стоят, двигатель прогрет, водитель на месте. Вызывать новое такси (поднимать TCP-соединение) с нуля на каждую поездку — долго и дорого.

Почему HikariCP быстрее HBCP и Apache DBCP

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

  • FastList вместо ArrayList для хранения statement-объектов — убран лишний range-check при removal.
  • ConcurrentBag — собственная структура для хранения свободных соединений, оптимизированная под паттерн "thread-local сначала, потом shared steal". Поток сначала ищет "своё" соединение в ThreadLocal-кэше, и только если там пусто — лезет в общий пул через CAS.
  • Минимум синхронизации — почти весь код написан вокруг lock-free структур, synchronized почти не используется в hot path.
  • Байткод оптимизирован вручную — авторы декомпилировали свой же код, чтобы убрать лишние проверки и autoboxing.

Итог — getConnection() у HikariCP занимает десятки наносекунд против микросекунд у конкурентов. На большом RPS это прямая экономия CPU.

Жизненный цикл соединения в пуле

1. Приложение вызывает dataSource.getConnection()
2. Пул отдаёт свободное физическое соединение из ConcurrentBag
   (если свободных нет и pool < maximumPoolSize — открывает новое)
3. Приложение работает с Connection как обычно
4. connection.close() НЕ закрывает TCP-сокет —
   это переопределённый метод прокси,
   который возвращает соединение в пул
5. Пул проверяет соединение (опционально) и помечает его свободным
connection.close() в контексте пула — это не "закрыть", а "вернуть в пул". Если забыть вызвать close() (например, исключение до него долетело, а try-with-resources не использовали) — соединение считается занятым навсегда. Это и есть утечка пула: через какое-то время maximumPoolSize исчерпан, и новые запросы висят в очереди в ожидании timeout.

Ключевые параметры конфигурации

  • maximumPoolSize — верхняя граница. Частая ошибка — ставить это число "побольше", думая, что так быстрее. На деле БД имеет свой лимит соединений (max_connections в Postgres), и каждое соединение — это процесс на стороне сервера с собственной памятью. Слишком большой пул может перегрузить CPU базы контекст-свитчами между сессиями.
  • minimumIdle — сколько простаивающих соединений держать всегда. HikariCP рекомендует вообще не задавать его отдельно, а делать равным maximumPoolSize — фиксированный размер пула стабильнее под нагрузкой, чем динамический.
  • connectionTimeout — сколько ждать свободное соединение из пула, прежде чем бросить исключение. По умолчанию 30 секунд — на проде обычно хотят меньше, чтобы быстрее фейлиться, а не копить очередь запросов.
  • maxLifetime — максимальное время жизни соединения, после которого оно принудительно закрывается и переоткрывается. Нужен, чтобы соединения не "протухали" — важно, если перед базой стоит балансировщик с собственным idle timeout, который может молча прибить TCP-соединение, а пул об этом не узнает, пока не попробует им воспользоваться.
  • leakDetectionThreshold — если соединение держится дольше указанного времени без возврата, HikariCP пишет warning со стектрейсом, откуда оно было взято. Бесценно для отладки утечек в проде.

Формула размера пула

Автор HikariCP предлагает формулу, отталкивающуюся не от RPS, а от числа ядер:

pool size = Tn x (Cm - 1) + 1

Tn — число потоков
Cm — число одновременных соединений,
     которые нужны одному потоку (обычно 1)

Для типичного веб-приложения, где один поток держит одно соединение на запрос, формула упрощается до: pool size ≈ число ядер x 2 + число дисков. Для большинства серверов это 10-20 соединений, а не сотни — потому что при большем количестве параллельных активных запросов к БД начинает доминировать borrow/queue latency, а не нехватка соединений.

Что произойдёт, если maximumPoolSize в приложении больше, чем max_connections в PostgreSQL, и у вас несколько инстансов сервиса?
При превышении суммарного числа соединений от всех инстансов над лимитом базы новые соединения будут получать ошибку "too many connections". Нужно либо уменьшить pool size на инстанс, либо использовать внешний pooler (PgBouncer), который мультиплексирует соединения от приложений на меньшее число реальных соединений к базе.
Почему увеличение maximumPoolSize не всегда ускоряет приложение?
Если узкое место не в ожидании свободного соединения, а в CPU или дисковом I/O самой базы, то больше соединений означает больше конкурирующих запросов на то же железо — пропускная способность не растёт, а контекст-свитчи и блокировки в базе растут. Оптимальный размер пула почти всегда меньше, чем кажется интуитивно.
PgBouncer в режиме transaction pooling несовместим с prepared statements и session-level фичами (SET, LISTEN/NOTIFY, advisory locks вне транзакции) — соединение может "уехать" к другому клиенту между транзакциями. Это частая причина странных багов при добавлении PgBouncer в уже работающую систему.

Ещё разборы