Пул соединений PostgreSQL: пул в приложении против PgBouncer
Пул соединений PostgreSQL: сравнение пулов в приложении и PgBouncer для бэкендов на Go, метрики для мониторинга и типичные неверные настройки, вызывающие пики задержки.

Почему пики задержки часто начинаются с соединений
Соединение с базой похоже на телефонную линию между вашим приложением и Postgres. Открытие соединения требует времени и ресурсов с обеих сторон: настройка TCP/TLS, аутентификация, память и процесс бэкенда на стороне Postgres. Пул соединений держит небольшой набор таких «линий связи» открытыми, чтобы приложение могло переиспользовать их вместо повторного набора номера для каждого запроса.
Когда пул отключён или неправильно подобран, вы редко сначала увидите аккуратную ошибку. Вы увидите случайную медленность. Запросы, которые обычно занимают 20–50 мс, внезапно тратят 500 мс или 5 секунд, и p95 резко растёт. Затем появляются таймауты, потом «too many connections», или очередь внутри приложения, пока оно ждёт свободного соединения.
Ограничения по соединениям важны даже для маленьких приложений, потому что трафик бывает всплесковым. Рассылка, cron-задача или несколько медленных эндпоинтов могут привести к десяткам запросов к базе одновременно. Если каждый запрос открывает новое соединение, Postgres может тратить большую часть ресурсов на принятие и управление соединениями вместо выполнения запросов. Если у вас уже есть пул, но он слишком большой, вы можете перегрузить Postgres слишком большим числом активных бэкендов, что вызовет переключение контекста и дефицит памяти.
Обратите внимание на ранние симптомы, такие как:
- резкие росты p95/p99 при нормальном среднем времени
- таймауты, стекающиеся во время всплесков трафика
- растущее время «ожидания соединения» в приложении
- частые подключения/отключения или насыщение соединений на Postgres
Пул уменьшает число смен соединений и помогает Postgres справляться со всплесками. Он не исправит медленные SQL-запросы. Если запрос делает полный скан таблицы или ждёт блокировок, пул скорее изменит то, как система ведёт себя при нагрузке (очередь раньше, таймауты позже), но не сделает запрос быстрым.
Пул в приложении против PgBouncer: какую проблему решает каждый
Пул соединений — это про контроль количества одновременных соединений с базой и их переиспользование. Это можно делать внутри приложения (уровень приложения) или отдельным сервисом перед Postgres (PgBouncer). Они решают близкие, но разные задачи.
Пул на уровне приложения (в Go обычно встроенный пул database/sql) управляет соединениями на процесс. Он решает, когда открыть новое соединение, когда переиспользовать и когда закрыть простое. Это избавляет от затрат на настройку при каждом запросе. Но он не умеет координировать поведение между разными инстансами приложения. Если вы запускаете 10 реплик, у вас фактически 10 отдельных пулов.
PgBouncer находится между приложением и Postgres и пулит от имени множества клиентов. Он особенно полезен при большом числе короткоживущих запросов, множестве инстансов приложения или всплесках трафика. Он ограничивает число серверных соединений к Postgres даже если сотни клиентских соединений приходят одновременно.
Простое разделение обязанностей:
- Пул в приложении формирует параллелизм внутри одного инстанса и предотвращает повторные подключения на запрос.
- PgBouncer ограничивает общее число соединений к Postgres по всем инстансам и сглаживает всплески.
- Postgres всё ещё имеет жёсткие пределы по CPU, диску и памяти. Пулинг не создаёт дополнительной емкости.
Они могут работать вместе без проблем «двойного пула», если у каждого слоя есть понятная роль: разумный database/sql пул на процесс Go и PgBouncer, контролирующий общий бюджет соединений.
Распространённое заблуждение — думать, что «больше пулов = больше мощности». Обычно это наоборот. Если у каждого сервиса, воркера и реплики большой локальный пул, суммарное число соединений взрывается и вызывает очередь, переключения контекста и резкие пики задержки.
Как действительно работает пул database/sql в Go
В Go sql.DB — это менеджер пула соединений, а не одно соединение. Когда вы вызываете db.Query или db.Exec, database/sql пытается переиспользовать простое соединение. Если не получается, он может открыть новое (до вашего лимита) или заставить вызов ждать.
Именно это ожидание часто вызывает «загадочную» латентность. Когда пул заполнен, запросы стоят в очереди внутри приложения. Со стороны кажется, что Postgres стал медленным, но на самом деле время уходит на ожидание свободного соединения.
Важные настройки
Большая часть тонкой настройки сводится к четырём параметрам:
MaxOpenConns: жёсткий предел открытых соединений (idle + in use). При достижении вызовы блокируются.MaxIdleConns: сколько соединений может лежать готовых к переиспользованию. Слишком мало вызывает частые переподключения.ConnMaxLifetime: принудительная периодическая ротация соединений. Полезно при балансировщиках нагрузки и NAT-таймаутах, но слишком маленькое значение вызывает лишний churn.ConnMaxIdleTime: закрывает соединения, простоявшие слишком долго.
Переиспользование соединений обычно снижает задержку и загрузку CPU базы, потому что вы избегаете повторной настройки (TCP/TLS, auth, init сессии). Но чрезмерно большой пул даёт обратный эффект: он позволяет запускать больше одновременных запросов, чем Postgres может эффективно обработать, что увеличивает конкуренцию и накладные расходы.
Думайте в суммарных величинах, а не на инстанс: если каждый Go-инстанс допускает 50 открытых соединений и вы масштабируетесь до 20 инстансов, вы фактически разрешили 1 000 соединений. Сравните это число с тем, что ваш сервер Postgres может стабильно обрабатывать.
Практическая отправная точка — привязать MaxOpenConns к ожидаемой конкуренции на инстанс, затем валидировать по метрикам пула (in-use, idle, wait time) перед увеличением.
Основы PgBouncer и режимы пула
PgBouncer — небольшой прокси между приложением и PostgreSQL. Сервис подключается к PgBouncer, а PgBouncer держит ограниченное число реальных серверных соединений к Postgres. Во время всплесков PgBouncer ставит работу клиентов в очередь вместо немедленного создания новых серверных бэкендов. Эта очередь может быть разницей между контролируемой деградацией и падением базы.
Три режима пула
У PgBouncer есть три режима пула:
- Session pooling: клиент держит одно и то же серверное соединение всё время своего подключения.
- Transaction pooling: клиент занимает серверное соединение на время транзакции, затем возвращает его.
- Statement pooling: клиент занимает серверное соединение на выполнение одного выражения.
Session pooling ближе всего к прямым соединениям с Postgres. Это наименее неожиданное поведение, но оно экономит меньше серверных соединений при всплесках нагрузки.
Что обычно подходит для Go HTTP API
Для типичных Go HTTP API транзакционный режим часто является хорошим дефолтом. Большинство запросов выполняют короткий запрос или небольшую транзакцию, после чего всё заканчивается. Transaction pooling позволяет многим клиентским соединениям делить меньший бюджет серверных соединений Postgres.
Компромисс — состояние сессии. В transaction режиме всё, что рассчитывает на постоянное серверное соединение, может сломаться или вести себя странно, включая:
- подготовленные выражения, созданные один раз и переиспользуемые позже
- настройки сессии, которые вы ожидаете увидеть постоянными (
SET,SET ROLE,search_path) - временные таблицы и advisory locks, используемые между выражениями
Если приложение полагается на такой тип состояния, безопаснее использовать session pooling. Statement pooling самый ограничивающий и редко подходит для веб-приложений.
Полезное правило: если каждый запрос может настроить всё необходимое внутри одной транзакции, transaction pooling обычно держит латентность ровнее под нагрузкой. Если вам нужна длительная сессионная логика, используйте session pooling и сфокусируйтесь на строгих лимитах в приложении.
Как выбрать стратегию для Go-бэкенда
Если вы запускаете Go-сервис с database/sql, у вас уже есть пул на стороне приложения. Для многих команд этого достаточно: несколько инстансов, стабильный трафик и запросы без экстремальных всплесков. В таком сценарии проще и безопаснее настроить пул Go, держать реалистичный лимит соединений и не усложнять архитектуру.
PgBouncer помогает особенно тогда, когда база получает слишком много клиентских соединений одновременно. Это проявляется множеством инстансов приложения (или серверлесс-стилем масштабирования), всплесками трафика и большим количеством коротких запросов.
PgBouncer также может навредить, если его используют в неверном режиме. Если ваш код зависит от состояния сессии (временные таблицы, подготовленные выражения между запросами, advisory locks, настройки сессии), transaction pooling может вызвать непредсказуемые ошибки. Если вам действительно нужно сессионное поведение, используйте session pooling или отключите PgBouncer и аккуратно подберите размеры пулов в приложениях.
Простое правило принятия решения
Используйте такое эмпирическое правило:
- Если у вас 1–3 инстанса приложения и суммарное число открытых соединений уверенно укладывается в лимит базы — хватит пула в приложении.
- Если у вас много инстансов или автошкалирование, и сумма max open connections может превысить то, что Postgres выдержит, добавляйте PgBouncer.
- Если большинство запросов короткие (быстрые чтения, маленькие записи), PgBouncer обычно окупается.
- Если запросы держат соединения долго (медленные отчёты, длинные транзакции), сначала фиксируйте запросы и будьте консервативны с размерами пулов.
Пошагово: как безопасно подобрать и внедрить пул
Ограничения по соединениям — это бюджет. Если вы тратите его весь сразу, каждый новый запрос ждёт, и хвостовая латентность растёт. Цель — ограничить параллелизм контролируемым образом, сохранив пропускную способность.
Практическая последовательность внедрения
-
Измерьте текущие пики и хвостовую латентность. Зафиксируйте пик активных соединений (не среднее), а также p50/p95/p99 для запросов и ключевых SQL-запросов. Отметьте ошибки соединения и таймауты.
-
Установите безопасный бюджет соединений Postgres для приложения. Исходите из
max_connections, вычтите место для админов, миграций, фоновых задач и всплесков. Если несколько сервисов делят базу — распределите бюджет заранее. -
Сопоставьте бюджет с лимитами Go на инстанс. Разделите бюджет приложения на число инстансов и установите
MaxOpenConnsна это значение (или чуть ниже). УстановитеMaxIdleConnsдостаточно высоким, чтобы избежать постоянных переподключений, и выберите времена жизни соединений так, чтобы ротация была редкой, но происходила. -
Добавляйте PgBouncer только при необходимости и выбирайте режим. Используйте session pooling, если нужно состояние сессии. Выбирайте transaction pooling, когда хотите максимального сокращения серверных соединений и приложение совместимо.
-
Внедряйте постепенно и сравнивайте до и после. Меняйте по одному параметру, делайте canary-выкат, затем сравнивайте хвостовую латентность, время ожидания пула и загрузку базы CPU.
Пример: если Postgres может безопасно выделить вашему сервису 200 соединений, а вы запускаете 10 Go-инстансов, начните с MaxOpenConns=15-18 на инстанс. Это оставит место для всплесков и снизит шанс, что все инстансы одновременно упрутся в потолок.
Метрики, чтобы раньше заметить проблему
Проблемы с пулом редко сначала показываются как «слишком много соединений». Чаще вы увидите медленный рост времени ожидания, а затем внезапный скачок p95 и p99.
Начните с метрик, которые предоставляет ваше Go-приложение. Для database/sql отслеживайте открытые соединения, in-use, idle, wait count и wait time. Если count ожиданий растёт при стабильном трафике, пул недостаточно велик или соединения держатся слишком долго.
Со стороны базы следите за активными соединениями против max, CPU и активностью блокировок. Если CPU низкий, а латентность высокая — часто это очередь или блокировки, а не вычислительная мощность.
Если вы используете PgBouncer, добавьте третий взгляд: клиентские соединения, серверные соединения к Postgres и глубину очереди. Растущая очередь при стабильном числе серверных соединений явно сигнализирует о сжатом бюджете.
Хорошие сигналы для алертов:
- p95/p99 растут, в то время как p50 остаётся нормальным
- увеличивающееся время ожидания соединения (на стороне приложения), особенно перед таймаутами
- очередь PgBouncer растёт быстрее, чем опорожняется
- рост ошибок и таймаутов одновременно
- увеличение блокировок вместе с долгими запросами
Частые неверные настройки, вызывающие пики
Проблемы пула часто проявляются во время всплесков: запросы накапливаются в ожидании соединения, потом всё снова нормализуется. Корнем обычно является настройка, которая выглядит разумной на одном инстансе, но опасна при множественных копиях сервиса.
Типичные причины:
MaxOpenConnsзадан на инстанс без учёта глобального бюджета. 100 соединений на инстанс при 20 инстансах = 2000 потенциальных соединений.- Слишком много idle соединений. Простые бэкенды всё равно потребляют память и могут выдавить другое полезное дело.
ConnMaxLifetime/ConnMaxIdleTimeзаданы слишком малыми. Это вызывает волны переподключений, когда многие соединения ротацируются одновременно.- PgBouncer в transaction режиме при коде, зависимом от состояния сессии. Временные таблицы, advisory locks и настройки сессии могут ломаться тонко.
- Фоновые задачи и health checks создают всплески. Короткие опросы или «open-and-close per request» шаблоны создают волны новых подключений.
Простой способ снизить пики — считать пул как общий лимит, а не как локальный по умолчанию: ограничьте суммарные соединения по всем инстансам, держите умеренное число idle, и используйте времена жизни достаточные, чтобы избежать синхронных переподключений.
Что делать, когда спрос превышает бюджет соединений
При всплесках обычно происходит одно из трёх: запросы встают в очередь в ожидании свободного соединения, запросы тайм-аутятся, или всё настолько замедляется, что ретраи усугубляют ситуацию.
Очередь — самая коварная. Хендлер всё ещё «работает», но он поставлен на ожидание соединения. Это ожидание становится частью времени ответа, поэтому маленький пул может превратить запрос на 50 мс в много секунд при нагрузке.
Полезная модель: если у пула 30 доступных соединений и внезапно появляется 300 конкурентных запросов, которые все нуждаются в базе, 270 из них должны ждать. Если каждый запрос держит соединение 100 мс, хвостовая латентность быстро вырастает до секунд.
Задайте чёткие таймауты и придерживайтесь их. Таймаут приложения должен быть чуть короче, чем таймаут базы, чтобы вы быстро фейлили и снижали нагрузку, а не давали работе зависнуть.
- Приложение: дедлайн запроса и более короткий дедлайн вокруг вызова БД
- БД:
statement_timeout, чтобы один плохой запрос не держал соединения вечно - Пулер (если используется): таймаут ожидания пула, чтобы получать отказ, а не бесконечную очередь
Затем добавьте обратное давление, чтобы не перегружать пул изначально. Выберите одну-две предсказуемые механики: ограничение параллелизма на эндпоинт, отбрасывание лишней нагрузки с понятными ошибками (например, 429) или отделение фоновых задач от пользовательского трафика.
Наконец, сначала исправляйте медленные запросы. Под нагрузкой медленные запросы дольше удерживают соединения, что увеличивает ожидания, таймауты и ретраи — так небольшая заминка превращается в массовую проблему.
Нагрузочное тестирование и планирование ёмкости без догадок
Рассматривайте нагрузочное тестирование как способ верифицировать ваш бюджет соединений, а не только пропускную способность. Цель — подтвердить, что поведение пула под давлением в тесте совпадает со сценарием в staging.
Тестируйте с реалистичным трафиком: тот же микс запросов, паттерны всплесков и то же число инстансов, что и в проде. Бенчмарки «одного эндпоинта» часто скрывают проблемы пула до дня запуска.
Добавьте прогрев, чтобы не измерять холодные кэши и эффекты нарастающих пулов. Дайте пулам достичь обычного размера, затем начинайте запись метрик.
Если сравниваете стратегии, держите нагрузку идентичной и прогоняйте три варианта:
- только пул в приложении (настроенный
database/sql, без PgBouncer) - PgBouncer перед приложением (приложения держат маленькие пулы, PgBouncer ограничивает серверные соединения)
- оба вместе (маленькие пулы в приложениях + PgBouncer)
После каждого прогона фиксируйте короткую карточку результатов, которую можно повторять после каждого релиза:
- p95 и p99 задержки во время стабильного состояния и при всплеске
- макс суммарных соединений (на клиенте и на сервере)
- сигналы времени очереди (ожидание свободного соединения)
- количество ошибок и таймаутов
- пропускная способность в точке, где задержка начинает резко расти
Со временем это превращает планирование ёмкости в повторяемый процесс, а не в гадание.
Быстрая чек-лист и дальнейшие шаги
Прежде чем трогать размеры пулов, запишите одно число: ваш бюджет соединений. Это максимальное безопасное число активных соединений Postgres для данной среды (dev, staging, prod), включая фоновые задачи и доступ админов. Если вы не можете назвать его — вы предполагаете.
Короткий чек-лист:
- Явно задайте максимум в Go и убедитесь, что (instances x
MaxOpenConns) укладывается в бюджет (или в кап PgBouncer). - Установите таймауты, чтобы «ожидание вечно» не скрывало проблему до пика.
- Если используете PgBouncer, выберите режим пула, соответствующий использованию сессионного состояния.
- Избегайте очень коротких времен жизни соединений, вызывающих постоянные переподключения.
- Убедитесь, что
max_connectionsи зарезервированные соединения соответствуют плану.
План выката с лёгким откатом:
- Внедрите изменения в staging под нагрузочным тестом, имитирующим продовую конкуренцию и микс чтений/записей.
- Вкатывайте в прод поэтапно (часть инстансов или один сервис за раз).
- Наблюдайте p95, время ожидания пула, ошибки и число соединений Postgres как минимум через одно пиковое окно.
- Если p95 вырос или время ожидания пула подскочило, откатитесь и уменьшите конкуренцию или лимиты пула.
Если вы строите и хостите Go + PostgreSQL приложение на Koder.ai (koder.ai), Planning Mode может помочь спланировать изменения и что вы будете измерять, а снимки и откат упростят возврат, если хвостовая латентность ухудшится.
Следующий шаг: добавьте одно измерение до следующего всплеска трафика. «Время, потраченное на ожидание соединения» в приложении часто самое полезное, потому что показывает давление пула до того, как почувствуют это пользователи.
FAQ
What is connection pooling in Postgres, in plain terms?
Пул поддерживает небольшое число открытых соединений PostgreSQL и переиспользует их для разных запросов. Это избавляет от повторных затрат на установку соединения (TCP/TLS, аутентификация, запуск бэкенд-процесса) и помогает держать хвостовую латентность под контролем в моменты пиковой нагрузки.
Why do latency spikes show up before I see “too many connections” errors?
Когда пул насыщен, запросы внутри вашего приложения ждут свободного соединения, и это время ожидания проявляется как медленные ответы. Поэтому вы чаще увидите «случайную» медленность до того, как появятся ошибки «too many connections», поскольку среднее значение может оставаться нормальным, а p95/p99 вырастут во время всплесков трафика.
Will pooling fix slow SQL queries?
Нет. Пул в основном меняет поведение системы под нагрузкой, уменьшая смену соединений и контролируя параллелизм. Если запрос медленный из-за полного сканирования таблицы, блокировок или плохих индексов, пул не сделает его быстрее — он лишь ограничит число одновременно выполняемых медленных запросов.
What’s the difference between app-level pooling and PgBouncer?
Пул в приложении управляет соединениями в рамках одного процесса, то есть каждый инстанс приложения имеет свой собственный пул и свои пределы. PgBouncer располагается перед Postgres и обеспечивает глобальный лимит соединений для множества клиентов — это полезно при большом числе реплик или всплесках трафика.
When should I use only Go `database/sql` pooling, and when do I add PgBouncer?
Если у вас немного инстансов и суммарное число открытых соединений комфортно укладывается в лимит базы данных, достаточно настроить database/sql в Go. Добавляйте PgBouncer, когда автошкалирование, много инстансов или всплески трафика могут превысить то, что Postgres способен стабильно обрабатывать.
How do I choose a sane `MaxOpenConns` for a Go service?
Хорошая практика — задать бюджет соединений для сервиса в целом, разделить его на число инстансов и установить MaxOpenConns немного ниже полученного числа на инстанс. Начните с маленького значения, отслеживайте время ожидания и p95/p99, и увеличивайте только если база явно имеет запас по ресурсам.
Which PgBouncer pooling mode should I pick for a Go HTTP API?
Для типичных HTTP API на Go транзакционный режим (transaction pooling) часто является хорошим выбором: большинство запросов выполняют короткие транзакции, и этот режим позволяет множеству клиентских соединений делить меньше серверных соединений, удерживая латентность стабильной при всплесках.
What can break when PgBouncer is in transaction pooling mode?
Подготовленные выражения, временные таблицы, advisory locks и настройки сессии могут работать иначе, потому что клиент не всегда получает тот же серверный сокет при следующем вызове. Если вы используете такие механизмы, либо держите всё в рамках одной транзакции на запрос, либо переключитесь на session pooling.
What metrics best reveal pooling trouble early?
Следите за ростом p95/p99 вместе с увеличением времени ожидания соединения (app-side). На Postgres контролируйте активные соединения, нагрузку CPU и блокировки; на PgBouncer — клиентские соединения, серверные соединения и глубину очереди, чтобы увидеть, исчерпан ли бюджет.
What should I do if traffic exceeds my connection budget?
Во-первых — перестаньте позволять бесконечные ожидания: задайте дедлайны на запросы и statement_timeout, чтобы один медленный запрос не удерживал соединение вечно. Затем добавьте защиту от перегрузки: ограничение параллелизма на тяжёлых эндпоинтах, возврат 429 при перегрузке или отделение фоновых задач от пользовательского трафика. И исправьте медленные запросы — они увеличивают время удержания соединения и вызывают каскад ретраев.