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

Почему это ломается после роста трафика
PgBouncer и подготовленные запросы часто выглядят безобидно в локальных тестах. На ноутбуке у вас может быть один процесс приложения, несколько запросов и сессия базы, которая живёт достаточно долго, чтобы всё работало. Баг остаётся скрытым, потому что ничто не заставляет ваш код в неподходящий момент попасть на другое соединение с базой.
В продакшене всё меняется. Многие команды запускают PgBouncer в режиме transaction pooling, чтобы держать число соединений PostgreSQL под контролем. В этом режиме запрос не держит одно фиксированное серверное соединение. Он получает соединение на время транзакции, а потом PgBouncer возвращает его в пул.
Это важно, потому что подготовленный запрос живёт на том соединении PostgreSQL, которое его создало. Он не живёт в процессе приложения и не следует за запросом, когда PgBouncer отправляет следующую транзакцию на другой backend. Если ваш драйвер считает, что запрос всё ещё есть, вы получите ошибки вроде "prepared statement does not exist". Если драйвер попытается создать тот же самый запрос повторно по другому пути, вместо этого можно увидеть "already exists".
Низкий трафик удивительно хорошо скрывает проблему. Спокойный staging может так часто переиспользовать одни и те же backend-соединения, что ошибка кажется безвредной. Но как только трафик растёт, запросы начинают распределяться по большему числу пуловых соединений, меняется тайминг, и ошибки начинают выглядеть случайными. Один запрос проходит, следующий падает, а повторная попытка уже срабатывает.
Именно поэтому этот баг такой скользкий. SQL может быть правильным. PostgreSQL может быть в порядке. PgBouncer может делать ровно то, что вы просили. Проблема появляется потому, что приложение, драйвер и режим пула по-разному понимают, где живёт состояние сессии.
Хороший знакомый пример — API, которое работает неделями, а потом начинает падать только в часы пик. В запросе ничего не менялось. Просто трафик вырос настолько, что transaction pooling начал переносить запросы между соединениями и показал предположение, которое было неверным с самого начала.
Что именно делает PgBouncer
PgBouncer не пулит запросы. Он пулит серверные соединения с PostgreSQL. На первый взгляд это мелочь, но именно она меняет поведение приложения, когда к базе одновременно приходит много запросов.
Обычное приложение предполагает, что может какое-то время общаться с одним соединением с базой. PgBouncer может оставить это так, а может поменять соединение между запросами. Всё зависит от режима пула.
Три режима пула
В режиме session одно клиентское соединение сохраняет то же самое серверное соединение на всю сессию. Если приложение создаёт подготовленный запрос, задаёт переменную сессии или использует временную таблицу, это состояние остаётся привязанным к этому клиенту, пока он не отключится.
В режиме transaction PgBouncer даёт приложению серверное соединение только на время одной транзакции. После commit или rollback он возвращает это соединение в пул. Следующий запрос может уйти на другое соединение, без какого-либо прежнего состояния сессии.
В режиме statement PgBouncer меняет соединения ещё агрессивнее. Каждый запрос может выполняться на другом серверном соединении. Это убирает большую часть поведения сессии, поэтому большинство приложений избегают этого режима, если только нагрузка не совсем простая.
Именно здесь подготовленные запросы конфликтуют с пулом. Подготовленный запрос обычно живёт на одном конкретном соединении PostgreSQL. Если приложение подготовило запрос на соединении A, а следующий запуск попал на соединение B, PostgreSQL не знает, что такой запрос вообще существует.
Схема проста. Запрос один готовит get_user и работает нормально. Запрос два использует то же клиентское соединение в приложении, но PgBouncer отправляет его на другой backend. Приложение пытается снова выполнить get_user и получает ошибку, потому что этот подготовленный запрос на новом backend никогда не создавался.
Если вы используете режим session, подготовленные запросы обычно работают как ожидается. Если вы используете transaction, нужна настройка драйвера, которая предполагает, что состояние сессии может исчезнуть после каждой транзакции. В режиме statement стоит считать, что большая часть функций, завязанных на сессию, сломается, если вы не проверили их очень внимательно.
Что на самом деле делают подготовленные запросы
Подготовленный запрос делит работу с базой на два шага. Сначала приложение отправляет текст SQL в PostgreSQL и просит сервер сохранить его. Потом приложение запускает этот запрос снова с новыми значениями, чтобы повторно использовать ту же форму запроса вместо того, чтобы каждый раз отправлять весь SQL.
Это может немного сэкономить на повторяющихся запросах. Представьте загруженное приложение, которое по ID загружает пользователя, проверяет токен сессии или снова и снова вставляет заказ. SQL остаётся тем же, меняются только значения.
Деталь, которая подводит команды, — где именно живёт подготовленный запрос. Именованный подготовленный запрос остаётся на одном конкретном серверном соединении. Если приложение подготовило get_user на соединении A, PostgreSQL знает это имя только на соединении A. Соединение B не понимает, что такое get_user.
То есть приложение думает, что создало один переиспользуемый запрос. А PostgreSQL на самом деле сохранил его внутри одной backend-сессии.
Обычный запрос каждый раз отправляет полный SQL. Именованный подготовленный запрос сохраняет SQL на одном серверном соединении и потом запускается по имени. Безымянный подготовленный запрос более временный, но драйверы обращаются с ним по-разному, поэтому поведение становится менее предсказуемым.
Именно последняя часть и создаёт проблемы. Один драйвер может повторно использовать безымянный слот для следующего выполнения. Другой может готовить и выполнять запрос за один шаг. Третий вообще может не делать server-side prepare, пока вы не включите отдельную настройку. Код может выглядеть одинаково, а поведение на уровне сети при этом сильно меняться.
Некоторые драйверы ещё и ждут, прежде чем что-то готовить. Они смотрят, сколько раз один и тот же запрос был выполнен, а потом автоматически переходят к prepare. Из-за этого локальные тесты вводят в заблуждение. Запрос может выполнять роль обычного statement в начале, а потом превратиться в именованный подготовленный запрос только после роста трафика.
Небольшой пример делает проблему очевидной. Допустим, приложение выполняет select * from accounts where id = $1 сто раз в минуту. Сначала драйвер отправляет его как обычный запрос. После пятого или десятого повторения драйвер решает подготовить его и начать переиспользовать имя. Ваш код не менялся, но разговор с PostgreSQL уже другой.
Именно на этом скрытом переключении и начинается множество сбоев.
Поведение драйверов, которое удивляет
Большинство команд не ломают эту схему в первый день. Баг проявляется позже, когда трафик растёт, а драйвер тихо меняет то, как он общается с PostgreSQL.
Часто всё выглядит безобидно. Приложение несколько раз отправляет один и тот же запрос, драйвер проходит порог автоматической подготовки, а потом начинает использовать именованный подготовленный запрос. На прямом соединении с базой это может работать отлично. Но при transaction pooling следующий запрос часто уходит на другой backend, и всё начинает ломаться.
Многие драйверы и ORM включают такое поведение не особо заметно. Иногда приходится копаться в документации драйвера, настройках ORM или параметрах строки подключения, чтобы вообще это найти.
Именно поэтому баг кажется случайным. Локальные тесты проходят, staging выглядит нормально, а в продакшене ошибки появляются только после того, как достаточное число повторяющихся запросов переводит драйвер в prepared mode.
Ещё одна ловушка — имена запросов. Некоторые драйверы используют короткие имена вроде stmt_1 или крутят маленький набор имён на многих запросах. В transaction pooling это может конфликтовать с состоянием, которое уже есть на том backend-соединении, которое PgBouncer выбрал для следующей транзакции.
Обычно это заканчивается одной из нескольких знакомых ошибок: "prepared statement already exists", "prepared statement does not exist", ошибками несовпадения типов после повторного использования имени для другого запроса или сбоями, которые проявляются только при повторах и всплесках трафика.
Повторы делают ситуацию хуже. Запрос может упасть по одной причине, потом повториться на уровне приложения и попасть уже на другой backend. Если драйвер считает, что его подготовленный запрос там всё ещё есть, повторная попытка падает по причине, которая выглядит совсем не связанной с первой ошибкой.
ORM добавляют ещё один слой путаницы. Они скрывают настройки драйвера, оборачивают путь выполнения запроса и мешают понять, используют ли они обычные запросы, безымянные подготовленные запросы или именованные запросы с кэшированием.
Перед релизом проверьте четыре вещи: включается ли автоматическая подготовка после порога, как драйвер называет запросы, показывает ли ORM эту настройку и как ведут себя повторы после перехода на другое соединение. Такой небольшой аудит потом экономит очень много догадок.
Как выбрать безопасную схему
Начните с режима пула в PgBouncer. Именно этот выбор решает, сохранит ли клиент ту же PostgreSQL-сессию или получит другую на следующем запросе. Session pooling обычно работает с подготовленными запросами. Transaction pooling часто — нет, потому что следующая транзакция может попасть на другой backend.
Вот почему проблема становится заметной только после того, как сервис начинает активно работать. Низкий трафик может скрывать её неделями. При высокой конкуренции повторное использование соединений становится агрессивнее, и состояние запросов начинает расходиться с тем, чего ждёт драйвер.
Безопасная схема обычно сводится к четырём шагам:
- Проверьте, создаёт ли ваш драйвер или ORM подготовленные запросы сам по себе. Многие делают это тихо после того, как один и тот же запрос выполнится несколько раз.
- Если сервис обязан использовать transaction pooling, отключите server-side prepare для этого пути.
- Если сервису действительно нужны подготовленные запросы, переведите его на session pooling вместо того, чтобы надеяться на правильное поведение драйвера.
- Тестируйте под реальной параллельностью. Один локальный скрипт, который шлёт запросы по одному, этот баг не поймает.
Часто наименее болезненный вариант — смешанная схема. Публичный API с большим числом коротких запросов может работать через transaction pooling с отключённой подготовкой. Фоновый воркер или админский сервис, который живёт дольше, может использовать session pooling.
Перед релизом добавьте логи для ошибок подготовленных запросов и времени ожидания пула. Следите за сообщениями вроде "prepared statement does not exist" или "already exists". Также смотрите на задержку запросов, когда пул становится тесным. Эти два сигнала часто появляются раньше, чем пользователи что-то сообщат.
Если нужен один простой принцип, используйте transaction pooling только для безсостоянийного поведения запросов. Как только драйвер начинает кэшировать состояние сессии, либо переведите этот сервис на session mode, либо отключите эту функцию.
Пример реального сбоя под нагрузкой
Небольшой SaaS API может выглядеть совершенно здоровым неделями. Несколько пользователей входят в систему, фоновые задачи выполняются, запросы отвечают достаточно быстро. Команда добавляет PgBouncer с transaction pooling, видит меньшее давление на соединения и выкатывает изменение.
А потом после запуска или подключения нового клиента трафик резко растёт. Большая часть запросов всё ещё работает, и из-за этого проблему сложнее отловить. Небольшая доля начинает падать с ошибками вроде "prepared statement does not exist" или "prepared statement already exists". Один и тот же endpoint может упасть один раз, а на следующем вызове снова работать нормально.
Команда часто считает, что одно приложение-соединение какое-то время соответствует одному backend базы. В transaction pooling это просто не так. PgBouncer может отправить каждую новую транзакцию на любой свободный backend.
Типичный сценарий сбоя довольно прямолинейный. Драйвер решает подготовить запрос после того, как он выполнился несколько раз. Один запрос создаёт этот запрос на backend A. Следующий запрос использует то же приложение-соединение, но попадает на backend B. На backend B такого подготовленного запроса нет, поэтому запрос падает.
Низкий трафик может скрывать это очень долго. Когда пользователей мало, один и тот же backend часто переиспользуется просто по удаче, и никто ничего не замечает. Больше трафика ломает эту удачу. Повторное использование backend становится менее предсказуемым, а процент ошибок растёт ровно настолько, чтобы мешать клиентам, но не положить весь сервис.
Именно поэтому команды тратят время на этот баг. Они проверяют настройки PostgreSQL, смотрят на медленные запросы и винят случайные сетевые проблемы. Во многих случаях проблема сидит всего лишь в одной настройке драйвера.
Обычно исправление простое: оставить transaction pooling, но запретить драйверу использовать подготовленные запросы на сервере в этом пути. В зависимости от драйвера это значит отключить автоматическую подготовку, выключить кэш подготовленных запросов или принудительно переключить пуловые соединения в обычный режим запросов.
Это чуть менее изящно, но намного безопаснее. Вы сохраняете экономию соединений от PgBouncer, а случайные сбои под нагрузкой исчезают.
Ошибки, которые тратят время впустую
Больше всего времени уходит на проверку не того пути. Команда тестирует прямое соединение с PostgreSQL, не видит ошибок и считает, что в продакшене всё безопасно. Потом трафик идёт через PgBouncer, и сбои начинаются. Код не менялся. Изменился путь подключения.
Другая частая ошибка — сразу винить PostgreSQL. Во многих случаях база в порядке. Сначала проверьте режим пула в PgBouncer и настройки драйвера, а уже потом смотрите планы запросов или тюнинг памяти. Если приложение зависит от состояния сессии, transaction pooling может ломать всё так, что это выглядит случайным.
Это часто случается, когда кто-то включает transaction pooling ради экономии соединений, но приложение всё ещё ждёт стабильную сессию. Именованные подготовленные запросы, временные таблицы, переменные сессии и некоторые кэши драйверов предполагают, что один клиент остаётся привязан к одному серверному соединению. Transaction pooling этого не обещает.
Команды ещё и меняют PgBouncer, оставляя настройки приложения по умолчанию. Так и прячутся баги автоматической подготовки. Драйвер может начать подготавливать запрос после нескольких повторов одного и того же SQL. В разработке это выглядит нормально. Под нагрузкой следующая транзакция может попасть на другое backend-соединение, и приложение начинает выдавать "prepared statement does not exist" или "already exists".
Деплои и события failover быстро это вскрывают. Появляются новые соединения, старые постепенно закрываются, и кэшированные предположения рассыпаются. Если ошибки появляются только во время релизов, не списывайте их на обычный шум деплоя. Такие короткие всплески часто показывают настоящий баг.
Помогает быстрая проверка: протестируйте полный production-путь от приложения до PgBouncer и PostgreSQL, сравните session и transaction pooling под одной и той же нагрузкой, проверьте настройки драйвера на автоматическую подготовку и кэширование запросов, а также прогоните в staging сценарии деплоя и failover.
Большинство таких проблем возникают из-за несовпадения настроек, а не из-за одной сломанной части. Когда режим пула, поведение драйвера и ожидания приложения совпадают, странные ошибки обычно исчезают.
Быстрые проверки перед релизом
Небольшие пробелы в конфиге могут скрываться неделями, а потом всплыть в день, когда трафик становится настоящим. Обычно эта проблема ломается именно так. Лёгкое тестирование выглядит нормально, а потом пуловые соединения начинают переиспользовать backend-сессии не так, как ожидал драйвер.
Чек-лист перед релизом
Проверяйте каждый сервис отдельно. Не думайте, что всем приложениям подойдут одинаковые настройки пула только потому, что они все используют PostgreSQL.
- Проверьте режим пула для каждого сервиса. Transaction pooling часто и есть источник проблем.
- Посмотрите, создаёт ли драйвер именованные подготовленные запросы и включается ли это автоматически после повторяющихся запросов.
- Запустите параллельные тесты через тот же вход PgBouncer, который вы будете использовать в продакшене. Тестов напрямую к базе недостаточно.
- Держите наготове быстрый вариант отката, будь то отключение подготовленных запросов, смена режима пула для одного сервиса или обход PgBouncer для одного приложения.
Простой тест ловит многое. Запустите два или три экземпляра приложения, отправляйте повторяющиеся запросы с одним и тем же SQL и держите параллельность достаточно высокой, чтобы соединения переиспользовались. Если схема небезопасна, ошибки часто появляются в течение минут, а не через неделю обычного трафика.
Команды ещё и теряют время, когда тестируют с одним процессом, одним соединением и без PgBouncer в пути. Это доказывает, что запрос работает. Но это не доказывает, что работает развёртывание.
Если вам нужен спокойный релиз, сделайте откат дешёвым. Один флаг конфигурации может спасти вечер.
Что отслеживать после роста трафика
Сначала всё может выглядеть нормально, а потом короткими всплесками начать падать, когда в игру входят больше экземпляров приложения, повторы запросов и переподключения. Важен не только счёт ошибок, но и их характер. Несколько случайных сбоев после каждого деплоя указывают на одну причину, а стабильный рост ошибок в часы пик — на другую.
Начните со счёта ошибок подготовленных запросов по сервисам и по релизам. Если одна служба даёт большинство ошибок, баг обычно в ней, а не в самом PostgreSQL. Если ошибки начались сразу после релиза, сначала проверьте, что поменялось в драйвере, настройках подключения или логике retry, а уже потом обвиняйте PgBouncer.
Смотрите и на время ожидания пула. Когда ожидание растёт, приложения дольше стоят перед получением серверного соединения, и ошибки тайминга проявляются чаще. Одновременно следите за churn серверных соединений. Если соединения открываются и закрываются слишком часто, подготовленное состояние исчезает чаще, а ошибки выглядят случайными даже тогда, когда причина стабильна.
Полезная проверка простая: сравните ошибки подготовленных запросов с retry, failover и перезапусками на одной временной линии. Перезапуск может очистить состояние сессии на сервере. Потом шторм retry может начать бить по тому же сломанному пути и сделать маленькую проблему намного больше.
Стоит также проверить задержку после отключения prepare для одного сервиса. Некоторые команды ожидают огромного замедления, но реальная цена для коротких запросов часто небольшая. Если ошибки исчезают, а задержка почти не меняется, этому сервису, возможно, безопаснее жить без автоматической подготовки.
Частый сценарий выглядит так: API работает без проблем неделями, а потом ломается только после деплоя и короткого переподключения к базе. Деплой меняет размер пула клиента, переподключения учащаются, и один драйвер начинает переиспользовать запросы в режиме transaction. Баг остаётся скрытым, пока на этот точный путь не попадёт достаточно трафика.
Завершите это коротким обзором: каким сервисам всё ещё нужен session mode, а какие могут оставаться в transaction mode без подготовленных запросов. Список обычно небольшой, но он экономит часы, когда придёт следующий всплеск трафика.
Следующие шаги для более безопасного запуска
Трафик редко ломает такую схему в первый день. Проблемы появляются, когда один сервис использует поведение сессии, другой — transaction pooling, а никто не записал, чем они отличаются. Исправление начинается с простого инвентаря: каким приложениям нужны функции сессии, а каким хватает безсостоянийного пула.
В этот инвентарь стоит включить подготовленные запросы, временные таблицы, переменные сессии и всё остальное, что зависит от стабильного backend-соединения. Фоновый воркер может отлично работать через transaction pooling, тогда как админский инструмент или задача миграции могут требовать session mode. Поместить всё это под одни и те же правила — хороший способ создать баг, который проявится только под нагрузкой.
Чистый запуск обычно означает разделение трафика по поведению. Статeless web-трафик отправьте в один пул. Задачи или инструменты, которым нужно поведение сессии, отправьте в другой. Держите настройки драйвера рядом с конфигом приложения, а не прячьте их в дефолтах, и проверяйте automatic prepare до того, как повышать параллельность.
Именно этот последний шаг экономит больше всего времени. Многие команды настраивают PgBouncer, а потом забывают, что драйвер всё ещё сам решает, когда готовить, повторно использовать или переименовывать запросы. Если эти настройки лежат рядом с конфигом приложения, разработчики видят их во время ревью и при смене окружений. Они перестают быть скрытыми сюрпризами.
Хороший план релиза проверяет не только успех, но и сбои. Перезапустите PgBouncer в staging. Поменяйте соединения. Дайте достаточно параллельных запросов, чтобы заставить пул переиспользовать backend. Если ошибки подготовленных запросов появляются только после churn, вы нашли реальный риск до того, как его увидят пользователи.
Если в вашем стеке несколько сервисов, смешанные драйверы или новые пути кода, сгенерированные ИИ, внешний взгляд может быть полезен. Oleg Sotnikov из oleg.is помогает командам разобраться с такими вопросами, как стратегия пулов, поведение драйвера, безопасность запуска и переход к AI-first разработке, не превращая инфраструктуру в догадки.
Часто задаваемые вопросы
Почему я получаю "prepared statement does not exist" с PgBouncer?
Это обычно происходит, когда ваш драйвер готовит запрос на одном соединении PostgreSQL, а затем PgBouncer отправляет следующую транзакцию на другое. SQL выглядит правильным, но новый backend не знает это имя запроса.
Можно ли использовать подготовленные запросы с transaction pooling?
По умолчанию — не всегда. Transaction pooling лучше всего работает, когда каждая транзакция ведёт себя как новый визит без памяти о прошлой сессии. Именованные подготовленные запросы хранят состояние сессии на одном backend, поэтому там они часто ломаются.
Почему это работает на моём ноутбуке, но ломается под нагрузкой?
Локальные тесты часто по удаче используют один и тот же небольшой набор backend-соединений, поэтому баг остаётся скрытым. Когда трафик растёт, PgBouncer распределяет работу по большему числу соединений, и неверное предположение быстро всплывает.
Какое самое простое исправление, если я хочу оставить PgBouncer?
Начните с отключения серверных подготовленных запросов для сервисов, которые используют transaction pooling. Так вы сохраните PgBouncer и уберёте состояние сессии, из-за которого возникают случайные ошибки.
Когда мне стоит перейти с transaction pooling на session pooling?
Используйте session pooling, когда сервису действительно нужны подготовленные запросы, временные таблицы или переменные сессии. Вы пожертвуете частью эффективности соединений, но получите стабильное поведение сессии, что часто имеет смысл для воркеров, админских инструментов или долгих задач.
Может ли мой ORM вызвать это, даже если я сам никогда не вызываю PREPARE?
Да. Многие ORM скрывают настройки драйвера и могут включить автоматическую подготовку после того, как один и тот же запрос выполнится несколько раз. Из-за этого приложение выглядит нормально в тестах, а потом ломается, когда трафик пересекает порог.
Почему деплои и повторы запросов делают ошибки похожими на случайные?
Может. Повтор может попасть на другой backend, а деплой или failover часто сбрасывают соединения и очищают состояние сессии. Эти события и показывают баг, потому что ломают ту схему повторного использования соединений, которая раньше казалась стабильной.
Что ещё ломается в transaction pooling, кроме подготовленных запросов?
Смотрите на временные таблицы, переменные сессии, advisory locks, привязанные к сессии, и любой кэш драйвера, который рассчитывает на одно стабильное backend-соединение. Transaction pooling меняет backend после каждой транзакции, поэтому такие возможности часто ломаются так же, как подготовленные запросы.
Как мне протестировать это перед релизом?
Запускайте тест через тот же вход PgBouncer, который вы будете использовать в продакшене, и добавляйте реальную параллельность. Отправляйте одни и те же запросы много раз из более чем одного экземпляра приложения, чтобы драйвер успел перейти в prepared mode, а PgBouncer — переиспользовать backend-соединения.
Сильно ли замедлит приложение отключение подготовленных запросов?
Обычно не сильно, если запросы короткие. Во многих приложениях небольшая цена на разбор SQL важна меньше, чем случайные ошибки в продакшене, поэтому обычные запросы дают более спокойную систему и проще проходят релиз.