Архитектура бэкенда
Пулинг на масштабе: много инстансов, одна база и PgBouncer
Один сервис с пулом на 20 здоров. Затем он масштабируется: автоскейлинг гонит 50 инстансов под нагрузкой, каждый со своим пулом на 20. Это 1 000 соединений, затребованных от Postgres, настроенного с дефолтным max_connections = 100. База начинает отвергать соединения с FATAL: sorry, too many clients already, и отвержение бьёт по каждому инстансу, поэтому весь флот деградирует разом. Ничего не утекает, и ни один пул не сконфигурён неверно. Арифметика просто переполнила: каждый инстанс задал размер пула изолированно, но все они черпают из одного общего жёсткого лимита — и это проблема, ради которой существует распределённый пулинг.
Проблема fan-in
Пул на стороне клиента ограничивает соединения от одного инстанса. Он ничего не знает о других инстансах, указывающих на ту же базу. Реально важное ограничение глобально:
(число инстансов) × (размер пула на инстанс) ≤ max_connections базыс вычетом запаса на admin, репликацию и инструменты миграции. С Postgres по умолчанию max_connections = 100 и каждым соединением как реальным backend-процессом, потребляющим несколько мегабайт, нельзя просто поднять max_connections до 1 000 — база истратилась бы на память на процесс и планирование, ровно та конкуренция из урока про размер, теперь на масштабе самого сервера. Поэтому в горизонтально масштабируемом или автоскейлящемся флоте размер пула на инстанс должен быть крошечным (иногда 2–5), и даже тогда событие скейл-апа может взорвать бюджет. Это стена, толкающая к иной архитектуре.
PgBouncer: мультиплексор в середине
Пулер соединений вроде PgBouncer сидит между приложениями и Postgres. Приложения подключаются к PgBouncer — дёшево, потому что клиентское соединение PgBouncer лёгкое, не backend-процесс — а PgBouncer держит небольшой пул реальных соединений к Postgres, одалживая их по нужде. Это fan-in мультиплексор: тысячи клиентских соединений вливаются в десятки серверных. Типичные соотношения драматичны — PgBouncer может предъявлять тысячи клиентских соединений, держа лишь несколько десятков реальных backend-ов Postgres, фактор мультиплексирования часто называемый около 25–50× или больше. 1 000 затребованных флотом соединений становятся, скажем, 25 реальными backend-ами, и Postgres снова комфортен.
Транзакционный пулинг покупает соотношение — и ломает состояние сессии
PgBouncer предлагает режимы пулинга, и выбор — весь компромисс:
- Сессионный пулинг. Клиент держит реальное серверное соединение всю свою клиентскую сессию. Безопасно и прозрачно — всё работает — но соотношение мультиплексирования бедное, потому что реальный backend связан столько, сколько клиент остаётся подключённым. Немногим лучше, чем без пулера по числу соединений.
- Транзакционный пулинг. Реальное серверное соединение назначается лишь на длительность транзакции, затем возвращается в пул в миг коммита транзакции. Это и даёт огромное соотношение, потому что соединения держатся миллисекунды, не минуты. Цена: всё, что опирается на состояние сессии поперёк транзакций, ломается. Подготовленные выражения (серверные),
SETсессионных переменных,LISTEN/NOTIFY, advisory-блокировки и сессионные временные таблицы больше не работают, потому что следующее выражение может приземлиться на другой backend. Драйверы должны бежать в режиме, избегающем серверных подготовленных выражений, а код приложения не должен предполагать непрерывность сессии.
Почему это работает
Почему транзакционный пулинг даёт такое большое соотношение мультиплексирования, пока сессионный едва помогает? Потому что соотношение управляется тем, как долго каждый клиент занимает реальный backend. В сессионном пулинге клиент владеет backend-ом всё время, пока подключён — часто минуты или часы в основном простаивающего keep-alive — поэтому число реальных backend-ов должно быть близко к числу одновременно подключённых клиентов, ровно тому счёту, что ты пытался сжать. В транзакционном пулинге backend занят лишь пока транзакция реально исполняется, обычно несколько миллисекунд, затем передан следующему клиенту на лету; поскольку в любой миг лишь малая доля подключённых клиентов внутри активной транзакции, несколько десятков backend-ов обслуживают тысячи соединений. Соотношение по сути обратно скважности — доле времени, которую клиент проводит реально транзакируя против простоя. Тот же механизм — почему ломается состояние сессии: состояние вроде подготовленного выражения или SET живёт на конкретном backend, но транзакционный пулинг намеренно перемещает тебя на другой backend на следующей транзакции, чтобы держать скважность низкой. Нельзя иметь и максимум мультиплексирования, и стойкое состояние сессии на сервере — это одна монета, ведь мультиплексирование работает именно тем, что не даёт никакому клиенту удержать backend.
Serverless делает это острым
Проблема fan-in достигает крайности с serverless-функциями, где каждый конкурентный вызов может быть своим процессом, желающим своё соединение, и вызовы масштабируются до сотен во всплеске. Традиционный пулинг предполагает долгоживущий процесс, амортизирующий пул по многим запросам; функция, живущая 200 мс, не может. Ответы той же формы — внешний пулер (PgBouncer или управляемый эквивалент вроде serverless data-прокси), впитывающий всплеск короткоживущих клиентов в стабильный набор реальных backend-ов, плюс агрессивно малые лимиты на функцию. Принцип никогда не меняется: число соединений базы — жёсткий, общий, дорогой ресурс, и каждый слой, вливающийся в него, должен быть ограничен.
| Подход | Реальных backend-ов Postgres | Ёмкость клиентов | Оговорка |
|---|---|---|---|
| Только пулы на инстанс | инстансы × размер пула | Ограничено max_connections | Переполняет на скейл-апе |
| PgBouncer сессионный режим | ≈ конкурентных клиентов | Малое мультиплексирование | Безопасно, прозрачно, слабое соотношение |
| PgBouncer транзакционный режим | десятки | Тысячи (25–50×+) | Нет prepared / SET / LISTEN / advisory-блокировок |
| Serverless + data-прокси | стабильный малый набор | Всплеск короткоживущих функций | Лимит на функцию должен быть крошечным |
Сервис, здоровый на одном инстансе с пулом на 20, начинает кидать 'FATAL: sorry, too many clients already' после автоскейла до 50 инстансов. Утечек пула нет. Почему?
Почему транзакционный пулинг PgBouncer достигает куда большего соотношения мультиплексирования, чем сессионный?
Какова главная цена корректности при переходе на транзакционный пулинг PgBouncer?
- 01Почему горизонтально масштабированный флот перегружает базу, даже когда каждый отдельный пул верно размерен?
- 02Что такое PgBouncer и как его режимы пулинга идут на компромисс?
- 03Почему высокое соотношение транзакционного пулинга и его потеря состояния сессии — две стороны одного механизма, и почему serverless — острый случай?
Решённый случай — один пул против одной базы; сложный — fan-in, где N инстансов, каждый размеряющий пул изолированно, сталкиваются против одного max_connections — 50 инстансов × пул 20 = 1 000 против дефолтных 100 Postgres, отвергнутых по всему флоту с “FATAL: sorry, too many clients already” — и подъём max_connections не помогает, потому что каждое соединение — реальный backend-процесс, конкурирующий за память и планирование. Пулер соединений вроде PgBouncer мультиплексирует тысячи лёгких клиентских соединений в несколько десятков реальных backend-ов, и большое соотношение идёт именно от транзакционного пулинга, занимающего backend лишь на миллисекунды транзакции вместо всей сессии. Это та же монета, что его цена: поскольку следующая транзакция может бежать на другом backend, ломается состояние через сессию — серверные prepared-выражения, SET, LISTEN/NOTIFY, advisory-блокировки — пока сессионный пулинг их сохраняет, но едва мультиплексирует. Serverless гонит fan-in к крайности и заставляет внешний пулер плюс крошечные лимиты на функцию. Это закрывает юнит пулинга: соединение базы — жёсткий, общий, дорогой ресурс, ограниченный на каждом слое. Следующий юнит поворачивает от удержания ресурса к выживанию проваленного вызова — идемпотентность и повторы, где безопасное повторение запроса становится фундаментом устойчивости.