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

Сценарий часто один и тот же: пользователи жалуются на подвисания, в логах появляются таймауты, а на графиках - странные всплески. При этом база вроде жива, CPU не всегда в потолок, и по ощущениям проблема где-то между приложением и Postgres.
Обычно это выглядит так:
В этот момент мысль «давайте просто увеличим пул» кажется логичной: больше соединений - меньше очереди. Но на практике увеличение пула часто ухудшает ситуацию. Причина простая: вы не убираете узкое место, а переносите очередь. Вместо понятного ожидания на стороне приложения получаете конкуренцию за ресурсы внутри базы (CPU, память, I/O, блокировки), больше переключений контекста и более непредсказуемые хвосты.
Один и тот же таймаут может означать разные проблемы:
Типичная ловушка: вы увеличили лимит соединений и «активных клиентов» в пулере, и таймаутов стало меньше на несколько минут. Затем база начинает чаще упираться в блокировки или CPU, а p99 становится хуже, чем было. Система не «сломалась снова» - вы просто разрешили ей одновременно делать больше тяжелых вещей.
Дальше разберем, как пул связан с транзакциями и медленными запросами, почему состояние idle in transaction особенно опасно, и какие метрики стоит снять заранее (включая pg_stat_statements), чтобы изменения можно было контролировать и спокойно откатывать.
Соединение с Postgres - это не просто «трубка» для запросов. На стороне сервера это отдельный процесс, память под сессию, настройки, контекст безопасности и сетевые накладные расходы. Открывать и закрывать соединение на каждый запрос дорого, поэтому большинство систем держат соединения открытыми и переиспользуют.
Важно не путать «соединения» и «запросы в секунду». Соединение - это канал, где запросы идут по очереди. QPS зависит от того, насколько быстро Postgres выполняет сами запросы, есть ли блокировки, хватает ли CPU и диска, и сколько параллельной работы база реально переваривает.
Даже если у вас 200 соединений, это не значит 200 запросов одновременно. Одно соединение выполняет один запрос за раз (упрощенно), а остальные ждут - в приложении или в пулере.
Пул обычно делают так:
Отдельный пулер полезен, когда приложений много, они часто переподключаются, или нужен единый контроль лимитов. Например, в бэкенде на Go и Postgres всплески соединений на пиках случаются легко, а пулер их сглаживает (в том числе в проектах, которые собирают на TakProsto).
Почему «слишком много соединений» часто замедляет Postgres:
Поэтому практическая цель пула простая: соединений должно хватать, чтобы база была занята полезной работой, но не настолько много, чтобы база тратила силы на обслуживание сессий вместо выполнения запросов.
Транзакция в Postgres - это период от BEGIN до COMMIT/ROLLBACK. Пока транзакция открыта, соединение занято одним клиентом, а база может держать снимок данных, блокировки и другие ресурсы. Поэтому проблема часто не в том, что «соединений мало», а в том, что часть соединений надолго зависает внутри транзакций.
Пул помогает перераспределять короткие запросы между ограниченным числом соединений. Но если приложение открывает транзакцию и не закрывает ее быстро, пул превращается в очередь: свободных соединений становится меньше, новые запросы ждут, задержка растет. В этой ситуации увеличение пула редко спасает - оно лишь меняет место, где вы увидите ожидание.
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 и похожие сообщенияactive, но фактически ждут LockЕсли такие признаки есть, начинать стоит с транзакций: укорачивать их, держать BEGIN ближе к реальным изменениям и не оставлять транзакцию открытой во время ожидания пользователя, сетевых вызовов и долгих операций в коде.
Пул не ускоряет запросы. Он лишь решает, кому и когда дать доступ к ограниченному числу подключений. Если внутри Postgres есть медленный запрос или блокировка, пул может сделать проблему заметнее: очередь появится раньше и станет длиннее.
Здесь важно различать две очереди:
Снаружи обе ситуации выглядят одинаково: «все тормозит». Лечение разное.
Блокировки особенно коварны. Один запрос удерживает lock на строке или таблице, и десятки других запросов выстраиваются за ним, хотя сами по себе они быстрые. Частый сценарий: транзакция обновила запись и не закоммитила, соединение зависло в idle in transaction, а все остальные попытки обновить эти же данные ждут.
Большой параллелизм делает медленные места болезненнее. Когда вы увеличиваете пул, вы запускаете больше запросов одновременно. Если узкое место - тяжелый запрос, индекс, блокировка или I/O, вы просто увеличите конкуренцию за те же ресурсы. В результате растет среднее время ответа и начинается лавина таймаутов.
Суть в том, где накапливается ожидание:
cl_waiting) при нормальной загрузке Postgreswait_event и какие именно (lock, IO и т.д.)idle in transactionПример: бэкенд на Go ходит в Postgres через PgBouncer. Увеличили пул, очередь на коннекты уменьшилась, но через 10 минут пошли массовые таймауты. Причина оказалась не в соединениях: один UPDATE без нужного индекса держал блокировки дольше обычного, а выросший параллелизм усилил конфликт.
Перед изменениями зафиксируйте состояние системы в спокойный час и в пиковый. Иначе легко перепутать эффект от настройки с обычными колебаниями нагрузки.
На уровне приложения достаточно набора, который отвечает на главный вопрос: пользователю стало лучше или хуже. Запишите RPS, p95/p99 по времени ответа, долю 5xx, количество таймаутов и ретраев. Если есть очереди задач - длину очереди и время ожидания.
По базе важно понять, во что упираетесь:
active) против простаивают (idle)idle in transaction и как давно они тамОтдельно снимите картину по запросам. Если включен pg_stat_statements, посмотрите топ по суммарному времени и по среднему времени, а также по числу вызовов. Частая история: один тяжелый запрос съедает основное время, а пул просто прячет это очередью.
По пулеру (например, PgBouncer) полезно знать:
Удобный формат «до»: короткая сводка за 10-15 минут в пике и вне пика. Например: RPS, p95, ошибки, активные сессии, idle in transaction, число блокировок, топ-3 запроса по времени, очередь в пулере, время ожидания соединения. После изменения сравнивайте те же числа.
Пул редко «ломается» из-за одного плохого числа. Чаще ломают процесс: меняют параметры без цели и без замеров, а потом ищут виноватого в пуле, хотя выросли блокировки или запросы стали дольше.
Сначала выберите одну главную цель, которая отражает реальную боль: p95 времени ответа API, доля таймаутов, очередь ожидания соединения. Без этого легко улучшить среднее и ухудшить хвост.
Практичная последовательность:
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, пул перестает быть пулом.
В режиме 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, но и на очереди в приложении, таймауты и поведение транзакций. Поэтому важнее всего заранее решить, как вы поймете, что стало лучше, и как быстро вернетесь назад, если стало хуже.
Сделайте короткий план на одну страницу:
idle in transaction)Лимиты должны сходиться между приложением (пул драйвера), пулером и Postgres. Иначе легко получить ситуацию, где приложение «держит 200», пулер пускает 50, а Postgres ограничен 100 - и вы видите очереди и таймауты, хотя «лимиты увеличили».
Если вы выкатываете изменения через платформу разработки, полезно иметь быстрый механизм фиксации и отката конфигурации. В TakProsto, например, это удобно делать через planning mode и снапшоты с rollback: сначала фиксируете план изменений, затем выкатываете, и при ухудшении быстро возвращаетесь к прошлой версии без лишней суеты.
Лучший способ понять возможности ТакПросто — попробовать самому.