Распределённые блокировки

Предпосылки: String, Lua-скрипты, репликация, AOF.

Rate limiting | Очереди

Два фоновых воркера (Sidekiq, Celery, любая очередь задач) одновременно берут в обработку один и тот же заказ. Оба читают заказ из базы, оба вызывают платёжный шлюз. Пользователю списывают деньги дважды: возврат, тикет в поддержку.

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

SET NX EX

SET lock:order:42 "owner-abc" NX EX 30 — атомарная команда: установить ключ, только если его не существует (NX), с автоматическим удалением через 30 секунд (EX). Первый воркер получает OK — блокировка захвачена. Второй получает nil — ресурс занят, воркер отступает (retry с random backoff, чтобы несколько ожидающих клиентов не штурмовали блокировку одновременно).

SET lock:order:42 "owner-abc-123" NX EX 30
-- → OK (блокировка получена)
-- → nil (ресурс уже заблокирован)

TTL — страховка от мёртвых блокировок: если процесс, захвативший ключ, упал и не освободил его, через 30 секунд блокировка исчезнет сама.

Удаление чужой блокировки

TTL создаёт новую проблему. Воркер A захватил блокировку с TTL 30 секунд, но работа заняла 35 секунд. На 30-й секунде TTL истёк, Redis удалил ключ. На 31-й секунде воркер B захватил блокировку. На 35-й секунде воркер A завершил работу и выполнил DEL lock:order:42 — удалив блокировку воркера B. Теперь воркер C тоже захватывает блокировку. Два воркера снова работают параллельно — двойная обработка.

UUID + Lua-скрипт

При захвате блокировки в значение записывается уникальный идентификатор (UUID, PID + thread ID + random). При освобождении Lua-скрипт атомарно проверяет, что значение совпадает с нашим идентификатором, и только тогда удаляет:

-- захват:
SET lock:order:42 "process-a-uuid-xyz" NX EX 30
 
-- освобождение (атомарно через Lua):
EVAL "
  if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
  else
    return 0
  end
" 1 lock:order:42 "process-a-uuid-xyz"

Без Lua между GET и DEL может вклиниться чужая операция: наш GET подтвердил, что значение наше, но до DEL TTL истёк, другой клиент захватил ключ — и наш DEL снёс чужую блокировку. Lua гарантирует, что Redis выполнит проверку и удаление как одну атомарную операцию.

Того же можно добиться через EXEC:

WATCH lock:order:42
GET lock:order:42
-- клиент проверяет: значение == наш UUID?
-- если да:
MULTI
DEL lock:order:42
EXEC
-- → [1] если ключ не менялся (удалили)
-- → nil если ключ изменился между WATCH и EXEC (транзакция отменена)

WATCH следит за ключом: если между GET и EXEC кто-то изменил его (TTL истёк, другой клиент захватил), EXEC вернёт nil — транзакция отменена, DEL не выполнился. Результат корректный: блокировка уже не наша, удалять нечего. Но клиентского кода больше (проверка на стороне клиента, обработка nil от EXEC), а Lua-скрипт в 4 строки возвращает однозначный ответ: 1 — удалили, 0 — не наша.

Единственный Redis

Блокировка с UUID + Lua защищает от логической гонки, но не от инфраструктурного сбоя. Если Redis — один инстанс, при его падении все блокировки теряются. Начиная с Redis 3.2, AOF записывает абсолютные timestamps для TTL, поэтому при восстановлении из AOF просроченные ключи корректно удаляются. Но при appendfsync everysec (по умолчанию) до секунды данных может не попасть в лог — блокировка, записанная прямо перед падением, исчезнет.

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

Redlock

Алгоритм Redlock убирает единую точку отказа, используя идею консенсуса большинства. Вместо одного Redis используются N независимых инстансов (рекомендуется 5, на разных машинах, без репликации между ними).

Захват блокировки пошагово: клиент фиксирует текущее время, выполняет SET NX EX на каждом из N инстансов с одним и тем же ключом и значением, фиксирует суммарное время захвата. Блокировка считается успешной, если выполнены два условия: ключ захвачен на большинстве инстансов (больше половины, то есть минимум 3 из 5) и суммарное время захвата меньше TTL. Если хотя бы одно условие не выполнено, клиент отправляет DEL (через Lua) на все инстансы — включая недоступные, которые он пропускает по таймауту — и повторяет попытку после случайной задержки. При освобождении блокировки клиент также отправляет DEL на все N инстансов: если один недоступен, на нём ключ просто истечёт по TTL.

Redlock защищает от падения меньшинства: если 2 из 5 инстансов упали, блокировка всё ещё действует на 3 оставшихся.

GC-пауза и потеря блокировки

Redlock решает проблему отказа инфраструктуры, но не решает проблему на стороне клиента. Воркер A получил Redlock, а затем завис: пауза сборщика мусора (GC — garbage collector, автоматическое освобождение неиспользуемой памяти, которое в некоторых средах останавливает все потоки приложения), нехватка RAM и вытеснение страниц на диск (swap), или ОС ограничила CPU контейнеру. Пауза длится дольше TTL — блокировка освобождается. Воркер B захватывает Redlock и начинает обработку. Воркер A возобновляет работу и продолжает как ни в чём не бывало — он не знает, что блокировка уже не его. Оба воркера пишут в платёжный шлюз — двойное списание.

Никакая блокировка на стороне Redis не может защитить от ситуации, когда клиент перестаёт проверять состояние блокировки (потому что его поток заморожен). Защиту нужно переносить на сторону ресурса.

Fencing tokens

Fencing token — монотонно возрастающий номер, который клиент получает при захвате блокировки. Каждый следующий захват увеличивает счётчик. Клиент передаёт этот номер вместе с запросом к защищаемому ресурсу (базе данных, платёжному шлюзу, внешнему API).

Ресурс хранит последний принятый token. Если пришёл запрос с token меньше последнего принятого — ресурс отклоняет его: значит, этот запрос от клиента, чья блокировка уже истекла. Воркер A получил token 33, заснул на GC-паузе. Воркер B получил token 34, успешно обработал заказ, шлюз запомнил 34. Воркер A проснулся, отправил запрос с token 33 — шлюз отклонил его.

Redis не предоставляет fencing tokens из коробки. Реализация — через INCR отдельного ключа при захвате блокировки: клиент выполняет INCR lock:order:42:fencing, полученное значение и есть token, который передаётся ресурсу.

На практике

Для большинства случаев (идемпотентные операции, координация между воркерами одного приложения) достаточно базовой блокировки SET NX EX + Lua для освобождения на одном Redis. Если все воркеры уже работают с PostgreSQL, альтернатива — advisory locks: pg_advisory_lock(key) блокирует на уровне СУБД, без отдельной инфраструктуры и без проблем с TTL. Redlock нужен, когда потеря блокировки при падении Redis приводит к реальным последствиям — двойные платежи, повреждение данных. Fencing tokens нужны, когда даже Redlock не даёт достаточных гарантий: финансовые операции, где GC-пауза или сетевая задержка на стороне клиента могут привести к двойной записи.

Martin Kleppmann в статье «How to do distributed locking» (2016) критикует Redlock: алгоритм зависит от предположений о времени (TTL, скорость сети, отсутствие длительных пауз), которые в распределённой системе не гарантированы. Kleppmann рекомендует fencing tokens как единственный надёжный механизм. Salvatore Sanfilippo (автор Redis) опубликовал ответ, защищая Redlock для сценариев, где точных гарантий не требуется. Дискуссия не завершена однозначно — выбор зависит от цены ошибки.

См. также

Sources


Rate limiting | Очереди