Learning Platform
Урок 11.05 · 22 мин
Продвинутый
Advisory lockpg_advisory_lockLeader electionCron jobApplication coordination

В предыдущих уроках мы говорили о блокировках, привязанных к данным: table-level (ROW EXCLUSIVE на таблицу) и row-level (через xmax). Все они автоматически возникают при работе с SQL.

Но иногда нужен именованный lock, не привязанный ни к какой строке или таблице. Примеры:

  • «Только один воркер в кластере должен запускать nightly_report cron в полночь.»
  • «На время миграции схемы только один deploy script должен делать DDL.»
  • «Обработка пользователя 42 должна быть атомарна — но в БД нет таблицы, где можно взять lock на пользователя, потому что обработка не пишет в БД, она дёргает внешнее API.»

Для таких случаев Postgres даёт advisory locks — lock’и на любой bigint-ключ, никак не связанные с данными. Что значит этот ключ — решает приложение. Postgres просто гарантирует: «один такой ключ — один lock в один момент».

API

Два пакета функций — для session-scope и transaction-scope:

Advisory locks API

Session-scope живёт пока сессия открыта (или пока не вызван unlock). Transaction-scope живёт только до COMMIT/ROLLBACK. Каждая функция имеет вариант с одним bigint или с двумя int (key1, key2)

pg_advisory_lock(key)session-scope, ждёт
pg_try_advisory_lock(key)session-scope, не ждёт; returns boolean
pg_advisory_unlock(key)вручную освободить session-scope
pg_advisory_unlock_all()освободить все мои session-scope locks
pg_advisory_xact_lock(key)transaction-scope, авто-unlock на COMMIT
pg_try_advisory_xact_lock(key)transaction-scope, не ждёт; returns boolean

Все функции имеют две формы:

  • pg_advisory_lock(key bigint) — один 64-битный ключ.
  • pg_advisory_lock(key1 int, key2 int) — два 32-битных. Семантически — отдельное пространство ключей от 64-битной формы.

Двух-аргументная форма удобна, когда хочешь логически структурировать ключи: key1 = 'тип ресурса', key2 = 'id ресурса'. Например, pg_advisory_lock(100, user_id) — для всех locks по пользователям использовать namespace 100.

Session-scope: классический leader election

Канонический use case — выбор «лидера» среди реплик/воркеров. Несколько процессов стартуют одновременно, каждый пытается взять lock с фиксированным ключом. Один получает — он leader. Остальные знают, что они follower’ы.

# В каждом инстансе при старте:
try:
    got_lock = db.execute("SELECT pg_try_advisory_lock(:key)", key=42).scalar()
    if got_lock:
        run_as_leader()
    else:
        run_as_follower()
finally:
    # На самом деле тут unlock не нужен — он автоматически освободится при
    # закрытии сессии. Но если воркер хочет добровольно сложить полномочия:
    db.execute("SELECT pg_advisory_unlock(:key)", key=42)

Если процесс-leader падает или теряет соединение — Postgres через TCP keepalive (или idle_in_transaction_session_timeout) закроет backend, и lock автоматически снимется. Другой follower успеет проверить и стать новым leader’ом.

Берём session-scope advisory lock на ключ 42. pg_try_advisory_lock возвращает true — взяли. Повторный вызов в этой же сессии вернёт true тоже — advisory locks reentrant внутри своей сессии. После unlock — отпустили.

PostgreSQL

Обрати внимание: в pg_locks advisory locks имеют locktype = 'advisory', а ключ хранится в classid и objid (если ключ 64-битный, он разбивается на верхние и нижние 32 бита).

Transaction-scope: безопасный default

Главная опасность session-scope advisory locks — забытый unlock. Если приложение взяло pg_advisory_lock(42), что-то сделало, и забыло unlock — lock висит до закрытия сессии. А connection-pool’ы переиспользуют сессии: следующий запрос на эту же сессию вообще не подозревает, что lock уже взят. Это приводит к загадочным «зависаниям»: один и тот же код иногда работает, иногда виснет, в зависимости от того, какую сессию ему выдал pool.

Решение — transaction-scope advisory locks:

BEGIN;
  -- автоматически освободится на COMMIT/ROLLBACK, забыть невозможно
  SELECT pg_advisory_xact_lock(100, user_id);

  -- ... работаем со связанным с user_id ресурсом ...

  -- никакого pg_advisory_unlock_xact не существует — он не нужен
COMMIT;

Если внутри транзакции exception, ROLLBACK сам освободит lock. Это default-выбор: используй pg_advisory_xact_lock, кроме случаев, где session-scope абсолютно необходим.

Transaction-scope advisory lock на ключ 100. Внутри BEGIN/COMMIT — взят. После COMMIT — освобождён автоматически, даже если бы мы забыли явный unlock.

PostgreSQL

После COMMIT advisory lock больше не виден в pg_locks — он автоматически освободился.

Use cases

1. Cron-like single executor

В кластере приложения, где nightly_report крутится по cron’у на 10 инстансах одновременно, только один должен реально работать. Все остальные должны быстро понять «я не нужен, остальные мне сделают» и завершиться.

def nightly_report_job():
    with db.transaction():
        got_lock = db.execute(
            "SELECT pg_try_advisory_xact_lock(:key)", key=NIGHTLY_REPORT_KEY
        ).scalar()
        if not got_lock:
            return  # другой инстанс уже работает, выходим

        # генерация отчёта в той же транзакции
        generate_report()
        # lock освободится на COMMIT

Если генерация отчёта быстрая (несколько секунд) — это всё. Если долгая (часы) — лучше использовать session-scope с явным cleanup или хитрый паттерн «возьми transaction-scope lock на короткую транзакцию, пометь себя в lockers-таблице как worker, потом обрабатывай вне транзакции».

2. Защита миграций

У тебя в CI/CD три deploy script могут одновременно стартовать миграцию. БД одна — миграции должны идти строго последовательно.

def run_migrations():
    with db.transaction():
        # ждём, пока другая миграция (если есть) завершится
        db.execute("SELECT pg_advisory_xact_lock(:key)", key=MIGRATION_LOCK_KEY)

        # к этому моменту мы единственный, кто внутри
        for migration in pending_migrations():
            apply(migration)

Все три script’а параллельно вызовут pg_advisory_xact_lock — один получит немедленно, два других будут ждать первого. Когда первый закоммитит — второй получит, и так далее. Никакого race condition, никакого partial state.

3. Per-user serialization

«Обработка пользователя должна быть атомарна, но БД в обработке не участвует — мы дёргаем внешний API. Лочить таблицу нельзя — на ней крутится OLTP.»

def process_user(user_id):
    with db.transaction():
        db.execute("SELECT pg_advisory_xact_lock(:k1, :k2)", k1=PROCESS_USER_NS, k2=user_id)

        # пока мы держим этот lock, ни один другой процесс
        # с тем же user_id не пройдёт.
        # сам процесс — без обращения к БД.
        external_api_process(user_id)

Подводные камни

  1. Reentrancy внутри сессии. Одна и та же сессия может взять один и тот же advisory lock несколько раз. Каждый lock нужно скомпенсировать парным unlock (для session-scope). Это easy-to-miss bug в рекурсивных функциях.

  2. Distinct lock pools. 64-битный ключ pg_advisory_lock(123) и пара 32-битных pg_advisory_lock(0, 123)разные lock’и. Это два разных пространства имён. Не смешивай.

  3. Replicas. Advisory locks существуют только на primary. Standby не реплицирует их. Это feature для leader election (каждый узел может иметь собственного leader’а), но сюрприз для систем, где advisory lock нужен для cross-cluster координации.

  4. Connection pooling. Если pool возвращает сессию с забытым session-scope lock — следующий клиент пула получит сессию с уже взятым lock’ом. PgBouncer в session-mode сохраняет advisory locks между транзакциями, а в transaction-mode (более популярный) — теряет session-scope locks при возврате connection’а в pool. Используй transaction-scope, чтобы не отлавливать pool-specific сюрпризы.

  5. Никакой защиты от bug в коде. Advisory lock — advisory, то есть «по соглашению». Postgres не мешает другому код-пути работать с тем же ресурсом без взятия lock’а. Это твоя задача — везде, где надо, брать lock с правильным ключом.

Demo: reentrancy. Берём lock дважды, освобождаем дважды. Если освободить только один раз — lock всё ещё держится.

PostgreSQL

Чек-лист

  • Advisory locks — lock’и на произвольный bigint-ключ, не связанные с данными.
  • Используются для application-level координации: leader election, cron-singleton, защита миграций, per-user serialization.
  • Session-scope: pg_advisory_lock(key) / pg_try_advisory_lock(key). Живёт до явного pg_advisory_unlock или закрытия сессии. Опасны при connection pooling.
  • Transaction-scope (рекомендуется по умолчанию): pg_advisory_xact_lock(key). Освобождается на COMMIT/ROLLBACK автоматически. Безопасно с pool’ами.
  • Try-варианты возвращают boolean сразу: true если взяли, false если занят. Без ожидания.
  • Reentrancy внутри сессии: lock на один ключ считается счётчиком; нужно столько же unlock’ов, сколько lock’ов.
  • 64-битный и пара 32-битных ключей — разные lock-пространства.
  • Advisory locks не реплицируются на standby. Полезно для multi-leader, опасно для cross-cluster координации.
  • pg_locks показывает все advisory locks с locktype = 'advisory'.
SysV vs POSIX — shared memory и message queues обзорно
Проверка знанийKnowledge check
У тебя крутится cron-job nightly_aggregate каждый день в 03:00 на 5 web-серверах. Из-за того, что cron crontab одинаков на всех 5, job стартует на всех одновременно. Тебе нужно: гарантировать, что только один из 5 реально выполнит работу; не сломаться, если один из серверов упадёт во время выполнения; ничего не оставить заблокированным навсегда. Какой паттерн advisory lock использовать и почему?
ОтветAnswer
Используй pg_try_advisory_xact_lock внутри одной долгой транзакции. Псевдокод: BEGIN; got = SELECT pg_try_advisory_xact_lock(нaш_ключ_для_nightly); IF got THEN <выполняем агрегацию>; COMMIT; ELSE ROLLBACK; END;. Что это даёт: 1) try_-вариант не блокирует — 4 проигравших сервера получают false и моментально выходят (не висят в ожидании, как было бы с обычным pg_advisory_xact_lock). 2) xact_-вариант гарантирует, что lock освободится в любом случае — на COMMIT, на ROLLBACK, на crash backend'а. Никакого 'забыли unlock' и ничего не останется заблокированным навсегда. 3) Если победитель упал в середине работы — Postgres через TCP keepalive (~2 минуты) закроет backend и автоматически освободит lock + откатит транзакцию. На следующий день один из 4-х других серверов спокойно возьмёт работу. Альтернатива — session-scope с try — тоже работает, но требует обработки exception и явного unlock в finally; легче забыть. Минус нашего подхода — если nightly_aggregate работает 8 часов и сервер падает в 04:00 после 1 часа работы, остальные 4 сервера попытаются только в 03:00 следующего дня. Если это критично — нужен sleep-and-retry в проигравших.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В чём ключевое отличие advisory lock от table-level или row-level lock?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5