Есть распространённое интуитивное убеждение: если приложению не хватает пропускной способности к базе, нужно просто разрешить больше одновременных соединений. Логика понятная, но для PostgreSQL она часто работает в обратную сторону — и разбираться с последствиями обычно приходится не тому, кто поднял max_connections.
Почему соединение в PostgreSQL — это дорого
Каждое соединение к PostgreSQL — это отдельный процесс операционной системы (backend process), а не лёгкий поток. У процесса свой стек, своя память, свой overhead на переключение контекста. Держать пятьсот таких процессов, даже если девяносто процентов из них простаивают — значит постоянно платить за память и планировщик ОС, который переключается между ними, а также за дополнительную нагрузку на блокировки внутри самого PostgreSQL, которые тоже масштабируются не бесконечно линейно.
На практике пропускная способность базы часто начинает падать после определённого числа активных соединений — не потому что PostgreSQL «плохо оптимизирован», а потому что контекст-свитчинг и конкуренция за CPU/блокировки растут быстрее, чем полезная работа.
Формула, с которой удобно начинать
Есть грубое, но полезное эмпирическое правило для числа полезных одновременных соединений:
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-режиме нельзя полагаться на состояние сессии между транзакциями: временные таблицы, SET без LOCAL, advisory locks вне транзакции могут вести себя неожиданно, потому что физическое соединение может смениться между запросами одного клиента. Именно поэтому многие ORM требуют явного переключения в режим, совместимый с pooling'ом, отключая server-side prepared statements по умолчанию.
Пример минимальной конфигурации
[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 предоставляет собственную консоль с рядом полезных команд, к которой можно подключиться как к обычной базе:
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раз за разом «решает» проблему на день-два, а потом она возвращается в большем масштабе.
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без анализа — это лечение симптома, а не причины.