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

Давление на пул — это поведение pg_doorman в момент, когда много клиентов одновременно просят серверное соединение, а очередь свободных соединений пуста. В этот момент пулер должен решить, кто получает соединение, кто ждёт, кто запускает новый connect() к PostgreSQL, а кому возвращается ошибка.

Внутри каждого пула (database, user) работают два механизма: упреждающее ожидание (anticipation) ждёт возврата соединения от соседа перед новым connect(), а ограничитель всплесков задаёт жёсткий лимит на одновременные подключения к PostgreSQL внутри одного пула. Если включён max_db_connections, поверх них работает координатор (coordinator) — общий лимит серверных соединений к одной базе данных для всех её пулов.

Аудитория: DBA или инженер эксплуатации, который уже знает PgBouncer и хочет понять, чем pg_doorman отличается и за чем нужно следить.

Зачем нужно давление на пул

Возьмём пул с pool_size = 40 и нагрузкой в 200 коротких транзакций, приходящих в одну и ту же миллисекунду. В пуле 4 свободных соединения. В наивном пулере первые 4 клиента забирают свободные соединения, а оставшиеся 196 независимо вызывают connect() к PostgreSQL. PostgreSQL получает 196 одновременных TCP-попыток подключения, на каждую из которых нужно выполнить SCRAM-аутентификацию и согласование параметров, только чтобы обнаружить, что пул разрешает ещё 36 соединений. Обращения к pg_authid взлетают всплеском, потолок max_connections пробивается, очередь accept() ядра насыщается, а хвостовая задержка (p99/p99.9, то есть редкие, но самые медленные запросы) уже подключённых клиентов растёт, потому что postmaster PostgreSQL занят порождением процессов вместо выполнения запросов. Это лавина одновременных подключений: ситуация, когда множество независимых задач одновременно реагируют на одно состояние и устраивают шторм одинаковых запросов к общему ресурсу. В нашем сценарии это 196 одновременных connect() к одному слушателю PostgreSQL вместо размазанного по времени потока.

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 одновременных подключений к PostgreSQL
   .             в один момент
Client_200 -[connect]-[auth]-[query]-[done]

PostgreSQL: 196 новых backend-процессов + 4 выполняющихся запроса

Давление на пул подавляет это поведение. pg_doorman заставляет большинство из этих 196 вызовов переиспользовать соединение, которое другой клиент вот-вот вернёт, либо подождать несколько миллисекунд за небольшим числом уже идущих подключений к PostgreSQL. Частота connect() к PostgreSQL остаётся ограниченной даже при всплесках клиентов.

Режим независимых пулов

Этот режим работает, когда max_db_connections не задан. Пулы независимы, общей координации между ними нет, давление управляется внутри каждого пула (database, user). Это режим по умолчанию, и большинство инсталляций находятся именно в нём.

Прогрев пула с холодного старта

Пул с pool_size = 40 и min_pool_size = 0 стартует с нулём соединений. Первый пришедший клиент не ждёт: pg_doorman сразу создаёт серверное соединение. Второй делает то же самое, третий тоже, пока пул не достигнет порога прогрева (warm threshold).

Порог прогрева равен pool_size × scaling_warm_pool_ratio / 100. При значении по умолчанию 20% и pool_size = 40 порог равен 8 соединениям. Ниже этого порога pg_doorman создаёт соединения сразу: пул холодный, цена ожидания выше цены нового подключения, и клиенты не могут конкурировать за свободные соединения, которых не существует.

Выше порога активируется зона упреждающего ожидания (anticipation zone): pg_doorman считает, что пул уже разогрет и кто-то из параллельных клиентов скоро вернёт занятое соединение, поэтому имеет смысл подождать его возврата вместо того, чтобы тратить ресурсы на новый connect(). Когда клиент не находит соединения в очереди свободных, pg_doorman сначала пытается перехватить такой возврат.

Третья зона накладывается поверх обеих: при любом размере пула, если число соединений, создаваемых прямо сейчас, достигает scaling_max_parallel_creates (по умолчанию 2), пул упирается в лимит ограничителя всплесков. Дополнительные вызовы ждут свободный слот независимо от того, сколько свободных соединений существует.

                        Three pressure zones
                        --------------------

Pool size:  0 ----------- 8 ---------------------------- 40
            ^             ^                              ^
            |             |                              |
            |  WARM ZONE  |  ANTICIPATION ZONE           |
            |             |                              |
            |  size <     |  size >= warm_threshold      |
            |  warm_thr   |                              |
            |             |                              |
            |  Пропускает |  Фаза 3: быстрый опрос       |
            |  фазы 3     |  Фаза 4: прямая передача     |
            |  и 4.       |   (ожидание возврата от       |
            |  Сразу к    |    соседа, дедлайн =          |
            |  фазе 5     |    query_wait_timeout         |
            |  (огранич.  |    − 500 ms)                  |
            |  + connect) |  Затем фаза 5                 |

                  Ограничение параллельных создаваемых соединений
                  (ортогонально размеру пула)
                  -----------------------------------------------

Создаваемых сейчас: 0 ---- 1 ---- 2 (= scaling_max_parallel_creates)
                                   ^
                                   |  На лимите: новый вызов встаёт
                                   |  в очередь на получение
                                   |  возвращённого соединения и ждёт
                                   |  завершения чужого создания.

Зоны прогрева и упреждающего ожидания отслеживают текущий размер пула. Отдельно работает ограничитель всплесков: жёсткий лимит на число одновременно идущих connect() к PostgreSQL внутри одного пула. Этот лимит срабатывает независимо от размера пула: пул может одновременно находиться в зоне упреждающего ожидания, при этом все слоты ограничителя всплесков уже заняты параллельными connect()-ами. Под нагрузкой это типичная ситуация. Пул ниже порога прогрева тоже может упереться в этот лимит, если во время холодного заполнения одновременно приходит много клиентов.

Получение соединения

Когда клиент запрашивает соединение через pool.get(), pg_doorman проходит по следующим фазам. Каждая фаза либо возвращает соединение, либо передаёт управление следующей фазе.

Фаза 1 — горячий путь recycle. Берём первое соединение из очереди свободных соединений (двусторонняя очередь свободных серверных соединений «голова очереди» — это то соединение, которое было возвращено раньше всех). Если оно проходит проверку пригодности (recycle), отдаём его клиенту. Проверка пригодности откатывает любые открытые транзакции, запускает проверку живости (liveness probe) если соединение простояло дольше server_idle_check_timeout, и сверяет поколение соединения с текущим поколением пула (reconnect-эпоха — счётчик, который увеличивается при admin-команде RECONNECT и при обнаруженных сбоях бэкенда). Соединения, созданные до увеличения счётчика, эту проверку не проходят и удаляются вместо возврата клиенту. Здоровый пул в установившемся режиме идёт только этим путём.

Фаза 2 — порог прогрева. Если размер пула ниже порога прогрева, пропускаем упреждающее ожидание и сразу переходим к созданию нового серверного соединения. Холодные пулы заполняются быстро.

Фаза 3 — быстрый опрос. Выше порога прогрева повторяем проверку пригодности до 10 раз в плотном цикле без пауз (контролируется параметром scaling_fast_retries). Так перехватывается случай, когда другой клиент завершил свой запрос в том же микросекундном диапазоне и вот-вот вернёт соединение. Полная стоимость порядка 10–50 микросекунд: без sleep и без блокирующего I/O.

Фаза 4 — прямая передача. Если быстрый опрос не поймал возврат, задача встаёт в очередь ожидающих. Когда любой клиент возвращает соединение, оно отправляется напрямую старейшему ожидающему, минуя очередь свободных соединений. Получатель забирает соединение без конкуренции с другими задачами — соединение никогда не попадает в общую очередь свободных соединений и достаётся ровно одному адресату.

Если передача удалась, соединение проходит проверку пригодности. При успехе соединение возвращается вызывающему. При ошибке (устаревший бэкенд) пул уменьшает текущий размер, и вызов переходит в путь создания нового соединения.

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

Дедлайн адаптивный: min(query_wait_timeout - 500 ms, adaptive_cap), где adaptive_cap вычисляется из реальной задержки транзакций:

Состояние пулаБюджетПример
Холодный старт (нет данных)100ms ± 20% jitter80-120ms
Устойчивый режим (steady state)xact_p99 × 2 ± 20% jitterp99=0.7ms → 5ms (min); p99=50ms → 100ms
Высокая задержкаОграничено 500msp99=300ms → 500ms

Фаза 1/2 ожидания семафора тратит из того же бюджета, поэтому суммарное ожидание не может увести клиента за его query_wait_timeout.

Случайный разброс (jitter) ±20% предотвращает обрыв таймаутов (timeout cliff): без него N клиентов, одновременно вошедших в Фазу 4, одновременно выходят из ожидания и лавиной входят в ограничитель всплесков, создавая N новых серверных соединений для пула, которому нужно значительно меньше. Со случайным разбросом клиенты выходят порциями — первые создают соединения, и к моменту выхода последних эти соединения уже использованы и возвращены в очередь свободных соединений для переиспользования.

Если дедлайн истёк без получения соединения, переходим к фазе 5.

Фаза 5 — ограничитель всплесков. Перед тем как пойти на новый connect(), задача должна получить слот в ограничителе — механизме, который параллельно пропускает не больше scaling_max_parallel_creates (по умолчанию 2) задач на пул. Если слот свободен, задача забирает его и идёт вызывать connect(). Если все слоты заняты, задача встаёт в очередь на прямую передачу и одновременно ожидает завершения чужого создания. Приоритет отдаётся прямой передаче: если соединение вернулось, пока задача ждала освобождения слота, оно доставляется напрямую — задача проверяет его пригодность и возвращает клиенту. Если передачи не было, задача снова пробует проверку пригодности и захват слота. Так лимитируется скорость рождения новых серверных соединений в одном пуле, а не размер самого пула.

Адаптивный таймаут ограничителя всплесков. Цикл ограничителя ограничен адаптивным бюджетом: xact_p99 × 2 ± 20% jitter (min 20 ms, max 500 ms). Если задача провела в цикле дольше бюджета, она прекращает ожидать прямую передачу и переходит к захвату слота ограничителя напрямую. Без этого механизма пул мог застрять на пороге прогрева навсегда: клиенты бесконечно получали переиспользованные соединения через прямую передачу, а до создания нового соединения дело не доходило. Счётчик burst_gate_budget_exhausted отслеживает срабатывания.

Фаза 6 — подключение к бэкенду. Запускаем connect(), аутентифицируемся, отдаём соединение клиенту. Слот ограничителя освобождается автоматически по завершении этой фазы независимо от исхода.

                  Получение соединения в режиме независимых пулов
                  -----------------------------------------------

   pool.get()
       |
       v
   +--------------+
   |  Фаза 1:     |  --- есть ----> вернуть свободное соединение
   |  recycle pop |
   +------+-------+
          | нет
          v
   +--------------+
   |  Фаза 2:     |  --- ниже порога ---> к фазе 5
   |  warm gate   |
   +------+-------+
          | выше порога
          v
   +--------------+
   |  Фаза 3:     |  --- есть ----> вернуть свободное соединение
   |  опрос       |
   +------+-------+
          | нет
          v
   +--------------+
   |  Фаза 4:     |  --- передача ----> вернуть соединение
   |  anticipate  |  --- таймаут -----> дальше
   |  передача    |
   +------+-------+
          |
          v
   +--------------+
   |  Фаза 5:     |  --- слот взят --> к фазе 6
   |  burst gate  |  --- нет слота --> ждать, повторить recycle
   +------+-------+
          |
          v
   +--------------+
   |  Фаза 6:     |
   |  connect()   | ----> вернуть новое соединение
   +--------------+

Подавление всплеска в действии

Тот же сценарий с 200 клиентами, но теперь в режиме независимых пулов и с 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, он просит либо сразу получить свободное соединение, либо запустить новый connect(), без ожидания. Фаза упреждающего ожидания и ожидание у ограничителя всплесков пропускаются. pg_doorman выполняет recycle на горячем пути, один раз пробует ограничитель всплесков, затем либо создаёт соединение, либо возвращает ошибку wait timeout.

Ограничение при включённом координаторе. Режим без ожидания пропускает только упреждающее ожидание и ожидание у ограничителя всплесков внутри пути в рамках одного пула. Если max_db_connections задан и фазы ожидания координатора (B–D) занимают время, такой вызов всё равно блокируется внутри coordinator.acquire() вплоть до reserve_pool_timeout (по умолчанию 3000 ms). Чтобы на базах под координатором соблюсти строгий дедлайн без ожидания, поставьте reserve_pool_timeout достаточно низким, чтобы он помещался в ваш бюджет.

Фоновый replenish

Когда задан min_pool_size, фоновая задача периодически дополняет пул до его минимума. Она использует тот же ограничитель всплесков, что и клиентский трафик. Эта задача не встаёт в очередь за занятым ограничителем: если ограничитель занят, она немедленно сдаётся и повторяет попытку на следующем retain-цикле (по умолчанию каждые 30 секунд, контролируется параметром retain_connections_time).

Логика такова: во время всплеска нагрузки клиенты уже насыщают ограничитель, создавая соединения, которые им нужны прямо сейчас. Если фоновый replenish будет конкурировать с ними за слоты, толку не будет: пул всё равно поднимется выше min_pool_size за счёт клиентских запросов на создание. При каждом таком отступлении фоновой задачи увеличивается счётчик replenish_deferred.

Следствие: под нагрузкой min_pool_size поддерживается в режиме по возможности: pg_doorman старается, но не обязуется держать прогретыми ровно столько соединений, если клиенты съедают весь бюджет. Если нужна жёсткая гарантия минимума, см. раздел Диагностика.

Прямая передача при возврате

Когда соединение возвращается, пул первым делом проверяет очередь ожидающих (direct-handoff). Если хотя бы один ожидающий зарегистрирован, соединение отправляется напрямую старейшему из них, минуя очередь свободных соединений. Ожидающие, чей вызывающий уже отвалился по таймауту, пропускаются: пул обнаруживает недоступного получателя и пробует следующего в очереди.

Если зарегистрированных ожидающих нет (типичный случай при высокой пропускной способности, когда каждая выдача соединения попадает в горячий путь), соединение кладётся в очередь свободных соединений и будит ближайшего клиента, ожидающего на Фазе 1/2.

В обоих случаях координатор (если настроен) уведомляется о возврате, чтобы ожидающие Фазы C из соседних пулов могли просканировать кандидатов для выселения. Ожидающие внутри того же пула получают соединения напрямую, а не через общее уведомление.

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. SLO «p99 < 15 мс» достижим с pg_doorman при этой нагрузке. С Odyssey те же настройки пула его нарушают. Единственный выход — запас по соединениям: увеличивать число соединений, пока даже невезучие клиенты укладываются.

  • Отсутствие голодания. При broadcast-notify клиент может проиграть гонку за пробуждение многократно. При прямой передаче соединение идёт ровно одному получателю, ожидающие с истёкшим дедлайном пропускаются. Нет лавины одновременных подключений, нет повторных проигрышей.

  • Предсказуемое планирование ёмкости. Когда p50 ≈ p99, удвоение числа клиентов примерно удваивает задержку. При соотношении хвоста 25x изменение нагрузки вызывает непредсказуемые всплески p99.

Теория очередей подтверждает: среди невытесняющих дисциплин планирования FIFO минимизирует дисперсию времени ожидания при том же среднем, что и LIFO. Среднее одинаково — разница только в хвосте.

Упреждающая замена при истечении lifetime

Когда настроен server_lifetime, серверные соединения закрываются по достижении индивидуального лимита (базовый ± 20% jitter). Закрытие означает, что в пуле на одно свободное соединение меньше — последующие выдачи соединения могут попасть в зону упреждающего ожидания или путь создания нового соединения, добавляя несколько миллисекунд к p99 во время кластеров истечения lifetime.

Упреждающая замена (pre-replacement) убирает этот всплеск задержки. Когда выдача соединения проверяет пригодность соединения и обнаруживает, что оно достигло 95% своего lifetime, фоновая задача создаёт замену и помещает её в очередь свободных соединений. Когда старое соединение отклоняется при 100% lifetime, следующая выдача соединения находит предсозданную замену через горячий путь — ноль ожидания.

Параллельно может работать до 3 упреждающих замен на пул. Во время окна перекрытия пул временно держит max_size + 3 соединений. Когда старые соединения умирают, текущий размер пула возвращается к max_size.

Условия, предотвращающие неконтролируемый рост:

УсловиеПредотвращает
Пул не под давлениемСоздание лишних при насыщении пула (старое соединение выживет, пропустив закрытие по lifetime)
Доля свободных соединений < 25%Замену в переразмеренном пуле, который должен сжаться
Запас координатора >= 2Захват последнего слота координатора у соседнего пула
Lifetime >= 60 sСрабатывание на коротких lifetime, где окно перекрытия слишком мало
Текущий размер <= max_size + capНакопление нескольких параллельных превышений
Лимит параллельных фоновых создаваемых (cap=3)Неограниченное число фоновых создаваемых соединений

Упреждающая замена срабатывает только при выдаче соединения (при проверке пригодности), не из фоновой задачи обслуживания. Свободные соединения, истекающие без выдачи клиенту, закрываются фоновой задачей без замены — так пул естественно сжимается при падении нагрузки.

Согласование лимита с PostgreSQL

Перед чтением про координатор проверьте, что число серверных соединений в худшем случае умещается в PostgreSQL. Без max_db_connections верхняя граница для одной базы:

N pools (users) × pool_size  =  верхняя граница серверных соединений

Пример с расчётом: три пула, у каждого pool_size = 40, без max_db_connections. В худшем случае получится 120 одновременных серверных соединений к этой базе, ограниченных только 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-соединения, репликацию и всплески.

Режим координатора

Режим координатора активируется, когда у пула задан max_db_connections. Он добавляет второй слой давления поверх того, что работает внутри каждого пула: общий семафор, который ограничивает суммарное число серверных соединений к базе по всем обслуживающим её пользовательским пулам. Без него единственным ограничением остаётся потолок N × pool_size из предыдущего раздела. С max_db_connections = 80 к базе одновременно может существовать только 80 соединений независимо от конфигурации пулов, и координатор решает, какие пулы могут расти.

При max_db_connections = 0 (по умолчанию) координатор не создаётся. Когда параметр задан, все механизмы режима независимых пулов, описанные выше, продолжают работать; координатор добавляет один шаг получения permit (разрешения на удержание слота в общем лимите на базу: пока permit не получен, серверное соединение создавать нельзя) на пути создания нового соединения. Переиспользование idle координатора не касается.

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

Три вещи:

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

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

  3. Выселение (eviction). Запасной путь на случай, когда резерв выключен (reserve_pool_size = 0) или уже полностью занят: координатор закрывает свободное соединение из пула другого пользователя, чтобы освободить основной слот. Кандидаты сортируются по p95 времени транзакции (по убыванию): медленные пулы отдают первыми, потому что лучше переносят стоимость пересоздания (1 ms pool wait добавляет 6.7% к 15 ms p95, но 104% к 0.96 ms p95). Излишек над эффективным минимумом — дополнительный признак среди пулов с похожим p95. Только соединения старше min_connection_lifetime (по умолчанию 30 000 ms) попадают в список. 30-секундный порог подавляет циклический reconnect между соседними пулами, которые по очереди воруют слоты друг у друга.

    Эффективный минимум для user-пула равен max(user.min_pool_size, pool.min_guaranteed_pool_size). Оба параметра защищают соединения от выселения; побеждает больший. Эффективный минимум уменьшается только при снижении того параметра, который сейчас задаёт максимум; снижение меньшего значения ничего не меняет.

Фазы получения слота в координаторе

Когда путь внутри пула доходит до шага создания нового соединения, координатор проходит шесть фаз. Первая фаза, выдавшая permit, завершает последовательность.

Фаза A — неблокирующая попытка (try-acquire). Вернуть permit сразу, если есть свободный слот, иначе немедленно сообщить об отсутствии. Если лимит не достигнут, забираем слот и возвращаемся.

Фаза R — сначала резерв (reserve-first). Фаза A установила, что база заполнена. До того как закрыть хоть одно соседнее серверное соединение, координатор проверяет, есть ли место в резервном пуле (reserve_in_use < reserve_pool_size). Если есть — сразу запрашивает permit у арбитра резерва. При успехе вызывающий получает reserve-permit без выселения, без закрытия соседнего бэкенда и без ожидания на connection_returned. В обычных условиях арбитр отвечает за доли миллисекунды.

Это путь, который держит p99 задержки низкой: reserve-permit стоит одного round-trip с арбитром, тогда как старая последовательность (Фаза B + Фаза C) могла заблокировать клиента на полный reserve_pool_timeout, даже если в резерве было свободно. Фаза R не работает при reserve_pool_size = 0 и проваливается в Фазу B, если арбитр отказывает (все reserve-permit-ы уже заняты, либо идёт гонка с другим вызывающим).

Фаза B — выселение. Выполняется, когда Фаза R не выдала permit: reserve_pool_size = 0, либо резервный семафор был полностью занят на момент проверки (reserve_in_use == reserve_pool_size), либо арбитр отказал. Обходим все остальные user-пулы той же базы, сортируем по p95 времени транзакции (по убыванию, медленные первые) с излишком (spare) как tiebreaker, и закрываем одно свободное соединение старше min_connection_lifetime у верхнего кандидата. Permit выселенного соединения освобождается синхронно, слот становится доступен сразу. Повторяем захват семафора. Если два вызова конкурируют, проигравший переходит к следующей фазе. p95 кешируется каждые 15 секунд (stats cycle) — сканирование читает одно кешированное значение на кандидата без блокировки гистограммы.

Фаза C — ожидание (wait). Выполняется, когда резерв отключён или полностью занят и Фаза B не нашла что выселить. Регистрируется подписка на уведомления, которая срабатывает на двух событиях:

  1. Был освобождён permit координатора соседнего пула — серверное соединение физически закрыто (истёк server_lifetime, ошибка проверки пригодности, RECONNECT), и слот семафора теперь свободен.
  2. Соседний пул вернул соединение в свою очередь свободных соединений — слот семафора НЕ освободился, но излишек над минимумом этого соседа только что вырос.

На каждое пробуждение Фаза C сначала пытается захватить свободный слот неблокирующей проверкой, и только если дешёвый путь не сработал — пробует выселение. Пробуждение от освобождения permit-а оставляет свободный слот в семафоре — дешёвый путь берёт его, и ни один соседний бэкенд не закрывается. Пробуждение от idle-return не освобождает слот напрямую, но могло вырастить излишек соседа, поэтому повторная попытка выселения находит кандидата, которого мгновение назад не было, освобождает permit соседа, и следующая неблокирующая проверка срабатывает. Этот порядок (дешёвый путь сначала, выселение потом) закреплён регрессионным тестом: будущий рефакторинг не сможет случайно вернуть закрытия соседних бэкендов на пробуждениях от 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. Разбор симптома есть в разделе «Диагностика».

Фаза D — повторная попытка резерва (reserve retry). Фаза R уже пробовала этот путь один раз. Фаза D повторяет попытку после того, как Фаза C исчерпала свой бюджет ожидания — на случай, если за время ожидания соседний держатель reserve-permit отпустил свой permit. Запросы ранжируются по паре (starving, queued_clients), где starving означает, что пул сейчас ниже своего эффективного минимума. Арбитр — это отдельный фоновый процесс, который раздаёт permit-ы резервного пула из приоритетной очереди.

Фаза E — ошибка (error). Если Фаза D тоже не выдала permit или резерв не настроен, клиент получает ошибку: all server connections to database 'X' are in use (max=N, ...).

Перенос соединения из резерва в основной лимит

reserve-permit — это буфер под всплеск, а не постоянное состояние. После того как всплеск прошёл, бэкенд с reserve-permit продолжает жить как обычное свободное соединение, но его permit координатора по-прежнему учитывается в reserve_in_use — даже когда current < max_db_connections и в основном семафоре есть свободные слоты. Без активного обслуживания SHOW POOL_COORDINATOR показывал бы занятый резерв при том, что реальная ёмкость для всплеска пустая, и следующему всплеску некуда расти.

retain-задача запускается каждые retain_connections_time (по умолчанию 30 секунд) и делает бухгалтерскую перестановку: для каждого пула, который не находится под давлением (см. определение ниже), он обходит очередь свободных соединений и для каждого бэкенда с reserve-permit пытается забрать permit из основного семафора.

Пул считается под давлением, когда его семафор пула имеет ноль свободных permit-ов. Одной колонки в SHOW POOLS, которая напрямую показывала бы состояние семафора, нет, и наблюдаемые колонки отстают от внутреннего состояния:

  • Строгий признак: sv_active == pool_size. Каждое активное серверное соединение держит permit, поэтому когда все серверы в пуле активны, все permit-ы заняты. Это направление строгое.
  • Слабый признак: cl_waiting > 0 означает, что как минимум один клиент находится внутри timeout_get — это часто означает, что семафор пуст, но клиент, который уже взял permit и припарковался в Фазе 4 упреждающего ожидания или Фазе C координатора, тоже числится ожидающим. Используйте как индикатор, не как доказательство.

retain-задача пропускает пулы под давлением по двум причинам: повышение в такой момент просто отдаёт слот ожидающему клиенту и не меняет reserve_used, а закрытие reserve-соединения заставит этого клиента делать свежий connect(). Очистка отработает на следующем цикле. При успехе reserve-permit возвращается в резервный семафор, reserve_in_use уменьшается на единицу, а тип permit у этого бэкенда переключается с резервного на основной. Никакого переподключения, никакого дёргания соседа. Обход прерывается на первом неудачном повышении в пуле: это доказывает, что основной семафор заполнен, и остальные reserve-permit-ы этого пула проверять бессмысленно. Тот же retain-цикл затем закрывает reserve-бэкенды, которые не удалось повысить до основного и которые простаивают дольше min_connection_lifetime.

При такой схеме reserve_in_use > 0 означает ровно одно: либо всплеск сейчас идёт, либо он закончился не более чем retain_connections_time назад. Исторический остаток резервной ёмкости сходится к нулю, как только в основном лимите появляется свободное место.

Получение слота координатора по требованию

Внутри пути получения соединения ограничитель всплесков работает до координатора. Это порядок just-in-time (JIT): permit координатора берётся только тогда, когда вызов уже занял слот ограничителя всплесков и готов вызвать connect().

Предыдущий порядок (координатор первый, потом ограничитель) вызывал фантомные permit-ы (phantom permits): N вызовов захватывали по permit координатора и вставали в очередь за ограничителем всплесков (cap=2). Реально соединения создавали только 2 вызова, но координатор видел N permit-ов в использовании и начинал выдавать reserve-permit-ы, хотя БД была далеко от предела.

С JIT-порядком в каждый момент permit-ы координатора держат не более max_parallel_creates вызовов. Остальные ждут слот ограничителя без расходования бюджета координатора.

Блокировка головы очереди (head-of-line blocking) снимается разделением координатора на быстрый и медленный путь. Быстрый — неблокирующая проверка доступности слота координатора внутри слота ограничителя (мгновенно). Если не прошла — вызов освобождает слот ограничителя, ждёт координатора (выселение / возврат от соседа), и затем снова занимает слот ограничителя.

        Координатор + получение соединения (JIT)
        ----------------------------------------

   pool.get()
       |
       v
   Фаза 1: recycle           --- есть ---> вернуть
       | нет
       v
   Фаза 2: порог прогрева    --- ниже ----+
       | выше порога                       |
       v                                    |
   Фаза 3: быстрый опрос     --- есть ---> вернуть
       | нет                               |
       v                                    |
   Фаза 4: прямая передача   --- есть ---> вернуть
       | дедлайн                           |
       v                                    |
       | <----------------------------------+
       v
   Фаза 5: ограничитель всплесков (scaling_max_parallel_creates)
              | слот получен
              v
   +-------------------------------+
   | JIT-захват координатора       |  только при max_db_connections > 0
   |  fast: неблокир. проверка     |  мгновенный ответ
   |  slow: отпустить слот         |  ожидание координатора (evict/return)
   |        → взять заново         |  затем продолжить create
   +------------+------------------+
                | permit получен
                v
   Фаза 6: server_pool.create()
                |
                v
                return new connection

Фазы пронумерованы так же, как в режиме независимых пулов. Захват координатора работает внутри слота ограничителя всплесков, когда max_db_connections > 0. В режиме независимых пулов он не работает.

Когда координатор настроен, но лимит не достигнут

Если max_db_connections = 80, а текущее использование 30, фаза A координатора всегда успешна. Фазы B–E никогда не запускаются. Поведение идентично режиму независимых пулов плюс одна атомарная инкрементация семафора на каждое новое соединение. Горячий путь (повторное использование свободного соединения) координатора вообще не касается, поэтому там у него нет измеримой стоимости. Платят только новые создания соединений, и платят ровно одной атомарной операцией.

По устройству координатор работает как лимит, а не очередь: он стоит ресурсов только когда вы упираетесь в потолок.

Фоновое пополнение под координатором

replenish берёт permit координатора через неблокирующую проверку доступности. Если база уже на лимите, replenish сдаётся и повторяет попытку на следующем retain-цикле. Та же логика, что и у ограничителя всплесков: фоновая задача не должна бороться с клиентским трафиком за скудные permit-ы.

Параметры тюнинга

Параметры масштабирования по умолчанию глобальные. Для scaling_warm_pool_ratio и scaling_fast_retries можно задать переопределение в каждом пуле. scaling_max_parallel_creates настраивается только глобально, переопределений на уровне пула для него нет.

ПараметрПо умолчаниюГдеЧто делает
scaling_warm_pool_ratio20 (процент)general, пулПорог, ниже которого соединения создаются без упреждающего ожидания. Ниже pool_size × ratio / 100 каждый запрос нового соединения идёт сразу к connect().
scaling_fast_retries10general, пулЧисло быстрых повторных проверок пригодности перед переходом к прямой передаче (ожиданию возврата от соседа).
scaling_max_parallel_creates2generalЖёсткий лимит одновременно идущих connect() к бэкенду на пул. Задачи сверх лимита ждут возврата свободного соединения или завершения чужого создания. Должен быть >= 1.
max_db_connectionsне задан (выключено)пулЛимит суммарного числа серверных соединений к базе по всем пользовательским пулам. Когда не задан, координатор не создаётся.
min_connection_lifetime30000 (ms)пулМинимальный возраст свободного соединения, после которого координатор может выселить его в пользу другого пула. 30-секундный порог подавляет циклический reconnect между соседними пулами.
reserve_pool_size0 (выключено)пулДополнительные permit-ы координатора поверх max_db_connections, выдаваемые по приоритету при исчерпании основного пула.
reserve_pool_timeout3000 (ms)пулМаксимальное время Фазы C перед повторной попыткой резерва или ошибкой.
min_guaranteed_pool_size0пулМинимум на пользователя, защищённый от выселения координатором. Соединения пользователя, у которого 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 с проверкой сертификата или медленный поиск в pg_authid);
  • в логах PostgreSQL появляется конкуренция за pg_authid;
  • бэкенд жалуется на переполнение очереди accept().

Симптом слишком низкого значения: частота burst_gate_waits растёт быстрее, чем частота прихода клиентов. Симптом слишком высокого: задержка connect() к PostgreSQL растёт, и возвращается шторм подключений: много одновременных connect() к бэкенду после лавины клиентских запросов.

Размер при множестве пулов. Суммарный потолок одновременных подключений равен N pools × scaling_max_parallel_creates. Если за одним PostgreSQL стоит 10 пулов и в любой момент суммарно по ним всем вам нужно не более 8 одновременных подключений к PostgreSQL, поставьте 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 остаётся единственным ограничением;
  • вам не нужно выселение между пулами (некоторые нагрузки предпочитают жёсткую изоляцию между пользователями).

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 короткий: в этом случае лучше быстро дойти до повторной попытки резерва или ошибки, чем блокировать клиентов на ожидании координатора.

Рецепт тюнинга: снизить p99 выдачи соединения на базе под координатором

Профиль нагрузки: PostgreSQL отвечает за ~1 ms (низкий p99 длительности запроса), но клиенты видят p99 задержки выдачи соединения 100–500 ms на базе под координатором. Задержка идёт от координатора, а не от PostgreSQL.

  1. Подтвердите фазу. Запустите SHOW POOL_COORDINATOR в момент всплеска задержки. Вычислите main_used = current - reserve_used: current включает reserve-permit-ы, а рецепт зависит от того, заполнен ли именно основной семафор.
    • main_used == max_db_conn и exhaustions не растёт → доминирует фаза ожидания. Клиент тратит свой бюджет в Фазе C перед переходом в Фазу D. Переходите к шагу 2.
    • main_used < max_db_conn без exhaustions → задержка идёт не от координатора. Смотрите SHOW POOL_SCALING create_fallback и раздел «Диагностика» для режима независимых пулов.
  2. Включите reserve-first, если он ещё не включён. Задайте reserve_pool_size как минимум max(2, 0.1 × max_db_connections). Reserve-first выдаёт permit меньше чем за миллисекунду, когда в резерве есть место, так что клиент, раньше сидевший в Фазе C, платит только один round-trip к арбитру.
  3. Уменьшите reserve_pool_timeout до 2 × p99 задержки запроса, но не ниже. Для запроса в 1 ms нижняя граница обычно 20 ms; начните с 50 ms и неделю наблюдайте reserve_acq и evictions.
  4. Оставьте min_connection_lifetime на дефолте 30 000 ms, если у вас нет явной цели ускорить кросс-пуловую ребалансировку; понижение увеличивает частоту выселений и повторных подключений.

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

ДоПослеВердикт
reserve_acq не растётreserve_acq растётReserve-first подхватил — задержка выдачи соединения должна упасть; ожидаемо
evictions стабиленevictions падаетФаза B перестала срабатывать, потому что Фаза R ловит вызывающего раньше; ожидаемо
exhaustions 0exhaustions > 0Перетянули: reserve_pool_timeout ниже реального времени возврата от соседа
reserve_used колеблется > 0reserve_used возвращается к 0 за 30 сПуть повышения из резерва в основной лимит работает; делать ничего не надо

Если p99 выдачи соединения не упал после шагов 2–3, путь не ограничен координатором. Перечитайте SHOW POOL_SCALING по пострадавшему пулу: create_fallback > 0 означает, что сам пул не может обслужить нагрузку из возвратов, и лечить нужно pool_size, а не reserve_pool_size.

Нижняя граница. Не опускайте reserve_pool_timeout ниже 2 × ваш p99 задержки запроса. Ниже этого порога фаза ожидания всегда истекает раньше, чем соседний пул вернёт соединение, и резерв превращается из клапана переполнения в обязательный permit для каждого нового соединения. reserve-permit-ов по замыслу мало, и использовать их как постоянный источник означает сломать их назначение.

Ловушка: query_wait_timeout < reserve_pool_timeout. Когда дедлайн клиента короче фазы ожидания координатора, клиент сдаётся первым, и вы видите ошибки wait timeout вместо более наглядной all server connections to database 'X' are in use. Фазы wait и reserve координатора отрабатывают полностью, но к этому моменту клиента, которому нужен результат, уже нет. На старте валидатор конфига pg_doorman выдаёт предупреждение; реагируйте на него.

Мониторинг

pg_doorman экспортирует состояние давления на пул через admin-консоль и через Prometheus. Оба показывают одни и те же счётчики; выбирайте то, что подходит вашему стеку мониторинга.

Консоль администратора: SHOW POOL_SCALING

Счётчики пути упреждающего ожидания и ограничителя всплесков в разрезе каждого пула. Подключитесь к admin-базе pgdoorman и выполните:

pgdoorman=> SHOW POOL_SCALING;
КолонкаТипЗначение
usertextПользователь пула
databasetextБаза пула
inflightgaugeВызовы connect() к бэкенду, выполняемые в этом пуле прямо сейчас. Ограничено scaling_max_parallel_creates.
createscounterСколько всего серверных соединений пул начинал создавать с момента старта. В паре с gate_waits используется для расчёта частоты попаданий в ограничитель.
gate_waitscounterСколько раз вызов наткнулся на заполненный ограничитель всплесков и был вынужден ждать слот. Высокие значения говорят, что scaling_max_parallel_creates слишком низкий.
gate_budget_excounterСколько раз адаптивный бюджет ограничителя истёк, и клиент перестал ждать прямую передачу перед созданием соединения.
antic_notifycounterПопытки упреждающего ожидания в Фазе 4, где прямая передача удалась. Инкрементируется один раз на успешное получение, до проверки пригодности. Высокий antic_notify при низком create_fallback — хороший признак: прямая передача ловит возвраты, клиенты не платят за connect().
antic_timeoutcounterПопытки упреждающего ожидания в Фазе 4, где ожидание истекло без получения соединения, либо бюджет был нулевой. Инкрементируется ровно один раз при каждом провале Фазы 4 в путь создания. Высокий antic_timeout означает, что клиенты упираются в query_wait_timeout, не успев получить соединение через прямую передачу.
create_fallbackcounterФаза 4 не получила соединение через прямую передачу: дедлайн исчерпан или бюджет был нулевой. Именно эти ожидания превращаются в новый connect(). Стабильно ненулевой create_fallback значит, что клиентского бюджета не хватает на перехват возвратов: пул либо мал, либо запросы длиннее query_wait_timeout.
replenish_defcounterЗапуски фонового replenish, упёршиеся в лимит ограничителя всплесков и отложенные до следующего retain-цикла. Устойчиво ненулевые значения означают, что min_pool_size нельзя поддержать при текущей нагрузке.

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

Консоль администратора: SHOW POOL_COORDINATOR

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

pgdoorman=> SHOW POOL_COORDINATOR;
КолонкаТипЗначение
databasetextИмя базы
max_db_conngaugeСконфигурированное max_db_connections
currentgaugeСколько всего серверных соединений сейчас удерживается под этим координатором (по всем пользовательским пулам)
reserve_sizegaugeСконфигурированное reserve_pool_size
reserve_usedgaugeСколько reserve-permit-ов используется прямо сейчас. Сходится обратно к 0, когда в основном лимите есть свободное место — retain-задача каждые retain_connections_time повышает свободные reserve-permit-ы до основных. Устойчивое ненулевое значение означает либо активный всплеск, либо базу, постоянно упёртую в max_db_connections.
evictionscounterСколько раз координатор выселил свободное соединение соседнего пула, чтобы освободить слот. С включённым reserve-first этот счётчик растёт только при реальном кросс-пуловом давлении — когда резерв заполнен и у соседа есть что выселить.
reserve_acqcounterСколько всего reserve-permit-ов выдал арбитр (Фаза 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 нет. Алерты здесь должны молчать.

После всплеска, повышение в процессе:

 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 означает, что в основном лимите есть место, поэтому retain-задача повысит эти три permit-а до основных на следующем цикле; reserve_used должен упасть до 0 в течение retain_connections_time (по умолчанию 30 секунд). Если не падает, смотрите раздел «Диагностика» ниже. evictions = 0 и reserve_acq > 0 вместе подтверждают, что reserve-first поглотил всплеск без закрытия соседних бэкендов.

Устойчивая перегрузка:

 database | max_db_conn | current | reserve_size | reserve_used | evictions | reserve_acq | exhaustions
----------+-------------+---------+--------------+--------------+-----------+-------------+-------------
 mydb     |          80 |      95 |           20 |           15 |       300 |         500 |           0

Основной лимит полностью занят (main_used = current - reserve_used = 80, равно max_db_conn), резерв использован на 75%, много выселений, много выдач из резерва. База не просто иногда под давлением — она постоянно недоразмерена и выживает только за счёт того, что выселение ротирует соединения между пользователями, а 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 — сработал адаптивный таймаут, клиент перешёл к созданию
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).

Каждый алерт ниже содержит блок Проверка с одной диагностической командой и двумя-тремя ветвями, привязанными к конкретным значениям счётчиков.

Исчерпан лимит координатора (page). Клиент получил ошибку "database exhausted". Жёсткий отказ: не сработали ни резерв, ни выселение.

rate(pg_doorman_pool_coordinator_total{type="exhaustions"}[5m]) > 0

Проверка:

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, чтобы защитить соседей.

Ограничитель всплесков насыщен (warn). Ограничитель ждёт чужие создания чаще, чем проходит напрямую. Короткие всплески выше порога при 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])

Проверка:

psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_SCALING'
  • inflight_creates сидит на лимите и в SHOW POOLS видны клиенты в cl_waitingconnect() медленный со стороны бэкенда. Смотрите раздел «Ограничитель всплесков как узкое место даже при низком трафике» перед тем, как поднимать лимит.
  • inflight_creates ходит ниже лимита, но gate_waits растёт → много коротких всплесков. Поднимайте scaling_max_parallel_creates, оставаясь в пределах потолка из раздела тюнинга.
  • Горит только один пул → рассмотрите min_guaranteed_pool_size для соседей или уменьшите pool_size у горячего.

Частый переход к созданию после упреждающего ожидания (warn). Фаза 4 упреждающего ожидания сдаётся, не поймав возврат, и проваливается в свежий 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

Проверка:

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 секунды) → дедлайн упреждающего ожидания (query_wait_timeout − 500 ms с верхней границей 500 ms) слишком короткий даже для нормальных возвратов. Поднимайте query_wait_timeout минимум до 2 × p99 длительности запроса.

Фоновое пополнение постоянно откладывается (warn). Фоновая задача не может поддерживать min_pool_size, потому что ограничитель всплесков занят клиентским трафиком.

increase(pg_doorman_pool_scaling_total{type="replenish_deferred"}[1h]) > 60

Проверка:

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 стабильно стоит на лимите → ограничитель полон по другой причине (медленный connect()); сначала это.

Резервный пул постоянно используется (warn). Метрика reserve-permit не возвращался к нулю в течение 15 минут. retain-задача повышает свободные reserve-permit-ы до основных каждые retain_connections_time (по умолчанию 30 секунд), поэтому алерт означает, что путь повышения не в состоянии отработать или получить успех, а не что он забыл запуститься.

min_over_time(pg_doorman_pool_coordinator{type="reserve_in_use"}[15m]) > 0

Проверка:

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 → основной лимит полностью занят, повышению негде забрать слот. База недоразмерена; поднимайте max_db_connections.
  • main_used < max_db_conn и каждый пул в SHOW POOLS имеет sv_active == pool_size (или cl_waiting > 0 как индикатор) → все пулы под давлением, retain-задача пропускает повышение. Поднимайте pool_size у того пула, у которого самый высокий cl_waiting или самое плотное соотношение sv_active / pool_size.
  • main_used < max_db_conn и ни у одного пула нет ни того, ни другого признака, а метрика всё равно ненулевая → заведите баг со снапшотами SHOW POOL_COORDINATOR и SHOW POOLS; этого не должно быть.

Координатор приближается к лимиту (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

Проверка:

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 соседям.

Создание соединений застряло на лимите (warn). inflight_creates, сидящий на сконфигурированном лимите больше 5 минут, означает, что вызовы connect() не завершаются.

min_over_time(pg_doorman_pool_scaling{type="inflight_creates"}[5m])
  >= 2  # подставьте своё scaling_max_parallel_creates

Проверка:

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 → подключение к PostgreSQL медленный. Проверьте 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.

Координатор постоянно выселяет соединения (warn). Лимит исчерпан и идут выселения: координатор постоянно закрывает соседние соединения, чтобы освободить место. Недоразмеренный пул, а не "иногда под давлением".

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

Проверка:

psql -h 127.0.0.1 -p 6432 -U admin pgdoorman -c 'SHOW POOL_COORDINATOR'
  • evictions растёт и reserve_used == 0 → резерв выключен или исчерпан, выселение — единственный клапан сброса. Включите / поднимите reserve_pool_size, чтобы всплеск поглощался без закрытия соседних бэкендов.
  • 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):

# Сначала пул с максимальной долей ожиданий на ограничителе.
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, $10 }' \
  | sort -k3 -nr | head

# Сначала пул с максимальной долей fallback после дедлайна.
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, $9/$4, $4, $9 }' \
  | sort -k3 -nr | head

# Координатор: базы, ближайшие к исчерпанию.
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 (жёсткий лимит, без выселения; для изоляции есть переопределения pool_size на базу или на пользователя)max_db_connections (жёсткий лимит плюс выселение между пулами и резервный пул)
Резервный пулreserve_pool_size, reserve_pool_timeoutreserve_pool_size, reserve_pool_timeout (плюс приоритизация в арбитре по starving/queued)
Выселение между пользователямиНе поддерживается. Пользователь, удерживающий свободные соединения, морит голодом соседа, которому они нужны.Координатор выселяет свободные соединения у пользователя с наибольшим излишком над эффективным минимумом (max(user.min_pool_size, min_guaranteed_pool_size)).
Одновременные connect() к бэкенду в одном пулеОднопоточный, обрабатывает события последовательно в пределах пула, вызовы connect() выпускаются по одному.Ограничено scaling_max_parallel_creates (по умолчанию 2 на пул): не больше N одновременных подключений к PostgreSQL в пуле, лишние задачи ждут на ограничитель всплесков.
Упреждающее ожидание возвратовНет. Клиенты ждут следующего доступного соединения в порядке прихода, в пределах wait_timeout.Возвращающееся соединение будит ровно одного из ожидающих в очереди, часто ещё до того, как будет выпущен хоть один новый connect().
Прогрев min_pool_sizeПоддерживается на каждом такте цикла событий (без отдельной задачи replenish).Периодический фоновый replenish (retain_connections_time, по умолчанию 30 s), который отступает, когда ограничитель всплесков занят.
Повторный логин после ошибки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.Арбитр резерва оценивает запросы по (starving, queued_clients).
Мониторинг давления на новые соединенияSHOW POOLS, SHOW STATS. Текущие inflight-коннекты и результаты anticipation не видны вовсе.SHOW POOL_SCALING и SHOW POOL_COORDINATOR показывают каждый счётчик нового кодового пути.

В промышленной эксплуатации важнее всего три различия:

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

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

  3. FIFO прямая передача. PgBouncer ставит клиентов в порядке прихода и отдаёт следующее свободное соединение, но PgBouncer обрабатывает события на одном потоке — под нагрузкой порядок зависит от обработчиков libevent. pg_doorman отправляет возвращённые соединения напрямую каждому ожидающему в строгом FIFO-порядке. Результат — p99/p50 в пределах 1.1x при любом числе клиентов, тогда как пулеры без строгого FIFO показывают 10-25x раздувание хвоста при той же нагрузке.

Диагностика

В логах одновременно несколько подключений к бэкенду

Симптом. В логах сервера (или в debug-логах pg_doorman) видно 5 или больше событий connect() к бэкенду в одну и ту же миллисекунду. Кажется, что ограничитель всплесков не работает.

Причина. Либо scaling_max_parallel_creates выставлен слишком высоко (проверьте SHOW CONFIG или ваш pg_doorman.yaml), либо существует 5 или больше пулов, каждый из которых независимо открывает одновременные подключения (ограничитель работает в пределах одного пула, а не глобально).

Исправление. Понизьте 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 откладывается, потому что ограничитель всплесков занят клиентским трафиком. Проверьте replenish_def в SHOW POOL_SCALING. Если он продолжает расти, replenish пропускает каждый retain-цикл.

Исправление. Так задумано: под нагрузкой ограничитель отдан клиентским запросам на создание. Пул дойдёт до 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 задержки растёт без видимой причины

Симптом. p99 клиентской задержки растёт, p50 остаётся ровным. Размер пула выглядит нормально, в логах нет ошибок.

Причина. Фаза 4 упреждающего ожидания (прямая передача) держит клиентов в ожидании возврата до query_wait_timeout - 500 ms, но возвраты приходят медленнее, чем клиент готов ждать. Ожидающие либо получают соединение через прямую передачу, либо проваливаются в путь создания по истечении бюджета. Проверьте create_fallback в SHOW POOL_SCALING: если он ненулевой и растёт, клиенты переходят к новому connect() после исчерпания бюджета Фазы 4.

Исправление. Сверьте 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 растёт пропорционально обороту пула: прямая передача работает, возвраты перехватываются, лишних 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"} растёт.

Причина. Все пять фаз координатора провалились: неблокирующая попытка не взял слот, выселять нечего, ожидание истекло, а резерв либо исчерпан, либо reserve_pool_size = 0.

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

  1. Сравните current и max_db_conn в SHOW POOL_COORDINATOR. Если current стабильно стоит на лимите, ваша нагрузка его превышает. Либо поднимайте max_db_connections, либо ищите разогнавшийся пул.
  2. Посмотрите на частоту evictions. Если она нулевая или близка к нулю, выселение не помогает: либо свободные соединения каждого пула моложе 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-permit до основного permit держался за своим бэкендом, пока тот не доживёт до min_connection_lifetime и retain-цикл не поймает его свободным. При устойчивом клиентском трафике last_used() на бэкенде обновлялся быстрее, чем min_connection_lifetime, и permit никогда не отпускался.

Исправление. В текущих сборках это решается автоматически: retain-задача каждые retain_connections_time (по умолчанию 30 секунд) запускает повышение reserve-permit-ов до основных. Для каждого reserve-бэкенда в пуле без давления permit меняется с резервного на основной, если в основном семафоре есть свободное место. Метрика reserve_used должна упасть до нуля в течение одного retain-цикла.

Если reserve_used всё равно не уходит, значит пул либо под устойчивым давлением (повышение пропускается, когда пул под давлением — и это правильно, иначе ожидающий клиент тут же заберёт освободившийся слот), либо current == max_db_connections (нет main-слота, который можно забрать). Оба случая означают, что база честно исчерпана; лечение — больше ёмкости, а не обход.

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

Симптом. Частота gate_waits заметная, но частота creates низкая, а inflight_creates всё время стоит на лимите.

Причина. connect() к бэкенду медленный. Каждое создание держит слот по нескольку секунд; даже с двумя слотами вы создадите всего около 2 / connect_seconds соединений в секунду.

Исправление. Разберитесь, почему connect() медленный со стороны PostgreSQL (слишком много SCRAM-итераций, конкуренция за блокировки pg_authid, медленный DNS, SSL handshake). Когда connect() станет быстрым, ограничитель перестанет быть узким местом. Поднятие scaling_max_parallel_creates лишь маскирует проблему и перенесёт шторм на 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, краш, плановое окно). Видна толпа клиентов, бьющих в ограничитель всплесков, inflight_creates стоит на лимите, частота creates_started резко растёт.

Причина. Когда pg_doorman замечает непригодный бэкенд (через server_idle_check_timeout или упавший запрос), он повышает reconnect-эпоху пула и сразу сливает все свободные соединения. Горячий путь recycle становится пустым, и каждый пришедший после слива клиент идёт по маршруту anticipation, ограничитель всплесков, connect. При scaling_max_parallel_creates = 2 каждый пул прирастает максимум двумя соединениями за раз, и скорость ограничена задержкой connect() к PostgreSQL.

Как выглядит здоровое восстановление. В первые несколько секунд inflight_creates = 2 непрерывно, creates_started быстро растёт, burst_gate_waits растёт вместе с ней. По мере того как новые соединения начинают циркулировать, anticipation_wakes_notify разгоняется, а create_fallback перестаёт расти: прямая передача ловит возвраты внутри клиентского query_wait_timeout и новые connect() уже не нужны. За время pool_size / 2 × connect() секунд пул возвращается в норму.

Исправление. Обычно никакого. Ограничитель всплесков делает свою работу: гасит шторм подключений к восстанавливающемуся primary, чтобы тот не получил сотню одновременных коннектов на и без того перегруженный postmaster. Если connect() действительно быстрый (< 50 ms), и у вашего max_connections есть запас, поднимите scaling_max_parallel_creates до 4 или 8, чтобы сократить восстановление, но оставайтесь в пределах жёсткого потолка из раздела тюнинга.

Глоссарий

  • ограничитель всплесков — ограничитель на уровне пула, пропускающий не более scaling_max_parallel_creates одновременных вызовов connect() к бэкенду. Задачи сверх лимита встают в очередь на прямую передачу и ожидают завершения чужого создания, пока слот не освободится.
  • permit координатора (coordinator permit) — разрешение на удержание одного слота в общем лимите координатора. Может быть основным (main) или резервным (reserve). Освобождается, когда серверное соединение физически уничтожается (а не когда оно возвращается в очередь свободных соединений); при освобождении слот возвращается либо в основной, либо в резервный семафор.
  • эффективный минимум — нижняя граница для выселения у пользовательского пула, равная max(user.min_pool_size, pool.min_guaranteed_pool_size). Координатор защищает именно столько соединений на пользователя от выселения соседями.
  • прямая передача — механизм доставки в Фазе 4. При возврате соединение отправляется напрямую старейшему зарегистрированному ожидающему, минуя очередь свободных соединений. Соединение достаётся ровно одному адресату, конкуренции нет.
  • Фаза R (reserve-first) — короткое замыкание координатора, вставленное между Фазой A и Фазой B. Когда база заполнена, а в резерве есть место, Фаза R выдаёт reserve-permit напрямую через арбитра, вместо того чтобы закрывать соседний бэкенд или парковать клиента в Фазе C.
  • жёсткий потолок Фазы 4 — каждая выдача соединения выбирает случайный потолок в диапазоне 300–500 ms. Верхняя граница времени ожидания Фазы 4 упреждающего ожидания, независимо от query_wait_timeout. Не настраивается. Случайный разброс предотвращает синхронные таймауты, вызывающие лавину запросов в ограничитель всплесков.
  • арбитр резерва (reserve arbiter) — отдельный фоновый процесс, владеющий reserve-permit-ами. Запросы на резерв ранжируются по паре (starving, queued_clients) и разгружаются из приоритетной очереди так, чтобы самые нуждающиеся пользователи получали permit первыми.
  • повышение из резерва в основной (reserve → main upgrade) — периодическая бухгалтерская перестановка. Когда свободный бэкенд удерживает reserve-permit, а в основном семафоре есть запас, retain-задача забирает основной permit, возвращает резервный слот и переключает тип permit. Без переподключения.
  • излишек над минимумом (spare_above_min) — текущий размер пула минус эффективный минимум, где текущий размер — это количество всех соединений пула (активные + свободные вместе, а не только свободные). Координатор сначала сортирует кандидатов по p95 времени транзакции, а излишек использует как дополнительный признак среди похожих пулов. Само соединение, чтобы его можно было выселить, всё равно должно быть свободным — излишек выбирает пул, а не конкретное соединение.
  • голодающий пользователь (starving) — пользовательский пул, у которого текущее число соединений ниже эффективного минимума. Арбитр резерва даёт starving-пользователям абсолютный приоритет перед обычными.
  • пул под давлением (under pressure) — состояние, при котором все permit-ы пула заняты, то есть каждый слот сейчас используется. retain-задача пропускает повышение/закрытие на таких пулах, потому что иначе слот просто перейдёт ожидающему клиенту.
  • порог прогрева (warm threshold)pool_size × scaling_warm_pool_ratio / 100. Ниже этого размера пул пропускает упреждающее ожидание и идёт сразу в connect(). Выше — упреждающее ожидание активно, и пул пытается ловить возвраты, прежде чем создавать новые бэкенды.