Перейти к содержимому
PgBouncer: лекарство с побочными эффектами. Часть 2 - конфигурация и эксплуатация

PgBouncer: лекарство с побочными эффектами. Часть 2 - конфигурация и эксплуатация

26 июня 2026 г.·
Влад Карандашов

PgBouncer: лекарство с побочными эффектами. Часть 2 - конфигурация и эксплуатация
PgBouncer: как не выстрелить в ногу при эксплуатации

В первой части мы разобрали базовую механику PgBouncer: client/server connections, режимы pooling, ограничения transaction pooling и prepared statements.

Теперь более приземленная часть. Какой конфиг брать за стартовую точку, как выбирать размеры пулов, какие таймауты согласовать с HikariCP, что мониторить и где PgBouncer ставить в production.

Стартовая точка для конфига

Ниже я привел не идеальный production config на все случаи жизни. Такого не бывает. Но это нормальная стартовая точка для backend сервисов, которые хотят использовать transaction pooling, не гонять DDL через PgBouncer и оставить себе пространство для мониторинга.

pgbouncer.ini
[databases]
; Пул для прикладного runtime-трафика.
; В production пароль лучше не класть прямо в строку базы.
; PgBouncer может брать данные из auth_file/auth_query, а PostgreSQL должен принимать выбранную схему аутентификации.
app_db = host=postgres-primary port=5432 dbname=app_db \
  pool_mode=transaction pool_size=20 reserve_pool_size=5 \
  connect_query='SET search_path TO app_schema'

[pgbouncer]
; Сетевой интерфейс PgBouncer.
listen_addr = 0.0.0.0
listen_port = 6432

; Для production лучше scram-sha-256 или hba/auth_query.
; plain годится для локального демо.
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgbouncer_admin
stats_users = pgbouncer_monitor

; Кажется уже обсудили.
pool_mode = transaction

; Сколько клиентских соединений PgBouncer готов принять.
; Это иожет быть гораздо больше числа реальных соединений к PostgreSQL.
max_client_conn = 300

; Размер server pool на пару database/user.
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 2

; Предохранители от случайного съедания всей базы одним пользователем или одной базой.
max_db_connections = 25
max_user_connections = 25

; Поддержка protocol-level prepared statements в transaction pooling.
max_prepared_statements = 200

; pgJDBC часто присылает extra_float_digits как startup parameter.
ignore_startup_parameters = extra_float_digits

; В transaction pooling нельзя полагаться на session reset.
server_reset_query =
server_reset_query_always = 0

; Таймауты server connections к PostgreSQL.
server_idle_timeout = 300
server_lifetime = 1800

; Очередь ожидания свободного server connection.
query_wait_timeout = 10

; Защита от зависших транзакций и медленного login.
idle_transaction_timeout = 30
client_login_timeout = 10

; Логи и агрегированная статистика.
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
log_stats = 1

Что здесь важно:

  • pool_size считается на пару database/user. Если у вас много пользователей или баз, суммарный лимит может оказаться выше, чем кажется.
  • max_db_connections и max_user_connections нужны как предохранители. Один забытый пользователь не должен съесть весь PostgreSQL.
  • reserve_pool_size помогает пережить короткий всплеск, но не должен маскировать постоянную нехватку основного пула.
  • server_reset_query в transaction pooling обычно не используется. Документация прямо говорит, что в transaction mode клиенты не должны полагаться на session-based features.
  • DDL, миграции, CREATE EXTENSION, CREATE INDEX CONCURRENTLY, codegen и ручной maintenance лучше вести прямым подключением к PostgreSQL, а не через этот runtime pool.

Для локального стенда можно упростить auth и положить user/password в строку базы. Для production так делать не надо: секреты потом придется ротировать, а PgBouncer — отдельный сервер аутентификации со своей процедурой обслуживания.

Как выбирать размеры пулов

При подборе размеров можно идти в таком порядке:

  1. Сначала ограничить локальный пул в приложении: maximumPoolSize не должен быть сильно больше реальной потребности сервиса.
  2. Оценить, сколько ваша инсталляция PostgreSQL реально переваривает backend-процессов под нагрузкой.
  3. Поставить pool_size PgBouncer ниже этого числа, оставив место для админских подключений, миграций, maintenance и фоновых задач.
  4. Добавить небольшой reserve_pool_size, если бывают короткие всплески.
  5. Дальше мониторить wait time и tail latency, а не только количество открытых соединений.

И не надо путать max_client_conn с емкостью базы. Большое значение max_client_conn означает, что PgBouncer примет много клиентов. Это не значит, что PostgreSQL внезапно стал готов выполнять столько же запросов одновременно. Запросы просто будут ожидать в очереди.

Помните: PgBouncer не решает проблемы плохих запросов. Почитайте домкликовский разбор «Выдерни шнур, выдави стекло». Если запрос стал медленным из-за плана, статистики, временных файлов или новых параметров — пулер не вылечит причину. Он только скроет ее за очередь.

Таймауты: PgBouncer и внутренний пул приложения должны договориться

Таймауты — это место, где PgBouncer легко превратить в генератор странных разрывов соединений. Они спасают систему от зависших клиентов, но при неправильных значениях дают неожиданные ошибки.

server_idle_timeout

server_idle_timeout закрывает server connection к PostgreSQL, если он простаивает дольше заданного времени. Дефолт — 600 секунд.

Слишком маленькое значение приводит к постоянному циклу:

  • load spike → PgBouncer opens server connections
  • short idle → PgBouncer closes them
  • next spike → PgBouncer opens them again

PostgreSQL снова тратит ресурсы на login, auth, backend process, прогрев кэшей. В мониторинге это будет похоже на зубчатый график server connections.

Слишком большое значение держит лишние backend-процессы в PostgreSQL даже после того, как нагрузка ушла.

Стартовая рекомендация: не ставить секунды «на всякий случай». Для web-нагрузки часто разумнее минуты: условные 300–600 секунд. Если нагрузка приходит редкими большими пачками, значение надо подбирать по реальному профилю.

server_lifetime

server_lifetime закрывает неиспользуемое server connection, если оно прожило дольше заданного времени. Дефолт — 3600 секунд.

Эта настройка помогает не держать backend-процессы бесконечно. В старом соединении могут накопиться prepared statements, кэши, relcache, session-level мусор от неидеального приложения. Но слишком короткий lifetime снова дает много накладных расходов.

query_wait_timeout

query_wait_timeout ограничивает время, которое query ждет свободный server connection. Если за это время PgBouncer не назначил backend, клиентское соединение отключается. Дефолт — 120 секунд.

120 секунд для web API часто слишком много. Пользователь уже ушел, upstream gateway уже вернул timeout, а PgBouncer все еще честно держит запрос в очереди. Потом он наконец выполнится или отвалится, но приложению от этого легче не станет.

Настраивайте query_wait_timeout от SLA запроса. Если API должен отвечать за 2 секунды, нет смысла держать запрос в очереди PgBouncer минуту. Лучше получить раннюю ошибку, заалертиться по saturation и чинить причину.

idle_transaction_timeout и transaction_timeout

idle_transaction_timeout отключает клиента, если он слишком долго сидит в idle in transaction. transaction_timeout ограничивает общее время состояния in transaction.

В transaction pooling это особенно полезно. Пока клиент держит транзакцию открытой, он держит server connection. Один забытый код вроде «открыли транзакцию, сходили в другой сервис, подождали ответ, потом продолжили SQL» быстро съедает пул.

Я бы не начинал с агрессивных значений на первом деплое. Сначала включить мониторинг, найти реальные длинные транзакции, починить код, потом ставить защитные лимиты. Но в целом это отличная гигиена: разработчики лишний раз подумают, что именно происходит внутри @Transactional метода.

client_idle_timeout

client_idle_timeout закрывает idle client connections. Документация советует держать его больше client-side connection lifetime и использовать в основном для сетевых проблем.

Для HikariCP это означает: не ставьте client_idle_timeout меньше, чем пул ожидает жить со своим соединением к PgBouncer. Иначе Hikari будет получать закрытые соединения извне, чаще валидировать и переоткрывать их.

В документации HikariCP для maxLifetime есть похожая логика: значение должно быть на несколько секунд меньше внешнего лимита жизни соединения. Если где-то между приложением и PgBouncer есть load balancer, NAT или proxy idle timeout, Hikari должен узнать об этом раньше пользователя.

Примерный набор для приложения:

application.yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 10
      connection-timeout: 5000
      max-lifetime: 1700000
      keepalive-time: 120000

И в PgBouncer:

pgbouncer.ini
server_idle_timeout = 300
server_lifetime = 1800
query_wait_timeout = 10
idle_transaction_timeout = 30

Не копируйте бездумно, смотрите на смысл:

  • Hikari connection-timeout отвечает за ожидание свободного соединения внутри приложения.
  • PgBouncer query_wait_timeout отвечает за ожидание свободного server connection к PostgreSQL.
  • Hikari max-lifetime и keepalive-time относятся к client connection до PgBouncer.
  • PgBouncer server_idle_timeout и server_lifetime относятся к server connection до PostgreSQL.

Когда эти уровни путают, начинаются лишние reconnect storm.

Мониторинг

У PgBouncer есть admin database pgbouncer, где доступны SHOW STATS, SHOW POOLS, SHOW CLIENTS, SHOW SERVERS.

Что смотреть в первую очередь:

  • cl_waiting в SHOW POOLS - клиенты ждут свободный server connection;
  • maxwait - сколько ждет самый старый клиент в очереди;
  • sv_active, sv_idle, sv_used, sv_login - состояние server connections;
  • total_wait_time / avg_wait_time в SHOW STATS - накопленное и среднее ожидание backend connection;
  • avg_xact_time и avg_query_time - насколько транзакции длиннее самих запросов;
  • total_client_parse_count, total_server_parse_count, total_bind_count - что происходит с prepared statements;
  • количество ошибок в логах: лимиты, login failures, query timeouts, pooler errors.

Мне нравится подход из статьи USE, RED, PgBouncer, его настройки и мониторинг. Там PgBouncer разбирают по USE:

  • Utilization: насколько заняты server connections, сколько времени они проводят в query/transaction.
  • Saturation: есть ли cl_waiting, растет ли maxwait, появляется ли total_wait_time.
  • Errors: отказы по лимитам, таймауты, ошибки авторизации, pooler errors.

Отдельно полезно смотреть разницу между total_xact_time и total_query_time. Если транзакции заметно длиннее запросов, приложение держит соединение, но не выполняет SQL. Для transaction pooling это по сути налог на емкость пула.

В production метрики PgBouncer надо смотреть рядом с:

  • Hikari active/idle/pending connections;
  • latency HTTP endpoints;
  • PostgreSQL pg_stat_activity;
  • PostgreSQL locks;
  • CPU PostgreSQL и PgBouncer;
  • количеством backend-процессов;
  • p95/p99 query latency.

Иначе легко перепутать причину и следствие. cl_waiting может расти потому, что pool_size мал. А может потому, что один SQL стал в 20 раз медленнее и занял все server connections.

Production: где поставить PgBouncer

Есть несколько типичных схем.

PgBouncer рядом с приложением

Sidecar или локальный процесс около приложения уменьшает latency на client connection и хорошо подходит для короткоживущих клиентов. Но каждый экземпляр приложения получает свой PgBouncer, а значит надо внимательно считать суммарные server connections к PostgreSQL.

Если 20 pod’ов и у каждого pool_size=20, база потенциально увидит 400 server connections. В этом случае PgBouncer перестает быть глобальным ограничителем.

PgBouncer как отдельный сервис

Один или несколько PgBouncer перед PostgreSQL лучше агрегируют нагрузку от многих приложений. Проще контролировать общий лимит соединений к базе, проще мониторить.

Цена - еще один сетевой hop и отдельная точка отказа. Нужны HA, rolling restart, health checks, понятная схема failover.

Официальный FAQ PgBouncer аккуратно отвечает на вопрос «ставить на web server или database server»: зависит от нагрузки и модели failover. То есть универсальной схемы нет.

Несколько PgBouncer под разные профили

Иногда один общий PgBouncer становится узким местом. Разные приложения могут иметь разный профиль:

  • короткие OLTP-запросы;
  • длинные read-only отчеты;
  • фоновые batch-job’ы;
  • админские операции;
  • отдельные listener’ы для LISTEN/NOTIFY;
  • сервисы с разными требованиями к latency.

В таком случае разумно разделить ресурсы: один PgBouncer или один pool для коротких runtime-запросов, другой — для длинных операций, третий — для отдельной группы приложений. Это не обязательно должны быть разные инстансы; иногда достаточно разных database aliases с разными pool_size, max_db_connections и query_wait_timeout.

Смысл простой: медленный batch не должен съедать server connections у пользовательского API. А пользовательский API не должен мешать maintenance-задачам, которым нужен отдельный прямой доступ к PostgreSQL.

Несколько PgBouncer и so_reuseport

PgBouncer исторически однопоточный: один процесс в основном использует одно CPU-ядро. На большой нагрузке это может стать бутылочным горлышком, особенно с включенным prepared statement tracking, где PgBouncer разбирает и переписывает сообщения.

Если не хотите делать полноценные разные экземпляры на разные группы сервисов — можно запускать несколько процессов на одном порту через so_reuseport. В документации это же используется как часть rolling restart сценария: несколько процессов слушают один порт, перезапускаются по одному, клиенты переподключаются без полного простоя.

Failover

Отдельно стоит сказать про работу в отказоустойчивом режиме.

PgBouncer не делает полноценный PostgreSQL failover. Он может перечитать DNS, принять RELOAD, выполнить RECONNECT, закрыть старые server connections и открыть новые. Но решение «кто теперь primary» принимает не PgBouncer.

Если за PgBouncer стоит HAProxy, Patroni, managed PostgreSQL или другой слой маршрутизации, надо отдельно проверять:

  • что происходит с уже открытыми server connections;
  • как быстро PgBouncer узнает новый адрес;
  • нужно ли выполнять RECONNECT;
  • что видят приложения во время переключения;
  • какие ошибки retry-логика считает безопасными.

Лучше один раз отрепетировать failover на стенде, чем впервые узнать реальное поведение PgBouncer ночью.

Где PgBouncer не нужен

PgBouncer не надо тащить в каждый проект просто потому что «в проде так делают».

Он может быть лишним, если:

  • у вас один маленький сервис и max_connections не проблема;
  • вы достаточно богаты и поднимаете отдельные БД на каждый чих;
  • приложение уже держит аккуратный маленький pool, а реплик мало;
  • workload завязан на session state;
  • нужно много LISTEN/NOTIFY;
  • используются server-side cursors между транзакциями;
  • миграции, аналитика и runtime перемешаны в одном канале;
  • проще поднять managed PostgreSQL с подходящим лимитом соединений.

Иногда первый правильный шаг — уменьшить Hikari maximumPoolSize, найти длинные транзакции и добавить индекс. PgBouncer не должен становиться способом отложить работу по оптимизации.

Итог

PgBouncer хорош, когда у вас много клиентов и ограниченное число PostgreSQL backend-процессов. Он особенно полезен в микросервисной среде, где каждый сервис приносит свой connection pool и сумма этих пулов быстро становится больше разумного max_connections. А ресурсы БД имеет смысл перераспределять.

PgBouncer не отменяет пул внутри приложения. HikariCP защищает сам сервис от лишней локальной конкуренции и затрат на подключение, а PgBouncer защищает PostgreSQL от лишних соединений, процессов и затрат.

Самый рабочий режим - transaction pooling, но он требует дисциплины: никаких session-level ожиданий, миграции напрямую, LISTEN отдельно, prepared statements настроены согласованно, таймауты замерены, мониторинг включен.

Когда эта дисциплина есть, PgBouncer становится скучным и полезным инфраструктурным компонентом. Именно таким он и должен быть.