Команда замечает, что PostgreSQL задыхается под наплывом соединений, ставит перед ним PgBouncer, по совету из интернета выставляет транзакционный режим и катит это в продакшен. Через час сыпятся загадочные ошибки про уже существующие подготовленные выражения, временные таблицы исчезают между запросами, а настройки сессии будто испаряются. Виноват не PgBouncer и не PostgreSQL. Виноват выбор режима пула, сделанный без понимания того, что именно он меняет в поведении соединения. Разница между сессионным и транзакционным режимом не косметическая, она затрагивает саму модель того, как состояние живёт внутри соединения.
Зачем вообще ставить пул перед PostgreSQL
Корень проблемы в архитектуре самого PostgreSQL. На каждое соединение он порождает отдельный процесс операционной системы, и высокое число соединений напрямую съедает огромные объёмы памяти. Современное веб-приложение без посредника открывает новое соединение почти на каждый запрос, и эта постоянная текучка бьёт по базе больно, быстро упираясь в предел числа клиентов и порождая внезапные отказы с жалобой на слишком много клиентов.
Пул соединений решает это, позволяя множеству клиентских подключений делить небольшой набор серверных. PgBouncer садится между приложением и базой и делает это легковесно, без изменения клиентского кода. Но дальше начинается развилка. PgBouncer предлагает три режима через настройку, и выбор между ними определяет, как долго серверное соединение остаётся закреплённым за клиентом и какие возможности при этом теряются.
Чем сессионный режим отличается от транзакционного на уровне модели
В сессионном режиме одно серверное соединение закрепляется за клиентом на весь срок его жизни и возвращается в пул только когда клиент отключается. Это самый совместимый и безопасный режим. Подготовленные выражения, команды настройки, рекомендательные блокировки и вообще всё работает ровно так, как при прямом подключении к базе. Расплата за совместимость в том, что выигрыш по числу соединений почти отсутствует. Если подключено триста клиентов, то и серверных соединений тоже триста. Польза в основном в том, что PgBouncer ставит клиентов в очередь, а не отвергает их при заполненном пуле: они ждут, а не получают отказ.
В транзакционном режиме серверное соединение выдаётся клиенту на время одной транзакции и сразу после её завершения, фиксации или отката, возвращается в пул. Это самый популярный режим, и именно он даёт то самое драматичное сокращение числа серверных соединений. Можно принимать тысячи клиентских подключений, держа при этом крошечный пул к реальной базе. Но за это приходится платить, и плата неочевидная: состояние уровня сессии не переживает границу транзакции. Следующая транзакция того же клиента может попасть совсем на другое серверное соединение, где никакого накопленного состояния нет.
Третий режим, выражательный, освобождает соединение после каждого отдельного оператора и не допускает многооператорных транзакций, поэтому для большинства обычных приложений не подходит.
Почему подготовленные выражения спотыкаются в транзакционном режиме
Подготовленное выражение по своей природе живёт на уровне сессии. Клиент один раз объявляет его на серверном соединении, а потом многократно исполняет, экономя на разборе и планировании запроса. В этом и фокус: оно привязано к конкретному серверному соединению.
В транзакционном режиме это ломается предсказуемым образом. Рассмотрим простейший сценарий, который наглядно показывает суть. Клиент выполняет объявление выражения и его исполнение дважды подряд.
PREPARE stmtc AS SELECT 1;
EXECUTE stmtc;
-- Первый раз: всё проходит
PREPARE stmtc AS SELECT 1;
EXECUTE stmtc;
-- ERROR: prepared statement "stmtc" already exists
В прямом подключении такого конфликта не было бы, ведь сессия одна. Но через пул второй вызов может попасть на серверное соединение, где это выражение уже объявлено предыдущим клиентом, и база честно сообщает о конфликте имён. Реальный случай из практики банковского приложения выглядел именно так: миграции порождали подготовленные выражения, режим стоял транзакционный, и после переключения на новую среду посыпались ошибки про уже существующие выражения. Лечение там свелось к тому, чтобы для конкретной базы с такими выражениями выставить сессионный режим, оставив транзакционный по умолчанию для остальных.
Как современный PgBouncer частично примиряет пул с подготовленными выражениями
Долгое время подготовленные выражения были железным аргументом против транзакционного режима. Но начиная с версии 1.21 ситуация изменилась. PgBouncer научился отслеживать подготовленные выражения уровня протокола и подготавливать их на лету на том серверном соединении, которое достаётся клиенту. Включается это установкой максимального числа подготовленных выражений в ненулевое значение.
[pgbouncer]
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 50
max_prepared_statements = 100
Под капотом PgBouncer запоминает, как клиент назвал выражение, и сопоставляет его со своим внутренним именем вида с префиксом пула. Когда клиента перебрасывает на другое серверное соединение, где этого выражения ещё нет, PgBouncer подготавливает его там заново и переписывает имя. На синтетических тестах эта возможность поднимала пропускную способность запросов от пятнадцати до двухсот пятидесяти процентов в зависимости от нагрузки.
Но у решения есть существенные оговорки, и игнорировать их нельзя. Первая: работает оно только с подготовленными выражениями уровня протокола, а не с теми, что объявлены через SQL-команду. То есть путь через расширенный протокол драйвера, а не через ручной оператор объявления. Вторая оговорка тоньше: возможности закрыть выражение на уровне протокола долго не было, поддержка появилась только в семнадцатой версии PostgreSQL. Из-за этого клиентские драйверы, пытающиеся освободить выражение командой удаления, могут выдавать странные ошибки. PgBouncer сам умеет подчищать выражения, так что проблема скорее на стороне клиента. В ряде драйверов появился способ пропускать освобождение, доверяя эту работу пулу.
Совместимость драйверов это отдельная боль. Распространённый драйвер PHP совместим с этой возможностью только при достаточно свежих версиях языка и клиентской библиотеки. Для драйвера JDBC корректный способ это добавить в строку подключения параметр, отключающий порог подготовки. Проще говоря, включить число подготовленных выражений мало, нужно ещё убедиться, что клиентская библиотека реально умеет с этим работать.
Что происходит с командами настройки сессии и временными таблицами
Подготовленные выражения это лишь самая заметная часть проблемы. Транзакционный режим по своей конструкции ломает целый ряд вещей, завязанных на состояние сессии. Команды настройки, не обёрнутые в транзакцию, по умолчанию действуют на уровне сессии и поэтому теряются. Сюда же попадают подписка на уведомления, удерживаемые курсоры, рекомендательные блокировки уровня сессии.
Особое внимание заслуживает локальная установка параметров внутри транзакции. На первый взгляд она кажется безопасной, ведь действует только в пределах текущей транзакции. И это действительно так: пока установка живёт ровно одну транзакцию и в ней же используется, проблемы нет. Опасность возникает, когда код полагается на то, что параметр переживёт границу транзакции. Опытные инженеры при ревизии приложения под транзакционный режим целенаправленно ищут в коде два красных флага: использование временных таблиц и установку параметров, рассчитанную на жизнь дольше одной транзакции.
Временные таблицы в транзакционном режиме обязаны жить в пределах одной транзакции: их создают, используют и удаляют, не выходя за её границы. Удобный приём это создавать их с указанием автоматического удаления при фиксации, тогда таблица сама исчезает по завершении создавшей её транзакции. Если же приложение создаёт временную таблицу в одной транзакции и ожидает увидеть её в следующей, оно гарантированно сломается, потому что следующая транзакция попадёт на другое серверное соединение.
Как выбрать режим осознанно и не обжечься
Главное решение при развёртывании PgBouncer это выбор режима пула, и его нельзя принимать второпях. Истории о том, как команда мимоходом переключила режим в продакшене и внезапно сломала временные таблицы, подготовленные выражения или долгие сессии, повторяются с пугающим постоянством. Подход должен быть структурным.
Для большинства веб-приложений транзакционный режим это правильный выбор по умолчанию, потому что именно он даёт максимальный выигрыш от мультиплексирования. Современные фреймворки и ORM в большинстве своём корректно работают с ним, особенно если выражения подготавливаются через протокол, а не через SQL-команды. Сессионный режим уместен там, где приложение по-настоящему зависит от состояния сессии и переписать его под транзакционную модель дороже, чем смириться с меньшим выигрышем по соединениям. Прелесть PgBouncer в том, что режим настраивается на уровне отдельной базы, поэтому никто не мешает держать транзакционный режим для основного трафика и сессионный точечно для той базы, которой нужны сессионные возможности.
Разумная стратегия выглядит так. Сначала тестируем приложение в транзакционном режиме, потому что большинство современных стеков с ним работают. Если всплывают ошибки про подготовленные выражения, сначала пробуем включить их отслеживание через настройку и проверяем совместимость драйвера, и лишь когда это не помогает или приложение завязано на сессионное состояние, отступаем к сессионному режиму для проблемной базы. И в любом режиме держим под наблюдением метрики пула: глубину очереди ожидающих клиентов и среднее время транзакции. Долгие транзакции в транзакционном режиме особенно вредны, потому что соединение нельзя переиспользовать, пока оно заперто в затянувшейся транзакции. Короткие и сфокусированные транзакции это не пожелание, а условие, при котором весь смысл транзакционного пула вообще работает.
PgBouncer полезен, важен и при этом коварен. Он не меняет SQL, но меняет правила жизни состояния внутри соединения, и тот, кто переключает режим не глядя, рано или поздно встречает загадочную ошибку в самый неподходящий момент. А тот, кто понимает, что именно теряется на границе транзакции, получает кратный рост по числу обслуживаемых клиентов почти бесплатно.