В 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: какие технологии реально нужны, а какие — ажиотаж». Записаться
Полный список бесплатных уроков марта смотрите в дайджесте.
