Connection pooling: почему 500 соединений — это не всегда хорошо

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

Почему соединение в PostgreSQL — это дорого

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

Наблюдение

На практике пропускная способность базы часто начинает падать после определённого числа активных соединений — не потому что PostgreSQL «плохо оптимизирован», а потому что контекст-свитчинг и конкуренция за CPU/блокировки растут быстрее, чем полезная работа.

Формула, с которой удобно начинать

Есть грубое, но полезное эмпирическое правило для числа полезных одновременных соединений:

text
connections = ((core_count * 2) + effective_spindle_count)

Для сервера с 8 ядрами и SSD (effective_spindle_count можно считать ~1) это даёт около 17 полезных активных соединений. Звучит парадоксально мало по сравнению с max_connections = 500, который часто встречается в конфигурациях «на всякий случай». Ключевое слово — «активных»: это не значит, что приложению нужно всего 17 клиентов, это значит, что одновременно выполняющих запрос backend-процессов больше этого числа редко приносят пользу, а сверх этого числа начинают друг другу мешать.

Где на самом деле нужны сотни соединений

Обычно потребность в «пятистах соединениях» на самом деле означает: у вас пятьсот клиентов, которые время от времени делают короткие запросы, а не пятьсот клиентов, постоянно нагружающих CPU. Это ровно та ситуация, для которой придуман connection pooling: разделить понятия «клиент приложения» и «физическое соединение к базе».

PgBouncer коротко

PgBouncer держит небольшой пул реальных соединений к PostgreSQL и раздаёт их клиентам по мере надобности, а не один-в-один. Три режима работы:

РежимКак работаетКогда подходит
sessionСоединение закрепляется за клиентом на всю сессиюНужны session-level функции (advisory locks, prepared statements)
transactionСоединение выдаётся на время одной транзакцииБольшинство веб-приложений — самый частый выбор
statementСоединение выдаётся на один statementОчень специфичные случаи, редко используется
Подвох transaction-режима

В transaction-режиме нельзя полагаться на состояние сессии между транзакциями: временные таблицы, SET без LOCAL, advisory locks вне транзакции могут вести себя неожиданно, потому что физическое соединение может смениться между запросами одного клиента. Именно поэтому многие ORM требуют явного переключения в режим, совместимый с pooling'ом, отключая server-side prepared statements по умолчанию.

Пример минимальной конфигурации

ini
[databases]
app = host=127.0.0.1 port=5432 dbname=app

[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20

Здесь 2000 клиентов приложения могут одновременно держать соединение с PgBouncer, но реально к PostgreSQL уходит не больше 20 физических соединений на базу. С точки зрения PostgreSQL нагрузка выглядит скромно и предсказуемо, даже если фронтенд отчаянно масштабируется горизонтально.

Как посмотреть на состояние пула изнутри

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

sql
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"

Колонки cl_active и cl_waiting показывают, сколько клиентов активно используют соединение прямо сейчас, а сколько ждёт свободного слота в пуле. Постоянно ненулевой cl_waiting — сигнал, что default_pool_size стоит увеличить либо разобраться, почему транзакции держат соединения дольше, чем нужно.

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

  • В pg_stat_activity много строк с состоянием idle и особенно idle in transaction.
  • Растёт задержка ответа при относительно небольшой полезной нагрузке на CPU базы.
  • Увеличение max_connections раз за разом «решает» проблему на день-два, а потом она возвращается в большем масштабе.
sql
SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state
ORDER BY count(*) DESC;

Если idle и idle in transaction в сумме заметно превышают active — это почти всегда сигнал, что нужен pooling, а не более высокий max_connections. По сути, вы платите памятью и переключениями контекста за клиентов, которые в данный момент вообще ничего не делают.

PgBouncer или Pgpool?

Иногда встаёт вопрос выбора между PgBouncer и Pgpool-II. На практике для чистого connection pooling PgBouncer почти всегда проще, легче и предсказуемее — он умеет ровно одну вещь и делает её хорошо. Pgpool добавляет балансировку нагрузки между репликами и встроенный failover, но ценой заметно большей сложности конфигурации. Если вам нужен именно пулинг, а маршрутизацию между репликами вы решаете на другом уровне (например, через приложение или отдельный прокси) — PgBouncer обычно выигрывает по соотношению пользы и сложности.

Коротко

  • Соединение PostgreSQL — это процесс ОС, а не лёгкий поток; их количество не бесконечно дёшево.
  • Пятьсот клиентов не равно пятьсот полезных активных соединений.
  • PgBouncer в transaction-режиме почти всегда лучший старт для веб-нагрузки.
  • Следите за cl_waiting в PgBouncer и долей idle in transaction в PostgreSQL.
  • Растущий max_connections без анализа — это лечение симптома, а не причины.