MAATRIX / Блог / pgBouncer в режиме transaction сломал подготовленные запросы

pgBouncer в режиме transaction сломал подготовленные запросы

MAATRIX

Сервис работал стабильно годами, а после одного, казалось бы, безобидного изменения в инфраструктуре — переезда на pgBouncer в режиме transaction pooling — начал сыпать случайными ошибками вида «prepared statement does not exist» и «prepared statement already exists». Ошибки были нечастыми, не привязанными ни к нагрузке, ни к конкретному эндпоинту, и две недели команда искала гонку в своём коде вместо того, чтобы посмотреть, как транзакционный пулинг обращается с серверными подготовленными запросами.

Симптомы: что сломалось после переезда на transaction pooling

Исходная точка: сервис оформления заказов на Java (Spring Boot + Hibernate + HikariCP как клиентский пул, PostgreSQL 15 в качестве базы). Раньше приложение подключалось к базе напрямую, но с ростом числа реплик сервиса в Kubernetes количество одновременных соединений к PostgreSQL начало приближаться к max_connections, и в момент раскатки новой версии (когда старые и новые поды какое-то время сосуществуют) база иногда отказывала в новых подключениях. Решение выглядело стандартным: поставить перед базой pgBouncer и перевести пул в pool_mode = transaction, чтобы десятки клиентских соединений могли использовать заметно меньше серверных.

Функционально всё поднялось нормально: нагрузочное тестирование на стейджинге прошло чисто, миграция на проде тоже. Проблемы начались не сразу, а спустя несколько часов после раскатки, и выглядели как случайные пятисотые ошибки на части запросов оформления заказа. В логах приложения — исключения от драйвера PostgreSQL JDBC:

org.postgresql.util.PSQLException: ERROR: prepared statement "S_1" already exists
org.postgresql.util.PSQLException: ERROR: prepared statement "S_3" does not exist

Частота была невысокой относительно общего трафика — по грубой прикидке, доли процента запросов, — но для сервиса оформления заказов это означало реальные потерянные покупки, поэтому инцидент сразу подняли в приоритет.

Что показывали логи и метрики на момент инцидента

Первым делом посмотрели на инфраструктурные метрики — и не увидели ничего примечательного. Загрузка CPU на сервере с PostgreSQL была в норме, число активных бэкендов не приближалось к лимиту, pg_stat_activity не показывал зависших или заблокированных запросов, средняя латентность типовых запросов не выросла. В логах самого PostgreSQL (log_min_error_statement = error) находились те же ошибки про prepared statement, но без дополнительного контекста, кроме имени statement и текста запроса.

В админ-консоли pgBouncer (SHOW POOLS, SHOW STATS, SHOW SERVERS) картина тоже была спокойной: пул не упирался в max_client_conn, серверные соединения переиспользовались активно, но не аномально — именно так, как и задумано в transaction pooling. Ключевое наблюдение, которое поначалу прошло мимо внимания: SHOW SERVERS показывал, что одно клиентское соединение приложения за время HTTP-сессии успевало «прокатиться» через несколько разных server_id — то есть разные транзакции одного клиента обслуживались разными физическими соединениями к PostgreSQL.

Ошибки не коррелировали ни со временем суток, ни с конкретными подами, ни с типом запроса — падал то один prepared statement, то другой, у разных клиентов. Единственная закономерность: частота ошибок росла вместе с трафиком, но не пропорционально ему, а скорее по формуле «чем больше уникальных SQL-паттернов активно одновременно, тем выше шанс столкновения».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотеза первая: сеть и инфраструктура — отброшена

Первой версией была сетевая нестабильность между подами и pgBouncer — благо переезд совпал с изменением сетевой топологии (сервис к тому моменту тоже переехал ближе к базе). Проверили tcpdump на нескольких подах во время воспроизведения — ни ретрансмиссий, ни рвущихся TCP-соединений, ни таймаутов на уровне сокета не было. Ошибки приходили как совершенно штатный ответ ERROR от PostgreSQL внутри уже установленного, живого соединения. Версию с сетью закрыли в первый же день: проблема была не в доставке пакетов, а в содержимом протокола.

Гипотеза вторая: баг или гонка в коде приложения — тоже отброшена

Вторая версия — гонка в самом сервисе: может быть, два потока делят один и тот же Connection из HikariCP, или Hibernate неправильно кеширует PreparedStatement между потоками. Это была правдоподобная теория, потому что HikariCP и правда переиспользует соединения в пуле, а Hibernate по умолчанию включает JDBC-фичу автоматической подготовки запросов на стороне сервера (prepareThreshold, значение по умолчанию в PgJDBC — 5 повторных выполнений одного и того же запроса до перехода на server-side prepare).

Команда добавила детальное логирование hashCode() соединения на входе и выходе из каждого метода репозитория, прогнала нагрузочный тест с повышенным уровнем параллелизма — гонки не нашли: каждый запрос честно брал соединение из пула, использовал и возвращал, без пересечений с другим потоком. Откатили последний деплой сервиса до состояния «неделю назад, когда всё работало» — ошибки не исчезли на этом же трафике через pgBouncer. Это стало решающим аргументом: проблема не в изменениях кода, а в том, что изменилось вокруг него — то есть в самом pgBouncer.

Настоящая причина: prepared statements — это состояние сессии, а transaction pooling его не сохраняет

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

PgJDBC (как и большинство современных драйверов PostgreSQL — asyncpg для Python, Npgsql для .NET) умеет ускорять повторяющиеся запросы, переводя их в серверные подготовленные запросы (PREPARE ... AS ... под капотом, с последующим EXECUTE). Это делается прозрачно: драйвер сам решает, когда запрос выполнялся достаточно раз, чтобы имело смысл готовить его на сервере, присваивает ему внутреннее имя вроде S_1, S_2 и держит это имя привязанным к конкретному физическому соединению с PostgreSQL.

Всё это прекрасно работает, пока клиентское соединение один-в-один соответствует серверному — как это было при прямом подключении к базе. Но pgBouncer в режиме transaction устроен иначе: он выдаёт клиенту физическое серверное соединение только на время одной транзакции, а после COMMIT/ROLLBACK возвращает это соединение в общий пул и на следующей транзакции может выдать клиенту уже совсем другое физическое соединение. С точки зрения клиента это одна логическая сессия, с точки зрения PostgreSQL — разные бэкенды, у каждого из которых своё, независимое пространство имён prepared statements.

Отсюда обе ошибки:

  • «prepared statement does not exist» — драйвер решил, что statement S_3 уже подготовлен (он подготовил его на транзакции N), и на транзакции N+1 сразу шлёт EXECUTE S_3, а физическое соединение под этой транзакцией — другое, и там S_3 никогда не готовился.
  • «prepared statement already exists» — на новом физическом соединении имя S_1 уже занято другим клиентским соединением, которое тоже успело что-то подготовить с тем же авто-сгенерированным именем, и попытка PREPARE S_1 AS ... от нового владельца транзакции падает с конфликтом.

Важный нюанс: pgBouncer версий до 1.21 вообще не умел работать с именованными подготовленными запросами в transaction/statement pooling — он честно документировал это ограничение, но на практике его либо не читали, либо посчитали неприменимым к своему стеку, потому что «мы же не вызываем PREPARE вручную». А драйвер вызывает его сам, без явного участия разработчика — в этом и была ловушка: команда искала баг в коде, который в принципе не содержал ни одной строчки, упоминающей prepared statements.

Что изменили: конфигурация пула и настройки драйвера

Фикс состоял из двух частей — тактической (быстро остановить инцидент) и стратегической (не наступать на грабли повторно при следующем переезде на пулинг).

Быстрое решение — отключить автоматическую серверную подготовку запросов на уровне драйвера для сервисов, которые ходят через transaction-режим pgBouncer. Для PgJDBC это параметр prepareThreshold в JDBC URL:

jdbc:postgresql://pgbouncer-host:6432/orders?prepareThreshold=0

Значение 0 полностью отключает переход на server-side prepare — драйвер работает в режиме simple/extended protocol без именованных statements на сервере. Аналоги в других экосистемах:

# Python, asyncpg
conn = await asyncpg.connect(dsn, statement_cache_size=0)

# .NET, Npgsql
Host=pgbouncer-host;Port=6432;Database=orders;Max Auto Prepare=0

# Python, psycopg (v3) — по умолчанию отключено, но проверить явно
prepare_threshold=None

Стратегическое решение — развести сервисы по двум разным пулам pgBouncer, а не тащить всё через один универсальный. В pgbouncer.ini для баз, где важна производительность за счёт server-side prepare (обычно это внутренние батч-джобы или сервисы с небольшим числом инстансов), оставили session pooling, а для основной массы stateless-запросов от масштабируемых сервисов — transaction:

[databases]
orders_tx = host=127.0.0.1 port=5432 dbname=orders pool_mode=transaction
orders_session = host=127.0.0.1 port=5432 dbname=orders pool_mode=session

[pgbouncer]
listen_port = 6432
auth_type = scram-sha-256
max_client_conn = 2000
default_pool_size = 30

Отдельно проверили версию pgBouncer: на момент инцидента стояла 1.18. В версии 1.21 добавили поддержку max_prepared_statements, при которой pgBouncer сам транслирует именованные подготовленные запросы между клиентским и серверным соединением в transaction-режиме — то есть именно тот сценарий, что сломался, штатно поддерживается новыми версиями при явном включении опции. Обновление до актуальной ветки взяли в бэклог как более чистое решение, но в моменте инцидента отключение server-side prepare на стороне драйвера было быстрее и безопаснее — не требовало обновления инфраструктурного компонента под нагрузкой.

Как проверить это у себя, пока не наступили сами

Если вы планируете (или уже сделали) переезд на pgBouncer в режиме transaction или statement, стоит пройтись по короткому чек-листу:

  • Проверьте версию pgBouncer (pgbouncer --version) и наличие/значение max_prepared_statements в конфиге, если версия 1.21 и новее.
  • Найдите в коде и конфигурации ORM/драйвера всё, что относится к server-side prepare: prepareThreshold у PgJDBC, statement_cache_size у asyncpg, Max Auto Prepare у Npgsql, настройки Hibernate вокруг пула соединений.
  • Отдельно проверьте библиотеки миграций и ORM — некоторые тоже полагаются на именованные подготовленные запросы или SET-команды сессии, которые в transaction pooling так же не переживают смену физического соединения между транзакциями.
  • Не полагайтесь на «оно же прошло нагрузочное тестирование на стейджинге»: коллизия имён prepared statements зависит от числа параллельных клиентских соединений, поэтому эффект часто проявляется только при боевом трафике.
  • Если сервису для скорости реально нужен server-side prepare, не совмещайте это с transaction pooling — выделите под такой сервис отдельную session-пару в pgBouncer или подключайте его к базе напрямую, компенсируя число соединений на уровне HikariCP.
  • Если решающего значения server-side prepare не имеет, отключение автопрепейра — самый дешёвый и предсказуемый выход: небольшая просадка на CPU базы за счёт повторного планирования запроса компенсируется предсказуемостью поведения пула. Разницу стоит измерить на своей нагрузке, а не полагаться на чужие бенчмарки.

Если вы только настраиваете пул соединений с нуля, разумнее сразу разобраться в разнице между режимами и синхронно продумать, какие сервисы каким режимом будут пользоваться — это дешевле, чем переезжать на transaction pooling «по умолчанию» и потом откатывать половину конфигурации драйверов, как пришлось делать в этом инциденте. О том, как вообще устроен pgBouncer и зачем нужен пул соединений, есть отдельный разбор: что такое connection pool и зачем он нужен, а базовую установку и режимы пулинга описывает статья как установить и настроить pgBouncer на VPS.

Если инцидент уже случился и нужно быстро разобраться, какая ещё связка настроек pgBouncer может стрелять в ногу — стоит свериться со списком типовых проблем: pgBouncer на сервере: частые ошибки и решения. А если проблема оказывается не в пуле, а в самой PostgreSQL — держите под рукой разбор наиболее частых ошибок на сервере базы: PostgreSQL на сервере: частые ошибки и решения.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему эта проблема не проявилась на стейджинге?

Коллизии имён prepared statements зависят от количества параллельных клиентских соединений и интенсивности трафика: чем больше уникальных клиентов одновременно активно в пуле, тем выше шанс, что два соединения возьмут одно и то же авто-сгенерированное имя statement на разных физических бэкендах. На стейджинге с низким параллелизмом вероятность коллизии на порядок ниже, поэтому баг может месяцами не проявляться в тестовой среде и полезть только под боевой нагрузкой.

Можно ли просто увеличить prepareThreshold, чтобы реже готовить запросы на сервере, вместо полного отключения?

Это снижает частоту ошибок, но не убирает причину: рано или поздно порог будет достигнут, и та же коллизия произойдёт снова, просто реже. Для transaction pooling без поддержки max_prepared_statements в pgBouncer правильный путь — либо полностью отключить server-side prepare на клиенте, либо перейти на версию pgBouncer, которая умеет транслировать именованные запросы между соединениями.

Session pooling полностью снимает эту проблему?

Да, потому что в session-режиме pgBouncer закрепляет одно серверное соединение за клиентом на всё время жизни клиентского соединения — состояние сессии, включая prepared statements, временные таблицы и SET-команды, не теряется. Обратная сторона — session pooling даёт гораздо меньший выигрыш по экономии серверных соединений, поэтому он не решает исходную задачу (нехватку max_connections) так же хорошо, как transaction pooling.

Как понять, что у меня в проекте вообще есть скрытая зависимость от server-side prepare?

Проверьте конфигурацию ORM и драйвера на предмет параметров, упомянутых выше (prepareThreshold, statement_cache_size, Max Auto Prepare), и посмотрите логи PostgreSQL на предмет PREPARE/EXECUTE в pg_stat_statements или через log_statement = all на короткий промежуток времени в тестовой среде — если такие вызовы есть, а пул работает в transaction/statement-режиме, стоит явно протестировать сценарий с большим числом параллельных клиентов до раскатки на прод.

Стоит ли вообще переходить с session на transaction pooling, если это настолько хрупко?

Да, но осознанно: transaction pooling — совершенно стандартный и надёжный инструмент для масштабирования числа клиентов при ограниченном max_connections, проблема не в самом режиме, а в неявном взаимодействии с фичами драйверов, о которых часто забывают на этапе планирования. Зная про это ограничение заранее, вы либо отключаете автопрепейр там, где он не критичен, либо обновляетесь до версии pgBouncer с явной поддержкой prepared statements — и тогда переезд проходит без сюрпризов.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →