Пул соединений с БД: как устроен HikariCP и почему он быстрее всех
Зачем нужен пул соединений
Открытие TCP-соединения с PostgreSQL или MySQL — это не бесплатно: хэндшейк, аутентификация, инициализация сессии на стороне базы. На типичной конфигурации это 5-30 мс. Если открывать соединение на каждый запрос и закрывать после, при нагрузке 1000 RPS вы будете тратить на одно это больше времени, чем на сам запрос к данным.
Пул решает это банально: соединения открываются заранее и переиспользуются. Приложение берёт готовое соединение из пула, выполняет запрос, возвращает соединение обратно — физическое 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. Пул проверяет соединение (опционально) и помечает его свободным
Ключевые параметры конфигурации
- 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, а не нехватка соединений.