MAATRIX / Блог / Что такое connection pool и почему без него база падает первой

Что такое connection pool и почему без него база падает первой

MAATRIX

Когда трафик растёт, первой обычно падает не веб-сервер и не бэкенд, а база данных — причём чаще всего не из-за нехватки CPU или диска, а из-за исчерпанных подключений. Connection pool — это простой, но не всегда очевидный новичку механизм, который держит подключения к базе уже открытыми и раздаёт их запросам вместо того, чтобы открывать новое соединение каждый раз заново. Разберём, почему открытие соединения — дорогая операция, что именно ломается без пула и как пул устроен на практике.

Почему открыть соединение с базой — это не бесплатно

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

  1. TCP handshake. Прежде чем обменяться хоть одним байтом данных о самой базе, клиент и сервер должны договориться о TCP-соединении — это минимум один полный round-trip по сети.
  2. TLS-рукопожатие, если соединение зашифровано — ещё несколько round-trip'ов и вычислений на криптографию, которые нагружают CPU обеих сторон.
  3. Аутентификация. База проверяет логин и пароль (или сертификат), сверяет права доступа к конкретной схеме или базе данных.
  4. Выделение памяти под сессию на стороне сервера БД. Вот это ключевой момент, который часто упускают: PostgreSQL под каждое соединение заводит отдельный процесс в ОС, MySQL — отдельный поток. Это не абстракция уровня протокола, а реальный процесс/поток со своим стеком, буферами, кэшем подготовленных запросов и служебными структурами. Он потребляет память независимо от того, выполняется в нём запрос прямо сейчас или нет.

Каждый из этих шагов занимает время — обычно единицы или десятки миллисекунд в зависимости от сети, настроек TLS и загрузки сервера БД. Пока запросов немного, это не заметно на глаз. Но если приложение открывает новое соединение на каждый входящий HTTP-запрос, то к времени самого запроса добавляется полный цикл установки соединения — и при росте трафика эта накладная часть начинает съедать заметную долю времени ответа, а заодно и ресурсов сервера БД, которому приходится постоянно создавать и уничтожать процессы или потоки вместо того, чтобы выполнять запросы.

Что ломается первым при росте трафика

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

Проблема "приложение открывает соединение на каждый запрос" обычно незаметна на этапе разработки и стейджинга — там одновременных запросов единицы. Она проявляется ровно в момент, когда трафик подскакивает: рекламная кампания, попадание в топ, просто рабочий час пик. Схема развития проблемы типична:

  • Приложение масштабируется — добавляются новые инстансы бэкенда или увеличивается число воркеров.
  • Каждый инстанс/воркер без пула норовит открыть своё соединение на каждый запрос, а иногда и не закрывает его вовремя.
  • Суммарное число подключений ко всем инстансам начинает расти быстрее, чем реальная полезная нагрузка на CPU или диск базы.
  • В какой-то момент база отказывает новым подключениям с ошибкой вида FATAL: sorry, too many clients already в PostgreSQL или Too many connections в MySQL.

Внешне это выглядит как "сайт лёг под нагрузкой", хотя формально ресурсов сервера ещё хватало — CPU не был загружен под сто процентов, диск не захлёбывался. Упёрлись именно в лимит подключений, а не в производительность. Это частая и обманчивая ситуация: администратор смотрит на графики CPU и памяти, видит там относительно спокойную картину, и не сразу понимает, что дело в количестве открытых сессий, а не в тяжести запросов.

Второй эффект, менее очевидный: даже если лимит подключений не пробит, сам процесс постоянного открытия и закрытия соединений создаёт паразитную нагрузку на сервер БД — CPU тратится на аутентификацию и выделение памяти под новую сессию вместо выполнения полезных запросов. То есть база может выглядеть "перегруженной", хотя реальных данных обрабатывается немного — просто накладные расходы на соединения съедают ресурсы.

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

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

Арендовать сервер

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

Connection pool — это промежуточный слой, который решает задачу иначе: вместо того чтобы открывать соединение под каждый запрос и закрывать его сразу после, пул держит заранее открытый и уже аутентифицированный набор соединений и выдаёт их запросам по требованию. Логика простая и одинаковая независимо от реализации:

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

Ключевой эффект: дорогие шаги — TCP handshake, TLS, аутентификация, выделение памяти под сессию — выполняются один раз при создании соединения в пуле, а не при каждом обращении к базе. Дальше соединение просто переиспользуется столько раз, сколько нужно, пока не будет явно закрыто (например, по превышении времени жизни или при ошибке). Для приложения, которое обслуживает сотни запросов в секунду, разница огромная: вместо сотен циклов "подключиться → выполнить → отключиться" происходит один цикл подключения и множество циклов "взять из пула → выполнить → вернуть в пул".

Важно понимать: пул не обманывает лимит подключений к базе — он просто ограничивает число одновременно открытых соединений сверху и переиспользует их. Именно поэтому размер пула становится параметром, который нужно осознанно выбирать, а не оставлять на волю случая.

Пул на стороне приложения vs отдельный сервис

Есть два принципиально разных места, где может жить пул, и разница между ними важна на практике.

Пул на стороне приложения — это библиотека или встроенный механизм фреймворка/ORM, который держит набор соединений внутри процесса самого приложения. Примеры: QueuePool в SQLAlchemy (Python), встроенный пул в Sequelize или в драйвере pg (Node.js), пул соединений в HikariCP (Java). Такой пул ограничивает число соединений, которые открывает конкретный процесс приложения, но ничего не знает о других процессах — если у вас пять инстансов бэкенда, каждый со своим пулом на 20 соединений, суммарно к базе может уйти до 100 соединений, и ни один из пяти пулов по отдельности этого не увидит.

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

КритерийПул в приложенииОтдельный сервис (PgBouncer)
Где живётВнутри процесса приложенияОтдельный процесс/контейнер
Что ограничиваетСоединения одного инстансаСуммарные соединения к БД от всех клиентов
Координация между инстансамиНетЕсть — общий лимит для всех
Что настраиватьРазмер пула, таймауты в конфиге ORMРазмер пула, режим пулинга (session/transaction/statement)
Дополнительная точка отказаНетДа — сам пулер может упасть или стать узким местом
Типичный сценарийОдин инстанс приложения, невысокая нагрузкаНесколько инстансов, cron-задачи, воркеры очередей — всё к одной базе

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

Как посмотреть текущее число соединений

Прежде чем что-то настраивать, полезно увидеть реальную картину: сколько соединений открыто сейчас и что они делают.

В PostgreSQL основной источник — системное представление pg_stat_activity:

-- Сколько всего соединений и в каком они состоянии
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;

-- Текущий лимит
SHOW max_connections;

-- Детали по каждому соединению: кто подключён, откуда, что делает
SELECT pid, usename, application_name, client_addr, state, query
FROM pg_stat_activity;

Состояние active означает, что соединение прямо сейчас выполняет запрос. Состояние idle — соединение свободно и просто ждёт. А вот idle in transaction — тревожный сигнал: приложение открыло транзакцию и не закрыло её (ни COMMIT, ни ROLLBACK), из-за чего соединение занято впустую и может держать блокировки. Много строк с idle in transaction почти всегда указывает на ошибку в коде приложения — не тот finally, забытый COMMIT, необработанное исключение внутри транзакции.

В MySQL аналогичная информация доступна проще:

SHOW STATUS LIKE 'Threads_connected';
SHOW VARIABLES LIKE 'max_connections';
SHOW PROCESSLIST;

Threads_connected — текущее число подключений, max_connections — лимит, SHOW PROCESSLIST — список соединений с их состоянием (Sleep — соединение простаивает, что при пуле нормально, а вот массовый Sleep с большим Time без пула часто означает утечку).

Если между приложением и базой уже стоит PgBouncer, у него есть собственная консоль администрирования — можно подключиться к базе pgbouncer и выполнить SHOW POOLS; или SHOW CLIENTS;, чтобы увидеть отдельно число клиентских подключений к пулеру и число реальных соединений, которые он держит к PostgreSQL. Разница между этими двумя числами — и есть тот выигрыш, который даёт пулинг: часто сотни клиентских подключений обслуживаются несколькими десятками реальных.

Типичные размеры пула и как их выбирать

Однозначного "правильного" числа не существует — размер пула зависит от характера нагрузки, числа ядер и памяти сервера БД, а также от того, короткие у вас запросы (типичный веб-бэкенд) или долгие аналитические. Но есть разумные ориентиры для старта, которые стоит воспринимать именно как отправную точку, а не как готовый ответ:

  • Для встроенного пула в ORM на один инстанс приложения с обычной веб-нагрузкой часто начинают с небольшого пула — порядка 10-20 соединений — и дальше корректируют по метрикам, а не подбирают наугад.
  • Для PgBouncer в режиме transaction реальное число соединений к самому PostgreSQL обычно держат заметно меньше, чем суммарное число клиентских подключений от приложения — это и есть весь смысл прокси-пулинга.
  • Есть и умозрительная формула, которую иногда приводят как стартовую точку для расчёта числа соединений к самой БД: число ядер CPU у сервера БД, умноженное на два, плюс число дисков с эффективной параллельной записью. Это ориентир из практики тюнинга PostgreSQL, а не строгое правило — при полностью SSD/NVMe хранилище и виртуализации формула работает не так буквально, как во времена, когда её вывели для дисковых массивов с вращающимися пластинами.

Важно держать в голове две ошибки в разные стороны. Слишком маленький пул создаёт очередь ожидания — запросы начинают ждать освобождения соединения, и растёт задержка ответа, хотя сама база не перегружена. Слишком большой пул может, наоборот, снизить пропускную способность: больше активных соединений — больше конкуренции за CPU, память и блокировки на стороне БД, и в какой-то момент база начинает тратить больше времени на переключение между параллельными запросами, чем на их выполнение. Проверять размер пула стоит не умозрительно, а по факту: смотреть на время ожидания соединения из пула и на загрузку самой БД (CPU, pg_stat_activity с разбивкой по состояниям) под реальной или близкой к реальной нагрузкой. Если база уже упирается в производительность независимо от размера пула, дело не в пуле, а в самих запросах или в конфигурации СУБД — про это у нас есть отдельная статья про тюнинг PostgreSQL на сервере.

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

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

Арендовать сервер

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

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

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

Пул соединений нужен даже на маленьком проекте?

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

Что произойдёт, если вообще не использовать пул?

Приложение будет открывать и закрывать соединение на каждый запрос к базе. При низком трафике это незаметно; при росте — растут задержки из-за постоянного handshake и аутентификации, а затем база упирается в лимит max_connections и начинает отказывать новым подключениям, хотя CPU и диск ещё не перегружены.

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

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

Пул в ORM и PgBouncer — нужно выбрать что-то одно?

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

Как понять, что дело именно в пуле, а не в медленных запросах?

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

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

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

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