Давление на пул

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% jitter80-120ms
Устойчивый режим (steady state)xact_p99 × 2 ± 20% jitterp99=0.7ms → 5ms (min); p99=50ms → 100ms
Высокая latencyОграничено 500msp99=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_doorman9.9310.5010.691.08
pgbouncer8.489.6210.451.23
odyssey0.8812.9322.4625.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 координатора не касается.

Что добавляет координатор

Три вещи:

  1. Жёсткий лимит общего числа соединений к базе. Если 80 уже занято, 81-й запрос ждёт или падает независимо от того, какой пул его подал.

  2. Reserve pool (резервный пул). Когда общий лимит достигнут и у reserve_pool_size есть свободное место, координатор сразу выдаёт permit из резерва — небольшого дополнительного пула поверх max_db_connections, работающего как буфер под всплеск. Это Фаза R (reserve-first) в схеме ниже: ни одного соседнего backend не закрывается, ни одного ожидания не возникает. Резерв ограничен reserve_pool_size (по умолчанию 0, то есть выключен) и приоритизирован: голодающие пользователи (те, кто ниже своего эффективного минимума) и пользователи с большим числом ожидающих клиентов обслуживаются первыми через арбитра.

  3. 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 не нашла что выселить. Регистрируется подписка на уведомления, которая срабатывает на двух событиях:

  1. Был освобождён permit координатора соседнего пула — серверное соединение физически закрыто (истёк server_lifetime, ошибка проверки пригодности, RECONNECT), и слот семафора теперь свободен.
  2. Соседний пул вернул соединение в свою 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_ratio20 (процент)general, per-poolПорог, ниже которого соединения создаются без anticipation. Ниже pool_size × ratio / 100 каждый запрос нового соединения идёт сразу к connect().
scaling_fast_retries10general, per-poolЧисло быстрых повторных проверок пригодности в фазе anticipation перед переходом к прямой передаче (ожиданию возврата от соседа).
scaling_max_parallel_creates2generalЖёсткий лимит одновременно идущих backend-connect() на пул. Задачи сверх лимита ждут возврата idle-соединения или завершения чужого создания. Должен быть >= 1.
max_db_connectionsне задан (выключено)per-poolЛимит суммарного числа backend-соединений к базе по всем user-пулам. Когда не задан, координатор не создаётся.
min_connection_lifetime30000 (ms)per-poolМинимальный возраст idle-соединения, после которого координатор может выселить его в пользу другого пула. 30-секундный порог подавляет циклический reconnect между соседними пулами.
reserve_pool_size0 (выключено)per-poolДополнительные permit-ы координатора поверх max_db_connections, выдаваемые по приоритету при исчерпании основного пула.
reserve_pool_timeout3000 (ms)per-poolМаксимальное время ожидания координатора перед переходом к reserve pool.
min_guaranteed_pool_size0per-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 с проверкой сертификата или медленный lookup pg_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.

  1. Подтвердите фазу. Запустите 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_SCALING create_fallback и troubleshooting plain-режима.
  2. Включите reserve-first, если он ещё не включён. Задайте reserve_pool_size как минимум max(2, 0.1 × max_db_connections). Reserve-first выдаёт permit за sub-ms, когда в резерве есть место, так что клиент, раньше сидевший в Фазе C, платит только один round-trip к арбитру.
  3. Уменьшите reserve_pool_timeout до 2 × p99 query latency, но не ниже. Для запроса в 1 ms нижняя граница обычно 20 ms; начните с 50 ms и неделю наблюдайте reserve_acq и evictions.
  4. Оставьте min_connection_lifetime на дефолте 30 000 ms, если у вас нет явной цели ускорить кросс-пуловую ребалансировку; понижение увеличивает частоту eviction и churn соединений.

За чем следить после каждого изменения (все в SHOW POOL_COORDINATOR):

ДоПослеВердикт
reserve_acq не растётreserve_acq растётReserve-first подхватил — checkout latency должен упасть; ожидаемо
evictions стабиленevictions падаетФаза B перестала срабатывать, потому что Фаза R ловит вызывающего раньше; ожидаемо
exhaustions 0exhaustions > 0Перетянули: reserve_pool_timeout ниже реального времени возврата от соседа
reserve_used колеблется > 0reserve_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;
КолонкаТипЗначение
usertextПользователь пула
databasetextБаза пула
inflightgaugeВызовы connect() к бэкенду, выполняемые в этом пуле прямо сейчас. Ограничено scaling_max_parallel_creates.
createscounterСколько всего backend-соединений пул начинал создавать с момента старта. В паре с gate_waits используется для расчёта частоты попаданий на gate.
gate_waitscounterСколько раз вызов наткнулся на заполненный burst gate и был вынужден ждать слот. Высокие значения говорят, что scaling_max_parallel_creates слишком низкий.
antic_notifycounterПопытки anticipation в Фазе 4, где прямая передача удалась. Инкрементируется один раз на успешное получение, до проверки пригодности. Высокий antic_notify при низком create_fallback — хороший признак: прямая передача ловит возвраты, клиенты не платят за connect().
antic_timeoutcounterПопытки anticipation в Фазе 4, где ожидание истекло без получения соединения, либо бюджет был нулевой. Инкрементируется ровно один раз при каждом провале Фазы 4 в путь создания. Высокий antic_timeout означает, что клиенты упираются в query_wait_timeout, не успев получить соединение через прямую передачу.
create_fallbackcounterФаза 4 не получила соединение через прямую передачу: дедлайн исчерпан или бюджет был нулевой. Именно эти ожидания превращаются в новый connect(). Стабильно ненулевой create_fallback значит, что клиентского бюджета не хватает на перехват возвратов: пул либо мал, либо запросы длиннее query_wait_timeout.
replenish_defcounterЗапуски фонового replenish, упёршиеся в лимит burst gate и отложенные до следующего retain-цикла. Устойчиво ненулевые значения означают, что min_pool_size нельзя поддержать при текущей нагрузке.

Все счётчики монотонные с момента старта. Считайте дельты между скрейпами; абсолютные значения полезны только для расчёта соотношений.

Admin: SHOW POOL_COORDINATOR

Состояние координатора в разрезе каждой базы. Присутствует только для баз с max_db_connections > 0.

pgdoorman=> SHOW POOL_COORDINATOR;
КолонкаТипЗначение
databasetextИмя базы
max_db_conngaugeСконфигурированное max_db_connections
currentgaugeСколько всего backend-соединений сейчас удерживается под этим координатором (по всем user-пулам)
reserve_sizegaugeСконфигурированное reserve_pool_size
reserve_usedgaugeСколько reserve-permit-ов используется прямо сейчас. Сходится обратно к 0, когда в main есть свободное место — retain task каждые retain_connections_time апгрейдит idle reserve-permit-ы в main. Устойчивое ненулевое значение означает либо активный всплеск, либо базу, постоянно упёртую в max_db_connections.
evictionscounterСколько раз координатор выселил idle-соединение соседнего пула, чтобы освободить слот. С включённым reserve-first этот счётчик растёт только при реальном кросс-пуловом давлении — когда резерв заполнен и у соседа есть что выселить.
reserve_acqcounterСколько всего reserve-permit-ов выдал arbiter (Фаза R быстрый путь плюс Фаза D fallback суммарно)
exhaustionscounterСколько раз координатор вернул клиенту ошибку исчерпания. Это главный сигнал на пейджер.

Чтение вывода 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"}gaugeuser, databaseinflight из SHOW POOL_SCALING
pg_doorman_pool_scaling_total{type="creates_started"}counteruser, databasecreates
pg_doorman_pool_scaling_total{type="burst_gate_waits"}counteruser, databasegate_waits
pg_doorman_pool_scaling_total{type="burst_gate_budget_exhausted"}counteruser, databasegate_budget_ex — adaptive timeout сработал, клиент перешёл к созданию
pg_doorman_pool_scaling_total{type="anticipation_wakes_notify"}counteruser, databaseantic_notify
pg_doorman_pool_scaling_total{type="anticipation_wakes_timeout"}counteruser, databaseantic_timeout
pg_doorman_pool_scaling_total{type="create_fallback"}counteruser, databasecreate_fallback
pg_doorman_pool_scaling_total{type="replenish_deferred"}counteruser, databasereplenish_def
pg_doorman_pool_coordinator{type="connections"}gaugedatabasecurrent из SHOW POOL_COORDINATOR
pg_doorman_pool_coordinator{type="reserve_in_use"}gaugedatabasereserve_used
pg_doorman_pool_coordinator{type="max_connections"}gaugedatabasemax_db_conn
pg_doorman_pool_coordinator{type="reserve_pool_size"}gaugedatabasereserve_size
pg_doorman_pool_coordinator_total{type="evictions"}counterdatabaseevictions
pg_doorman_pool_coordinator_total{type="reserve_acquisitions"}counterdatabasereserve_acq
pg_doorman_pool_coordinator_total{type="exhaustions"}counterdatabaseexhaustions

Алерты для настройки

Алерты ниже покрывают режимы отказа, на которые стоит реагировать пейджером или варнингом. Они написаны на синтаксисе 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_waitingconnect() медленный со стороны бэкенда. Смотрите 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 оба пулят соединения, но давление обрабатывают по-разному.

АспектPgBouncerpg_doorman
Лимит размера в каждом пулеpool_sizepool_size
Лимит на уровне БД, общий для пуловmax_db_connections (жёсткий лимит, без eviction; для изоляции есть переопределения pool_size на базу или на пользователя)max_db_connections (жёсткий лимит плюс eviction между пулами и reserve pool)
Reserve poolreserve_pool_size, reserve_pool_timeoutreserve_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 важнее всего два различия:

  1. Bounded burst gate. Размер пула в PgBouncer ограничивает, сколько соединений у вас есть, но не ограничивает, сколько вызовов connect() выпускается одновременно, когда в один момент приходит много клиентов. pg_doorman ограничивает частоту одновременных connect() к бэкенду независимо от размера пула, поэтому внезапный всплеск трафика не превращается в connection storm к PostgreSQL.

  2. Cross-pool eviction. max_db_connections в PgBouncer задаёт жёсткий потолок и не умеет перераспределять. Если пользователь A держит 80 idle-соединений, а пользователю B нужно одно, но лимит уже выбран, пользователь B ждёт или падает. Координатор pg_doorman может закрыть одно из соединений A (если оно старше min_connection_lifetime) и отдать слот B.

  3. 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_sizemax_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.

Исправление. Пройдите по фазам по порядку.

  1. Сравните current и max_db_conn в SHOW POOL_COORDINATOR. Если current стабильно стоит на лимите, ваша нагрузка его превышает. Либо поднимайте max_db_connections, либо ищите разогнавшийся пул.
  2. Посмотрите на частоту evictions. Если она нулевая или близка к нулю, eviction не помогает: либо idle-соединения каждого пула моложе min_connection_lifetime (по умолчанию 30 000 ms), либо все остальные пулы стоят на своём min_guaranteed_pool_size. Понизьте min_connection_lifetime, если у вас очень короткие запросы и вы явно хотите более быстрый cross-pool rebalance, или увеличьте max_db_connections.
  3. Сравните reserve_used и reserve_size. Если резерв занят полностью, поднимите reserve_pool_size. Если резерв пустой, а exhaustions всё равно происходят, значит резерв не настроен (reserve_pool_size = 0). Задайте его, чтобы поглощать всплески.
  4. Посмотрите 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-ы.