Пулинг из приложения
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 остаётся на старом бэкенде:
Транзакционный пул: бэкенд закреплён за тобой на ТРАНЗАКЦИЮ, не на сессию
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/NOTIFY | LISTEN через пул (молчит) | выделенный коннект |
| Prepared statements | кэш per-backend (падает) | простой протокол / max_prepared_statements |
| Параметры GUC | SET на сессию | 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_lock → false), но снять не
может (pg_advisory_unlock → false, лок течёт), а pg_advisory_xact_lock(99)
держится внутри транзакции и снимается на COMMIT. Во второй — выделенный коннект
ловит NOTIFY, а коннект без LISTEN молчит до таймаута. В третьей — отдельный пул
в режиме простого протокола гоняет параметрический SELECT name FROM drinks WHERE id = $1 и спокойно возвращает результат.
Одна тонкость про вывод: неудачный pg_advisory_unlock на чужом локе печатает в
лог WARNING. Это ожидаемо, и идёт он в stderr — stdout остаётся чистым, ровно
тот текст, что вставлен ниже.
Запуск
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-appT=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче:
make db-reset, затем make run. Поскольку это капстон, у юнита есть и make test — он гоняет интеграционный тест с проверкой утверждений (assert) на живой
базе.
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 читает наши таблицы без переписывания
схемы. Две книги сходятся на одной модели данных.