В предыдущих уроках мы говорили о блокировках, привязанных к данным: table-level (ROW EXCLUSIVE на таблицу) и row-level (через xmax). Все они автоматически возникают при работе с SQL.
Но иногда нужен именованный lock, не привязанный ни к какой строке или таблице. Примеры:
- «Только один воркер в кластере должен запускать
nightly_reportcron в полночь.» - «На время миграции схемы только один deploy script должен делать DDL.»
- «Обработка пользователя
42должна быть атомарна — но в БД нет таблицы, где можно взять lock на пользователя, потому что обработка не пишет в БД, она дёргает внешнее API.»
Для таких случаев Postgres даёт advisory locks — lock’и на любой bigint-ключ, никак не связанные с данными. Что значит этот ключ — решает приложение. Postgres просто гарантирует: «один такой ключ — один lock в один момент».
API
Два пакета функций — для session-scope и transaction-scope:
Session-scope живёт пока сессия открыта (или пока не вызван unlock). Transaction-scope живёт только до COMMIT/ROLLBACK. Каждая функция имеет вариант с одним bigint или с двумя int (key1, key2)
Все функции имеют две формы:
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 — отпустили.
Обрати внимание: в 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.
После 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)
Подводные камни
-
Reentrancy внутри сессии. Одна и та же сессия может взять один и тот же advisory lock несколько раз. Каждый
lockнужно скомпенсировать парнымunlock(для session-scope). Это easy-to-miss bug в рекурсивных функциях. -
Distinct lock pools. 64-битный ключ
pg_advisory_lock(123)и пара 32-битныхpg_advisory_lock(0, 123)— разные lock’и. Это два разных пространства имён. Не смешивай. -
Replicas. Advisory locks существуют только на primary. Standby не реплицирует их. Это feature для leader election (каждый узел может иметь собственного leader’а), но сюрприз для систем, где advisory lock нужен для cross-cluster координации.
-
Connection pooling. Если pool возвращает сессию с забытым session-scope lock — следующий клиент пула получит сессию с уже взятым lock’ом. PgBouncer в session-mode сохраняет advisory locks между транзакциями, а в transaction-mode (более популярный) — теряет session-scope locks при возврате connection’а в pool. Используй transaction-scope, чтобы не отлавливать pool-specific сюрпризы.
-
Никакой защиты от bug в коде. Advisory lock — advisory, то есть «по соглашению». Postgres не мешает другому код-пути работать с тем же ресурсом без взятия lock’а. Это твоя задача — везде, где надо, брать lock с правильным ключом.
Demo: reentrancy. Берём lock дважды, освобождаем дважды. Если освободить только один раз — lock всё ещё держится.
Чек-лист
- 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'.