В настройках клиента базы стоял statement_timeout в одну секунду, а запрос выполнялся больше сорока. Таймаут выглядел включённым и не действовал. У этой поломки нашлись четыре родственницы. Пулер в тексте ошибки назвала только одна. Остальные выглядели как пропавшая таблица, недоступная база и простой после выката.
Постановка
База живёт в Neon, перед ней штатный пулер соединений: PgBouncer в режиме транзакций. Через него ходят в PostgreSQL фоновые задачи XMBoost, сервиса, над которым я работаю.
В режиме транзакций серверное соединение закрепляется за клиентом только на время транзакции. Транзакция закончилась, соединение вернулось в пул, и следующий запрос того же клиента может уйти на любое другое. Для PostgreSQL сессия и есть серверное соединение, поэтому всё, что он привязывает к сессии, перестаёт принадлежать клиенту.
В документации PgBouncer всё, что при этом ломается, сведено в одну таблицу. Поломки на неё не ссылаются. Следствия я выводил из поломок, по одной.
Ловушка 1. Таймаут из настроек клиента до сервера не доезжает
Служебный запрос на проде читал таблицу на 10 ГБ и шёл от 43 до 48 секунд. В настройках клиента, который его отправлял, стояло statement_timeout: 1000. Ошибок не было. Проверка тем же клиентом через пулер заняла одну строку:
SHOW statement_timeout; -- 0
node-postgres отправляет statement_timeout отдельным параметром стартового пакета. Получает этот пакет пулер, а не PostgreSQL: серверные соединения он открывает сам. По документации PgBouncer пропускает оттуда только параметры, которые умеет отслеживать, на остальные отвечает ошибкой, а перечисленные в ignore_startup_parameters принимает и игнорирует. Мой ошибки не вызвал и до сервера не дошёл. Какой слой управляемого пулера его отбросил, снаружи не видно.
Обычный SET не выход: в таблице PgBouncer для режима транзакций напротив него стоит Never. Настройка осядет на том серверном соединении, которое досталось команде, и достанется чужим запросам. Для одного запроса её доносит SET LOCAL внутри транзакции:
function withStatementTimeout<T>( ms: number, run: (tx: Prisma.TransactionClient) => Promise<T>, ) { return db.$transaction(async (tx) => { await tx.$executeRawUnsafe( `SET LOCAL statement_timeout = ${Math.round(ms)}`); return run(tx); }, { timeout: ms + 4000 }); }
Транзакция целиком идёт на одном серверном соединении, а SET LOCAL действует до её конца. Число попадает в строку из константы, не из входных данных.
Если таймаут нужен всем соединениям роли, есть ALTER ROLE … SET statement_timeout: значение становится умолчанием для каждой новой серверной сессии этой роли. Мне нужен был потолок для одного клиента, поэтому транзакция.
Проверял отказом: pg_sleep(3) при таймауте в секунду обрывается, обычный запрос проходит, за пределы транзакции настройка не протекает.
Ловушка 2. Тот же стартовый пакет, но с отказом
Скрипт бэкапа для страховки открывал соединение только на чтение, через PGOPTIONS='-c default_transaction_read_only=on'. Первый же боевой снимок упал четыре раза:
unsupported startup parameter in options: default_transaction_read_only. Please use unpooled connection
Единственная ловушка из пяти, которая назвала себя сама.
Механизм тот же, разница в упаковке: statement_timeout node-postgres кладёт отдельным параметром, а PGOPTIONS попадает внутрь параметра options, в ошибке так и написано: «in options». Один параметр пулер проглотил, другой отверг. Что из двух случится, решают списки в его конфигурации, а у управляемого пулера их составлял не я.
Оговорка: документация Neon называет пять разрешённых параметров стартового пакета, и default_transaction_read_only среди них нет, а в актуальной документации PgBouncer он отслеживается по умолчанию. На своём PgBouncer пример может не воспроизвестись.
Что сделал: бэкап ходит на прямой адрес. У Neon он отличается от адреса пулера отсутствием суффикса -pooler в имени хоста. Защита «только чтение» осталась прежней.
Ловушка 3. Временная таблица пропадает между запросами
psql-скрипт для разового замера собирал выборку во временную таблицу и следующей командой её читал.
CREATE TEMP TABLE picked AS SELECT id FROM items WHERE flag; SELECT count(*) FROM picked; -- ERROR: relation "picked" does not exist
Первая команда отработала без ошибок. Два соседних скрипта с такими же таблицами до этого дня проходили.
Без явного BEGIN каждая команда идёт отдельной транзакцией. CREATE выполнился, серверное соединение вернулось в пул вместе с таблицей: временная таблица по умолчанию живёт до конца сессии, а сессия осталась на сервере. SELECT пулер вправе отдать другому соединению. Иногда он попадает на то же самое, и скрипт проходит. Удачный пробный прогон здесь ничего не доказывает.
Что сделал: любой скрипт с временной таблицей идёт в BEGIN; … COMMIT; и запускается с -v ON_ERROR_STOP=1, а таблица объявляется с ON COMMIT DROP. В таблице PgBouncer это единственный вид временных таблиц, отмеченный для режима транзакций как рабочий. Без него таблица переживёт COMMIT и останется на серверном соединении, которое пулер отдаст следующему клиенту.
Ловушка 4. Advisory lock остался на чужом соединении
Сборка упала на шаге миграций с кодом P1002 и текстом Timed out trying to acquire a postgres advisory lock. Следующая упала так же. По справочнику Prisma P1002 значит «сервер базы достигнут, но не ответил вовремя». Выглядит как авария базы, а не как ошибка конфигурации.
prisma migrate deploy перед накатом берёт сессионный advisory lock, pg_advisory_lock, чтобы два параллельных выката не накатывали одно и то же. Такую блокировку PostgreSQL держит, пока её не снимут явно или пока не закончится сессия. В таблице PgBouncer у неё то же Never.
Блокировку берёт один запрос. Запрос закончился, серверное соединение вернулось в пул, а блокировка осталась на нём: она привязана к сессии сервера, а не к клиенту. Пулер отдаёт это соединение приложению, и сессия с блокировкой живёт дальше. Следующий выкат ждёт её 10 секунд (срок у Prisma не настраивается) и падает с той же ошибкой.
Вскрылось это на двух одновременных сборках из одного репозитория в одну базу. Напрашивался диагноз «гонка», но гонка только сделала причину заметной.
Снять такую блокировку можно одним способом: оборвать серверное соединение, на котором она висит.
SELECT pid FROM pg_locks WHERE locktype = 'advisory'; SELECT pg_terminate_backend(12345); -- pid из первого запроса
Что сделал: prisma migrate deploy ходит только прямым адресом. Если прямой адрес не задан, команда падает сразу, а не уходит в пулер. Отключать блокировку переменной PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK не стал: она и защищает от двойного наката.
Ловушка 5. Замена блокировки не умирает вместе с процессом
Эта ловушка уже не про пулер, а про цену обхода.
Фоновый цикл должен крутить один процесс: два прочитают одни и те же строки очереди и оба сходят во внешний платный API. Сессионный advisory lock отпал по причине из ловушки 4. pg_advisory_xact_lock с пулером безопасен, но живёт до конца транзакции, а проход цикла длится минуты. Осталась аренда (lease): строка с именем цикла, держателем и сроком, которую берут одним условным UPDATE.
const expiresAt = new Date(Date.now() + ttlMs); const taken = await db.$executeRaw` UPDATE tick_lease SET holder = ${HOLDER}, expires_at = ${expiresAt} WHERE name = ${name} AND (holder = ${HOLDER} OR expires_at < (now() AT TIME ZONE 'UTC'))`; // taken > 0: цикл сейчас мой
В упрощённом коде опущены первая вставка строки (гонку в ней ловит первичный ключ) и запрет опоздавшему продлению перехватывать аренду у сменщика. Держателем записан процесс, а не сервис: имя, pid и случайный хвост. Живой держатель продлевает аренду каждую треть срока, а потерявший получает AbortSignal и останавливается на безопасной границе.
Сессионную блокировку PostgreSQL снимает сам, когда сессия заканчивается. Строка в таблице о смерти процесса не узнаёт. Сначала я увидел это на перезапусках: процесс, убитый посреди прохода, не выполняет finally, и следующая копия ждёт конца срока. Добавил освобождение аренды в обработчик остановки.
Потом один выкат всё равно остановил обработку. Очередь росла на 66 строк в минуту и за двадцать минут дошла до 1 574. Ожила она в ту секунду, когда истёк срок аренды. Старая копия завершалась не по сигналу остановки, а через обработчик ошибки, с process.exit(1), и на этом пути аренду никто не отпускал.
Просто позвать освобождение в catch перед выходом нельзя: до process.exit идущий проход ещё разбирает свою пачку, а сменщик уже видит свободную аренду и берёт те же строки. Порядок такой: запретить новый проход, убедиться, что текущий стоит, и только тогда отпускать, всё под таймаутом. Проход ещё идёт: аренду не трогаю, она истечёт сама. Два следующих выката в тот же день прошли без простоя.
Вторая ошибка была в часах. Колонка срока без часового пояса, драйвер пишет туда UTC, а голый now() PostgreSQL приводит по поясу сессии. В замере база с поясом Asia/Bangkok отдавала минутную аренду второму держателю сразу. Отсюда AT TIME ZONE 'UTC' в запросе.
Что из этого следует
У всех пяти случаев одна причина: приложение считает сессией своё клиентское соединение, а PostgreSQL считает сессией серверное.
Правила, которые у меня остались:
Состояние живёт внутри одной транзакции.
SET LOCAL,ON COMMIT DROP,pg_advisory_xact_lockчерез пулер работают, их сессионные близнецы нет.Настройку спрашивать у сервера.
SHOWтем же адресом, каким ходит приложение. Строка в конфиге клиента говорит о намерении, а не о результате.Всему, что берёт сессионную блокировку или живёт дольше одной транзакции, нужен прямой адрес. У меня это
prisma migrate deployи бэкап базы.Проверять отказом.
pg_sleepдольше таймаута, второй процесс рядом с арендой.Заменяя блокировку строкой, выписать, что блокировка делала сама. Она освобождалась при смерти процесса и жила по одним часам с базой. Оба свойства пришлось писать руками, и оба я сначала написал с ошибкой.

