PostgreSQL CookbookUse casesПулинг из приложения
0 / 63 (0%)

Пулинг из приложения

Brew вырос. Раньше один инстанс бэкенда держал свой пул соединений к Postgres напрямую, и всё работало. Теперь инстансов десятки, у каждого свой пул, и сумма их соединений упёрлась в max_connections. Классический ответ — поставить перед базой pgbouncer в транзакционном режиме: он держит небольшой набор реальных бэкендов и раздаёт их приложениям по требованию. Соединений к базе стало мало, все довольны — пока внезапно не перестают работать вещи, которые годами работали без единого нарекания: advisory-лок «течёт», LISTEN молчит, параметрический запрос валится с ошибкой про отсутствующий prepared statement.

Причина одна, и её надо понять буквально дословно. Транзакционный пул держит реальный бэкенд за тобой ровно на одну транзакцию, а не на «сессию». Закрыл транзакцию — бэкенд вернулся в общий котёл, и следующую транзакцию пул может отдать тебе уже на другом бэкенде. Всё, что живёт на уровне сессии, а не транзакции, при такой пересадке молча ломается.

Что значит «сессия» и почему она исчезает

Когда ты подключаешься к Postgres напрямую, у тебя есть сессия — она живёт весь коннект и хранит состояние: взятые session-level advisory-локи, подписки LISTEN, заданные через SET параметры (GUC), кэш prepared statements. Всё это привязано к конкретному бэкенду (процессу postgres, обслуживающему твой коннект).

Транзакционный пул эту иллюзию сессии разрушает. Между транзакциями ты можешь оказаться на другом бэкенде — а у него своё состояние, твоего там нет. Правило, которое стоит записать на стене: транзакционный пул гарантирует один бэкенд на одну транзакцию, а не на сессию. Дальше — три конкретные поломки и их фиксы.

Поломка 1: session-level advisory-лок

Session-level advisory-лок (pg_advisory_lock, см. 05-06) принадлежит бэкенду, который его взял. Лок при этом глобальный и видимый всем: другой бэкенд видит, что ключ занят. Но снять лок может только владелец. Если пул взял лок на одном бэкенде, а отпускать ты пришёл с другого — pg_advisory_unlock вернёт false, лок останется висеть, и это утечка.

Фикс — transaction-scoped лок pg_advisory_xact_lock. Он держится ровно одну транзакцию (которую пул честно держит на одном бэкенде) и снимается сам на COMMIT. Отпускать руками не нужно, а значит, и пересадка между бэкендами ничего не ломает.

Поломка 2: LISTEN/NOTIFY

LISTEN регистрирует подписку на конкретном бэкенде. NOTIFY доедет только до тех бэкендов, которые сделали LISTEN на этот канал. Под транзакционным пулом ты сделал LISTEN в одной транзакции, пул вернул бэкенд в котёл, ты пришёл слушать — а тебя пересадили на бэкенд, который про твой LISTEN ничего не знает. Уведомление к тебе не придёт никогда.

Фикс тот же, что в 09-04: выделенный коннект. Ты держишь один коннект за собой, делаешь на нём LISTEN и на нём же ждёшь уведомления — пул его не пересаживает, потому что ты его не отпускаешь. pg_notify приходит именно туда.

Поломка 3: prepared statements

pgx по умолчанию работает по расширенному протоколу и кэширует подготовленные запросы per-backend: первый раз готовит запрос на бэкенде, дальше переиспользует по имени. Под транзакционным пулом запрос, подготовленный на одном бэкенде, на следующем просто отсутствует — и запрос падает.

Фикс — перевести пул в режим простого протокола (pgx.QueryExecModeSimpleProtocol, выставляется через pg.Option): pgx перестаёт кэшировать prepared statements на бэкенде, и пересадка ничего не ломает. У платы за это есть нюанс — простой протокол чуть менее эффективен, но под транзакционным пулом это единственный надёжный путь. Реальный pgbouncer, к слову, умеет и обратное: max_prepared_statements позволяет ему самому отслеживать prepared statements поверх пула.

Пересадка между бэкендами — и что её переживает

Все три поломки — одна и та же картинка: пул закрепляет бэкенд за тобой на транзакцию, между транзакциями пересаживает, и всё session-scoped остаётся на старом бэкенде:

plaintext
Транзакционный пул: бэкенд закреплён за тобой на ТРАНЗАКЦИЮ, не на сессию
 
  TX1 ──► пул выдал backend A     LISTEN orders · pg_advisory_lock(42)
  COMMIT ──► backend A ушёл в общий котёл
  TX2 ──► пул выдал backend B     ← другой бэкенд!
                                    на B нет твоего LISTEN, нет твоего лока:
                                    NOTIFY не придёт, unlock вернёт false (течёт)
 
  фикс — не отпускать бэкенд между операциями:
    pg_advisory_xact_lock   снимается сам на COMMIT (живёт внутри TX)
    выделенный коннект      держишь backend A за собой, LISTEN на нём же
    простой протокол        нет prepared statements, привязанных к бэкенду

То же правило сводится в таблицу: что опасно держать на сессии и чем это заменить.

Что живёт на сессииSession-scoped (ломается под пулом)Transaction-safe (переживает)
Advisory-локpg_advisory_lock (течёт на чужом бэкенде)pg_advisory_xact_lock (снимается на COMMIT)
LISTEN/NOTIFYLISTEN через пул (молчит)выделенный коннект
Prepared statementsкэш per-backend (падает)простой протокол / max_prepared_statements
Параметры GUCSET на сессиюSET LOCAL внутри транзакции

Что показывает наш код

Реального pgbouncer здесь нет. Мы воспроизводим транзакционный пул на чистом Postgres, намеренно разводя операции по нескольким бэкендам из пула (pool.Acquire даёт разные коннекты = разные бэкенды) — ровно то, что делает транзакционный пул между транзакциями. Это честная симуляция: поведение session-состояния от этого не меняется.

Юнит написан на сыром pgx (escape-hatch, go.mod без sqlc): урок про управление соединениями — Acquire/Release, выделенный коннект, режим протокола. Это API пула, а не SQL, поэтому sqlc тут был бы не к месту.

cmd/demo/main.go показывает три поломки и три фикса. В первой части connA и connB — разные бэкенды (печатается их pg_backend_pid): A берёт pg_advisory_lock(42), B видит его (pg_try_advisory_lockfalse), но снять не может (pg_advisory_unlockfalse, лок течёт), а pg_advisory_xact_lock(99) держится внутри транзакции и снимается на COMMIT. Во второй — выделенный коннект ловит NOTIFY, а коннект без LISTEN молчит до таймаута. В третьей — отдельный пул в режиме простого протокола гоняет параметрический SELECT name FROM drinks WHERE id = $1 и спокойно возвращает результат.

Одна тонкость про вывод: неудачный pg_advisory_unlock на чужом локе печатает в лог WARNING. Это ожидаемо, и идёт он в stderr — stdout остаётся чистым, ровно тот текст, что вставлен ниже.

Запуск

sh
docker compose up -d
make lecture L=10-use-cases/10-04-pooling-from-the-app T=db-reset
make lecture L=10-use-cases/10-04-pooling-from-the-app

T=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче: make db-reset, затем make run. Поскольку это капстон, у юнита есть и make test — он гоняет интеграционный тест с проверкой утверждений (assert) на живой базе.

plaintext
1) Session advisory-лок привязан к бэкенду (транзакционный пул его ломает)
   A и B — разные бэкенды: true
   A: pg_advisory_lock(42) — взял
   B: pg_try_advisory_lock(42) → false (лок виден всем, держит A)
   B: pg_advisory_unlock(42) → false (не его лок — снять нельзя, лок течёт)
   фикс — pg_advisory_xact_lock: держится в транзакции true, после COMMIT false (снялся сам)
 
2) LISTEN/NOTIFY живёт на бэкенде — нужен выделенный коннект
   выделенный коннект (сам делал LISTEN): получил "order #1"
   коннект без LISTEN (как при пересадке пулом): услышал что-то false (таймаут — ничего)
 
3) Prepared statements под пулингом → режим простого протокола
   simple protocol: SELECT с параметром вернул "Эспрессо" — без кэша prepared-запросов на бэкенде
   (по умолчанию pgx кэширует prepared statements per-backend — под транзакционным пулом это ломается)

Блок 1: A и B действительно сидят на разных бэкендах. A взял лок, B его видит, но снять чужой лок не может — будь это прод, лок повис бы навсегда. А transaction-scoped лок держится внутри транзакции (true) и сам снимается на COMMIT (false). Блок 2: уведомление пришло только на тот коннект, который сам делал LISTEN; коннект без подписки честно отсидел таймаут и ничего не услышал. Блок 3: параметрический запрос в режиме простого протокола вернул Эспрессо без всякого per-backend кэша.

Заборчик

  • Это симуляция, а не настоящий pgbouncer. В проде перед Postgres стоял бы настоящий pgbouncer в транзакционном режиме; мы сымитировали его поведение, разведя операции по нескольким бэкендам пула. Симуляция честная ровно в одном: она ломает то же самое session-состояние, что сломал бы реальный пул. Но это не замена pgbouncer и не его конфиг.
  • Транзакционный пул гарантирует один бэкенд на транзакцию, не на сессию. Держи это в голове постоянно. Опасно всё session-scoped — session-level advisory-локи, LISTEN, сессионные GUC через SET, обычные prepared statements; безопасны их транзакционные эквиваленты (см. таблицу выше). Нужен другой режим пула — session pooling вернёт сессию целиком, но тогда исчезает сама экономия соединений, ради которой пул ставили.
  • Подбор размера пула под max_connections — операционная работа, не наша. Это реальная и важная задача, но территория твоего DBA. Юнит про то, как писать код, который переживёт транзакционный пул, а не про то, как этот пул настраивать.

Что забрать с собой

Транзакционный пул (pgbouncer transaction mode) держит реальный бэкенд за тобой ровно на одну транзакцию, не на сессию — между транзакциями он может пересадить тебя на другой бэкенд. Поэтому всё session-scoped тихо ломается, и лечится переходом на транзакционные эквиваленты: pg_advisory_xact_lock вместо session-локов, выделенный коннект для LISTEN/NOTIFY, режим простого протокола вместо per-backend кэша prepared statements. Пул соединений мы завели ещё в 00-06, advisory-локи разобрали в 05-06, а выделенный коннект для LISTEN/NOTIFY — в 09-04; здесь всё это столкнулось с реальностью пулинга.

Дальше — финал. Capstone 10-05 замыкает курс: базовые таблицы Brew (orders, outbox, CDC-источники с REPLICA IDENTITY FULL) уезжает в логическую репликацию, и шов CDC передаёт эстафету в kafka-cookbook — Debezium читает наши таблицы без переписывания схемы. Две книги сходятся на одной модели данных.

·Модуль 11

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Use cases / Пулинг из приложения