Давление на пул
Pool pressure (давление на пул) описывает, как pg_doorman ведёт
себя, когда множество клиентов одновременно запрашивают
backend-соединение, а в idle-пуле (очереди свободных, готовых к
выдаче серверных соединений; «idle» означает «открыто, но никем не
используется прямо сейчас») пусто.
Решение о том, кто получит соединение, кто подождёт, кто инициирует
свежий connect() к PostgreSQL, а кому будет отказано, принимают
два механизма. Локально в каждом пуле (database, user) работают
упреждающее ожидание (anticipation) — ожидание возврата соединения
от соседа прежде чем тратить ресурсы на новый connect() — и
ограничитель всплесков (bounded burst gate) — жёсткий лимит на
одновременные backend-connect() внутри одного пула. Поверх них при
необходимости подключается координатор (coordinator),
общий для всех пулов ограничитель суммарного числа backend-соединений
к одной базе данных.
Аудитория: DBA или инженер эксплуатации, который уже знает PgBouncer и хочет понять, чем pg_doorman отличается и за чем нужно следить.
Зачем нужно давление на пул
Возьмём пул с pool_size = 40 и нагрузкой в 200 коротких транзакций,
приходящих в одну и ту же миллисекунду. В пуле 4 idle-соединения. В
наивном пулере первые 4 клиента забирают idle-соединения, а оставшиеся
196 независимо вызывают connect() к PostgreSQL. PostgreSQL получает
196 одновременных TCP connect-попыток, на каждую из которых нужно
выполнить SCRAM-аутентификацию и согласование параметров, только чтобы
обнаружить, что пул разрешает ещё 36 соединений. Backend-обращения к
pg_authid взлетают всплеском, потолок max_connections пробивается,
очередь accept() ядра насыщается, а tail latency (задержка в
хвосте распределения, p99/p99.9, то есть редкие, но самые медленные
запросы) уже подключённых клиентов растёт, потому что postmaster
PostgreSQL занят порождением backend'ов вместо выполнения запросов.
Это thundering herd (лавинообразный эффект): ситуация, когда множество
независимых задач одновременно реагируют на одно состояние и
устраивают шторм одинаковых запросов к общему ресурсу. В нашем
сценарии это 196 одновременных connect() к одному PostgreSQL listener'у
вместо размазанного по времени потока.
Time: ----------------------------------------->
Client_1 -[idle hit]--[query]-----[done]
Client_2 -[idle hit]--[query]-----[done]
Client_3 -[idle hit]--[query]-----[done]
Client_4 -[idle hit]--[query]-----[done]
Client_5 -[connect]-[auth]-[query]-[done]
Client_6 -[connect]-[auth]-[query]-[done]
. ^
. 196 backend connect()s
. fired in the same instant
Client_200 -[connect]-[auth]-[query]-[done]
PostgreSQL: 196 spawning backends + 4 running queries
Давление на пул подавляет это поведение. pg_doorman заставляет
большинство из этих 196 вызовов переиспользовать соединение, которое
другой клиент вот-вот вернёт, либо подождать несколько миллисекунд за
небольшим числом уже идущих backend-connect-ов. Частота connect() к
PostgreSQL остаётся ограниченной даже при всплесках клиентов.
Режим простого пула (plain pool mode)
Этот режим работает, когда max_db_connections не задан. Пулы
независимы, общей координации между ними нет, давление управляется
внутри каждого пула (database, user). Это режим по умолчанию, и
большинство инсталляций находятся именно в нём.
Прогрев пула с холодного старта
Пул с pool_size = 40 и min_pool_size = 0 стартует с нулём
соединений. Первый пришедший клиент не ждёт: pg_doorman сразу создаёт
backend-соединение. Второй делает то же самое, третий тоже,
пока пул не достигнет порога прогрева (warm threshold).
Порог прогрева равен pool_size × scaling_warm_pool_ratio / 100. При
дефолтном значении 20% и pool_size = 40 порог равен 8 соединениям.
Ниже этого порога pg_doorman создаёт соединения без раздумий: пул
холодный, цена ожидания выше цены коннекта, и клиенты не могут
конкурировать за idle-соединения, которых не существует.
Выше порога активируется зона упреждающего ожидания (anticipation zone):
pg_doorman считает, что пул уже разогрет и кто-то
из параллельных клиентов скоро вернёт занятое соединение, поэтому имеет
смысл подождать его возврата вместо того, чтобы тратить ресурсы на
новый connect(). Когда клиент не находит соединения в idle pool,
pg_doorman сначала пытается перехватить такой возврат.
Третья зона накладывается поверх обеих: при любом размере пула, если
число соединений, создаваемых прямо сейчас, достигает
scaling_max_parallel_creates (по умолчанию 2), пул упирается в
лимит ограничителя всплесков. Дополнительные вызовы ждут свободный слот независимо
от того, сколько idle-соединений существует.
Three pressure zones
--------------------
Pool size: 0 ----------- 8 ---------------------------- 40
^ ^ ^
| | |
| WARM ZONE | ANTICIPATION ZONE |
| | |
| size < | size >= warm_threshold |
| warm_thr | |
| | |
| Пропускает | Фаза 3: быстрый опрос |
| фазы 3 | Фаза 4: прямая передача |
| и 4. | (ожидание возврата от |
| Сразу к | соседа, дедлайн = |
| фазе 5 | wait_timeout − 500ms) |
| (огранич. | Затем фаза 5 |
| + connect) | |
Ограничение параллельных создаваемых соединений
(ортогонально размеру пула)
-----------------------------------------------
Создаваемых сейчас: 0 ---- 1 ---- 2 (= scaling_max_parallel_creates)
^
| На лимите: новый вызов встаёт
| в очередь на получение
| возвращённого соединения и ждёт
| завершения чужого создания.
Зоны warm и anticipation отслеживают текущий размер пула. Отдельно
работает bounded burst gate: жёсткий лимит на число одновременно
идущих connect() к PostgreSQL внутри одного пула. Этот лимит
срабатывает независимо от размера пула: пул может одновременно
находиться в anticipation zone и иметь все слоты burst gate занятыми
параллельными connect()-ами, под нагрузкой это типичная ситуация.
Пул ниже порога прогрева тоже может упереться в этот лимит, если во
время холодного заполнения одновременно приходит много клиентов.
Получение соединения
Когда клиент запрашивает соединение через pool.get(), pg_doorman
проходит по следующим фазам. Каждая фаза либо возвращает соединение,
либо передаёт управление следующей фазе.
Фаза 1 — горячий путь recycle. Берём первое соединение из
idle-очереди (двусторонняя очередь свободных серверных соединений
«голова очереди» — это то соединение, которое было возвращено раньше
всех). Если оно проходит проверку пригодности (recycle), отдаём его
клиенту.
Проверка пригодности откатывает любые открытые транзакции, запускает
проверку живости (liveness probe) если соединение простояло дольше
server_idle_check_timeout, и сверяет поколение соединения с текущим
поколением пула (reconnect-эпоха — счётчик, который увеличивается при
admin-команде RECONNECT и при обнаруженных сбоях backend-а).
Соединения, созданные до увеличения счётчика, эту проверку не проходят
и удаляются вместо возврата клиенту. Здоровый пул в установившемся
режиме идёт только этим путём.
Фаза 2 — warm zone gate. Если размер пула ниже порога прогрева, пропускаем anticipation и сразу переходим к созданию нового backend-соединения. Холодные пулы заполняются быстро.
Фаза 3 — anticipation spin. Выше порога прогрева повторяем
проверку пригодности до 10 раз в плотном цикле без пауз (контролируется
параметром scaling_fast_retries). Так перехватывается случай, когда
другой
клиент завершил свой запрос в том же микросекундном диапазоне и
вот-вот вернёт соединение. Полная стоимость порядка 10–50
микросекунд. Без sleep, без блокирующего I/O.
Фаза 4 — direct handoff (прямая передача). Если spin не поймал возврат, задача встаёт в очередь ожидающих. Когда любой клиент возвращает соединение, оно отправляется напрямую старейшему ожидающему, минуя idle-очередь целиком. Получатель забирает соединение без конкуренции с другими задачами — соединение никогда не попадает в общую idle-очередь и достаётся ровно одному адресату.
Если передача удалась, соединение проходит проверку пригодности. При успехе соединение возвращается вызывающему. При ошибке (устаревший backend) пул уменьшает текущий размер, и вызов проваливается в путь создания нового соединения.
Если соединение не пришло до дедлайна, ожидающий снимается с очереди. При попытке доставки пул обнаруживает снятого ожидающего, пропускает его и пробует следующего. Таким образом устаревшие ожидающие вычищаются по мере обработки очереди, без отдельного прохода.
Дедлайн адаптивный: min(query_wait_timeout - 500 ms, adaptive_cap),
где adaptive_cap вычисляется из реальной latency транзакций:
| Состояние пула | Бюджет | Пример |
|---|---|---|
| Холодный старт (нет данных) | 100ms ± 20% jitter | 80-120ms |
| Устойчивый режим (steady state) | xact_p99 × 2 ± 20% jitter | p99=0.7ms → 5ms (min); p99=50ms → 100ms |
| Высокая latency | Ограничено 500ms | p99=300ms → 500ms |
Фаза 1/2 ожидания семафора тратит из того же бюджета, поэтому
суммарное ожидание не может увести клиента за его query_wait_timeout.
Случайный разброс (jitter) ±20% предотвращает обрыв таймаутов (timeout cliff): без него N клиентов, вошедших в Phase 4 одновременно, выходят одновременно и лавиной входят в burst gate, создавая N новых backend-соединений для пула, которому нужно значительно меньше. С jitter'ом клиенты выходят порциями — первые создают соединения, и к моменту выхода последних эти соединения уже использованы и возвращены в idle queue для переиспользования.
Если дедлайн истёк без получения соединения, переходим к фазе 5.
Фаза 5 — ограничитель всплесков (bounded burst gate). Перед тем
как пойти на новый connect(), задача должна получить слот в
ограничителе — механизме, который параллельно пропускает не больше
scaling_max_parallel_creates (по умолчанию 2) задач на пул. Если
слот свободен, задача забирает его и идёт вызывать connect(). Если
все слоты заняты, задача встаёт в очередь на прямую передачу и
одновременно ожидает завершения чужого создания. Приоритет отдаётся
прямой передаче: если соединение вернулось, пока задача ждала
освобождения слота, оно доставляется напрямую — задача проверяет его
пригодность и возвращает клиенту. Если передачи не было, задача снова
пробует проверку пригодности и захват слота. Так лимитируется скорость
рождения новых backend-соединений в одном пуле, а не размер самого
пула.
Адаптивный таймаут ограничителя всплесков (adaptive timeout burst
gate). Цикл ограничителя лимитирован адаптивным бюджетом:
xact_p99 × 2 ± 20% jitter (min 20 ms, max 500 ms). Если задача
провела в цикле дольше бюджета, она прекращает ожидать прямую
передачу и переходит к захвату слота ограничителя напрямую. Без этого
механизма пул мог застрять на пороге прогрева навсегда: клиенты
бесконечно получали переиспользованные соединения через прямую
передачу, а до создания нового соединения дело не доходило. Счётчик
burst_gate_budget_exhausted отслеживает срабатывания.
Фаза 6 — backend connect. Запускаем connect(), аутентифицируемся,
отдаём соединение клиенту. Слот ограничителя освобождается автоматически по
завершении этой фазы независимо от исхода.
Plain mode acquisition flow
---------------------------
pool.get()
|
v
+--------------+
| Phase 1: | --- HIT ----> return idle connection
| recycle pop |
+------+-------+
| MISS
v
+--------------+
| Phase 2: | --- below warm ---> jump to phase 5
| warm gate |
+------+-------+
| above warm
v
+--------------+
| Phase 3: | --- HIT ----> return idle connection
| fast spin |
+------+-------+
| MISS
v
+--------------+
| Phase 4: | --- handoff ----> return connection
| anticipate | --- timeout ----> fall through
| direct h/o |
+------+-------+
|
v
+--------------+
| Phase 5: | --- slot taken --> proceed to phase 6
| burst gate | --- slot full --> wait, retry recycle
+------+-------+
|
v
+--------------+
| Phase 6: |
| connect() | ----> return new connection
+--------------+
Подавление всплеска в действии
Тот же сценарий с 200 клиентами в формате thundering herd, но теперь
в plain mode и с scaling_max_parallel_creates = 2:
Time: t=0ms t=5ms t=10ms t=15ms t=20ms t=25ms
C_1 [idle]--[query]-[done]
C_2 [idle]--[query]-[done]
C_3 [idle]--[query]-[done]
C_4 [idle]--[query]-[done]
C_5 [spin/wait]------[recycled C_1]--[query]-[done]
C_6 [spin/wait]------[recycled C_2]--[query]-[done]
C_7 [gate=1]-[connect]----[auth]--[query]-[done]
C_8 [gate=2]-[connect]----[auth]--[query]-[done]
C_9 [gate full, wait]---[recycled C_3]--[query]
C_10 [gate full, wait]---[recycled C_4]--[query]
.
. [...196 clients use a mix of recycle, anticipation, and at
. most 2 in-flight connects...]
.
C_200 [gate=2]-[connect]--[auth]--[query]--[done]
PostgreSQL: at most 2 spawning backends at any moment
+ the 4 connections that were already there
Тот же пул обслуживает все 200 клиентов, но PostgreSQL никогда не видит
больше scaling_max_parallel_creates (по умолчанию 2) одновременных
запусков backend-процессов из этого пула. Большинство клиентов попадают на
переиспользованное соединение от соседа, который завершил работу
мгновением раньше, а не на свежий connect().
Получение соединения без ожидания
Когда клиент устанавливает query_wait_timeout = 0, он просит либо
сразу попасть на idle-соединение, либо свежий connect, без ожидания.
Фаза anticipation и ожидание у burst gate пропускаются. pg_doorman
выполняет recycle на горячем пути, один раз пробует burst gate, затем
либо создаёт соединение, либо возвращает ошибку wait timeout.
Ограничение при включённом coordinator. Режим без ожидания
пропускает только anticipation и ожидание у burst gate внутри пути в
рамках одного пула. Если max_db_connections задан и фазы ожидания
координатора (B–D) занимают время, такой вызов всё равно блокируется
внутри coordinator.acquire() вплоть до reserve_pool_timeout (по
умолчанию 3000 ms). Чтобы на базах под координатором соблюсти строгий
дедлайн без ожидания, поставьте reserve_pool_timeout достаточно
низким, чтобы он помещался в ваш бюджет.
Фоновый replenish
Когда задан min_pool_size, фоновая задача периодически дополняет пул
до его минимума. Она использует тот же burst gate, что и клиентский
трафик. Эта задача не встаёт в очередь за занятым gate: если
gate занят, она немедленно сдаётся и повторяет попытку
на следующем retain-цикле (по умолчанию каждые 30 секунд, контролируется
параметром retain_connections_time).
Логика такова: во время всплеска нагрузки клиенты уже насыщают gate,
создавая соединения, которые им нужны прямо сейчас. Если фоновый
replenish будет конкурировать с ними за слоты, толку не будет: пул всё равно
поднимется выше min_pool_size за счёт клиентских запросов на создание.
При каждом таком отступлении фоновой задачи увеличивается счётчик
replenish_deferred.
Следствие: под нагрузкой min_pool_size поддерживается в режиме
best-effort (без гарантий: pg_doorman старается, но не обязуется
держать прогретыми ровно столько соединений, если клиенты съедают весь
бюджет). Если нужна жёсткая гарантия минимума, см. раздел
troubleshooting.
Прямая передача при возврате (direct handoff)
Когда соединение возвращается, пул первым делом проверяет очередь ожидающих (direct-handoff). Если хотя бы один ожидающий зарегистрирован, соединение отправляется напрямую старейшему из них, минуя idle-очередь. Ожидающие, чей вызывающий уже отвалился по таймауту, пропускаются: пул обнаруживает недоступного получателя и пробует следующего в очереди.
Если зарегистрированных ожидающих нет (типичный случай при высоком throughput, когда каждый checkout попадает в горячий путь), соединение кладётся в idle-очередь и будит ближайшего клиента, ожидающего на Фазе 1/2.
В обоих случаях координатор (если настроен) уведомляется о возврате, чтобы ожидающие Фазы C из соседних пулов могли просканировать кандидатов для eviction. Ожидающие внутри того же пула получают соединения напрямую, а не через общее уведомление.
FIFO-честность и распределение латенси
Очередь ожидающих — двусторонняя очередь (FIFO): новые ожидающие добавляются в конец, соединения доставляются из начала. Старейший ожидающий всегда получает следующее возвращённое соединение.
Это даёт измеримо другой профиль латенси по сравнению с пулерами, использующими широковещательное пробуждение (broadcast-notify) или LIFO-планирование. При 500 клиентах на пул из 40 соединений (AWS Fargate):
| Пулер | p50 (мс) | p95 (мс) | p99 (мс) | p99/p50 |
|---|---|---|---|---|
| pg_doorman | 9.93 | 10.50 | 10.69 | 1.08 |
| pgbouncer | 8.48 | 9.62 | 10.45 | 1.23 |
| odyssey | 0.88 | 12.93 | 22.46 | 25.5 |
p50 у Odyssey в 11 раз ниже, чем у pg_doorman — большинство транзакций попадают на горячее соединение мгновенно. Но p99 в 2 раза выше. Одни клиенты ждут больше 22 мс, а другие завершаются меньше чем за 1 мс. При FIFO каждый клиент платит примерно одинаковую цену за очередь.
Что это значит для эксплуатации:
-
SLO compliance. SLO «p99 < 15 мс» достижим с pg_doorman при этой нагрузке. С Odyssey те же настройки пула его нарушают. Единственный выход — overprovisioning: увеличивать число соединений, пока даже невезучие клиенты укладываются.
-
Отсутствие голодания. При broadcast-notify клиент может проиграть гонку за wake-up многократно. При direct handoff соединение идёт ровно одному получателю, протухшие waiter'ы пропускаются. Нет thundering herd, нет повторных проигрышей.
-
Предсказуемое capacity planning. Когда p50 ≈ p99, удвоение числа клиентов примерно удваивает латенси. При соотношении хвоста 25x изменение нагрузки вызывает непредсказуемые всплески p99.
Теория очередей подтверждает: среди non-preemptive дисциплин планирования FIFO минимизирует дисперсию времени ожидания при том же среднем, что и LIFO. Среднее одинаково — разница только в хвосте.
Упреждающая замена при истечении lifetime (pre-replacement)
Когда настроен server_lifetime, backend-соединения закрываются по
достижении индивидуального лимита (базовый ± 20% jitter). Закрытие
означает, что в пуле на одно idle-соединение меньше — последующие
checkout'ы могут попасть в зону упреждающего ожидания или путь
создания нового соединения, добавляя несколько миллисекунд к p99 во
время кластеров истечения lifetime.
Упреждающая замена (pre-replacement) убирает этот всплеск задержки. Когда checkout проверяет пригодность соединения и обнаруживает, что оно достигло 95% своего lifetime, фоновая задача создаёт замену и помещает её в idle-очередь. Когда старое соединение отклоняется при 100% lifetime, следующий checkout находит предсозданную замену через горячий путь — ноль ожидания.
Параллельно может работать до 3 упреждающих замен на пул. Во время
окна перекрытия пул временно держит max_size + 3 соединений. Когда
старые соединения умирают, текущий размер пула возвращается к
max_size.
Условия, предотвращающие неконтролируемый рост:
| Условие | Предотвращает |
|---|---|
| Пул не под давлением | Создание лишних при насыщении пула (старое соединение выживет, пропустив закрытие по lifetime) |
| Доля idle-соединений < 25% | Замену в переразмеренном пуле, который должен сжаться |
| Запас координатора >= 2 | Захват последнего слота координатора у соседнего пула |
| Lifetime >= 60 s | Срабатывание на коротких lifetime, где окно перекрытия слишком мало |
| Текущий размер <= max_size + cap | Накопление нескольких параллельных превышений |
| Лимит параллельных фоновых создаваемых (cap=3) | Неограниченное число фоновых создаваемых соединений |
Упреждающая замена срабатывает только на пути checkout (при проверке пригодности), не из фоновой задачи обслуживания. Idle-соединения, истекающие без checkout'а, закрываются фоновой задачей без замены — так пул естественно сжимается при падении нагрузки.
Согласование лимита с PostgreSQL
Перед чтением про координатор проверьте, что число backend-соединений
в худшем случае умещается в PostgreSQL. Без max_db_connections верхняя
граница для одной базы:
N pools (users) × pool_size = ceiling on backend connections
Пример с расчётом: три пула, у каждого pool_size = 40, без
max_db_connections. В худшем случае получится 120 одновременных
backend-соединений к этой базе, ограниченных только
scaling_max_parallel_creates на пул (по умолчанию 2 на каждый, то
есть до 6 одновременно идущих вызовов connect()). Если в PostgreSQL
выставлен max_connections = 100, база начнёт отказывать в новых
соединениях во время общего всплеска нагрузки, и клиенты получат
FATAL: too many connections.
Два решения:
- Понизить
pool_sizeтак, чтобыN × pool_sizeукладывалось нижеmax_connectionsс запасом наsuperuser_reserved_connections, слоты репликации и любые прямые коннекторы в обход pg_doorman. - Задать
max_db_connectionsкак жёсткий лимит (см. следующий раздел).
Эмпирическое правило: суммарная потребность pg_doorman не должна
превышать 80% от PostgreSQL max_connections - superuser_reserved_connections.
Оставшиеся 20% оставьте под admin-соединения, репликацию и всплески.
Режим координатора (coordinator mode)
Режим координатора активируется, когда у пула задан max_db_connections.
Он добавляет второй слой давления поверх того, что работает внутри
каждого пула: общий семафор, который ограничивает суммарное число
backend-соединений к базе по всем обслуживающим её user-пулам. Без
него единственным ограничением остаётся потолок N × pool_size из
предыдущего раздела. С max_db_connections = 80 к базе одновременно
может существовать только 80 соединений независимо от конфигурации
пулов, и координатор решает, какие пулы могут расти.
При max_db_connections = 0 (по умолчанию) координатор не создаётся.
Когда параметр задан, все механизмы plain mode, описанные выше,
продолжают работать; координатор добавляет один шаг получения
permit (разрешения на удержание слота в общем лимите на базу:
пока permit не получен, backend-соединение создавать нельзя) на
пути создания нового соединения. Переиспользование idle координатора
не касается.
Что добавляет координатор
Три вещи:
-
Жёсткий лимит общего числа соединений к базе. Если 80 уже занято, 81-й запрос ждёт или падает независимо от того, какой пул его подал.
-
Reserve pool (резервный пул). Когда общий лимит достигнут и у
reserve_pool_sizeесть свободное место, координатор сразу выдаёт permit из резерва — небольшого дополнительного пула поверхmax_db_connections, работающего как буфер под всплеск. Это Фаза R (reserve-first) в схеме ниже: ни одного соседнего backend не закрывается, ни одного ожидания не возникает. Резерв ограниченreserve_pool_size(по умолчанию 0, то есть выключен) и приоритизирован: голодающие пользователи (те, кто ниже своего эффективного минимума) и пользователи с большим числом ожидающих клиентов обслуживаются первыми через арбитра. -
Eviction (выселение). Fallback на случай, когда резерв выключен (
reserve_pool_size = 0) или уже полностью занят: координатор закрывает idle-соединение из пула другого пользователя, чтобы освободить main-слот. Кандидаты сортируются по p95 времени транзакции (по убыванию): медленные пулы отдают первыми, потому что лучше переносят стоимость пересоздания (1 ms pool wait добавляет 6.7% к 15 ms p95, но 104% к 0.96 ms p95). Излишек над эффективным минимумом — tiebreaker среди пулов с похожим p95. Только соединения старшеmin_connection_lifetime(по умолчанию 30 000 ms) попадают в список. 30-секундный порог подавляет циклический reconnect между соседними пулами, которые по очереди воруют слоты друг у друга.Эффективный минимум для user-пула равен
max(user.min_pool_size, pool.min_guaranteed_pool_size). Оба параметра защищают соединения от eviction; побеждает больший. Если снизить любой из них, эффективный минимум становится меньше и пользователь становится более доступен для eviction.
Фазы получения permit в координаторе
Когда путь внутри пула доходит до шага создания нового соединения, координатор проходит шесть фаз. Первая фаза, выдавшая permit, завершает последовательность.
Фаза A — Try-acquire (неблокирующая попытка: вернуть permit сразу, если есть свободный слот, иначе немедленно сообщить об отсутствии). Если лимит не достигнут, забираем слот и возвращаемся.
Фаза R — Reserve-first. Фаза A установила, что база заполнена. До
того как закрыть хоть одно соседское backend-соединение, координатор
проверяет, есть ли место в резервном пуле (reserve_in_use < reserve_pool_size). Если есть — сразу запрашивает permit у reserve
arbiter. При успехе вызывающий получает reserve-permit без eviction,
без закрытия соседнего backend и без ожидания на connection_returned.
В обычных условиях арбитр отвечает за доли миллисекунды.
Это путь, который держит p99 латенси низкой: reserve-permit стоит
одного round-trip с арбитром, тогда как старая последовательность
(Фаза B + Фаза C) могла заблокировать клиента на полный
reserve_pool_timeout, даже если в резерве было свободно. Фаза R не
работает при reserve_pool_size = 0 и проваливается в Фазу B, если
арбитр отказывает (все reserve permit-ы уже заняты, либо идёт гонка
с другим вызывающим).
Фаза B — Eviction. Выполняется, когда Фаза R не выдала permit:
reserve_pool_size = 0, либо резервный семафор был полностью занят
на момент проверки (reserve_in_use == reserve_pool_size), либо
арбитр отказал. Обходим все остальные user-пулы той же базы,
сортируем по p95 времени транзакции (по убыванию, медленные
первые) с излишком (spare) как tiebreaker, и закрываем одно
idle-соединение старше min_connection_lifetime у верхнего кандидата.
Permit выселенного соединения освобождается синхронно, слот становится
доступен сразу. Повторяем захват семафора. Если два вызова конкурируют,
проигравший переходит к следующей фазе. p95 кешируется каждые 15
секунд (stats cycle) — сканирование читает одно кешированное значение
на кандидата без блокировки гистограммы.
Фаза C — Wait. Выполняется, когда резерв отключён или полностью занят и Фаза B не нашла что выселить. Регистрируется подписка на уведомления, которая срабатывает на двух событиях:
- Был освобождён permit координатора соседнего пула — серверное
соединение физически закрыто (истёк
server_lifetime, ошибка проверки пригодности,RECONNECT), и слот семафора теперь свободен. - Соседний пул вернул соединение в свою idle-очередь — слот семафора НЕ освободился, но излишек над минимумом этого соседа только что вырос.
На каждое пробуждение Фаза C сначала пытается захватить свободный слот неблокирующей проверкой, и только если дешёвый путь не сработал — пробует выселение. Пробуждение от освобождения permit-а оставляет свободный слот в семафоре — дешёвый путь берёт его, и ни один соседний backend не закрывается. Пробуждение от idle-return не освобождает слот напрямую, но могло вырастить излишек соседа, поэтому повторная попытка eviction находит кандидата, которого мгновение назад не было, освобождает permit соседа, и следующая неблокирующая проверка срабатывает. Этот порядок (дешёвый путь сначала, выселение потом) закреплён регрессионным тестом: будущий рефакторинг не сможет случайно вернуть закрытия соседних backend-ов на пробуждениях от drop permit-а.
Ждём до reserve_pool_timeout (по умолчанию 3000 ms) либо пробуждения,
либо дедлайна. Этот таймаут применяется даже при
reserve_pool_size = 0: он задаёт бюджет всей фазы wait, а не только
окно входа в резерв. Если ваш query_wait_timeout короче, чем
reserve_pool_timeout, клиент сдаётся первым, и вы видите ошибки
wait timeout вместо более наглядной
all server connections to database 'X' are in use. Разбор симптома
есть в разделе troubleshooting.
Фаза D — Reserve retry. Фаза R уже пробовала этот путь один раз.
Фаза D повторяет попытку после того, как Фаза C исчерпала свой
бюджет ожидания — на случай, если за время ожидания соседний
reserve-держатель отпустил свой permit. Запросы ранжируются по паре
(starving, queued_clients), где starving означает, что пул сейчас
ниже своего эффективного минимума. Арбитр — это отдельный фоновый
процесс, который раздаёт permit-ы резервного пула из приоритетной
очереди.
Фаза E — Error. Если Фаза D тоже не выдала permit или резерв не
настроен, клиент получает ошибку:
all server connections to database 'X' are in use (max=N, ...).
Повышение из резерва в основной пул (reserve → main upgrade, retain task)
Reserve-permit — это буфер под всплеск, а не постоянное состояние.
После того как всплеск прошёл, backend, получивший reserve-permit,
продолжает жить как обычное idle-соединение, но его
permit координатора по-прежнему учитывается в reserve_in_use — даже
когда current < max_db_connections и в main-семафоре есть свободные
слоты. Без активного обслуживания SHOW POOL_COORDINATOR показывал
бы занятый резерв при том, что реальная ёмкость для всплеска пустая, и
следующему всплеску некуда расти.
Retain task запускается каждые retain_connections_time (по умолчанию
30 секунд) и делает бухгалтерскую перестановку: для каждого пула,
который не находится под давлением (см. определение ниже), он
обходит idle-очередь и для каждого backend-а, удерживающего
reserve-permit, пытается
забрать permit из main-семафора.
Пул считается под давлением, когда его per-pool семафор имеет
ноль свободных permit-ов. Одной колонки в SHOW POOLS, которая
напрямую показывала бы состояние семафора, нет, и наблюдаемые
колонки отстают от внутреннего состояния:
- Строгий прокси:
sv_active == pool_size. Каждое активное серверное соединение держит permit, поэтому когда все серверы в пуле активны, все permit-ы заняты. Это направление строгое. - Слабый прокси:
cl_waiting > 0означает, что как минимум один клиент находится внутриtimeout_get— это часто означает, что семафор пуст, но клиент, который уже взял permit и припарковался в Фазе 4 anticipation или Фазе C координатора, тоже числится ожидающим. Используйте как индикатор, не как доказательство.
Retain task пропускает пулы под давлением по двум причинам: upgrade
в такой момент просто отдаёт слот ожидающему клиенту (нет эффекта
на reserve_used), а закрытие reserve-соединения заставит этого
клиента делать свежий connect(). Очистка отработает на следующем
цикле. При успехе
reserve-permit возвращается в reserve-семафор, reserve_in_use
уменьшается на единицу, а тип permit у этого backend переключается с
reserve на main. Никакого переподключения, никакого дёргания соседа.
Обход прерывается на первом неудачном upgrade
в пуле: это доказывает, что main-семафор заполнен, и остальные
reserve-permit-ы этого пула проверять бессмысленно. Тот же retain-цикл
затем закрывает reserve-backend-ы, которые не удалось повысить до
основного и которые
простаивают дольше min_connection_lifetime.
При такой схеме reserve_in_use > 0 означает ровно одно: либо
всплеск сейчас идёт, либо он закончился не более чем
retain_connections_time назад. Исторический остаток reserve-ёмкости
сходится к нулю, как только в main появляется свободное место.
Получение permit координатора по требованию (JIT coordinator permits, burst gate первым)
Внутри пути получения соединения burst gate работает до
координатора. Это JIT (just-in-time) порядок: coordinator-permit
берётся только когда вызов уже занял burst gate слот и готов вызвать
connect().
Предыдущий порядок (координатор первый, потом gate) вызывал фантомные permit-ы (phantom permits): N вызовов захватывали по coordinator-permit и вставали в очередь за burst gate (cap=2). Реально создавали соединения только 2, но координатор видел N permit'ов в использовании и начинал выдавать reserve-permit'ы — хотя БД была далеко от предела.
С JIT-порядком в каждый момент coordinator-permit'ы держат не более
max_parallel_creates вызовов.
Остальные ждут gate слот без расходования бюджета координатора.
Блокировка головы очереди (head-of-line blocking) снимается разделением координатора на быстрый и медленный путь. Быстрый — неблокирующая проверка доступности слота координатора внутри слота ограничителя (мгновенно). Если не прошла — вызов освобождает слот ограничителя, ждёт координатора (eviction / возврат от соседа), и затем снова занимает слот ограничителя.
Coordinator + plain mode acquisition flow (JIT)
-----------------------------------------------
pool.get()
|
v
Phase 1: hot path recycle --- HIT ---> return
| MISS
v
Phase 2: warm gate --- below ---+
| above warm |
v |
Phase 3: fast spin --- HIT ---> return
| MISS |
v |
Phase 4: direct handoff --- HIT ---> return
| deadline |
v |
| <----------------------------------+
v
Phase 5: bounded burst gate (scaling_max_parallel_creates)
| slot acquired
v
+---------------------------+
| JIT coordinator acquire | only when max_db_connections > 0
| fast: неблокир. проверка | мгновенный ответ
| slow: release gate slot | ожидание координатора (evict/return)
| → re-acquire slot | затем продолжить create
+------------+--------------+
| permit granted
v
Phase 6: server_pool.create()
|
v
return new connection
Фазы пронумерованы так же, как в plain mode. Coordinator acquire
работает внутри burst gate слота, когда max_db_connections > 0.
В plain mode он не работает.
Когда координатор настроен, но лимит не достигнут
Если max_db_connections = 80, а текущее использование 30, фаза A
координатора всегда успешна. Фазы B–E никогда не запускаются. Поведение
идентично plain mode плюс одна атомарная инкрементация семафора на
каждое новое соединение. Горячий путь (повторное использование idle)
координатора вообще не касается, поэтому там у него нет измеримой
стоимости. Платят только новые создания соединений, и платят ровно
одной атомарной операцией.
По устройству координатор работает как лимит, а не очередь: он стоит ресурсов только когда вы упираетесь в потолок.
Фоновый replenish под координатором
replenish берёт permit координатора через неблокирующую проверку
доступности. Если база уже на лимите, replenish сдаётся и
повторяет попытку на следующем retain-цикле. Та же логика, что и у
burst gate: фоновая задача не должна бороться с клиентским трафиком
за скудные permit-ы.
Параметры тюнинга
Scaling-параметры по умолчанию глобальные. Для
scaling_warm_pool_ratio и scaling_fast_retries можно задать
переопределение в каждом пуле. scaling_max_parallel_creates
настраивается только глобально, переопределений на уровне пула для
него нет.
| Параметр | По умолчанию | Где | Что делает |
|---|---|---|---|
scaling_warm_pool_ratio | 20 (процент) | general, per-pool | Порог, ниже которого соединения создаются без anticipation. Ниже pool_size × ratio / 100 каждый запрос нового соединения идёт сразу к connect(). |
scaling_fast_retries | 10 | general, per-pool | Число быстрых повторных проверок пригодности в фазе anticipation перед переходом к прямой передаче (ожиданию возврата от соседа). |
scaling_max_parallel_creates | 2 | general | Жёсткий лимит одновременно идущих backend-connect() на пул. Задачи сверх лимита ждут возврата idle-соединения или завершения чужого создания. Должен быть >= 1. |
max_db_connections | не задан (выключено) | per-pool | Лимит суммарного числа backend-соединений к базе по всем user-пулам. Когда не задан, координатор не создаётся. |
min_connection_lifetime | 30000 (ms) | per-pool | Минимальный возраст idle-соединения, после которого координатор может выселить его в пользу другого пула. 30-секундный порог подавляет циклический reconnect между соседними пулами. |
reserve_pool_size | 0 (выключено) | per-pool | Дополнительные permit-ы координатора поверх max_db_connections, выдаваемые по приоритету при исчерпании основного пула. |
reserve_pool_timeout | 3000 (ms) | per-pool | Максимальное время ожидания координатора перед переходом к reserve pool. |
min_guaranteed_pool_size | 0 | per-pool | Минимум на пользователя, защищённый от eviction координатором. Соединения пользователя, у которого current_size <= min_guaranteed_pool_size, не могут быть выселены другими пользователями. |
Когда повышать scaling_max_parallel_creates
Повышайте, если:
burst_gate_waitsустойчиво растёт между скрейпами, иreplenish_deferredтоже ненулевой: клиентский трафик и фоновая задача оба борются за слоты, которых нет;connect()к бэкенду быстрый (< 50 ms), и у PostgreSQL есть запас поmax_connections;- скачки задержки соединения совпадают с ростом частоты
burst_gate_waits.
Жёсткий потолок. Не поднимайте scaling_max_parallel_creates выше
ни одного из этих лимитов:
pool_size / 4для самого маленького пула, в котором используется этот параметр. Выше этого значения лимит теряет смысл: одновременно создаётся половина пула, и сглаживание всплеска ломается.(PostgreSQL max_connections - superuser_reserved_connections) / (10 × N pools), гдеN poolsобозначает все пулы, делящие этот инстанс PostgreSQL. Выше этого значения суммарная частота одновременных коннектов превышает то, что бэкенд успевает обрабатывать, не переполняя очередьaccept().
Понижайте, если:
connect()к PostgreSQL дорогой (> 200 ms, например, SSL с проверкой сертификата или медленный lookuppg_authid);- в логах PostgreSQL появляется конкуренция за
pg_authid; - бэкенд жалуется на переполнение очереди
accept().
Симптом слишком низкого значения: частота burst_gate_waits растёт
быстрее, чем частота прихода клиентов. Симптом слишком высокого:
задержка connect() к PostgreSQL растёт, и connection storm
(шторм одновременных connect() к бэкенду, последствие thundering
herd) возвращается.
Размер при множестве пулов. Суммарный потолок одновременных
коннектов равен N pools × scaling_max_parallel_creates. Если за
одним PostgreSQL стоит 10 пулов и в любой момент суммарно по ним всем
вам нужно не более 8 одновременных backend-коннектов, поставьте
scaling_max_parallel_creates около desired_aggregate / N pools,
округляя вниз. Ниже 1 нельзя; если арифметика даёт меньше единицы,
уменьшайте N pools, объединяя пользователей.
Когда повышать scaling_warm_pool_ratio
Повышайте, если:
- пулы медленно прогреваются на старте, и
min_pool_sizeне используется; - клиенты ждут anticipation в момент, когда пул в основном пуст (anticipation активируется только выше порога прогрева, поэтому такого быть не должно, но более высокий ratio дополнительно сужает окно, в котором это может случиться).
Понижайте, если:
- пулы избыточны по размеру, и вы хотите, чтобы anticipation начинала подавлять создания на меньших значениях текущего размера.
Этот параметр редко требует вмешательства. Значение по умолчанию 20% подходит большинству нагрузок.
Когда задавать max_db_connections
Задавайте, если:
- один хост PostgreSQL обслуживает несколько пулов
(database, user), и суммаpool_sizeпо всем пулам превышаетmax_connectionsбазы; - нужен жёсткий потолок, который сохранится даже при неправильной конфигурации какого-то отдельного пула;
- нужна общая честность между пулами через eviction.
Оставляйте незаданным, если:
- один пул обслуживает одну базу, и
pool_sizeостаётся единственным ограничением; - вам не нужна никакая eviction между пулами (некоторые нагрузки предпочитают жёсткую изоляцию между пользователями).
reserve_pool_size и reserve_pool_timeout
Резерв работает как временный клапан переполнения, а не как
дополнительная постоянная ёмкость. Он предотвращает видимые клиенту
ошибки исчерпания во время коротких всплесков. В нормальном режиме
reserve_in_use большую часть времени должен быть равен нулю.
Эмпирическое правило размера: reserve_pool_size ≤ 0.25 × max_db_connections.
За этим порогом резерв перестаёт вести себя как буфер. Если половина
нагрузки постоянно лежит в резерве, поднимайте max_db_connections,
а не расширяйте клапан переполнения.
reserve_pool_timeout задаёт, сколько клиент ждёт в фазе C координатора
перед обращением к резерву. Значение 3000 ms по умолчанию консервативное.
Понижайте его, если ваш query_wait_timeout короткий и вам выгоднее
быстро переходить к резерву, чем блокировать клиентов на coordinator wait.
Рецепт тюнинга: снизить p99 checkout на базе под координатором
Профиль нагрузки: PostgreSQL отвечает за ~1 ms (низкий p99 длительности запроса), но клиенты видят p99 checkout latency 100–500 ms на базе под координатором. Задержка идёт от координатора, а не от PostgreSQL.
- Подтвердите фазу. Запустите
SHOW POOL_COORDINATORв момент всплеска latency. Вычислитеmain_used = current - reserve_used:currentвключает reserve-permit-ы, а рецепт зависит от того, заполнен ли именно main семафор.main_used == max_db_connиexhaustionsне растёт → доминирует wait-фаза. Клиент тратит свой бюджет в Фазе C перед тем, как провалиться в Фазу D. Переходите к шагу 2.main_used < max_db_connбез exhaustions → задержка идёт не от координатора. СмотритеSHOW POOL_SCALINGcreate_fallbackи troubleshooting plain-режима.
- Включите reserve-first, если он ещё не включён. Задайте
reserve_pool_sizeкак минимумmax(2, 0.1 × max_db_connections). Reserve-first выдаёт permit за sub-ms, когда в резерве есть место, так что клиент, раньше сидевший в Фазе C, платит только один round-trip к арбитру. - Уменьшите
reserve_pool_timeoutдо2 × p99 query latency, но не ниже. Для запроса в 1 ms нижняя граница обычно 20 ms; начните с 50 ms и неделю наблюдайтеreserve_acqиevictions. - Оставьте
min_connection_lifetimeна дефолте 30 000 ms, если у вас нет явной цели ускорить кросс-пуловую ребалансировку; понижение увеличивает частоту eviction и churn соединений.
За чем следить после каждого изменения (все в SHOW POOL_COORDINATOR):
| До | После | Вердикт |
|---|---|---|
reserve_acq не растёт | reserve_acq растёт | Reserve-first подхватил — checkout latency должен упасть; ожидаемо |
evictions стабилен | evictions падает | Фаза B перестала срабатывать, потому что Фаза R ловит вызывающего раньше; ожидаемо |
exhaustions 0 | exhaustions > 0 | Перетянули: reserve_pool_timeout ниже реального времени возврата от соседа |
reserve_used колеблется > 0 | reserve_used возвращается к 0 за 30 с | Retain upgrade-путь работает; делать ничего не надо |
Если p99 checkout не упал после шагов 2–3, путь не ограничен
координатором. Перечитайте SHOW POOL_SCALING по пострадавшему пулу:
create_fallback > 0 означает, что сам пул не может обслужить нагрузку
из возвратов, и лечить нужно pool_size, а не reserve_pool_size.
Нижняя граница. Не опускайте reserve_pool_timeout ниже
2 × ваш p99 query latency. Ниже этого порога фаза wait всегда
истекает раньше, чем соседний пул вернёт соединение, и резерв
превращается из клапана переполнения в обязательный permit для
каждого нового соединения. Reserve-permit-ов по замыслу мало, и
использовать их как постоянный источник означает сломать их
назначение.
Ловушка: query_wait_timeout < reserve_pool_timeout. Когда дедлайн
клиента короче фазы ожидания координатора, клиент сдаётся первым, и вы
видите ошибки wait timeout вместо более наглядной
all server connections to database 'X' are in use. Фазы wait и
reserve координатора отрабатывают полностью, но к этому моменту
клиента, которому нужен результат, уже нет. На старте валидатор
конфига pg_doorman выдаёт предупреждение; реагируйте на него.
Observability
pg_doorman экспортирует состояние давления на пул через admin-консоль и через Prometheus. Оба показывают одни и те же счётчики; выбирайте то, что подходит вашему стеку мониторинга.
Admin: SHOW POOL_SCALING
Счётчики пути anticipation + bounded burst в разрезе каждого пула.
Подключитесь к admin-базе pgdoorman и выполните:
pgdoorman=> SHOW POOL_SCALING;
| Колонка | Тип | Значение |
|---|---|---|
user | text | Пользователь пула |
database | text | База пула |
inflight | gauge | Вызовы connect() к бэкенду, выполняемые в этом пуле прямо сейчас. Ограничено scaling_max_parallel_creates. |
creates | counter | Сколько всего backend-соединений пул начинал создавать с момента старта. В паре с gate_waits используется для расчёта частоты попаданий на gate. |
gate_waits | counter | Сколько раз вызов наткнулся на заполненный burst gate и был вынужден ждать слот. Высокие значения говорят, что scaling_max_parallel_creates слишком низкий. |
antic_notify | counter | Попытки anticipation в Фазе 4, где прямая передача удалась. Инкрементируется один раз на успешное получение, до проверки пригодности. Высокий antic_notify при низком create_fallback — хороший признак: прямая передача ловит возвраты, клиенты не платят за connect(). |
antic_timeout | counter | Попытки anticipation в Фазе 4, где ожидание истекло без получения соединения, либо бюджет был нулевой. Инкрементируется ровно один раз при каждом провале Фазы 4 в путь создания. Высокий antic_timeout означает, что клиенты упираются в query_wait_timeout, не успев получить соединение через прямую передачу. |
create_fallback | counter | Фаза 4 не получила соединение через прямую передачу: дедлайн исчерпан или бюджет был нулевой. Именно эти ожидания превращаются в новый connect(). Стабильно ненулевой create_fallback значит, что клиентского бюджета не хватает на перехват возвратов: пул либо мал, либо запросы длиннее query_wait_timeout. |
replenish_def | counter | Запуски фонового replenish, упёршиеся в лимит burst gate и отложенные до следующего retain-цикла. Устойчиво ненулевые значения означают, что min_pool_size нельзя поддержать при текущей нагрузке. |
Все счётчики монотонные с момента старта. Считайте дельты между скрейпами; абсолютные значения полезны только для расчёта соотношений.
Admin: SHOW POOL_COORDINATOR
Состояние координатора в разрезе каждой базы. Присутствует только
для баз с max_db_connections > 0.
pgdoorman=> SHOW POOL_COORDINATOR;
| Колонка | Тип | Значение |
|---|---|---|
database | text | Имя базы |
max_db_conn | gauge | Сконфигурированное max_db_connections |
current | gauge | Сколько всего backend-соединений сейчас удерживается под этим координатором (по всем user-пулам) |
reserve_size | gauge | Сконфигурированное reserve_pool_size |
reserve_used | gauge | Сколько reserve-permit-ов используется прямо сейчас. Сходится обратно к 0, когда в main есть свободное место — retain task каждые retain_connections_time апгрейдит idle reserve-permit-ы в main. Устойчивое ненулевое значение означает либо активный всплеск, либо базу, постоянно упёртую в max_db_connections. |
evictions | counter | Сколько раз координатор выселил idle-соединение соседнего пула, чтобы освободить слот. С включённым reserve-first этот счётчик растёт только при реальном кросс-пуловом давлении — когда резерв заполнен и у соседа есть что выселить. |
reserve_acq | counter | Сколько всего reserve-permit-ов выдал arbiter (Фаза R быстрый путь плюс Фаза D fallback суммарно) |
exhaustions | counter | Сколько раз координатор вернул клиенту ошибку исчерпания. Это главный сигнал на пейджер. |
Чтение вывода SHOW POOL_COORDINATOR
Три снапшота и что каждый означает для оператора:
Здоровая спокойная база:
database | max_db_conn | current | reserve_size | reserve_used | evictions | reserve_acq | exhaustions
----------+-------------+---------+--------------+--------------+-----------+-------------+-------------
mydb | 80 | 24 | 10 | 0 | 0 | 0 | 0
Нормальное устойчивое состояние. Запас большой, резерв спит, evictions нет, exhaustions нет. Алерты здесь должны молчать.
После всплеска, upgrade в процессе:
database | max_db_conn | current | reserve_size | reserve_used | evictions | reserve_acq | exhaustions
----------+-------------+---------+--------------+--------------+-----------+-------------+-------------
mydb | 80 | 65 | 10 | 3 | 0 | 12 | 0
Всплеск занял большую часть max_db_connections и три соединения
перелились в резерв. current < max_db_conn означает, что в main
есть место, поэтому retain task повысит эти три permit-а до main
на следующем цикле; reserve_used должен упасть до 0 в течение
retain_connections_time (по умолчанию 30 секунд). Если не падает,
смотрите раздел troubleshooting ниже. evictions = 0 и
reserve_acq > 0 вместе подтверждают, что reserve-first поглотил
всплеск без закрытия соседских backend-ов.
Устойчивая перегрузка:
database | max_db_conn | current | reserve_size | reserve_used | evictions | reserve_acq | exhaustions
----------+-------------+---------+--------------+--------------+-----------+-------------+-------------
mydb | 80 | 95 | 20 | 15 | 300 | 500 | 0
Main полностью занят (main_used = current - reserve_used = 80,
равно max_db_conn), резерв использован на 75%, много eviction-ов,
много reserve-грантов. База не просто иногда под давлением — она
постоянно недоразмерена и выживает только за счёт того, что
eviction ротирует соединения между пользователями, а reserve-first
поглощает каждого нового вызывающего. exhaustions = 0 означает,
что арбитр пока успевает, но любой переходный пик опрокинет
ситуацию. Действие: поднимите max_db_connections (сначала
проверьте запас PostgreSQL) или найдите жадный пул через
SHOW POOLS и уменьшите его pool_size.
Метрики Prometheus
Два семейства метрик в разрезе пула и два в разрезе координатора. Все
четыре живут в namespace pg_doorman_pool_scaling* и
pg_doorman_pool_coordinator*.
| Метрика | Тип | Лейблы | Источник |
|---|---|---|---|
pg_doorman_pool_scaling{type="inflight_creates"} | gauge | user, database | inflight из SHOW POOL_SCALING |
pg_doorman_pool_scaling_total{type="creates_started"} | counter | user, database | creates |
pg_doorman_pool_scaling_total{type="burst_gate_waits"} | counter | user, database | gate_waits |
pg_doorman_pool_scaling_total{type="burst_gate_budget_exhausted"} | counter | user, database | gate_budget_ex — adaptive timeout сработал, клиент перешёл к созданию |
pg_doorman_pool_scaling_total{type="anticipation_wakes_notify"} | counter | user, database | antic_notify |
pg_doorman_pool_scaling_total{type="anticipation_wakes_timeout"} | counter | user, database | antic_timeout |
pg_doorman_pool_scaling_total{type="create_fallback"} | counter | user, database | create_fallback |
pg_doorman_pool_scaling_total{type="replenish_deferred"} | counter | user, database | replenish_def |
pg_doorman_pool_coordinator{type="connections"} | gauge | database | current из SHOW POOL_COORDINATOR |
pg_doorman_pool_coordinator{type="reserve_in_use"} | gauge | database | reserve_used |
pg_doorman_pool_coordinator{type="max_connections"} | gauge | database | max_db_conn |
pg_doorman_pool_coordinator{type="reserve_pool_size"} | gauge | database | reserve_size |
pg_doorman_pool_coordinator_total{type="evictions"} | counter | database | evictions |
pg_doorman_pool_coordinator_total{type="reserve_acquisitions"} | counter | database | reserve_acq |
pg_doorman_pool_coordinator_total{type="exhaustions"} | counter | database | exhaustions |
Алерты для настройки
Алерты ниже покрывают режимы отказа, на которые стоит реагировать пейджером или варнингом. Они написаны на синтаксисе Prometheus; адаптируйте под свой стек. Все используют окна устойчивого условия, чтобы короткие всплески не будили дежурного.
Если вы часто перезагружаете pg_doorman, и пулы появляются и исчезают,
ограничьте алерты недавно активными пулами (например, добавьте фильтр
pg_doorman_pool_scaling_total{type="creates_started"} > 0).
Каждый алерт ниже содержит блок Runbook с одной диагностической командой и двумя-тремя ветвями, привязанными к конкретным значениям счётчиков.
Coordinator exhaustion (page). Клиент получил ошибку "database exhausted". Жёсткий отказ — резерв и eviction оба не сработали.
rate(pg_doorman_pool_coordinator_total{type="exhaustions"}[5m]) > 0
Runbook:
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_COORDINATOR'
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOLS'
current — это суммарное число соединений main + reserve
(current == max_db_conn + reserve_size означает, что оба семафора
пусты).
current == max_db_conn + reserve_size→ оба семафора полностью пусты. Поднимитеmax_db_connections(сначала проверьте, что у PostgreSQL есть запасmax_connections) или увеличьте резерв.reserve_size == 0иcurrent == max_db_conn→ резерв выключен, main полностью занят. Задайтеreserve_pool_size, чтобы поглощать всплески, и, еслиexhaustionsпродолжит расти, поднимайтеmax_db_connections.current < max_db_conn + reserve_size, ноexhaustionsрастёт → гонка в Фазе R/D, такого не должно быть устойчиво; заведите баг со снапшотомSHOW POOL_COORDINATOR.- В
SHOW POOLSу одного пользователяsv_idleсильно больше, чем у остальных → один пул захватывает соединения. Уменьшите егоpool_sizeили задайтеmin_guaranteed_pool_size, чтобы защитить соседей.
Burst gate saturated (warn). Burst gate ждёт чужие create
чаще, чем проходит напрямую. Короткие всплески выше порога при
failover или рестарте нормальны. Устойчивые значения означают,
что scaling_max_parallel_creates слишком низкий.
rate(pg_doorman_pool_scaling_total{type="burst_gate_waits"}[5m])
> 0.5 * rate(pg_doorman_pool_scaling_total{type="creates_started"}[5m])
Runbook:
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_SCALING'
inflight_createsсидит на лимите И вSHOW POOLSвидны клиенты вcl_waiting→connect()медленный со стороны бэкенда. Смотрите troubleshooting "Burst gate как узкое место даже при низком трафике" перед тем, как поднимать лимит.inflight_createsходит ниже лимита, ноgate_waitsрастёт → много коротких всплесков. Поднимайтеscaling_max_parallel_creates, оставаясь в пределах потолка из раздела тюнинга.- Горит только один пул → рассмотрите
min_guaranteed_pool_sizeдля соседей или уменьшитеpool_sizeу горячего.
Anticipation create fallback rate (warn). Фаза 4 anticipation
сдаётся, не поймав возврат, и проваливается в свежий connect().
В устойчивом состоянии должно быть близко к нулю.
rate(pg_doorman_pool_scaling_total{type="create_fallback"}[5m]) > 0.1
and
rate(pg_doorman_pool_scaling_total{type="creates_started"}[5m]) > 0.1
Runbook:
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_SCALING'
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman \
-c 'SHOW STATS' | grep -E 'database|avg_xact_time|avg_query_time'
create_fallbackвысокий на одном пуле Иavg_xact_timeэтой базы растёт → долгие запросы держат соединения вне ротации. Сначала лечите долгий запрос; пул рассчитан на обычную длительность, а не на эту транзакцию.create_fallbackвысокий у всех пулов Иcreates_startedтоже растёт → нагрузка превышает то, что возвраты могут обслужить в пределах дедлайна. Поднимайтеpool_size.create_fallbackвысокий, ноquery_wait_timeoutкороткий (< 1 секунды) → дедлайн anticipation (query_wait_timeout − 500 msс верхней границей 500 ms) слишком короткий даже для нормальных возвратов. Поднимайтеquery_wait_timeoutминимум до2 × p99 длительности запроса.
Replenish deferred persistently (warn). Фоновая задача не
может поддерживать min_pool_size, потому что burst gate занят
клиентским трафиком.
increase(pg_doorman_pool_scaling_total{type="replenish_deferred"}[1h]) > 60
Runbook:
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_SCALING'
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOLS'
- Пострадавший пул показывает
sv_idle + sv_active < min_pool_sizeодновременно с растущимgate_waits→ replenish проигрывает клиентскому трафику. Поднимайтеscaling_max_parallel_creates, чтобы у фоновой задачи был запас, или принимайте defer как косметическую проблему (под нагрузкой клиенты сами поднимут пул вышеmin_pool_size). inflight_createsстабильно стоит на лимите → gate полон по другой причине (медленныйconnect()); сначала это.
Reserve pool continuously in use (warn). Gauge reserve-permit
не возвращался к нулю в течение 15 минут. Retain task апгрейдит
idle reserve-permit-ы обратно в main каждые retain_connections_time
(по умолчанию 30 секунд), поэтому алерт означает, что upgrade-путь
не в состоянии отработать или получить успех, а не что он забыл
запуститься.
min_over_time(pg_doorman_pool_coordinator{type="reserve_in_use"}[15m]) > 0
Runbook:
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_COORDINATOR'
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOLS'
Вычислите main_used = current - reserve_used из строки — current
это суммарное число permit-ов main и reserve, а не только main.
main_used == max_db_conn→ main полностью занят, upgrade-у негде забрать слот. База недоразмерена; поднимайтеmax_db_connections.main_used < max_db_connИ каждый пул вSHOW POOLSимеетsv_active == pool_size(илиcl_waiting > 0как индикатор) → все пулы под давлением, retain task пропускает upgrade. Поднимайтеpool_sizeу того пула, у которого самый высокийcl_waitingили самое плотное соотношениеsv_active / pool_size.main_used < max_db_connИ ни у одного пула нет ни того, ни другого признака, а gauge всё равно ненулевой → заведите баг со снапшотамиSHOW POOL_COORDINATORиSHOW POOLS; этого не должно быть.
Coordinator approaching cap (warn). Ранний сигнал: координатор подходит к потолку.
pg_doorman_pool_coordinator{type="max_connections"} > 0
and
pg_doorman_pool_coordinator{type="connections"}
/ pg_doorman_pool_coordinator{type="max_connections"} > 0.85
Runbook:
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_COORDINATOR'
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOLS'
currentмонотонно растёт часами → проблема планирования ёмкости. Поднимайтеmax_db_connections(сначала проверьте запас PostgreSQL) до следующего всплеска.currentколеблется рядом с лимитом → burst-driven. Поднимайтеreserve_pool_size, чтобы всплески поглощались без касанияmax_db_connections, потом следите заreserve_acq.- Один пул доминирует в
SHOW POOLS(sv_active + sv_idleсильно больше, чем у соседей) → один пул захватывает соединения; уменьшите егоpool_sizeили задайтеmin_guaranteed_pool_sizeсоседям.
Inflight stuck at cap (warn). inflight_creates, сидящий на
сконфигурированном лимите больше 5 минут, означает, что вызовы
connect() не завершаются.
min_over_time(pg_doorman_pool_scaling{type="inflight_creates"}[5m])
>= 2 # adjust to your scaling_max_parallel_creates value
Runbook:
time psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -c 'SELECT 1'
psql -h $PG_HOST -p $PG_PORT -c \
"SELECT state, count(*) FROM pg_stat_activity GROUP BY state"
time psqlпоказываетconnect()> 500 ms → backend-connect медленный. Проверьтеpg_stat_sslна стоимость SSL-handshake,pg_authidна конкуренцию за поиск роли и время DNS-резолва с хоста pg_doorman.pg_stat_activityпоказывает много сессий вstartupилиauthenticating→ бэкенд спавнит процессы, но не разгружает очередь handshake. Вероятно, упёрлись вmax_connectionsна стороне базы — выполнитеSELECT setting FROM pg_settings WHERE name = 'max_connections'и сравните с активными сессиями.pg_stat_activityпо пользователю pg_doorman пустой → сетевая проблема или firewall между pg_doorman и PostgreSQL.
Coordinator thrashing (warn). Лимит исчерпан и идут eviction-ы: координатор постоянно закрывает соседние соединения, чтобы освободить место. Недоразмеренный пул, а не "иногда под давлением".
pg_doorman_pool_coordinator{type="connections"}
/ pg_doorman_pool_coordinator{type="max_connections"} > 0.95
and
rate(pg_doorman_pool_coordinator_total{type="evictions"}[5m]) > 0
Runbook:
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_COORDINATOR'
evictionsрастёт Иreserve_used == 0→ резерв выключен или исчерпан, eviction — единственный клапан сброса. Включите / поднимитеreserve_pool_size, чтобы всплеск поглощался без закрытия соседних backend-ов.evictionsИreserve_acqрастут одновременно → резерва тоже не хватает. Поднимайтеmax_db_connectionsилиreserve_pool_size; сначала проверьтеmax_connectionsу PostgreSQL.
Чтение admin-вывода во время инцидента
Admin-консоль принимает только SHOW <subcommand>, SET, RELOAD,
SHUTDOWN, UPGRADE, PAUSE, RESUME и RECONNECT. SHOW это не
виртуальная таблица, поэтому к admin-базе нельзя выполнить SELECT.
Чтобы запрашивать счётчики в shell-пайплайнах, запускайте SHOW из
psql и обрабатывайте вывод.
Шаблоны ниже используют psql к admin-листенеру (по умолчанию
учётные данные admin/admin):
# Highest burst-gate-wait ratio first (the hot pool).
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman \
-c 'SHOW POOL_SCALING' --no-align --field-separator='|' \
| awk -F'|' 'NR>1 && $4>0 { printf "%-20s %-20s %.3f inflight=%d defer=%d\n", $1, $2, $5/$4, $3, $9 }' \
| sort -k3 -nr | head
# Highest create fallback ratio (cycle gave up on deadline and paid connect()).
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman \
-c 'SHOW POOL_SCALING' --no-align --field-separator='|' \
| awk -F'|' 'NR>1 && $4>0 { printf "%-20s %-20s %.3f creates=%d fallback=%d\n", $1, $2, $8/$4, $4, $8 }' \
| sort -k3 -nr | head
# Coordinator: closest databases to exhaustion.
psql -h 127.0.0.1 -p 6432 -U admin pgdoorman \
-c 'SHOW POOL_COORDINATOR' --no-align --field-separator='|' \
| awk -F'|' 'NR>1 && $2>0 { printf "%-30s %.3f used=%d/%d reserve=%d exhaustions=%d\n", $1, $3/$2, $3, $2, $5, $8 }' \
| sort -k2 -nr
Позиции полей в awk соответствуют порядку колонок, описанному выше:
для POOL_SCALING это user|database|inflight|creates|gate_waits|gate_budget_ex|antic_notify|antic_timeout|create_fallback|replenish_def,
для POOL_COORDINATOR это database|max_db_conn|current|reserve_size|reserve_used|evictions|reserve_acq|exhaustions.
Сравнение с PgBouncer
PgBouncer и pg_doorman оба пулят соединения, но давление обрабатывают по-разному.
| Аспект | PgBouncer | pg_doorman |
|---|---|---|
| Лимит размера в каждом пуле | pool_size | pool_size |
| Лимит на уровне БД, общий для пулов | max_db_connections (жёсткий лимит, без eviction; для изоляции есть переопределения pool_size на базу или на пользователя) | max_db_connections (жёсткий лимит плюс eviction между пулами и reserve pool) |
| Reserve pool | reserve_pool_size, reserve_pool_timeout | reserve_pool_size, reserve_pool_timeout (плюс приоритизация в arbiter по starving/queued) |
| Eviction между пользователями | Не поддерживается. Пользователь, удерживающий idle-соединения, морит голодом соседа, которому они нужны. | Координатор выселяет idle-соединения у пользователя с наибольшим излишком над эффективным минимумом (max(user.min_pool_size, min_guaranteed_pool_size)). |
Одновременные connect() к бэкенду в одном пуле | Однопоточный, обрабатывает события последовательно в пределах пула, вызовы connect() выпускаются по одному. | Ограничено scaling_max_parallel_creates (по умолчанию 2 на пул): не больше N одновременных backend-коннектов в пуле, лишние задачи ждут на burst gate. |
| Anticipation возвратов | Нет. Клиенты ждут следующего доступного соединения в порядке прихода, в пределах wait_timeout. | Event-driven anticipation: возвращающееся соединение будит ровно одного из ожидающих в очереди, часто ещё до того, как будет выпущен хоть один новый connect(). |
Прогрев min_pool_size | Поддерживается на каждом такте event loop (без отдельной задачи replenish). | Периодический фоновый replenish (retain_connections_time, по умолчанию 30 s), который отступает, когда burst gate занят. |
| Повторный логин после ошибки | server_login_retry (по умолчанию 15 s) блокирует новые попытки логина после отказа бэкенда. | Аналога нет. Ошибки логина бэкенда пробрасываются клиенту на каждую попытку. |
| Jitter на lifetime | Нет. server_lifetime точный. | ±20% jitter на server_lifetime и idle_timeout, чтобы избежать одновременного массового закрытия. |
| Ключ поиска пула | (database, user, auth_type) | (database, user) |
| Честность между пользователями на общем лимите | First come first served на max_db_connections. | Reserve arbiter оценивает запросы по (starving, queued_clients). |
| Observability давления на новые соединения | SHOW POOLS, SHOW STATS. Текущие inflight-коннекты и результаты anticipation не видны вовсе. | SHOW POOL_SCALING и SHOW POOL_COORDINATOR показывают каждый счётчик нового кодового пути. |
В production важнее всего два различия:
-
Bounded burst gate. Размер пула в PgBouncer ограничивает, сколько соединений у вас есть, но не ограничивает, сколько вызовов
connect()выпускается одновременно, когда в один момент приходит много клиентов. pg_doorman ограничивает частоту одновременныхconnect()к бэкенду независимо от размера пула, поэтому внезапный всплеск трафика не превращается в connection storm к PostgreSQL. -
Cross-pool eviction.
max_db_connectionsв PgBouncer задаёт жёсткий потолок и не умеет перераспределять. Если пользователь A держит 80 idle-соединений, а пользователю B нужно одно, но лимит уже выбран, пользователь B ждёт или падает. Координатор pg_doorman может закрыть одно из соединений A (если оно старшеmin_connection_lifetime) и отдать слот B. -
FIFO direct handoff. PgBouncer ставит клиентов в порядке прихода и отдаёт следующее свободное соединение, но PgBouncer обрабатывает события на одном потоке — под нагрузкой порядок зависит от callback'ов libevent. pg_doorman отправляет возвращённые соединения напрямую каждому ожидающему в строгом FIFO-порядке. Результат — p99/p50 в пределах 1.1x при любом числе клиентов, тогда как пулеры без строгого FIFO показывают 10-25x раздувание хвоста при той же нагрузке.
Troubleshooting
В логах одновременно несколько backend connect
Симптом. В логах сервера (или в debug-логах pg_doorman) видно
5 или больше событий connect() к бэкенду в одну и ту же миллисекунду.
Кажется, что burst gate не работает.
Причина. Либо scaling_max_parallel_creates выставлен слишком
высоко (проверьте SHOW CONFIG или ваш pg_doorman.yaml), либо
существует 5 или больше пулов, каждый из которых независимо
открывает одновременные коннекты (gate работает в пределах одного
пула, а не глобально).
Исправление. Понизьте scaling_max_parallel_creates. Значение по
умолчанию 2 подходит большинству нагрузок. Когда пулов много,
суммарная частота одновременных коннектов равна
pools × scaling_max_parallel_creates, и это ожидаемо. Чтобы
ограничить и сумму, задайте max_db_connections на базу; тогда
координатор поставит в очередь создания сверх лимита.
min_pool_size не поддерживается
Симптом. Пул с min_pool_size = 10 показывает sv_idle = 4 в
SHOW POOLS и держится так минутами.
Причина. Фоновый replenish откладывается, потому что burst gate
занят клиентским трафиком. Проверьте replenish_def в
SHOW POOL_SCALING. Если он продолжает расти, replenish пропускает
каждый retain-цикл.
Исправление. Так задумано: под нагрузкой gate отдан клиентским
запросам на создание. Пул дойдёт до min_pool_size, когда клиентский
трафик ослабнет. Если нужна жёсткая гарантия минимума, поднимайте
scaling_max_parallel_creates, чтобы у replenish была свободная
ёмкость, либо сократите retain_connections_time, чтобы replenish
запускался чаще.
Для transaction pooling (pool_mode = transaction) значение
min_pool_size выше, чем pool_size / 2, обычно указывает на то,
что пул мал: большинство соединений должно быть доступно для выдачи
клиентам, а не пришпилено к минимуму. Для session pooling
эвристика не работает: min_pool_size = pool_size это нормальная
настройка, чтобы держать всё состояние сессий в горячем виде.
p99 latency растёт без видимой причины
Симптом. p99 клиентской задержки растёт, p50 остаётся ровным. Размер пула выглядит нормально, в логах нет ошибок.
Причина. Фаза 4 anticipation (direct handoff) держит клиентов
в ожидании возврата до query_wait_timeout - 500 ms, но возвраты
приходят медленнее, чем клиент готов ждать. Ожидающие либо получают
соединение через прямую передачу, либо проваливаются в путь создания
по истечении бюджета. Проверьте create_fallback в
SHOW POOL_SCALING: если он ненулевой и растёт, клиенты уходят
по таймауту.
Исправление. Сверьте query_wait_timeout с p95 длительности
запросов, обслуживаемых этим пулом. Два случая.
query_wait_timeoutсопоставим или короче p95: клиент не успевает дождаться освобождения соединения, каждый такой клиент платит полный бюджет ожидания. Либо поднимайтеquery_wait_timeout, либо увеличивайтеpool_size, чтобы освобождения приходили чаще.query_wait_timeoutзаметно длиннее p95, ноcreate_fallbackвсё равно растёт: пул слишком мал для своей нагрузки, все соединения заняты дольше p95 из-за конкуренции. Поднимайтеpool_size(иmax_db_connections, если задан координатор).create_fallbackнулевой, аantic_notifyрастёт пропорционально обороту пула: direct handoff работает, возвраты перехватываются, лишнихconnect()нет. Задержка приходит откуда-то ещё: PostgreSQL, сеть, клиентская сторона. ПроверьтеSHOW STATS avg_wait_time.
max_db_connections исчерпан, клиенты получают ошибки
Симптом. Клиенты видят ошибки вида all server connections to database 'X' are in use (max=80, ...).
pg_doorman_pool_coordinator_total{type="exhaustions"} растёт.
Причина. Все пять фаз координатора провалились: try-acquire не
взял слот, выселять нечего, ожидание истекло, а резерв либо исчерпан,
либо reserve_pool_size = 0.
Исправление. Пройдите по фазам по порядку.
- Сравните
currentиmax_db_connвSHOW POOL_COORDINATOR. Еслиcurrentстабильно стоит на лимите, ваша нагрузка его превышает. Либо поднимайтеmax_db_connections, либо ищите разогнавшийся пул. - Посмотрите на частоту
evictions. Если она нулевая или близка к нулю, eviction не помогает: либо idle-соединения каждого пула моложеmin_connection_lifetime(по умолчанию 30 000 ms), либо все остальные пулы стоят на своёмmin_guaranteed_pool_size. Понизьтеmin_connection_lifetime, если у вас очень короткие запросы и вы явно хотите более быстрый cross-pool rebalance, или увеличьтеmax_db_connections. - Сравните
reserve_usedиreserve_size. Если резерв занят полностью, поднимитеreserve_pool_size. Если резерв пустой, аexhaustionsвсё равно происходят, значит резерв не настроен (reserve_pool_size = 0). Задайте его, чтобы поглощать всплески. - Посмотрите
SHOW POOLSдля базы. Если у одного пользователяsv_idleнамного больше, чем у остальных, этот пользователь копит соединения; задайтеmin_guaranteed_pool_size, чтобы защитить мелких пользователей от вытеснения, либо понизьтеpool_sizeнакопителю.
Фаза ожидания координатора как узкое место
Симптом. Клиенты в среднем платят 3 секунды задержки, ровно
совпадающие с reserve_pool_timeout.
Причина. Фаза C wait стабильно истекает по таймауту. Попадание в
Фазу C при включённом reserve-first означает, что на момент прихода
вызывающего резерв уже был полностью занят, — единственный путь вперёд
это возврат от соседа. Либо база действительно стоит на лимите, и
соединения не возвращаются; либо reserve_pool_size = 0, и wait
отрабатывает до конца, прежде чем клиент получит хоть какой-то ответ.
Исправление. Понизьте reserve_pool_timeout, чтобы получать
быстрый отказ, либо задайте reserve_pool_size > 0, чтобы Фаза R /
Фаза D обрабатывала переполнение в рамках того же пути получения
соединения, вообще не заходя в Фазу C.
reserve_used остаётся ненулевым, а пул выглядит пустым
Симптом. SHOW POOL_COORDINATOR показывает reserve_used = 4
(или любое ненулевое значение) при том, что SHOW POOLS показывает
cl_waiting = 0, низкий cl_active и current < max_db_conn.
Резервный пул выглядит занятым «призраками».
Причина. В сборках до reserve→main upgrade reserve-permit
держался за своим backend, пока тот не доживёт до
min_connection_lifetime и retain-цикл не поймает его idle. При
устойчивом клиентском трафике last_used() на backend обновлялся
быстрее, чем min_connection_lifetime, и permit никогда не
отпускался.
Исправление. В текущих сборках это решается автоматически: retain
task каждые retain_connections_time (по умолчанию 30 секунд)
запускает повышение reserve-permit-ов до main. Для каждого
reserve-backend в пуле без давления permit меняется с reserve на main,
если в основном семафоре есть свободное место. Gauge reserve_used должен
упасть до нуля в течение одного retain-цикла.
Если reserve_used всё равно не уходит, значит пул либо под
устойчивым давлением (повышение пропускается, когда пул под давлением —
и это правильно, иначе ожидающий клиент тут же заберёт освободившийся
слот), либо current == max_db_connections (нет main-слота, который
можно забрать). Оба случая означают, что база честно исчерпана;
лечение — больше ёмкости, а не обход.
Burst gate как узкое место даже при низком трафике
Симптом. Частота gate_waits заметная, но частота creates
низкая, а inflight_creates всё время стоит на лимите.
Причина. connect() к бэкенду медленный. Каждый create держит
слот по нескольку секунд; даже с двумя слотами вы создадите всего
около 2 / connect_seconds соединений в секунду.
Исправление. Разберитесь, почему connect() медленный со стороны
PostgreSQL (слишком много SCRAM-итераций, конкуренция за блокировки
pg_authid, медленный DNS, SSL handshake). Когда connect() станет
быстрым, gate перестанет быть узким местом. Поднятие
scaling_max_parallel_creates лишь маскирует проблему и перенесёт
storm на PostgreSQL. Сначала разбирайтесь, потом поднимайте лимит.
Один пользователь забирает весь резерв
Симптом. reserve_acquisitions_total продолжает расти. Большую
часть резервов забирает один и тот же небольшой пользователь.
Причина. Пользователь ниже своего эффективного минимума
(max(user.min_pool_size, min_guaranteed_pool_size)), и координатор
не может удовлетворить этот минимум, не выселяя соединения у соседей.
Каждый клиентский запрос от этого пользователя попадает в Фазу R
(reserve-first), как только база заполнена, и захватывает
reserve-permit — арбитр даёт starving-пользователям наивысший балл,
поэтому они выигрывают грант. Более глубокий вопрос: почему
пользователю постоянно нужны свежие соединения? Либо его pool_size
слишком мал, чтобы поглотить собственную нагрузку, либо его трафик
всплесковый, и резерв делает ровно то, для чего нужен.
Исправление. Три варианта, выбор зависит от настоящей причины:
- Если
pool_sizeпользователя действительно мал для постоянной нагрузки, поднимитеpool_sizeи (если нужно)max_db_connections, чтобы увеличенный пул туда поместился. - Если у пользователя высокий эффективный минимум, который координатор
не может удовлетворить, понизьте тот параметр, который реально
задаёт границу (проверьте оба:
user.min_pool_sizeиmin_guaranteed_pool_size). - Если трафик действительно всплесковый, а резервы ловят всплески, оставьте как есть. Кратковременное использование резерва это и есть его назначение.
Клиенты получают wait timeout, а не database exhausted
Симптом. Под давлением координатора клиенты видят ошибку
wait timeout (во внутренних логах pg_doorman она записана как
PoolError::Timeout(Wait)), но
pg_doorman_pool_coordinator_total{type="exhaustions"} стоит на нуле.
Координатор так и не объявил исчерпание, при этом каждый клиент уходит
по таймауту.
Причина. query_wait_timeout короче, чем reserve_pool_timeout.
Клиент сдаётся раньше, чем фаза ожидания координатора успевает
завершиться. Счётчик exhaustions никогда не увеличивается, потому
что координатор в итоге получает permit для запроса, у которого
ожидающего клиента больше нет.
Исправление. Либо поднимите query_wait_timeout выше
reserve_pool_timeout плюс типичное время connect(), либо понизьте
reserve_pool_timeout (в пределах нижней границы из раздела тюнинга).
На старте валидатор конфига выдаёт предупреждение для такой
конфигурации; реагируйте на него.
Восстановление пула после рестарта PostgreSQL
Симптом. Мастер PostgreSQL перезапустился (failover, краш,
плановое окно). Видна толпа клиентов, бьющих в burst gate,
inflight_creates стоит на лимите, частота creates_started резко
растёт.
Причина. Когда pg_doorman замечает непригодный бэкенд (через
server_idle_check_timeout или упавший запрос), он повышает
reconnect-эпоху пула и сразу сливает все idle-соединения. Горячий
путь recycle становится пустым, и каждый пришедший после слива
клиент идёт по маршруту anticipation, burst gate, connect. При
scaling_max_parallel_creates = 2 каждый пул прирастает максимум
двумя соединениями за раз, и скорость ограничена задержкой
connect() к PostgreSQL.
Как выглядит здоровое восстановление. В первые несколько секунд
inflight_creates = 2 непрерывно, creates_started быстро растёт,
burst_gate_waits растёт вместе с ней. По мере того как новые
соединения начинают циркулировать, anticipation_wakes_notify
разгоняется, а create_fallback перестаёт расти: direct handoff
ловит возвраты внутри клиентского query_wait_timeout и новые
connect() уже не нужны. За время pool_size / 2 × connect()
секунд пул возвращается в норму.
Исправление. Обычно никакого. Bounded burst gate делает свою
работу: гасит connection storm к восстанавливающемуся primary, чтобы
тот не получил сотню одновременных коннектов на и без того перегруженный
postmaster. Если connect() действительно быстрый (< 50 ms), и у
вашего max_connections есть запас, поднимите
scaling_max_parallel_creates до 4 или 8, чтобы сократить
восстановление, но оставайтесь в пределах жёсткого потолка из раздела
тюнинга.
Глоссарий
- ограничитель всплесков (bounded burst gate) — ограничитель
на уровне пула, пропускающий не более
scaling_max_parallel_createsодновременных вызововconnect()к бэкенду. Задачи сверх лимита встают в очередь на прямую передачу и ожидают завершения чужого создания, пока слот не освободится. - permit координатора (coordinator permit) — разрешение на удержание одного слота в общем лимите координатора. Может быть основным (main) или резервным (reserve). Освобождается, когда backend-соединение физически уничтожается (а не когда оно возвращается в idle-очередь); при освобождении слот возвращается либо в основной, либо в резервный семафор.
- эффективный минимум — пол для eviction у user-пула,
равный
max(user.min_pool_size, pool.min_guaranteed_pool_size). Координатор защищает именно столько соединений на пользователя от выселения соседями. - прямая передача (direct handoff) — механизм доставки в Фазе 4. При возврате соединение отправляется напрямую старейшему зарегистрированному ожидающему, минуя idle-очередь. Соединение достаётся ровно одному адресату, конкуренции нет.
- Фаза R (reserve-first) — короткое замыкание координатора, вставленное между Фазой A и Фазой B. Когда база заполнена, а в резерве есть место, Фаза R выдаёт reserve-permit напрямую через арбитра, вместо того чтобы закрывать соседний backend или парковать клиента в Фазе C.
- жёсткий потолок Фазы 4 — каждый checkout выбирает случайный
потолок в диапазоне 300–500 ms. Верхняя граница времени ожидания
Фазы 4 anticipation, независимо от
query_wait_timeout. Не настраивается. Случайный разброс предотвращает синхронные таймауты, вызывающие лавину запросов в ограничитель всплесков. - арбитр резерва (reserve arbiter) — отдельный фоновый процесс,
владеющий reserve-permit-ами. Запросы на reserve ранжируются по
паре
(starving, queued_clients)и разгружаются из приоритетной очереди так, чтобы самые нуждающиеся пользователи получали permit первыми. - повышение из резерва в основной (reserve → main upgrade) — периодическая бухгалтерская перестановка. Когда idle-backend удерживает reserve-permit, а в основном семафоре есть запас, retain task забирает основной permit, возвращает резервный слот и переключает тип permit. Без переподключения.
- излишек над минимумом (spare_above_min) — текущий размер пула минус эффективный минимум, где текущий размер — это количество всех соединений пула (активные + idle вместе, а не только idle). Координатор использует это значение, чтобы выбрать жертву eviction: пул с самым большим излишком теряет соединение первым. Само соединение, чтобы его можно было выселить, всё равно должно быть свободным — излишек выбирает пул, а не конкретное соединение.
- голодающий пользователь (starving) — user-пул, у которого текущее число соединений ниже эффективного минимума. Арбитр резерва даёт starving-пользователям абсолютный приоритет перед обычными.
- пул под давлением (under pressure) — состояние, при котором все permit-ы пула заняты, то есть каждый слот сейчас используется. Retain task пропускает повышение/закрытие на таких пулах, потому что иначе слот просто перейдёт ожидающему клиенту.
- порог прогрева (warm threshold) —
pool_size × scaling_warm_pool_ratio / 100. Ниже этого размера пул пропускает упреждающее ожидание и идёт сразу вconnect(). Выше — упреждающее ожидание активно, и пул пытается ловить возвраты, прежде чем создавать новые backend-ы.