Типовая ночь дежурного инженера: приложение начало отдавать ошибки тайм-аута, в журнале растёт очередь к пулу соединений, кто-то в чате предлагает поднять лимит с 10 до 200. Логика на поверхности проста, соединений много не бывает, пусть лежат про запас. Правка выкатывается за пять минут, ошибки исчезают, все расходятся по делам. Через неделю сервер БД захлёбывается уже при вдвое меньшей нагрузке, и очередь к пулу на графике выглядит прежней, только время отклика выросло втрое. Так работает один из самых живучих мифов серверной разработки: будто большой пул безвреден, а маленький опасен. На деле всё наоборот. Лишние соединения не добавляют пропускной способности, они её отнимают, и ниже разобрано, из чего именно складывается рабочий размер пула и как проверить его расчётом, а не вслепую.
Число процессорных ядер задаёт потолок одновременной работы СУБД
Начать стоит с факта из основ вычислительной техники. Процессорное ядро в каждый момент исполняет один поток. Когда активных потоков больше, чем ядер, операционная система переключает контексты, и каждое переключение стоит времени. Если потоков пятьсот, а ядер восемь, система тратит заметную долю тактов не на работу, а на перетасовку очередей. Сервер БД не исключение, каждый активный запрос это поток или процесс на его стороне, и лишние соединения лишь удлиняют эти очереди.
Классическая отправная точка, которую давно применяют администраторы PostgreSQL, записывается одной строкой:
connections = core_count * 2 + effective_spindle_count
Под core_count понимают физические ядра без учёта гипертрединга, а effective_spindle_count это число активных дисковых накопителей, ноль, если рабочий набор целиком лежит в кэше. Для сервера с четырьмя ядрами и одним накопителем формула даёт девять соединений, круглым числом выходит десять. Смешно для сервиса с тысячами пользователей, но замеры показывают ровно это: пропускная способность растёт до некоторого числа соединений, дальше выравнивается и начинает падать. В одном из опубликованных графиков PostgreSQL рост останавливался примерно на пятидесяти соединениях, а в демонстрационном эксперименте группы производительности Oracle сокращение пула с 2048 до 96 соединений уменьшило типовое время отклика примерно со 100 до 2 миллисекунд, без каких-либо других изменений в системе.
На сервере из этого примера пул из десяти соединений по опубликованным замерам вытягивал порядка трёх тысяч активных пользователей и около шести тысяч транзакций в секунду. Цифра выглядит неправдоподобно, пока не вспомнишь, что отдельная транзакция занимает миллисекунды и одно соединение пропускает через себя сотни операций в минуту. Проблемы начинаются не тогда, когда пользователей много, а тогда, когда время отдельного запроса растёт и соединения перестают возвращаться в пул.
Почему коэффициент равен двум, а не единице. Часть времени запрос проводит в ожидании диска и сети, и пока он ждёт, ядро исполняет другой поток. Диски и сеть создают паузы, ради которых стоит держать чуть больше соединений, чем ядер. С быстрыми NVMe слагаемое дисков теряет прежний смысл, у накопителя нет механического поиска дорожки, пауз становится меньше, и лучший результат смещается ближе к удвоенному числу ядер, а иногда и к самому числу ядер. Формула потому и называется отправной точкой: она задаёт порядок величины, единицы и десятки соединений, а не сотни.
Что происходит внутри сервера БД при избыточном пуле соединений
Каждое соединение на стороне PostgreSQL это отдельный процесс со своей памятью. Параметр max_connections по умолчанию обычно равен 100, и сервер заранее распределяет под него часть разделяемой памяти. Поднять лимит до нескольких тысяч можно, быстрее сервер от этого не станет, вырастет только счётчик ожидающих.
Избыточный пул вредит по трём причинам. Первая это планировщик: сотни потоков делят восемь ядер, и переключения контекста съедают полезное время. Само переключение измеряется микросекундами, но вместе с потерей локальности кэшей процессора суммарный счёт растёт быстро. Вторая причина это блокировки: чем больше запросов одновременно трогают одни строки, тем чаще они выстраиваются в очередь на локи. Третья это память: каждый бэкенд-процесс занимает мегабайты, и при сотнях процессов кэш СУБД сжимается, растёт вероятность перечитывания с диска. Вместо ускорения выходит медленная работа всех запросов разом, причём ровно в момент пиковой нагрузки, когда деградация заметнее всего клиентам.
Расчёт потребности из пиковой нагрузки и среднего времени запроса
Формула по ядрам задаёт потолок со стороны железа. Потребность со стороны нагрузки считают отдельно, по закону Литтла, связывающему число одновременных операций с интенсивностью потока и временем обработки:
L = lambda * W
Если сервис в пике принимает 200 запросов в секунду и средний запрос к СУБД занимает 50 миллисекунд, то 200 умножить на 0.05 даёт 10 соединений. Не сто и не тысяча, просто десять. Когда среднее время вырастает до 500 миллисекунд, потребность подскакивает до ста, но это симптом больных запросов, а не довод расширять пул. Лечится такое индексами и переписыванием запросов, а не новыми соединениями.
Расчёт на один экземпляр выглядит ещё скромнее. Пиковый поток в 50 запросов в секунду при среднем времени 40 миллисекунд требует всего двух соединений, 50 умножить на 0.04 равно 2. Пул из шести на экземпляр закрывает потребность с тройным запасом, а тридцать экземпляров вместе дают уже 180 соединений, что не влезает в стандартный лимит сервера. Отсюда и вырастает настоящая работа: сначала расчёт на экземпляр, затем сложение по всем экземплярам, затем либо сжатие пулов, либо промежуточный пулер.
Отдельно стоят фоновые задания: отчёты, массовые обновления, пересчёты. Им нужен собственный маленький пул, иначе один тяжёлый отчёт займёт соединения из общей очереди и задержит пользовательские запросы. Правило простое: свой пул на каждый класс нагрузки и свой расчёт для каждого пула.
Фиксированный пул в HikariCP без свободного роста по требованию
HikariCP по умолчанию ограничивает пул десятью соединениями, и это не экономия, а осознанная установка. В приложении на Spring Boot рабочая конфигурация выглядит скромно:
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.leak-detection-threshold=60000
Одинаковые maximum-pool-size и minimum-idle делают пул фиксированным: соединения не создаются в панике на пике и не выбрасываются в затишье. Создание соединения не бесплатно, рукопожатие TCP, аутентификация, запуск бэкенд-процесса на стороне СУБД, всё это миллисекунды, и волна таких операций на растущей нагрузке бьёт по серверу сама по себе. connection-timeout в три секунды заставляет приложение быстро падать с понятной ошибкой, если пул исчерпан, вместо вязкого зависания потока. leak-detection-threshold пишет в журнал предупреждение о соединении, которое держат дольше минуты, и именно такие утечки в коде чаще всего маскируются под нехватку пула.
Тот же набор настроек в Java-приложении без Spring выглядит так:
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://db.internal:5432/billing");
config.setMaximumPoolSize(10);
config.setMinimumIdle(10);
config.setConnectionTimeout(3000);
config.setLeakDetectionThreshold(60000);
Наблюдать за пулом удобно через метрики: в экосистеме Micrometer они экспортируются под именами вида hikaricp.connections.pending и hikaricp.connections.active, и именно график pending, то есть длина очереди к пулу, первым расскажет, что конфигурация ошибочна.
В стандартной библиотеке Go пул устроен иначе: database/sql по умолчанию не ограничивает число открытых соединений, а бездействующими держит лишь два. Отсутствие верхнего предела означает, что под нагрузкой приложение откроет столько соединений, сколько позволит сервер, и упрётся в его лимит с самыми неприятными последствиями. Лечение то же, явная цифра через SetMaxOpenConns и фиксированный пул вместо безразмерного.
Случай взаимных блокировок, где пул считают отдельной формулой
Есть сценарий, где пул увеличивают осознанно и по расчёту: вложенное получение нескольких соединений одним потоком. Если транзакция берёт соединение, а внутри вызывает метод, которому нужны ещё два, при неудачной комбинации параллельных вызовов пул вырождается в мёртвую очередь, где каждый ждёт всех. Для защиты от такого вырождения применяют формулу безопасного размера:
pool_size = Tn * (Cm - 1) + 1
Здесь Tn это наибольшее число потоков, исполняющих подобный шаблон одновременно, а Cm это максимум соединений, которое один поток держит в руках. Если четыре потока могут одновременно держать по три соединения, пул должен быть не меньше девяти. Вложенные захваты соединений это дефект архитектуры приложения, и лечить его расширением пула допустимо только после того, как рефакторинг признан нецелесообразным.
Несколько экземпляров приложения и общий лимит соединений СУБД
Все расчёты выше относились к одному экземпляру приложения, а в эксплуатации их обычно десятки. Арифметика становится жёсткой: двенадцать экземпляров с пулом по двадцать дают 240 соединений, а сервер с max_connections равным 100 откажет в новых задолго до конца списка. Рабочее неравенство выглядит так: произведение числа экземпляров на размер пула плюс резерв не должно превышать лимит сервера. Резерв обязателен, PostgreSQL по умолчанию отводит три соединения суперпользователю, и администратору нужен запас на аварийный вход, когда пул приложений съел всё.
Когда экземпляров много, а лимит сервера мал, между приложением и СУБД ставят пулер вроде PgBouncer. В режиме транзакций он принимает тысячи клиентских соединений и мультиплексирует их на десятке серверных, удерживая реальную нагрузку на сервере в пределах формулы по ядрам. Промежуточный пулер не отменяет расчёт, он лишь переносит его на новый уровень, где считать нужно уже соединения к пулеру.
Нагрузочный тест и метрики, которые закрывают вопрос размера пула
Формулы дают отправную точку, окончательный ответ даёт только нагрузочный тест на реальном профиле запросов. Полезных метрик три: время ожидания соединения из пула, длина очереди к пулу и число активных бэкендов на сервере. Для последней в PostgreSQL есть готовый взгляд:
SELECT state, count(*) FROM pg_stat_activity
GROUP BY state;
Если active стабильно равен размеру пула, а idle почти нет, сервер работает на пределе, и первым делом стоит проверить, не медленны ли сами запросы. Медленный запрос в десять раз удорожает каждое соединение, которое его исполняет, и никакой размер пула этого не компенсирует.
Ещё один разрез показывает, чего именно ждут активные процессы:
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY 3 DESC;
Если в лидерах события Lock, виноваты конкурирующие транзакции, если Client, работа стоит на стороне приложения, а сервер свободен. Такой разрез превращает спор о размере пула в разговор по существу.
Рабочий порядок подбора размера пула выглядит так:
1. посчитайте потолок по формуле ядер и дисков сервера СУБД;
2. посчитайте потребность по пиковому потоку запросов и среднему времени исполнения;
3. возьмите число потребности, но не выше потолка, а при нехватке чините запросы или наращивайте сервер;
4. выделите фоновым заданиям отдельный маленький пул со своим расчётом;
5. сделайте пул фиксированным, без свободного роста по требованию;
6. прогоните нагрузочный тест и настройте алерты на ожидание соединения и число активных бэкендов.
Инженерная правда оказывается скучнее суеверия. Небольшой насыщенный пул с короткой очередью ожидающих потоков это не угроза, а спроектированное состояние системы, в котором сервер работает в комфортном режиме. Большие числа в конфиге лечат не нехватку соединений, а неуверенность того, кто их вписал. Неуверенность снимается иначе: расчётом на салфетке, нагрузочным тестом и алертом на время ожидания. Один раз посчитать, один раз проверить, и дальше спать спокойно уже по праву, а не по привычке.