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

В первой части мы разобрали базовую механику PgBouncer: client/server connections, режимы pooling, ограничения transaction pooling и prepared statements.
Теперь более приземленная часть. Какой конфиг брать за стартовую точку, как выбирать размеры пулов, какие таймауты согласовать с HikariCP, что мониторить и где PgBouncer ставить в production.
Стартовая точка для конфига
Ниже я привел не идеальный production config на все случаи жизни. Такого не бывает. Но это нормальная стартовая точка для backend
сервисов, которые хотят использовать transaction pooling, не гонять DDL через PgBouncer и оставить себе пространство
для мониторинга.
[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 — отдельный сервер аутентификации со своей процедурой обслуживания.
Как выбирать размеры пулов
При подборе размеров можно идти в таком порядке:
- Сначала ограничить локальный пул в приложении:
maximumPoolSizeне должен быть сильно больше реальной потребности сервиса. - Оценить, сколько ваша инсталляция PostgreSQL реально переваривает backend-процессов под нагрузкой.
- Поставить
pool_sizePgBouncer ниже этого числа, оставив место для админских подключений, миграций, maintenance и фоновых задач. - Добавить небольшой
reserve_pool_size, если бывают короткие всплески. - Дальше мониторить 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 должен узнать об этом раньше пользователя.
Примерный набор для приложения:
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 10
connection-timeout: 5000
max-lifetime: 1700000
keepalive-time: 120000И в PgBouncer:
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 становится скучным и полезным инфраструктурным компонентом. Именно таким он и должен быть.