07 сент. 2025 г.·6 мин

Advisory-блокировки PostgreSQL для предотвращения двойной обработки

Узнайте, как advisory-блокировки PostgreSQL предотвращают двойную обработку в утверждениях, биллинге и планировщиках — практичные шаблоны, SQL-фрагменты и простые проверки.

Advisory-блокировки PostgreSQL для предотвращения двойной обработки

Настоящая проблема: два процесса делают одну и ту же работу

Двойная обработка — это когда один и тот же объект обрабатывают дважды, потому что два разных исполнителя оба считают, что они отвечают за него. В реальных приложениях это проявляется как двойное списание со счёта клиента, двойное применение утверждения или письмо «счёт готов» дважды. Всё может выглядеть нормально в тестах, но сломаться под реальной нагрузкой.

Обычно это случается, когда тайминги становятся плотными и несколько сущностей могут действовать одновременно:

Два воркера забирают одну и ту же задачу одновременно. Ретрай запускается из-за медленного сетевого вызова, но первый процесс ещё работает. Пользователь дважды кликает «Утвердить», потому что интерфейс завис на секунду. Два планировщика пересекаются после деплоя или из-за дрейфа часов. Даже один тап может породить два запроса, если мобильное приложение повторно отправляет запрос после таймаута.

Самое болезненное — каждый актор по отдельности ведёт себя «разумно». Баг — это промежуток между ними: ни один из них не знает, что другой уже обрабатывает ту же запись.

Цель простая: для любого элемента (заказ, запрос на утверждение, счёт) только один актор в любой момент должен выполнять критическую работу. Остальные должны либо подождать немного, либо отойти и попробовать позже.

PostgreSQL advisory-блокировки в этом помогают. Это лёгкий способ сказать «я работаю над объектом X», используя базу данных, которой вы уже доверяете для консистентности.

Но задайте ожидания правильно. Блокировка — это не полноценная система очередей. Она не будет планировать задачи за вас, гарантировать порядок или хранить сообщения. Это защитный барьер вокруг той части рабочего процесса, которая не должна выполняться дважды.

Что такое advisory-блокировки PostgreSQL (и чем они не являются)

Advisory-блокировки PostgreSQL — это способ гарантировать, что только один воркер выполняет часть работы в единицу времени. Вы выбираете ключ блокировки (например, «invoice 123»), просите базу поставить блокировку, выполняете работу, затем снимаете её.

Слово «advisory» важно. Postgres не знает смысла вашего ключа и автоматически ничего не защитит. Он только хранит один факт: этот ключ заблокирован или нет. Ваш код должен соглашаться по формату ключа и брать блокировку перед тем, как выполнять рискованную часть.

Полезно сравнить advisory-блокировки с блокировками строк. Блокировки строк (например, SELECT ... FOR UPDATE) защищают реальные строки таблицы. Они отлично подходят, когда работа соотносится с одной строкой. Advisory-блокировки защищают ключ, который вы выбираете — это удобно, когда рабочий процесс затрагивает много таблиц, вызывает внешние сервисы или начинается до момента появления строки.

Advisory-блокировки полезны, когда вам нужно:

  • Последовательные действия по сущности (одно утверждение на запрос, один платёж по счёту)
  • Координация между несколькими серверами приложения без отдельного сервиса блокировок
  • Защита шага рабочего процесса, который шире одного обновления строки

Они не заменяют другие инструменты безопасности. Они не делают операции идемпотентными, не применяют бизнес-правила и не остановят дублирование, если код забудет брать блокировку.

Их часто называют «лёгкими», потому что можно использовать без изменений схемы или дополнительной инфраструктуры. Во многих случаях можно исправить проблему двойной обработки, добавив один вызов блокировки вокруг критической секции и оставив остальную архитектуру без изменений.

Типы блокировок, которые вы действительно будете использовать

Когда говорят «advisory-блокировки PostgreSQL», обычно имеют в виду маленький набор функций. Выбор правильной функции меняет поведение при ошибках, таймаутах и повторных попытках.

Сессионные vs транзакционные блокировки

Сессионная блокировка (pg_advisory_lock) держится столько, сколько живёт соединение с базой. Это удобно для долгоживущих воркеров, но тогда блокировка может «зависнуть», если приложение упадёт так, что соединение в пуле останется занятым.

Транзакционная блокировка (pg_advisory_xact_lock) привязана к текущей транзакции. При commit или rollback Postgres снимает её автоматически. Для большинства сценариев request/response (утверждения, клики по оплате, админские действия) это безопасный выбор по умолчанию, потому что проще не забыть освободить блокировку.

Блокирующая vs попытка захвата (try-lock)

Блокирующие вызовы ждут, пока блокировка освободится. Это просто, но может сделать веб-запрос «зависшим», если другая сессия держит блок.

Вызовы типа try-lock возвращают результат сразу:

  • pg_try_advisory_lock (сессионная)
  • pg_try_advisory_xact_lock (транзакционная)

Try-lock часто лучше для действий из UI. Если блок занят, вы можете вернуть понятное сообщение вроде «Уже в обработке» и предложить пользователю повторить попытку.

Разделяемые vs эксклюзивные

Эксклюзивные блокировки — это «один за раз». Разделяемые позволяют нескольким держателям, но блокируют эксклюзивную блокировку. Большинство проблем с двойной обработкой решают эксклюзивные блокировки. Разделяемые полезны, когда много читателей могут идти параллельно, но редкий писатель должен работать один.

Как снимаются блокировки

Снятие зависит от типа:

  • Сессионные: снимаются при отключении или явно через pg_advisory_unlock
  • Транзакционные: снимаются автоматически при завершении транзакции

Выбор ключа блокировки

Advisory-блокировка сработает только если каждый воркер пытается захватить ровно такой же ключ для той же единицы работы. Если один путь блокирует «invoice 123», а другой — «customer 45», дубли всё ещё возможны.

Начните с явного определения того «чего» вы защищаете. Сделайте это конкретным: один счёт, один запрос на утверждение, один запуск планировщика или одна цикл биллинга клиента. Этот выбор решит, какую степень параллелизма вы допускаете.

Выберите область, соответствующую риску

Большинство команд приходит к одному из вариантов:

  • По записи: безопаснее всего для утверждений и счетов (блокировать по invoice_id или request_id)
  • По клиенту/аккаунту: полезно, когда действия должны выполняться сериализовано для клиента (биллинг, изменения кредита)
  • По шагу рабочего процесса: когда разные шаги могут идти параллельно, но каждый шаг должен выполняться по одному

Рассматривайте область как продуктовое решение, а не деталь базы данных. «По записи» предотвращает двойные клики, ведущие к двойному списанию. «По клиенту» предотвращает пересекающиеся фоновые задачи, которые могли бы генерировать накладывающиеся выписки.

Выберите стабильную стратегию ключа

Обычно есть два варианта: два 32-битных целых (часто namespace + id) или одно 64-битное целое (bigint), иногда получаемое хешированием строкового ID.

Два-int ключа просто стандартизировать: выберите фиксированное число пространства имён для каждого рабочего процесса (например, approvals vs billing) и используйте ID записи как второе значение.

Хеширование удобно при UUID, но вы принимаете небольшой риск коллизий и должны быть последовательны везде.

Что бы вы ни выбрали — пропишите формат и централизуйте реализацию. «Почти одинаковый ключ» в двух местах — частая причина вернуть дубли.

Пошаговый безопасный шаблон для одиночной обработки

Моделируйте состояния рабочего процесса
Используйте Data Designer для моделирования таблиц и явных полей статуса.
Проектировать данные

Хороший workflow с advisory-блокировкой прост: взять блокировку, перепроверить, выполнить, записать результат, зафиксировать. Блокировка сама по себе не бизнес-правило — это ограничение, которое делает правило надёжным при одновременных обращениях.

Практичный шаблон:

  1. Откройте транзакцию, если результат должен быть атомарным.
  2. Получите блокировку для конкретной единицы работы. Предпочитайте транзакционную блокировку (pg_advisory_xact_lock), чтобы она снималась автоматически.
  3. Повторно проверьте состояние в базе. Не предполагайте, что вы первый.
  4. Выполните работу и запишите устойчивую отметку «сделано» в базе (обновление статуса, запись в учёт, аудит).
  5. Зафиксируйте транзакцию и отпустите блокировку. Если использовали сессионную блокировку — разблокируйте перед возвращением соединения в пул.

Пример: два сервера получают «Утвердить счёт #123» в одну секунду. Оба стартуют, но только один получает блокировку по 123. Победитель проверяет, что счёт #123 ещё pending, помечает его approved, записывает аудит/платёж и делает COMMIT. Второй сервер либо быстро завершается с отказом (try-lock), либо ждёт и после получения блокировки видит, что статус уже другой, и выходит без дублирования. Так вы избегаете двойной обработки и держите интерфейс отзывчивым.

Где применяются advisory-блокировки: утверждения, биллинг, планировщики

Advisory-блокировки лучше всего подходят, когда правило простое: для конкретной сущности только один процесс может выполнить «победную» работу в каждый момент времени. Вы сохраняете существующую базу и код приложения, добавляя небольшой «шлаг», который делает гонки гораздо реже.

Утверждения

Утверждения — классическая ловушка конкуренции. Два ревьюера (или один человек, дважды кликнувший) могут нажать Approve за миллисекунды. С блокировкой по ID запроса только одна транзакция выполнит изменение состояния. Остальные быстро узнают результат и покажут понятное сообщение вроде «уже одобрено» или «уже отклонено». Это часто встречается в порталах клиентов и админках, где много людей наблюдают одну очередь.

Биллинг

Биллинг обычно требует более строгого правила: одна попытка платежа на счёт, даже при ретраях. Сетевой таймаут может заставить пользователя нажать «Pay» снова, или фоновый ретрай может запуститься, пока первая попытка ещё в пути.

Блокировка по invoice ID гарантирует, что только один путь одновременно общается с платёжным провайдером. Вторая попытка может вернуть «платёж в процессе» или прочитать актуальный статус платежа. Это предотвращает двойные списания.

Планировщики и фоновые воркеры

В многозвёздных окружениях планировщики могут случайно запускать одно и то же окно параллельно. Блокировка по имени задания + временному окну (например, daily-settlement:2026-01-29) гарантирует, что запущен только один инстанс.

Аналогично для воркеров, которые выбирают элементы из таблицы: блокируйте на ID элемента, чтобы только один воркер его обрабатывал.

Обычно блокируют по: ID запроса на утверждение, ID счёта, имени задачи + временному окну, ID клиента для «один экспорт за раз» или по уникальному идемпотентному ключу для ретраев.

Реалистичный пример: остановить двойное утверждение в портале

Сделайте повторные попытки биллинга безопасными
Добавьте однократную обработку счетов перед вызовом Stripe или других платёжных провайдеров.
Начать разработку

Представьте запрос на утверждение: заказ ждёт, и два менеджера нажимают Approve в одну секунду. Без защиты оба запроса могут прочитать pending и оба записать approved, создав дубли в аудите, дубли уведомлений или двукратные триггеры downstream.

Advisory-блокировки PostgreSQL дают простой способ сделать это действие по одному на запрос.

Поток

Когда API получает действие approve, оно сначала берёт блокировку по id запроса (так разные запросы всё ещё могут обрабатываться параллельно).

Общий шаблон: блокировка по approval_id, чтение текущего статуса, обновление статуса, запись аудита — всё в одной транзакции.

BEGIN;

-- One-at-a-time per approval_id
SELECT pg_try_advisory_xact_lock($1) AS got_lock;  -- $1 = approval_id

-- If got_lock = false, return "someone else is approving, try again".

SELECT status FROM approvals WHERE id = $1 FOR UPDATE;

-- If status != 'pending', return "already processed".

UPDATE approvals
SET status = 'approved', approved_by = $2, approved_at = now()
WHERE id = $1;

INSERT INTO approval_audit(approval_id, actor_id, action, created_at)
VALUES ($1, $2, 'approved', now());

COMMIT;

(код SQL оставлен без изменений)

Что видит второй клик

Второй запрос либо не получает блокировку (и быстро возвращает «Уже в обработке»), либо получает её позже и видит, что статус уже approved, и выходит без изменений. В любом случае вы избегаете двойной обработки и сохраняете отзывчивость UI.

Для отладки логируйте достаточно данных: id запроса, approval id и вычисленный ключ блокировки, actor id, результат (lock_busy, already_approved, approved_ok) и время выполнения.

Как управлять ожиданием, таймаутами и ретраями, не «замораживая» приложение

Исправьте одно критическое действие в первую очередь
Выберите «снять платёж» или «утвердить запрос» и оберните это транзакционной блокировкой.
Начать

Ожидание блокировки кажется безобидным, пока не превратится в крутящуюся кнопку, зависший воркер или очередной застрявший backlog. Если блок не получен, быстро завершайте путь, где есть человек, и ждите только там, где это безопасно.

Для действий пользователя: try-lock и понятный ответ

Если кто-то нажимает Approve или Charge, не держите его запрос висящим несколько секунд. Используйте try-lock, чтобы приложение могло ответить сразу.

Практический подход: попробовать взять блокировку, и если не удалось — вернуть чёткий ответ «занято, попробуйте снова» (или обновить состояние элемента). Это уменьшает таймауты и снижает количество повторных кликов.

Держите заблокированную секцию короткой: валидация состояния, применение изменения и commit.

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

Для планировщиков и воркеров блокировка может быть нормальной, но устанавливайте пределы, иначе одна медленная задача может заблокировать весь флот.

Используйте таймауты, чтобы воркер мог сдаться и перейти дальше:

SET lock_timeout = '2s';
SET statement_timeout = '30s';
SELECT pg_advisory_lock(123456);

Также задавайте максимально ожидаемое время выполнения задачи. Если биллинг обычно укладывается в 10 секунд, считайте 2 минуты инцидентом. Отслеживайте время старта, id задачи и продолжительность удержания блокировок. Если раннер задач поддерживает отмену, прерывайте задания, которые превышают лимит, чтобы сессия завершилась и блокировка снялась.

Планируйте ретраи осознанно. Когда блок не получен, решите: перепланировать с бэкофом и случайностью, пропустить работу для этого цикла или пометить элемент как «контендится», если серии неудач требуют вмешательства.

Распространённые ошибки, приводящие к зависшим блокировкам или дублиам

Самый частый сюрприз — сессионные блокировки, которые никогда не снимаются. Пулы соединений держат соединения открытыми, поэтому сессионная блокировка может пережить запрос. Если вы взяли сессионную блокировку и забыли её снять, блок останется до переработки соединения. Другие воркеры будут ждать (или падать), и разобраться будет сложно.

Ещё одна причина дублей — взять блокировку, но не перепроверить состояние. Блокировка гарантирует только, что один воркер выполняет критическую секцию за раз. Она не гарантирует, что запись ещё годна для обработки. Всегда перепроверяйте внутри той же транзакции (например, подтвердите pending перед тем, как пометить approved).

Ключи блокировок тоже подводят команды. Если один сервис блокирует по order_id, а другой — по другому вычисленному ключу для того же ресурса, получится две независимые блокировки. Оба пути смогут выполняться одновременно, что даёт ложное чувство безопасности.

Длительное удержание блокировок обычно самоиндуцировано. Если вы делаете медленные сетевые вызовы, держа блокировку (платёжный провайдер, email/SMS, вебхуки), то короткая страховка превращается в узкое место. Держите заблокированную секцию для быстрых операций с базой: проверьте состояние, запишите новое состояние, отметьте, что нужно сделать дальше. Побочные эффекты триггерьте после коммита.

Наконец, advisory-блокировки не заменяют идемпотентность или ограничения базы. Считайте их светофором, а не доказательной системой. Используйте уникальные ограничения там, где они подходят, и идемпотентные ключи для внешних вызовов.

Быстрый чек-лист перед релизом

Постройте логику, избегающую гонок
Используйте Business Process Editor, чтобы держать критические участки короткими и проверяемыми.
Попробовать

Относитесь к advisory-блокировкам как к небольшому контракту: вся команда должна знать, что защищает блокировка и что разрешено делать, пока она удерживается.

Короткий чек-лист, который ловит большинство проблем:

  • Один понятный ключ блокировки на ресурс, задокументированный и используемый везде
  • Брать блокировку до всего необратимого (платежи, письма, внешние API)
  • Повторно проверять состояние после получения блокировки и перед записью
  • Держать заблокированную секцию короткой и измеряемой (логировать ожидание и время выполнения)
  • Решить, что значит «lock busy» для каждого пути (сообщение UI, ретрай с бэкофом, пропуск)

Следующие шаги: применяйте шаблон и поддерживайте его

Выберите одно место, где дубли наиболее болезненны, и начните с него. Хорошие первые цели — действия, которые стоят денег или навсегда меняют состояние, например «снять платёж» или «утвердить запрос». Оберните только эту критическую секцию advisory-блокировкой, затем расширяйте защиту по мере уверенности.

Добавьте базовую наблюдаемость как можно раньше. Логируйте случаи, когда воркер не может получить блокировку, и сколько времени держатся блокировки. Если ожидания блокировок растут — обычно это признак слишком большой критической секции или медленного запроса внутри неё.

Блокировки работают лучше поверх защиты данных, а не вместо неё. Держите явные поля состояния (pending, processing, done, failed) и поддерживайте ограничения там, где можно. Если ретрай попадает в худший момент, уникальный индекс или идемпотентный ключ могут стать второй линией защиты.

Если вы строите рабочие процессы в AppMaster (appmaster.io), вы можете применить тот же шаблон, поместив критическое изменение состояния в одну транзакцию и добавив небольшой SQL-шаг для взятия транзакционной advisory-блокировки перед финализирующим шагом.

Advisory-блокировки подходят до тех пор, пока вам действительно не нужны функции очереди (приоритеты, отложенные задачи, dead-letter), у вас нет сильной конкуренции и не нужны более умные стратегии параллелизма, вы не координируете действия между базами без общей Postgres или вам не нужны более строгие правила изоляции. Цель — скучная надёжность: держите шаблон маленьким, последовательным, видимым в логах и дополненным ограничениями.

Вопросы и ответы

Когда стоит использовать advisory-блокировки PostgreSQL вместо простой логики приложения?

Используйте advisory-блокировку, когда для конкретной единицы работы нужен режим «только один актор за раз»: утверждение запроса, снятие оплаты по счёту или выполнение расписного окна. Это особенно полезно, если несколько инстансов приложения могут падать на один и тот же объект и вы не хотите вводить отдельный сервис блокировок.

Чем advisory-блокировки отличаются от SELECT ... FOR UPDATE?

Строковые блокировки защищают реальные строки таблицы и отлично подходят, когда вся операция сводится к одному обновлению строки. Advisory-блокировки защищают выбранный вами ключ, поэтому они работают даже если рабочий процесс затрагивает много таблиц, вызывает внешние сервисы или начинается до появления окончательной строки.

Стоит ли использовать транзакционные или сессионные advisory-блокировки?

По умолчанию выбирайте pg_advisory_xact_lock (уровень транзакции) для запросов request/response — она снимается автоматически при commit или rollback. Используйте pg_advisory_lock (уровень сессии) только если вам действительно нужно, чтобы блокировка пережила транзакцию, и вы уверены, что всегда будете явно разблокировать перед возвратом соединения в пул.

Лучше ждать блокировку или использовать try-lock?

Для действий из UI предпочтительнее try-lock (pg_try_advisory_xact_lock), чтобы запрос мог быстро завершиться и вернуть понятный ответ «уже в обработке». Для фоновых воркеров блокировка может быть приемлемой, но её нужно ограничивать с помощью lock_timeout, чтобы одна зависшая задача не стояла в очереди у всех.

На чём лучше ставить блокировку: на запись, на клиента или на что-то ещё?

Блокируйте минимальную вещь, которая не должна выполняться дважды — обычно это «один счёт» или «один запрос на утверждение». Если блокировать слишком широко (например, по клиенту), вы снизите пропускную способность; если слишком узко — сохраняется риск дублей.

Как выбрать ключ блокировки, чтобы все сервисы использовали один и тот же?

Выберите стабильный формат ключа и используйте его везде, где выполняется одна и та же критическая операция. Часто применяют два целых числа: фиксированное пространство имён для рабочего процесса и ID сущности, чтобы разные рабочие процессы не блокировали друг друга и при этом корректно координировались.

Заменяют ли advisory-блокировки идемпотентность и уникальные ограничения?

Нет. Блокировка только предотвращает одновременное исполнение; она не гарантирует, что операция безопасно повторяема. Всегда повторно проверяйте состояние внутри транзакции (например, что элемент всё ещё pending) и используйте уникальные ограничения или идемпотентные ключи для внешних вызовов.

Что делать внутри заблокированного участка, чтобы не замедлять систему?

Держите критическую секцию короткой и ориентируйтесь на базу: взять блокировку, проверить право на выполнение, записать новое состояние и зафиксировать транзакцию. Медленные побочные эффекты (платежи, письма, вебхуки) выполняйте после commit или через outbox, чтобы не задерживать блокировку сетью.

Почему advisory-блокировки иногда кажутся «зависшими» даже после завершения запроса?

Чаще всего причина — сессионная блокировка, удерживаемая pooled-соединением, которое не было разблокировано из-за ошибки в коде. Предпочтительнее транзакционные блокировки; если используете сессионные, убедитесь, что pg_advisory_unlock вызывается до возврата соединения в пул.

Что стоит логировать или мониторить, чтобы понять, что advisory-блокировки работают?

Логируйте ID сущности и вычисленный ключ блокировки, была ли блокировка получена, сколько заняло получение и сколько длилась транзакция. Также отмечайте исход (lock_busy, already_processed, processed_ok), чтобы отличать конкуренцию от реальных дублей.

Легко начать
Создай что-то невероятное

Экспериментируйте с AppMaster с бесплатной подпиской.
Как только вы будете готовы, вы сможете выбрать подходящий платный план.

Попробовать AppMaster