MAATRIX / Блог / "Connection pooling: зачем нужен и как настроить"

"Connection pooling: зачем нужен и как настроить"

"Connection pooling: зачем нужен и как настроить"

MAATRIX

Если приложение открывает новое соединение с базой данных на каждый запрос, оно рано или поздно упрётся в стену — либо в лимит одновременных подключений на стороне БД, либо в задержки, которые накапливаются из-за постоянного handshake. Connection pooling решает эту проблему за счёт простой идеи: держать заранее открытый набор соединений и выдавать их по очереди, вместо того чтобы каждый раз устанавливать новое.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Почему открывать новое соединение на каждый запрос — плохая идея

Установка TCP-соединения с базой данных — это не одна операция, а несколько: TCP handshake, аутентификация, для PostgreSQL и MySQL ещё и согласование параметров сессии, иногда TLS-рукопожатие поверх всего этого. Каждый такой цикл занимает время — обычно единицы-десятки миллисекунд в зависимости от сети и настроек СУБД, но при высокой частоте запросов эти миллисекунды складываются в заметную часть времени ответа.

Хуже другое: каждое открытое соединение с PostgreSQL — это отдельный процесс на стороне сервера (в MySQL — поток), который занимает память независимо от того, выполняется ли в нём запрос прямо сейчас. У PostgreSQL по умолчанию max_connections обычно стоит в районе 100, и это не случайная цифра — с ростом числа соединений растёт и потребление памяти, и накладные расходы на планировщик ОС при переключении контекста между процессами. Приложение с пулом воркеров, которое плодит соединения бездумно (например, по одному на каждый HTTP-запрос без пула), способно упереться в этот лимит уже при паре сотен одновременных пользователей.

Типичная картина: сайт работает нормально днём, а в момент всплеска нагрузки начинает сыпать ошибками вида FATAL: sorry, too many clients already в PostgreSQL или Too many connections в MySQL. Формально ресурсов сервера хватает, но упёрлись именно в лимит подключений, а не в CPU или диск.

Как работает connection pool

Пул соединений — это промежуточный слой, который держит уже открытые и авторизованные соединения с БД и выдаёт их запросам приложения по требованию. Логика простая:

  1. Приложению нужно выполнить запрос к БД.
  2. Вместо того чтобы открывать новое соединение, оно берёт свободное из пула.
  3. Выполняет запрос.
  4. Возвращает соединение обратно в пул (не закрывает).
  5. Следующий запрос переиспользует то же самое соединение.

Ключевой эффект — соединения с БД становятся дорогим и разделяемым ресурсом, а не тем, что создаётся и уничтожается на лету. Пул можно реализовать на разных уровнях:

  • На уровне приложения — библиотека или ORM держит пул внутри процесса приложения (например, пул SQLAlchemy в Python или пул Sequelize в Node.js).
  • На уровне отдельного сервиса — прокси между приложением и БД, который сам управляет пулом и мультиплексирует соединения (для PostgreSQL это в первую очередь PgBouncer).
  • На уровне драйвера БД — некоторые клиентские библиотеки умеют держать несколько фоновых соединений сами, без внешних инструментов.

Эти уровни не взаимоисключающие: вполне нормальная конфигурация — пул в ORM внутри каждого экземпляра приложения плюс общий PgBouncer перед базой, который дополнительно ограничивает число реальных соединений до PostgreSQL со всех экземпляров сразу.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

PgBouncer: пул для PostgreSQL

Для PostgreSQL стандартный внешний пулер — PgBouncer. Он работает как лёгкий прокси: приложение подключается к PgBouncer как к обычному PostgreSQL, а PgBouncer уже сам держит ограниченный пул реальных соединений к базе и раздаёт их клиентам.

Смысл в том, что PgBouncer способен обслуживать тысячи клиентских подключений, реально открывая к PostgreSQL лишь десятки. Это особенно ценно, когда к одной базе подключается много экземпляров приложения (несколько инстансов backend, воркеры очередей, cron-скрипты) — без прокси суммарное число подключений к самой БД быстро превышает разумные пределы.

У PgBouncer есть три режима пулинга, и выбор режима — это компромисс между эффективностью и совместимостью:

РежимКак работаетКогда подходит
sessionСоединение закрепляется за клиентом на всю сессиюМаксимальная совместимость, минимальная экономия соединений
transactionСоединение выдаётся на время одной транзакцииЗолотая середина, подходит для большинства веб-приложений
statementСоединение выдаётся на один SQL-запросМаксимальная плотность, но ломает многие фичи (prepared statements, транзакции с несколькими запросами)

На практике для большинства веб-бэкендов выбирают transaction — он даёт основной выигрыш в плотности соединений, но не требует переписывать код так агрессивно, как statement. Важный нюанс: в режиме transaction нельзя полагаться на сессионные фичи PostgreSQL вроде SET вне транзакции, временных таблиц без ON COMMIT DROP или advisory locks, которые должны жить дольше одной транзакции — соединение может достаться другому клиенту сразу после коммита.

Подробный разбор установки и конфигурации PgBouncer — отдельная тема, мы писали про неё в статье про установку и настройку PgBouncer; если после настройки что-то пошло не так, вторая статья про частые ошибки PgBouncer и их решения закрывает большинство практических вопросов. Здесь важно зафиксировать саму концепцию: PgBouncer — это отдельный процесс, который стоит между приложением и PostgreSQL, и без него на нагруженном проекте с несколькими инстансами приложения connection pooling обычно всё равно приходится решать на каком-то уровне.

Пулы соединений в ORM и фреймворках

Даже если внешнего пулера вроде PgBouncer нет, у большинства современных ORM и фреймворков есть встроенный пул на уровне процесса приложения. Он решает более узкую задачу — не даёт одному экземпляру приложения открывать неограниченное число соединений, но не помогает координировать общий лимит между несколькими экземплярами.

В Python экосистеме SQLAlchemy по умолчанию использует QueuePool — пул с фиксированным размером и очередью ожидания, если все соединения заняты. Основные параметры пула — размер (pool_size), количество "запасных" соединений сверх основного размера (max_overflow) и таймаут ожидания свободного соединения (pool_timeout). Если приложение регулярно упирается в max_overflow, это сигнал либо увеличить пул, либо разобраться, почему соединения держатся дольше, чем нужно.

В Node.js экосистеме похожая роль у пула в Sequelize (и у пула, который под капотом использует драйвер pg для PostgreSQL или mysql2 для MySQL) — там задаются max и min для размера пула, acquire — таймаут на получение соединения, idle — через сколько неиспользуемое соединение закрывается.

Общая логика у всех таких пулов одна: держать разумное число живых соединений, переиспользовать их между запросами и не позволять одному "зависшему" запросу истощить весь пул. Разница в основном в терминологии параметров и деталях поведения при исчерпании пула — где-то это ошибка сразу, где-то ожидание с таймаутом.

Как выбрать размер пула

Интуитивно кажется, что чем больше пул, тем лучше — на практике это не так. Слишком маленький пул создаёт очередь ожидания и рост задержек под нагрузкой. Слишком большой пул увеличивает конкуренцию за ресурсы на стороне самой БД (CPU, память, блокировки) и может парадоксальным образом снизить пропускную способность — база начинает тратить больше времени на переключение между активными запросами, чем на сами запросы.

Отправная точка для расчёта — классическая формула из практики PostgreSQL: количество соединений примерно равно ((число ядер CPU у БД) * 2) + число дисков с эффективной параллельной записью. Это ориентир, а не жёсткое правило — реальное оптимальное значение зависит от характера нагрузки (короткие OLTP-запросы или тяжёлая аналитика), доли времени, которое запросы проводят в ожидании I/O, и от того, стоит ли перед базой пулер вроде PgBouncer.

Практический подход, который работает лучше умозрительных расчётов:

  1. Начните с небольшого пула (например, 10-20 соединений на инстанс приложения) и следите за метриками.
  2. Смотрите на время ожидания соединения из пула (pool_timeout события, счётчики ожидания) — если оно регулярно ненулевое под обычной нагрузкой, пул мал.
  3. Смотрите на загрузку самой БД (CPU, pg_stat_activity с состоянием active против idle in transaction) — если БД упирается в CPU при относительно небольшом пуле, увеличение пула не поможет, а усугубит.
  4. Меняйте размер постепенно и перепроверяйте под реальной или приближенной к реальной нагрузкой, а не только локально.

Отдельно стоит держать в голове: connection pooling не панацея от медленных запросов. Если проблема в отсутствующем индексе или в запросе, который сканирует всю таблицу, увеличение пула просто даст возможность большему числу медленных запросов выполняться параллельно и ещё сильнее нагрузит диск и CPU. Пул решает проблему накладных расходов на установку соединений и лимита одновременных подключений — а не проблему неоптимизированных запросов. Если после настройки пула база всё равно упирается в производительность, стоит смотреть в сторону тюнинга самого PostgreSQL — у нас есть отдельная статья про тюнинг PostgreSQL на сервере.

Мониторинг и типичные ошибки

После настройки пула стоит держать под наблюдением несколько вещей:

  • Число активных и простаивающих соединений. В PostgreSQL это видно через SELECT state, count(*) FROM pg_stat_activity GROUP BY state; — большое число idle in transaction обычно значит, что приложение открывает транзакции и не закрывает их вовремя, что держит соединения занятыми впустую.
  • Время ожидания соединения из пула. Если оно растёт, значит пул стал узким местом раньше, чем сама БД.
  • Утечки соединений. Самая частая практическая ошибка — код, который берёт соединение из пула (например, в блоке try) и не возвращает его при исключении, потому что забыли finally или аналог. Со временем пул полностью исчерпывается "зависшими" соединениями, и новые запросы начинают вставать в очередь без видимой причины по нагрузке.
  • Слишком короткий или слишком длинный timeout ожидания. Слишком короткий превращает временный всплеск нагрузки в лавину ошибок у пользователей; слишком длинный маскирует проблему и превращает её в накопление зависших запросов.

Если для одной задачи используются одновременно и пул в ORM, и внешний PgBouncer, важно понимать, что они складываются: пул приложения ограничивает соединения от этого конкретного инстанса, а PgBouncer — суммарно от всех клиентов к реальной базе. Настройка одного без учёта другого — частая причина, когда лимиты выставлены "с запасом" на каждом уровне, а суммарно всё равно превышают то, что база способна выдержать.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Нужен ли connection pooling на маленьком проекте с редкими запросами?

Если нагрузка невысокая и число одновременных подключений заведомо укладывается в лимит БД, отдельный пулер вроде PgBouncer может быть избыточен — достаточно встроенного пула в ORM с небольшим размером. Внешний пулер становится нужен, когда либо растёт число инстансов приложения, либо нагрузка становится всплесковой.

PgBouncer в режиме transaction — это всегда безопасно?

Нет, если приложение полагается на сессионные фичи PostgreSQL (например, SET search_path, временные таблицы без ON COMMIT DROP, LISTEN/NOTIFY, advisory locks вне транзакции). Перед переключением режима стоит проверить код на такие зависимости или явно перенести их в транзакцию.

Чем отличается пул на уровне приложения от PgBouncer?

Пул в ORM ограничивает соединения одного процесса приложения, но не координирует их между несколькими инстансами. PgBouncer стоит перед базой и держит общий лимит соединений независимо от того, сколько инстансов приложения к нему подключено — это разные уровни одной и той же задачи, и часто используются вместе.

Можно ли просто увеличить max_connections в PostgreSQL вместо настройки пула?

Технически можно, но каждое дополнительное соединение — это отдельный процесс с накладными расходами на память и планировщик ОС. Голое увеличение лимита без пулинга откладывает проблему, а не решает её, и на определённом масштабе начинает деградировать производительность самой БД.

Как понять, что пул настроен неправильно, а не что база перегружена?

Смотрите на связку метрик: если время ожидания соединения из пула растёт, а сама БД (CPU, диск) не загружена — пул слишком мал. Если БД загружена независимо от размера пула — проблема не в пуле, а в запросах или в ресурсах сервера.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →