В PostgreSQL есть фича, про которую знают далеко не все, хотя она существует с незапамятных времён. Advisory locks — пользовательские блокировки, которыми управляет не БД, а ваше приложение. PostgreSQL только хранит их состояние и разруливает конкуренцию. А вы решаете, что именно заблокировать и когда отпустить.

Зачем это нужно? Вы строите распределённую систему, несколько инстансов приложения работают с одной базой, и вам нужно гарантировать, что определённую операцию выполняет только один инстанс одновременно. Классический ответ — Redis с SETNX или Consul/ZooKeeper. Но если у вас уже есть PostgreSQL — зачем тащить ещё одну зависимость?

Как это работает

Advisory locks — это блокировки, которые не привязаны к таблицам или строкам. Вы берёте произвольный числовой идентификатор (bigint или пару int), и PostgreSQL гарантирует, что только одна сессия может удерживать эксклюзивный лок с этим идентификатором.

-- Захватить блокировку с ID 12345 (блокирующий вызов — ждёт, если занято)
SELECT pg_advisory_lock(12345);

-- Выполняем критическую секцию...

-- Отпустить
SELECT pg_advisory_unlock(12345);

Есть неблокирующий вариант — pg_try_advisory_lock. Он возвращает true, если лок получен, и false, если занят. Не ждёт:

SELECT pg_try_advisory_lock(12345);
-- true  → лок получен, работаем
-- false → кто-то другой уже держит лок, уходим

Два уровня: сессия и транзакция

Advisory locks бывают двух видов, и путать их — частая ошибка.

Session-level — лок живёт, пока жива сессия (соединение с БД) или пока вы явно не вызовете pg_advisory_unlock. Даже если транзакция откатилась — лок остаётся. Если вы взяли session-level лок внутри транзакции, которая сделала ROLLBACK, лок не отпустится.

-- Session-level: держится до явного unlock или конца сессии
SELECT pg_advisory_lock(100);
-- ...
SELECT pg_advisory_unlock(100);

Transaction-level — лок автоматически освобождается при завершении транзакции (COMMIT или ROLLBACK). Явного unlock нет — и не нужен.

BEGIN;
SELECT pg_advisory_xact_lock(100);
-- Работаем...
COMMIT; -- Лок автоматически отпускается

Для большинства задач transaction-level — безопаснее. Не забудете отпустить, не получите утечку локов. Session-level нужен, когда блокировка должна жить дольше одной транзакции — например, при long-running операциях или leader election.

Два формата ключей

У advisory locks есть два формата ключей: один bigint или два integer:

-- Один bigint
SELECT pg_advisory_lock(9876543210);

-- Два integer — удобно для namespace:id паттерна
SELECT pg_advisory_lock(1, 42);  -- namespace=1, id=42

Формат с двумя integer удобен для организации пространства имён. Первый аргумент — тип операции, второй — ID ресурса:

-- 1 = обработка заказа, 42 = ID заказа
SELECT pg_advisory_lock(1, 42);

-- 2 = генерация отчёта, 7 = ID отчёта
SELECT pg_advisory_lock(2, 7);

Если нужно заблокировать по строковому ключу — используем hashtext:

SELECT pg_advisory_lock(hashtext('process-user-payments'));

Или комбинацию OID таблицы и ID строки:

-- Лочим конкретную строку конкретной таблицы
SELECT pg_advisory_lock('users'::regclass::int, 42);

Кейс 1: идемпотентная обработка заказов

Несколько воркеров обрабатывают заказы из очереди. Нужно гарантировать, что один заказ не обрабатывается одновременно двумя воркерами:

import psycopg2

def process_order(conn, order_id: int) -> bool:
    """Обработка заказа с advisory lock для идемпотентности."""
    cur = conn.cursor()

    # Пробуем захватить лок (неблокирующий)
    cur.execute("SELECT pg_try_advisory_xact_lock(1, %s)", (order_id,))
    acquired = cur.fetchone()[0]

    if not acquired:
        # Другой воркер уже обрабатывает этот заказ
        return False

    # Лок получен — обрабатываем
    cur.execute("""
        UPDATE orders
        SET status = 'processing', updated_at = now()
        WHERE id = %s AND status = 'pending'
        RETURNING id
    """, (order_id,))

    row = cur.fetchone()
    if row is None:
        # Заказ уже не pending — ничего не делаем
        return False

    # ... бизнес-логика обработки ...

    cur.execute("""
        UPDATE orders
        SET status = 'completed', updated_at = now()
        WHERE id = %s
    """, (order_id,))

    conn.commit()  # Лок отпускается автоматически (xact_lock)
    return True

Почему advisory lock, а не SELECT ... FOR UPDATE? Потому что FOR UPDATE блокирует строку — и все остальные транзакции, которые хотят прочитать или обновить эту строку, будут ждать. Advisory lock блокирует только тех, кто тоже пытается взять advisory lock с тем же ID. Обычные SELECT и UPDATE продолжают работать без задержек.

Кейс 2: синглтон-задача (только один воркер)

Cron-задача должна выполняться ровно на одном инстансе, даже если запущено пять:

def run_daily_report(conn):
    """Генерация ежедневного отчёта — только один воркер."""
    cur = conn.cursor()

    cur.execute("SELECT pg_try_advisory_lock(hashtext('daily-report'))")
    got_lock = cur.fetchone()[0]

    if not got_lock:
        print("Другой инстанс уже генерирует отчёт, пропускаем")
        return

    try:
        # Session-level лок — будет жить всё время генерации отчёта,
        # даже если внутри несколько транзакций
        generate_report()
    finally:
        cur.execute("SELECT pg_advisory_unlock(hashtext('daily-report'))")

Здесь намеренно используется session-level лок (не xact), потому что генерация отчёта может включать несколько транзакций.

Кейс 3: защита миграций

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

DO $$
BEGIN
    IF NOT pg_try_advisory_lock(0, 0) THEN
        RAISE NOTICE 'Миграции уже выполняются другим инстансом';
        RETURN;
    END IF;

    -- Выполняем миграции
    ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT;

    -- ... другие миграции ...

    PERFORM pg_advisory_unlock(0, 0);
END $$;

Flyway и Liquibase используют этот подход.

Кейс 4: rate limiting на уровне БД

Ограничить количество лайков от одного пользователя — не более 20 за час:

CREATE OR REPLACE FUNCTION add_like(
    p_user_id INT,
    p_object_id INT
) RETURNS BOOLEAN AS $$
DECLARE
    like_count INT;
BEGIN
    -- Лочим по user_id, чтобы два параллельных запроса
    -- от одного пользователя не обошли лимит
    PERFORM pg_advisory_xact_lock(3, p_user_id);

    SELECT count(*) INTO like_count
    FROM likes
    WHERE user_id = p_user_id
      AND created_at > now() - interval '1 hour';

    IF like_count >= 20 THEN
        RETURN FALSE;
    END IF;

    INSERT INTO likes (user_id, object_id, created_at)
    VALUES (p_user_id, p_object_id, now());

    RETURN TRUE;
END;
$$ LANGUAGE plpgsql;

Без advisory lock два параллельных запроса могут одновременно прочитать count = 19, оба пройдут проверку и вставят 20-й и 21-й лайк.

Другие проблемы

LIMIT + advisory lock = утечка локов. Классическая ловушка:

-- ОПАСНО: PostgreSQL может захватить локи для строк,
-- которые потом отбросит из-за LIMIT
SELECT pg_advisory_lock(id) FROM orders WHERE status = 'pending' LIMIT 10;

Планировщик может решить сначала захватить локи для всех pending-заказов, а потом применить LIMIT. Вы получите локи на строки, которые не вернулись в результате, и не сможете их отпустить. Решение — подзапрос:

-- БЕЗОПАСНО: сначала выбираем, потом лочим
SELECT pg_advisory_lock(id) FROM (
    SELECT id FROM orders WHERE status = 'pending' LIMIT 10
) sub;

PgBouncer в transaction pooling mode. В этом режиме PgBouncer переиспользует соединения между транзакциями. Session-level advisory locks при этом ломаются: вы захватили лок в одном соединении, PgBouncer отдал это соединение другому клиенту, лок «утёк». Решение: используйте только transaction-level локи (pg_advisory_xact_lock) или переключаем PgBouncer в session pooling для сессий, использующих advisory locks.

Shared memory limits. Advisory locks хранятся в shared memory вместе с обычными локами. Лимит определяется max_locks_per_transaction × max_connections. Не создавайте миллионы advisory locks — можете исчерпать пул, и база перестанет выдавать любые локи.

Реентерабельность. Session-level advisory locks реентерабельны: одна сессия может захватить один и тот же лок несколько раз. Но для каждого захвата нужен соответствующий unlock. Если вызвали pg_advisory_lock(42) три раза — нужно три раза вызвать pg_advisory_unlock(42).

Мониторинг

Все advisory locks видны в pg_locks:

-- Посмотреть все текущие advisory locks
SELECT pid, granted, mode, classid, objid
FROM pg_locks
WHERE locktype = 'advisory';

-- Кто кого ждёт
SELECT
    blocked.pid AS blocked_pid,
    blocking.pid AS blocking_pid,
    blocked.classid,
    blocked.objid
FROM pg_locks blocked
JOIN pg_locks blocking
    ON blocked.classid = blocking.classid
    AND blocked.objid = blocking.objid
    AND blocked.locktype = 'advisory'
    AND blocking.locktype = 'advisory'
    AND blocked.granted = false
    AND blocking.granted = true;

В pg_stat_activity сессия, ожидающая advisory lock, покажет wait_event_type = 'Lock' и wait_event = 'advisory'.


Когда не использовать advisory locks

Если координация нужна между разными БД или разными системами (не только PostgreSQL) — advisory locks не подойдут, они работают в пределах одной базы данных. Для cross-database координации нужен Redis, Consul или etcd.

Если нужны миллионы одновременных локов — shared memory кончится. Advisory locks подходят для тысяч, не миллионов.

Если у вас PgBouncer в transaction pooling и вы не можете использовать transaction-level локи — лучше взять Redis.

Для всего остального advisory locks — неплохое такое решение, которое не требует дополнительной инфраструктуры. База у вас уже есть.

Когда данных становится много, а решений — ещё больше, проблема обычно не в ещё одном инструменте, а в понимании, как собрать целостное хранилище и не утонуть в компромиссах между моделью, загрузкой и отчётностью. На курсе «Аналитик хранилищ данных (DWH)» как раз разбирают эту связку на практике: от ELT-процессов, аналитических СУБД и моделирования данных до показателей, воронок и сквозной аналитики — с акцентом не только на «как сделать», но и на «зачем именно так». Готовы к обучению? Пройдите вступительный тест.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:

  • 11 марта, 20:00. «Почему нельзя ограничиваться только методологией моделирования хранилищ данных (Data Vault) в архитектуре хранилища данных». Записаться

  • 23 марта, 20:00. «Современное хранилище данных в 2026: какие технологии реально нужны, а какие — ажиотаж». Записаться

Полный список бесплатных уроков марта смотрите в дайджесте.