У этого выигрыша есть определенная цена: режим транзакций незаметно разрывает все, что предполагает стабильное соединение Postgres, от одного запроса к другому. Сеанс SET, LISTEN/NOTIFY, консультативные блокировки, курсоры, сохраняющие транзакцию, именованные подготовленные операторы. В этой статье подробно описан механизм, перечислены эти ограничения с их точными симптомами, а затем показано, как работающий бэкэнд (наш, проверенный непосредственно в его репозитории) настраивает его, не попадая в его ловушку. Метод измерения производительности, приведенный здесь, см. в нашей методике тестирования .
Самое необходимое
- Режим транзакции: соединение с сервером разъединяется в конце каждой транзакции, а не при отключении клиента. Это наиболее эффективный режим для совместного использования коротких соединений типа REST API.
- По конструкции несовместим с: сеансами SET/RESET, LISTEN/NOTIFY, консультативными блокировками сеанса, курсорами с HOLD, временными таблицами, повторно используемыми от одного запроса к другому.
- Самая распространенная ошибка на практике: именованные подготовленные операторы, которые несколько драйверов (sqlx, asyncpg, драйвер JDBC pgjdbc) активируют по умолчанию, могут воспроизводиться при другом соединении с сервером и вызывать ошибку типа
prepared statement does not existпод нагрузкой. - Начиная с версии 1.21, PgBouncer может следовать инструкциям, подготовленным протоколом, в режиме транзакций (кэш LRU через соединение с сервером). Это не предотвращает отключение кэша на стороне клиента, если ваше приложение меняет
search_pathпри каждом запросе. - Проверено в коде Aurabase: пулы клиентов работают с
statement_cache_capacity(0)и PgBouncer вpool_mode=transaction, а PostgREST добровольно остается в прямом соединении для перезагрузки своей схемы через LISTEN/NOTIFY.
3 режима объединения PgBouncer
PgBouncer предлагает три режима, которые отличаются только тем, когда соединение с сервером Postgres возвращается в общий пул. В официальной документации они называются session, transaction и statement (pgbouncer.org/features.html, раздел «Режимы объединения», по состоянию на 24 августа 2026 г.).
| Мода | Соединение с сервером потеряно | Совместимость сеансов |
|---|---|---|
| сеанс (по умолчанию) | При отключении клиента | Итого: SET, LISTEN, курсоры, все работает как живое |
| сделка | В конце каждой транзакции (COMMIT/ROLLBACK) | Частичный: только то, что остается локальным для транзакции. |
| заявление | После каждого отдельного запроса | Минимальный: явные транзакции с несколькими запросами запрещены. |
Сеансовый режим является наиболее разрешающим, но наименее эффективным с точки зрения масштабируемости: соединение Postgres остается зарезервированным для клиента до тех пор, пока оно остается подключенным, даже если между двумя запросами оно ничего не делает. Режим операторов зарезервирован для очень специфических случаев (прокси-сервер только для чтения, проверки работоспособности) и даже нарушает классические явные транзакции. Режим транзакций — это компромисс, который на практике доминирует в REST API: каждый HTTP-запрос обычно соответствует одной короткой транзакции Postgres.
Как работает режим транзакций, соединение за соединением
В режиме транзакции PgBouncer подключает соединение с сервером к клиенту только тогда, когда последний открывает транзакцию, и возвращает ее в пул при COMMIT или ROLLBACK. Между двумя транзакциями один и тот же клиент может быть переназначен на совершенно другое соединение с сервером.
Конкретно, с default_pool_size, равным 20, PgBouncer может одновременно обрабатывать несколько сотен клиентов, у которых в любой момент времени фактически выполняются только несколько транзакций. Именно это соотношение оправдывает режим транзакций для REST API с высоким трафиком, но короткими транзакциями: редкий ресурс (соединение Postgres, требующее больших затрат памяти на стороне сервера) занят только на строго необходимое время.
Этот последний комментарий суммирует суть: режим транзакций работает, потому что он намеренно разрывает связь между «сеансом моего приложения» и «моим соединением Postgres». Все, что основано на этой ссылке, ломается. В следующем разделе перечислено именно то, что именно.
Что ломается в режиме пула транзакций
В официальной документации PgBouncer явно перечислены функции PostgreSQL, которые теряют свое значение, как только соединение с сервером может быть перезапущено между двумя запросами от одного и того же клиента.
| Затронутая функциональность | Почему оно ломается | Типичный симптом |
|---|---|---|
| УСТАНОВКА / УСТАНОВКА СЕССИИ | Этот параметр применяется к соединению, которое можно перезапустить сразу после | Кажется, что параметр случайно забыт между двумя запросами |
| СЛУШАЙТЕ / УВЕДОМЛЯЙТЕ | Предполагается постоянное соединение для получения уведомлений. | Клиент никогда не уведомляется или только время от времени |
| Консультативные блокировки сеанса | Блокировка удерживается соединением с сервером, а не с логическим клиентом. | Блокировка снимается до ожидаемого завершения или никогда не снимается. |
| С ползунками HOLD | Должен выжить после транзакции, которая его открыла | Ошибка «курсор не существует» на следующей итерации |
| Временные таблицы | Связано с сеансом Postgres, а не с транзакцией. | Таблица «исчезает» при следующем запросе |
| Подготовленные заявления по имени | Подготовлено на одном соединении с сервером, воспроизведено на другом | «подготовленный оператор... не существует» под нагрузкой |
Большинство этих ограничений не проявляются при локальной разработке, где обычно весь трафик обслуживается одним соединением. Они появляются при реальной нагрузке, когда несколько клиентов фактически делят пул и соединение с сервером фактически переходит из рук в руки между двумя запросами от одного и того же логического клиента. Дым-тест почти никогда их не выявляет.
Готовые заявления: самый непонятый лимит
Большинство современных драйверов Postgres по умолчанию готовят именованные запросы на стороне протокола, без явного запроса кода приложения. Именно поэтому эту ловушку трудно предвидеть.
Подготовленный протоколом оператор получает имя и кэшируется на определенном соединении с сервером во время Parse. В режиме транзакции это соединение можно переназначить другому клиенту между двумя запросами от одного и того же логического клиента. Если драйвер затем воспроизводит то же имя оператора в соединении, где оно никогда не было подготовлено, Postgres отвечает явной ошибкой, обычно prepared statement "sqlx_s_N" does not exist для клиента sqlx. Поведение прерывистое: оно зависит от того, как соединения работают под нагрузкой, а не от детерминированной ошибки, воспроизводимой при каждом вызове.
Исправление на стороне клиента одинаково, независимо от языка: отключите кеш именованных подготовленных операторов или принудительно задайте неименованные запросы для любого пула, который пересекает средство объединения в режиме транзакций. В Rust с sqlx в параметрах подключения он проходит через statement_cache_capacity(0).
Начиная с версии 1.21, PgBouncer частично устраняет проблему на стороне сервера: он может следовать подготовленным протоколом операторам в режиме транзакций и готовить их на лету в назначенном соединении с LRU-кешем для каждого соединения, размер которого настраивается с помощью max_prepared_statements. Это уменьшает количество промахов, но не освобождает вас от отключения кэша клиента в многотенантном пуле, где search_path меняется от одного запроса к другому: кэшированный план замораживает внутренний идентификатор (OID) разрешенной таблицы во время Parse, и его воспроизведение в другой схеме может вернуть данные не от того клиента, а не простую ошибку.
Как Aurabase настраивает PgBouncer в режиме транзакции
Репозиторий Aurabase развертывает PgBouncer как pool_mode=transaction перед общей плоскостью данных (deploy/helm/aurabase/templates/infra/pgbouncer.yaml) и идентично настроенный Pooler CNPG перед каждым выделенным экземпляром Postgres клиента (deploy/cnpg/tenant-pooler.yaml). Оба пути применяют одну и ту же дисциплину, описанную выше.
В исходном коде указана конкретная причина этого выбора, связанная с безопасностью, а не только причина стабильности. Пулы Postgres, совместно используемые арендаторами, размещают разные search_path для каждого проекта в повторно используемых соединениях. Кэшированный подготовленный оператор замораживает OID разрешенной таблицы во время Parse; воспроизведение его для другого арендатора в том же соединении приведет к запуску запроса к схеме первого арендатора, что является обходом изоляции, а не просто ошибкой приложения. Таким образом, statement_cache_capacity(0) применяется без исключения, в том числе к выделенным экземплярам, которые также проходят через пул CNPG в режиме транзакций.
Вторая мера гигиены сеанса: когда каждое соединение возвращается в пул, перехват выполняет DISCARD ALL (сброс настроек, освобождение подготовленных операторов на стороне сервера, снятие консультативных блокировок, очистка курсоров и временных таблиц). Без этого перехватчика остаток сеанса, созданный предыдущим запросом, может просочиться при следующем запросе от другого клиента, повторно использующего то же переработанное соединение.
Принимается исключение: выделенные экземпляры PostgREST остаются в прямом соединении с основным, минуя пул. Перезагрузка схемы PostgREST опирается на LISTEN/NOTIFY, который предполагает постоянное соединение, а это именно та функциональность, которую нарушает режим транзакций (подробности уже описаны в нашей статье о совместимости PostgREST на Aurabase). Параметры RLS для каждого запроса передаются в SET LOCAL в рамках явной транзакции. Это единственный способ сохранить совместимость с пулом, который может изменять подключения к серверу при любом COMMIT (см. нашу статью о мультитенантной изоляции RLS).
Включите режим транзакций, не нарушая работу вашего приложения.
Краткий контрольный список, применимый к любому бэкэнду, переходящему с прямого соединения Postgres на PgBouncer в режиме транзакции.
- Проведите аудит кода приложения. Ищите нетранзакционные
SET,LISTEN/NOTIFY, консультативные блокировки сеанса, курсорыWITH HOLDи временные таблицы, повторно используемые между запросами. - Замените сеансовые SET на LOCAL SET в рамках явной транзакции. Это единственный параметр, который правильно выдерживает перезапуск соединения, поскольку он очищается при COMMIT/ROLLBACK, а не теряется при следующем соединении.
- Отключите кэш подготовленных операторов на стороне драйвера, если ваш пул проходит через средство объединения и схема или роль изменяются от одного запроса к другому. Затраты на производительность реальны, но измеримы и намного ниже, чем риск утечки информации между арендаторами.
- Изолируйте соединения, которым действительно нужен режим сеанса (миграции, сценарии администратора и все, что зависит от LISTEN/NOTIFY), прямым соединением без объединения в пул, а не отказывайтесь от режима транзакций для всего остального трафика.
- Размер
default_pool_sizeиmax_client_connотносительно фактическогоmax_connectionsPostgres, а не произвольной цифры, скопированной из другого проекта. - Тестируйте под реальной нагрузкой, а не просто дымовым тестом. Ошибки подготовленных операторов и утечки настроек сеанса практически никогда не возникают при одном локальном соединении.
- Мониторинг
SHOW POOLSиSHOW STATSиз консоли администрирования PgBouncer после запуска в производство, чтобы определить насыщение пула до того, как оно станет видимым на стороне клиента.
Следует ли всегда выбирать режим транзакции, а не сеанса?
Нет, но это правильный выбор по умолчанию для подавляющего большинства REST API. Режим сеанса остается предпочтительным для устаревшего приложения, сильно зависящего от функций сеанса, которые невозможно быстро реорганизовать, или для низкого трафика, когда выигрыш от объединения не компенсирует усилия по миграции.
PgBouncer — не единственная реализация этой модели объединения: Supavisor (Supabase) и PgCat — две недавние альтернативы с разными компромиссами в распределении нагрузки и кластеризации. См. наше подробное сравнение PgBouncer, Supavisor и PgCat, чтобы выбрать один из трех в зависимости от вашей топологии.
Часто задаваемые вопросы
Вопросы, которые возникают чаще всего после активации режима транзакций в рабочей среде.