Настройка пула соединений Postgres: что чаще делает хуже
Настройка пула соединений Postgres: как пул связан с транзакциями и медленными запросами, какие метрики снять до правок и как менять безопасно.

Когда настройка пула внезапно делает систему медленнее
Сценарий часто один и тот же: пользователи жалуются на подвисания, в логах появляются таймауты, а на графиках - странные всплески. При этом база вроде жива, CPU не всегда в потолок, и по ощущениям проблема где-то между приложением и Postgres.
Обычно это выглядит так:
- растет p95/p99 задержек даже у простых запросов
- появляются ошибки timeout / context deadline exceeded
- скачет число активных сессий и фоновых воркеров
- увеличивается время ожидания соединения в приложении
- нагрузка становится рваной: то пусто, то резко много
В этот момент мысль «давайте просто увеличим пул» кажется логичной: больше соединений - меньше очереди. Но на практике увеличение пула часто ухудшает ситуацию. Причина простая: вы не убираете узкое место, а переносите очередь. Вместо понятного ожидания на стороне приложения получаете конкуренцию за ресурсы внутри базы (CPU, память, I/O, блокировки), больше переключений контекста и более непредсказуемые хвосты.
Один и тот же таймаут может означать разные проблемы:
- база: медленные запросы, блокировки, нехватка I/O, плохие планы
- приложение: длинные транзакции, лишние запросы, N+1, агрессивные ретраи
- сеть/инфраструктура: задержки, лимиты, перегруженные контейнеры
- пулер (например, PgBouncer): очередь на выдачу соединений, режим пула, внутренние лимиты
Типичная ловушка: вы увеличили лимит соединений и «активных клиентов» в пулере, и таймаутов стало меньше на несколько минут. Затем база начинает чаще упираться в блокировки или CPU, а p99 становится хуже, чем было. Система не «сломалась снова» - вы просто разрешили ей одновременно делать больше тяжелых вещей.
Дальше разберем, как пул связан с транзакциями и медленными запросами, почему состояние idle in transaction особенно опасно, и какие метрики стоит снять заранее (включая pg_stat_statements), чтобы изменения можно было контролировать и спокойно откатывать.
Как работает пул соединений: простая модель
Соединение с Postgres - это не просто «трубка» для запросов. На стороне сервера это отдельный процесс, память под сессию, настройки, контекст безопасности и сетевые накладные расходы. Открывать и закрывать соединение на каждый запрос дорого, поэтому большинство систем держат соединения открытыми и переиспользуют.
Важно не путать «соединения» и «запросы в секунду». Соединение - это канал, где запросы идут по очереди. QPS зависит от того, насколько быстро Postgres выполняет сами запросы, есть ли блокировки, хватает ли CPU и диска, и сколько параллельной работы база реально переваривает.
Даже если у вас 200 соединений, это не значит 200 запросов одновременно. Одно соединение выполняет один запрос за раз (упрощенно), а остальные ждут - в приложении или в пулере.
Пул обычно делают так:
- внутри приложения: каждый инстанс держит свой набор соединений и раздает их потокам
- отдельный пулер (например, PgBouncer): приложения подключаются к пулеру, а он держит ограниченное число реальных соединений к Postgres
Отдельный пулер полезен, когда приложений много, они часто переподключаются, или нужен единый контроль лимитов. Например, в бэкенде на Go и Postgres всплески соединений на пиках случаются легко, а пулер их сглаживает (в том числе в проектах, которые собирают на TakProsto).
Почему «слишком много соединений» часто замедляет Postgres:
- CPU уходит на обслуживание большого числа процессов и переключения контекста
- растет потребление памяти, кэш держит меньше полезных данных
- очереди становятся менее предсказуемыми: больше конкуренции за те же ресурсы
- VACUUM и административные операции тяжелее выполнять при большом числе активных сессий
Поэтому практическая цель пула простая: соединений должно хватать, чтобы база была занята полезной работой, но не настолько много, чтобы база тратила силы на обслуживание сессий вместо выполнения запросов.
Пул и транзакции: где чаще всего прячется задержка
Транзакция в Postgres - это период от BEGIN до COMMIT/ROLLBACK. Пока транзакция открыта, соединение занято одним клиентом, а база может держать снимок данных, блокировки и другие ресурсы. Поэтому проблема часто не в том, что «соединений мало», а в том, что часть соединений надолго зависает внутри транзакций.
Пул помогает перераспределять короткие запросы между ограниченным числом соединений. Но если приложение открывает транзакцию и не закрывает ее быстро, пул превращается в очередь: свободных соединений становится меньше, новые запросы ждут, задержка растет. В этой ситуации увеличение пула редко спасает - оно лишь меняет место, где вы увидите ожидание.
Idle и idle in transaction: тихие убийцы
idle означает, что соединение простаивает без активного запроса. Это обычно нормально, если соединений не слишком много.
idle in transaction намного хуже: запросов сейчас нет, но транзакция открыта. Такое соединение может удерживать блокировки или старый снимок, из-за чего растет bloat и тормозит VACUUM. В пулах это особенно заметно: кажется, что соединения «свободны», но на деле они заняты незакрытыми транзакциями.
Пример: пул на 50 соединений, но 10 из них зависли в idle in transaction из-за того, что пользователь открыл экран и ушел, а код держит транзакцию до конца «жизненного цикла UI». Оставшихся 40 уже может не хватить под пик.
Как понять, что задержка из-за транзакций
Смотрите не только «сколько соединений», а «какие они»:
- в
pg_stat_activityрастет числоidle in transaction, аxact_startу них уходит на минуты и часы - в логах появляются
canceling statement due to lock timeoutи похожие сообщения - растет время ожидания соединения в приложении, при этом CPU базы может почти не меняться
- запросы долго висят в статусе
active, но фактически ждутLock - VACUUM не успевает, размер таблиц и bloat растут при похожей нагрузке
Если такие признаки есть, начинать стоит с транзакций: укорачивать их, держать BEGIN ближе к реальным изменениям и не оставлять транзакцию открытой во время ожидания пользователя, сетевых вызовов и долгих операций в коде.
Медленные запросы и блокировки: почему пул не спасает
Пул не ускоряет запросы. Он лишь решает, кому и когда дать доступ к ограниченному числу подключений. Если внутри Postgres есть медленный запрос или блокировка, пул может сделать проблему заметнее: очередь появится раньше и станет длиннее.
Здесь важно различать две очереди:
- очередь в пулере: клиентов много, свободных подключений мало, запросы ждут слот
- очередь внутри Postgres: подключение есть, но запрос ждет блокировку, упирается в I/O или крутится на CPU
Снаружи обе ситуации выглядят одинаково: «все тормозит». Лечение разное.
Блокировки особенно коварны. Один запрос удерживает lock на строке или таблице, и десятки других запросов выстраиваются за ним, хотя сами по себе они быстрые. Частый сценарий: транзакция обновила запись и не закоммитила, соединение зависло в idle in transaction, а все остальные попытки обновить эти же данные ждут.
Большой параллелизм делает медленные места болезненнее. Когда вы увеличиваете пул, вы запускаете больше запросов одновременно. Если узкое место - тяжелый запрос, индекс, блокировка или I/O, вы просто увеличите конкуренцию за те же ресурсы. В результате растет среднее время ответа и начинается лавина таймаутов.
Как отличить «медленный запрос» от проблемы пула
Суть в том, где накапливается ожидание:
- в PgBouncer: растет ли очередь ожидания (например,
cl_waiting) при нормальной загрузке Postgres - в Postgres: есть ли активные ожидания
wait_eventи какие именно (lock, IO и т.д.) - есть ли блокирующие пары: кто держит блокировку и кто ждет
- есть ли long-running транзакции и
idle in transaction - в приложении: время теряется до отправки SQL (ожидание соединения) или после (ожидание выполнения)
Пример: бэкенд на Go ходит в Postgres через PgBouncer. Увеличили пул, очередь на коннекты уменьшилась, но через 10 минут пошли массовые таймауты. Причина оказалась не в соединениях: один UPDATE без нужного индекса держал блокировки дольше обычного, а выросший параллелизм усилил конфликт.
Какие метрики смотреть до изменений (и записать как «до»)
Перед изменениями зафиксируйте состояние системы в спокойный час и в пиковый. Иначе легко перепутать эффект от настройки с обычными колебаниями нагрузки.
На уровне приложения достаточно набора, который отвечает на главный вопрос: пользователю стало лучше или хуже. Запишите RPS, p95/p99 по времени ответа, долю 5xx, количество таймаутов и ретраев. Если есть очереди задач - длину очереди и время ожидания.
По базе важно понять, во что упираетесь:
- сколько активных сессий и сколько реально работают (
active) против простаивают (idle) - сколько сессий в
idle in transactionи как давно они там - блокировки и основные типы ожиданий (wait events)
- TPS (commit/rollback) и примерная длительность транзакций
- ресурсы: CPU, I/O, рост временных файлов (если есть в мониторинге)
Отдельно снимите картину по запросам. Если включен pg_stat_statements, посмотрите топ по суммарному времени и по среднему времени, а также по числу вызовов. Частая история: один тяжелый запрос съедает основное время, а пул просто прячет это очередью.
По пулеру (например, PgBouncer) полезно знать:
- есть ли очередь ожидания соединения и как она ведет себя в пик
- занятость пула (занято/свободно)
- среднее и p95 время ожидания соединения
- число отказов и таймаутов на подключение
Удобный формат «до»: короткая сводка за 10-15 минут в пике и вне пика. Например: RPS, p95, ошибки, активные сессии, idle in transaction, число блокировок, топ-3 запроса по времени, очередь в пулере, время ожидания соединения. После изменения сравнивайте те же числа.
Пошагово: как настраивать пул безопасно
Пул редко «ломается» из-за одного плохого числа. Чаще ломают процесс: меняют параметры без цели и без замеров, а потом ищут виноватого в пуле, хотя выросли блокировки или запросы стали дольше.
Сначала выберите одну главную цель, которая отражает реальную боль: p95 времени ответа API, доля таймаутов, очередь ожидания соединения. Без этого легко улучшить среднее и ухудшить хвост.
Практичная последовательность:
- зафиксируйте 1-2 метрики успеха и 1 ограничение (например, p95 < 300 мс, таймаутов < 0,1%, CPU базы не выше 70%)
- проверьте реальные лимиты:
max_connections, CPU/RAM базы и максимальную конкурентность приложения в пике - выберите стратегию: для веб-сервисов обычно важнее предсказуемые таймауты и понятный верхний предел конкуренции, чем «пул побольше»
- меняйте один параметр за раз и сравнивайте с «до»
- проверяйте под нагрузкой и в пиковые периоды: средние значения часто скрывают очереди
Если видите рост idle in transaction, безопаснее сначала ограничить параллелизм и укоротить ожидание соединения, а не расширять пул. Иначе вы просто загоните больше запросов в ожидание блокировок.
Настройки, которые чаще всего ухудшают ситуацию
Самая частая ошибка - лечить симптом. Пул держит подключения под контролем, но его легко превратить в усилитель задержек.
Пул «побольше, на всякий случай»
Большой пул выглядит как запас прочности, но чаще дает больше конкуренции за CPU, диски и блокировки. В итоге растет время запросов и хвосты p95/p99. Если увеличиваете pool_size в PgBouncer, проверьте, что база реально может выполнить столько одновременной работы, а не просто «принимает подключения».
Слишком длинные таймауты ожидания
Длинные таймауты «чтобы не падало» превращают проблему в очередь. Пользователь ждет 30-60 секунд, очередь растет, а под нагрузкой начинается лавина. Часто лучше ограничить ожидание в пуле и быстро вернуть ошибку, чем копить зависшие запросы.
Каждый сервис умножает лимиты
Один сервис выставил 100 соединений, другой 200, третий еще 50. При автоскейле это быстро превращается в хаос. По отдельности значения выглядят разумно, а суммарно база получает в разы больше конкуренции.
Транзакции держат соединение дольше, чем нужно
Проблема часто не в SQL, а в коде вокруг: внешние HTTP-вызовы, тяжелая сериализация, лишняя логика между BEGIN и COMMIT. Если соединение зависает в idle in transaction, пул перестает быть пулом.
Prepared statements конфликтуют с режимом пула
В режиме transaction pooling prepared statements могут вести себя не так, как ожидает приложение: подготовили на одном соединении, а следующий запрос попал на другое. Перед сменой режима проверьте, как драйвер и код используют prepared statements.
Пример из жизни: «увеличили пул» и получили таймауты
Интернет-магазин, вечерний пик после рекламной рассылки. Выросли таймауты в оформлении заказа. Команда решила, что не хватает соединений, и увеличила пул.
Первые 10 минут стало легче: ошибок меньше. Потом p99 времени ответа вырос, CPU базы ушел в потолок, а очередь запросов стала длиннее.
Разбор показал две причины:
- много сессий в
idle in transaction(транзакция открыта, запрос уже не выполняется) - регулярные блокировки на таблице заказов из-за конкурентных обновлений одних и тех же строк
Увеличение пула ухудшило ситуацию, потому что приложение получило возможность держать больше одновременных транзакций. Каждая стала дольше ждать блокировку, а пока ждет - удерживает соединение и часто другие ресурсы. База делает больше переключений контекста и греет CPU, но полезной работы больше не становится.
Исправления оказались не про «еще больше соединений», а про контроль времени владения соединением и параллелизма:
- укоротили транзакции: убрали лишнюю логику между
BEGINиCOMMIT, особенно сетевые вызовы и тяжелые вычисления - ограничили параллелизм на уровне приложения для операций оформления заказа
- починили один запрос из топа
pg_stat_statements(индекс + правка условия) - настроили таймауты на транзакции и ожидание блокировок, чтобы зависшие операции не держали систему часами
После этого пул вернули ближе к исходному, p99 упал, CPU стабилизировался, таймауты ушли.
Короткий чеклист перед тем, как трогать параметры пула
Если хочется «просто подкрутить pgbouncer настройки» или поднять лимит соединений, остановитесь и проверьте базовые вещи. Часто причина не в пуле.
- Длинные транзакции и
idle in transaction
Посмотрите активность в базе: есть ли сессии, которые держат транзакцию слишком долго. Если таких много, увеличение пула обычно только добавит клиентов, которые будут ждать друг друга.
- Топ медленных и самых затратных запросов
Смотрите не только на «самый долгий», но и на то, что выполняется часто и суммарно съедает больше всего времени. Для этого подходят pg_stat_statements и логи медленных запросов.
- Очередь на соединение
Если есть очередь на получение соединения, измерьте время ожидания коннекта и долю занятых коннектов в пик. В сервисах на Go это легко перепутать с «медленной базой», если не разделять ожидание коннекта и выполнение SQL.
max_connectionsи реальная потребность по сервисам
Сложите потребности: API, воркеры, админка, интеграции. Рост max_connections без оценки памяти на соединение и общей нагрузки часто ухудшает задержки.
- План отката и фиксация «до»
Запишите текущие значения параметров и метрики «до» (p95, ошибки, активные сессии, idle in transaction, ожидание соединения, топ запросов). Заранее договоритесь, что считать ухудшением и как быстро вернуть прошлые значения.
Следующие шаги: план изменений, контроль и аккуратный откат
Пул влияет не только на Postgres, но и на очереди в приложении, таймауты и поведение транзакций. Поэтому важнее всего заранее решить, как вы поймете, что стало лучше, и как быстро вернетесь назад, если стало хуже.
Сделайте короткий план на одну страницу:
- что меняем: конкретные параметры (лимиты в пулере, таймауты, лимит соединений Postgres)
- зачем: какая проблема сейчас (таймауты, рост p95, ожидания блокировок,
idle in transaction) - метрики успеха: 2-4 числа, которые должны улучшиться
- риски: что может ухудшиться (очереди, блокировки, рост CPU)
- план отката: кто откатывает и сколько времени это займет
Лимиты должны сходиться между приложением (пул драйвера), пулером и Postgres. Иначе легко получить ситуацию, где приложение «держит 200», пулер пускает 50, а Postgres ограничен 100 - и вы видите очереди и таймауты, хотя «лимиты увеличили».
Если вы выкатываете изменения через платформу разработки, полезно иметь быстрый механизм фиксации и отката конфигурации. В TakProsto, например, это удобно делать через planning mode и снапшоты с rollback: сначала фиксируете план изменений, затем выкатываете, и при ухудшении быстро возвращаетесь к прошлой версии без лишней суеты.