Пулы на serverless: проблема умножения, transaction mode в pgbouncer и размер пула на инстанс
У каждой lambda свой пул: 100 инвокаций по 5 соединений — это 500 против max_connections 100, FATAL: too many clients. Пул на инстанс сжимается до 1-2; лестница лечений: pgbouncer в transaction mode с его ценой, RDS Proxy, HTTP-драйверы. Холодные старты дозваниваются стадом.
Утро запуска. Маркетинговая рассылка в 09:00 приземляется, трафик прыгает в сорок раз — ровно то, под что закладывались: serverless масштабируется вширь, база взята с запасом на два размера, нагрузочные тесты пройдены. В 09:02 трекер ошибок заливает фразой, которую никто в команде не видел: FATAL: sorry, too many clients already. Дашборд делает картину страннее — CPU Postgres на 8 процентах. База не перегружена; она заполнена. Каждый инстанс функции прилежно создал собственный пул из пяти соединений, платформа отмасштабировалась до сотни с лишним конкурентных инстансов, и 500 попыток подключения промаршировали в max_connections = 100. Ретраи удвоили стадо. Лечением тем утром была не база побольше — а понимание, что на serverless каждая ваша интуиция о размере пула умножается на число, которое вы не контролируете.
Проблема умножения
На serverful-деплое мудрость пулинга устоялась: три app-сервера с пулом по 20 держат 60 тёплых соединений и стабильны годами. Serverless ломает допущение, спрятанное в этой арифметике, — что владельцев пула мало и их число фиксировано. Инстанс в духе lambda обслуживает один запрос за раз, а платформа масштабируется добавлением инстансов; пул живёт на процесс, и процессы не могут делить сокеты. Так что пул из 5 на инстанс не покупает почти ничего — один запрос редко использует пять соединений сразу, — но каждый инстанс всё равно держит пять слотов. Арифметика для доски: пиковые конкурентные инстансы × размер пула на инстанс должны оставаться меньше max_connections минус зарезервированные слоты. С дефолтами Postgres потолок — 100, минус superuser_reserved_connections (3) и всё, что держат миграции, крон-джобы и дашборды. 100 конкурентных инвокаций × пул 5 = 500 затребованных; инцидент дня запуска — не невезение, а умножение.
Почему просто не поднять max_connections до 5000? Потому что соединения Postgres дороги по построению: каждое — это форкнутый backend-процесс с памятью на бэкенд (work_mem выделяется на операцию сортировки/хеша и умножается со сложностью) и накладными расходами планировщика. После нескольких сотен активных бэкендов пропускная способность деградирует, даже когда большинство простаивает. Поэтому мудрость и переворачивается: на serverless пул на инстанс должен быть 1–2. Дефолтный размер пула Prisma — num_physical_cpus × 2 + 1 — рассчитан на сервер и неверен для lambda; поставьте connection_limit=1 в строке подключения, и пусть конкурентность приходит из инстансов, а не из потоков внутри одного.
Лестница лечений
Каждая ступень ниже жертвует какой-то возможностью ради меньшего числа соединений на функцию — разберитесь, что теряете, прежде чем прыгать на следующую.
Ступень первая: внешний пулер — pgbouncer в transaction mode. Клиенты подключаются к pgbouncer, где соединение дёшево (килобайты состояния, без форка процесса), а pgbouncer держит маленький пул настоящих серверных соединений — скажем, default_pool_size = 20. В transaction mode серверное соединение выдаётся клиенту только на время одной транзакции и возвращается; 500 клиентских соединений мультиплексируются через 20 бэкендов, и Postgres не чувствует стада. Цена реальна и конкретна. Ломается всё, что живёт на уровне сессии: настройки SET молча применяются к тому бэкенду, который достанется следующим, сессионные advisory-локи и временные таблицы испаряются, LISTEN/NOTIFY не работает. И prepared statements уровня протокола по умолчанию ломаются — стейтмент, подготовленный на одном бэкенде, не существует на следующем, — классическая мина для ORM: Prisma нужен ?pgbouncer=true в URL, чтобы перестать на них полагаться, postgres.js — prepare: false. (Свежий pgbouncer, 1.21+, умеет отслеживать prepared statements в transaction mode через max_prepared_statements — проверьте, прежде чем предполагать в любую сторону.)
Ступень вторая: управляемые прокси — RDS Proxy и родня. Та же идея мультиплексирования, запущенная как сервис, с IAM-аутентификацией и обработкой failover. Подвох, который надо знать: сессионное состояние вызывает pinning — соединение, выполнившее SET или другую сессионную вещь, прикрепляется к одному клиенту, тихо превращая ваш мультиплексор обратно в маппер 1:1 ровно на тех нагрузках, которые шалят.
Ступень третья: HTTP/WebSocket serverless-драйверы — стиль Neon и PlanetScale. Драйвер говорит по HTTP или WebSocket со шлюзом на стороне провайдера, который владеет настоящим пулом; ваша функция вообще не держит TCP-соединение к базе. Это обходит умножение целиком и работает в edge-рантаймах, где сырой TCP недоступен. Компромиссы: задержка шлюза на запрос, привязка к провайдеру, а интерактивным транзакциям нужен WebSocket-вариант — простой HTTP-раундтрип на стейтмент не может держать транзакцию открытой.
Функции работают с пулом 5; пик трафика — 100 конкурентных инвокаций; у Postgres max_connections=100. Ops отвечает на инцидент поднятием max_connections до 200. Что случится на следующем пике?
Арифметика размеров — вместе со стадом
Прогоните числа, как на дизайн-ревью. Управляемый Postgres, max_connections = 100, 3 зарезервированы: 97 доступных, и другие потребители — миграции, крон-воркер, аналитический читатель — реалистично держат 10–15, оставляя приложению ~85. Прямые соединения с connection_limit=1 ограничивают вас 85 конкурентными инвокациями до FATAL; с дефолтным пулом 5 потолок — 17. Через pgbouncer в transaction mode та же база обслуживает тысячи клиентских соединений поверх default_pool_size = 20 бэкендов, и вашим реальным пределом становится пропускная способность транзакций — 20 бэкендов при 5 мс на транзакцию — это примерно 4 000 транзакций в секунду, далеко за исходной стеной.
Теперь добавьте режим отказа, который таблицы размеров пропускают: стадо холодных стартов. Деплой заменяет все инстансы разом; следующую минуту трафик обслуживают только холодные старты, и каждый одновременно набирает TCP + TLS + auth. Напрямую в Postgres это шторм подключений в фиксированный потолок — преходящие FATAL, даже когда арифметика устоявшегося режима сходилась. За пулером шторм приземляется на pgbouncer, который поглощает его очередью (всплеск задержки, а не ошибки). Вторая serverless-морщина: замороженный инстанс держит соединение без keepalive, пока платформа не разморозит или не пожнёт его, — база копит idle-соединения процессов, которые могут никогда не проснуться. Ставьте агрессивные idle-таймауты с обеих сторон и предпочитайте пулеры или HTTP-драйверы, делающие смерть инстанса невидимой.
▸Почему это работает
Почему transaction mode в pgbouncer вообще ломает prepared statements? Потому что prepared statements уровня протокола — именованные объекты в памяти одного бэкенда: клиент один раз говорит PREPARE s0, потом дёшево выполняет EXECUTE s0. Контракт предполагает, что клиент продолжает говорить с тем же бэкендом. Transaction mode намеренно ломает это допущение — на каждый BEGIN он выдаёт свободный бэкенд, — так что s0 существует на бэкенде, который его готовил, и нигде больше. Флаги ORM (pgbouncer=true, prepare: false) переключают клиентов на отправку полных стейтментов каждый раз; pgbouncer 1.21+ вместо этого отслеживает и воспроизводит PREPARE по бэкендам. Корень тот же, что у поломки LISTEN/NOTIFY и SET: ничто со временем жизни «сессия» не переживает пулер, чья единица аренды — транзакция.
После переключения DATABASE_URL на pgbouncer в transaction mode запросы Prisma начинают падать с ошибкой: prepared statement 's0' does not exist. Что сломалось?
- 01Запишите serverless-арифметику соединений и объясните, почему подъём max_connections — не лечение.
- 02Что именно стоит вам transaction mode в pgbouncer и где на нём спотыкаются ORM?
Serverless переворачивает расчёт пулов, потому что умножает владельцев пула: каждый инстанс обслуживает один запрос за раз и владеет собственным пулом на процесс, так что пиковые конкурентные инстансы, умноженные на размер пула на инстанс, — ваш настоящий спрос на соединения, и он обязан проходить под max_connections минус резерв. FATAL дня запуска при базе на 8 процентах CPU — это умножение, а не нагрузка, — и подъём max_connections в основном передвигает стену, потому что каждое соединение Postgres — форкнутый backend-процесс с реальной памятью и ценой планирования. Пулы на инстанс сжимаются до 1–2 (перекройте серверный дефолт Prisma через connection_limit=1), а пулинг переезжает в слой, который делят все инстансы. Лестница: pgbouncer в transaction mode мультиплексирует сотни дешёвых клиентских соединений через маленький пул бэкендов ценой всего сессионного — SET, сессионных advisory-локов, временных таблиц, LISTEN/NOTIFY — и prepared statements уровня протокола, классической мины ORM, обезвреживаемой pgbouncer=true или prepare: false, либо нативно отслеживанием в pgbouncer 1.21+. Управляемые прокси покупают то же мультиплексирование как сервис, но прикрепляют соединения, когда появляется сессионное состояние. HTTP/WebSocket-драйверы убирают клиентское соединение целиком и работают на edge-рантаймах, разменивая задержку шлюза на запрос и привязку к провайдеру, а интерактивным транзакциям нужен WebSocket-путь. Наконец, считайте на стадо, а не только на устоявшийся режим: деплой означает, что все инстансы дозваниваются холодными разом, а замороженные инстансы держат idle-соединения, которые платформа может никогда не разморозить, — пулер превращает шторм в очередь, idle-таймауты подчищают зомби, а доска с арифметикой «инстансы × пул против потолка» — однострочное ревью, предотвращающее весь класс инцидентов. Теперь, когда увидите FATAL: too many clients в трекере ошибок, первым делом перемножьте: пиковые инстансы × размер пула. Почти всегда именно это число всё объясняет — ещё до того, как трогать max_connections.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.