Лок в облачном Postgres завис на 5 дней. Виноват не код, а serverless
Если у вас Postgres на Neon (или Supabase, или любом другом serverless), и вы используете "pg_advisory_lock" для координации между репликами - проверьте сегодня. Скорее всего у вас тлеющий баг.
Где словил
У меня cron каждые 6 часов запускает функцию: проверить, есть ли пост за сегодня, если нет - сгенерировать, отправить в TG-каналы и в VK. Между двумя репликами на хостинге координация через advisory lock на номер UTC-дня. Учебниковый паттерн. Плюс отдельная retry-функция, которая досылает посты, не ушедшие в каналы с прошлого тика.
И вдруг утром обнаруживаю: retry пятый день подряд логирует одно и то же:
> [blog] retry sweep skipped (lock not acquired)
> [blog] retry sweep skipped (lock not acquired)
Retry-lock висит залоченный кем-то - взять его никто не может, retry никогда не запускается. А посты в каналы не идут. И хвост постов с "channel_posted_at IS NULL" тем временем растёт.
Первое подозрение - lock и unlock на разных клиентах
Перечитал PostgreSQL-доку про Explicit Locking. По смыслу: session-level advisory lock привязан к конкретному соединению и держится до явного освобождения или до конца сессии. Если взял на одном клиенте - отпускать нужно с того же. У меня же везде было "pool().query(...)", а "node-postgres" "pool.query" берёт первое доступное соединение из пула. Между lock и unlock пул мог отдать разные клиенты.
Тут я и налажал. Думал, pool сам разрулит сессионную привязку - он же с обычными запросами разруливает. (Нет).
Фикс - пинить один клиент через всю операцию:
> // retryKey = что-то стабильное на день, чтобы лок был общий
> const client = await pool().connect();
> try {
> const acq = (await client.query(
> 'SELECT pg_try_advisory_lock($1) AS acquired',
> [retryKey],
> )).rows[0]?.acquired;
> if (acq) {
> try {
> await retryTodayChannelPosts();
> } finally {
> await client.query('SELECT pg_advisory_unlock($1)', [retryKey]);
> }
> }
> } finally {
> client.release();
> }
"pool.connect()" отдаёт один клиент, в "finally" возвращаем в пул. Lock и unlock теперь гарантированно через одно соединение. Деплоил с уверенностью, что разобрался.
Это работало два дня.
Второе попадание - Neon обрывает TCP у pinned-клиента
Просыпаюсь утром на третий день - в логах опять то же:
> [blog] retry sweep skipped (lock not acquired)
Снова. Дошло. Между "pg_try_advisory_lock" и "pg_advisory_unlock" у меня внутри блока шёл retry с TG/VK API-вызовами, это десятки секунд. Что происходит за это время:
Я взял клиент A через "pool.connect()".
Сделал "pg_try_advisory_lock" на A, получил "true".
Начал делать retry, дёргаю TG/VK API. Обычно 30-60 секунд, но иногда залипает на 60-120, если TG отвечает медленно. Пока я в API, на клиенте A никто не выполняет SQL. Соединение idle.
Neon - serverless: compute-узлы суспендятся после idle (на Free-тарифе через 5 минут), плюс свои таймауты у proxy-слоя между клиентом и backend-ом. В моём кейсе proxy успевал убить сокет раньше, чем срабатывал compute-suspend - и в особо медленные retry-сеансы unlock уже ехал в мёртвое соединение.
Возвращаюсь к коду, пытаюсь сделать "pg_advisory_unlock" через клиент A.
node-postgres видит, что underlying socket мёртв: клиент A на самом деле труп, "client.query" падает с "Connection terminated unexpectedly". Сам по себе клиент не переподключится - просто мёртв, пул его выбросит при "release()".
Unlock в принципе не выполнился. Старая backend-сессия с локом - на сервере она пока ещё "жива" в смысле, что её процесс выполняется. С её точки зрения TCP клиента просто отвалился, но backend не завершается моментально.
В обычном Postgres зазор "TCP пропал -> backend это понял -> сессия закрылась" зависит от tcp_keepalive: при активности это секунды, на дефолтных Linux-настройках без активности backend может узнать о клиенте через часы. В Neon ещё веселее: между моим клиентом и реальным backend-ом стоит proxy-слой со своими таймаутами. Плюс backend может быть suspended (cold compute) и узнаёт о моей смерти только когда его поднимут обратно. Это могут быть минуты или дольше. Всё это время "pg_try_advisory_lock" для остальных возвращает "false".
Я тогда офигел. Unlock не сработал, лок болтается на сервере без хозяина, реплики приходят, видят "false", разворачиваются. И так всё новые сутки. Никто не может зайти.
Локально Postgres такого не делает - он не serverless, idle-сессии висят сколько угодно. В тестах и подавно: моки соединений не разрывают, CI зелёный. А на проде стабильно ловит раз в 2-3 дня.
Я попробовал keepalive - пинговать клиент "SELECT 1" каждые 10 секунд, чтобы не ушёл в idle. Это снизило частоту до "раз в неделю", но не убрало корень. На любой network blip - то же самое.
Что сработало
Перевести retry на row-level locks через "FOR UPDATE SKIP LOCKED" - вообще без advisory locks.
Логика retry была: "найди в БД сегодняшние посты с "channel_posted_at IS NULL", для каждого попробуй послать, если успешно - проставь дату". Это же ровно то, что Postgres умеет нативно через "FOR UPDATE SKIP LOCKED".
> const client = await pool().connect();
> try {
> while (true) {
> await client.query('BEGIN');
> // в боевом коде ещё фильтр slug LIKE 'YYYY-MM-DD-%' под сегодняшние, опускаю для простоты
> const res = await client.query(
> `SELECT slug, title, content_md, lang FROM blog_posts
> WHERE published = TRUE AND channel_posted_at IS NULL
> ORDER BY created_at LIMIT 1
> FOR UPDATE SKIP LOCKED`,
> );
> if (res.rowCount === 0) {
> await client.query('COMMIT');
> return;
> }
> const post = res.rows[0];
> try {
> const { channelOk } = await publishToChannel(post);
> if (channelOk) {
> await client.query(
> 'UPDATE blog_posts SET channel_posted_at = NOW() WHERE slug = $1',
> [post.slug],
> );
> await client.query('COMMIT');
> } else {
> await client.query('ROLLBACK');
> return; // bail на первой ошибке, не ddos-им сломанный API
> }
> } catch (err) {
> try { await client.query('ROLLBACK'); } catch {}
> return;
> }
> }
> } finally {
> client.release();
> }
Что меняется. Лок выдаётся не сессии, а строке. "FOR UPDATE SKIP LOCKED" захватывает первую незаблокированную, остальные потоки её пропускают и идут к следующей. Если транзакция коммитится - лок отпускается, дата стоит. Если роллбэчится (упали или TG ответил ошибкой) - лок отпускается, дата остаётся "NULL", на следующем тике другая реплика возьмёт ту же строку.
И главное: если коннект умер прямо в середине транзакции, Postgres сам делает rollback на серверной стороне и отпускает все локи в этой транзакции. Транзакционный лок не может остаться "потерянным" как advisory: TCP-сброс, idle-timeout, suspended compute - всё это Postgres обрабатывает через штатный transaction abortion. Гарантирует это сама архитектура движка, не настройки.
Если две реплики одновременно начали retry - они просто будут последовательно брать разные строки благодаря "SKIP LOCKED". На уровне нагрузки безразлично, потому что в день максимум 1-3 поста попадают в retry.
Деплоил, четыре дня тишины. Пока нормас.
Итого
Если у вас в коде стоит "pg_advisory_lock" на serverless Postgres - сегодня хороший день пройти по всем местам и спросить себя: а что тут координируется на самом деле? Часто оказывается, что session-lock не нужен вовсе - хватает "INSERT ... ON CONFLICT" или сравнения состояния. В остальных случаях его можно заменить на "FOR UPDATE SKIP LOCKED" под конкретные строки.
Те случаи, когда session-lock действительно нужен - это long-running worker, который держит соединение всю свою жизнь, не отпуская его в idle. В serverless такого зверя нет.
Что бесило больше всего - баг проявлялся раз в 2-3 дня и локально не воспроизводился вообще. Тесты зелёные, CI зелёный. На проде тлеет. Узнавал глубже только когда читатель в комментариях писал «а где сегодняшний пост». Не лучший способ узнавать.
#postgres #neon #nodejs #грабли










