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

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

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

Зачем это нужно на практике

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

Простейший лок на SET NX EX

Идея минимальна: попытаться создать ключ, только если его ещё нет. Команда SET с флагом NX (set if Not eXists) делает это атомарно, а EX сразу вешает срок жизни, чтобы лок не остался навечно, если владелец упадёт.

SET lock:report "held" NX EX 30
# OK   -> ключа не было, лок ваш на 30 секунд
# (nil) -> ключ уже есть, лок занят кем-то другим

Когда работа закончена, владелец удаляет ключ командой DEL lock:report, освобождая ресурс. Срок EX 30 — это страховка: даже если процесс аварийно завершится и не вызовет DEL, через 30 секунд лок протухнет сам и систему не заклинит навсегда.

Почему голый DEL опасен: нужен уникальный токен

У наивного варианта есть коварная гонка. Представьте: процесс A взял лок на 30 секунд, но из-за паузы (сборка мусора, перегрузка) провозился 31 секунду. На 30-й секунде лок протух, и его законно перехватил процесс B. Теперь A «просыпается», доделывает работу и вызывает DEL lock:report — и удаляет уже ЧУЖОЙ лок, принадлежащий B. Два процесса оказываются в критической секции одновременно.

Лекарство — записывать в значение ключа уникальный токен владельца (например, случайный UUID), а удалять лок, только если токен совпал. Тогда A не сможет снять лок B, потому что в ключе лежит токен B.

SET lock:report 7f3a-9c21-uuid NX EX 30   # запоминаем СВОЙ токен в значении

Снятие через Lua: атомарность compare-and-delete

Но «проверить токен, потом удалить» — это две команды, и между ними снова может вклиниться чужой захват. Нужна атомарная операция «сравнил и удалил». В Redis это решает Lua-скрипт через EVAL: скрипт целиком выполняется на сервере без прерываний.

EVAL "if redis.call('GET', KEYS[1]) == ARGV[1]
      then return redis.call('DEL', KEYS[1])
      else return 0 end" 1 lock:report 7f3a-9c21-uuid

Скрипт сравнивает текущее значение ключа со своим токеном (ARGV[1]) и удаляет ключ только при совпадении. Вернёт 1 — сняли свой лок; 0 — лок уже не наш, ничего не трогаем. Смоделируем эту логику на Python, чтобы увидеть поведение compare-and-delete.

# Иллюстрация безопасного снятия лока: сравнить токен и удалить ТОЛЬКО свой ключ.
# Эмулируем хранилище Redis обычным словарём, чтобы показать логику compare-and-delete.
store = {}

def set_nx(key, value):
    if key in store:
        return False          # ключ занят — лок у кого-то другого
    store[key] = value
    return True

def unlock(key, token):
    # та самая логика, что в Lua-скрипте: удаляем, только если токен совпал
    if store.get(key) == token:
        del store[key]
        return 1
    return 0

# Клиент A берёт лок со своим уникальным токеном
print("A захватил лок:", set_nx("lock:order", "tokenA"))
# Клиент B пытается взять тот же лок — занято
print("B захватил лок:", set_nx("lock:order", "tokenB"))
# B по ошибке пробует снять лок чужим токеном — Lua это запрещает
print("B снял лок (чужой токен):", unlock("lock:order", "tokenB"))
# A корректно снимает свой лок
print("A снял лок (свой токен):", unlock("lock:order", "tokenA"))

Вывод:

A захватил лок: True
B захватил лок: False
B снял лок (чужой токен): 0
A снял лок (свой токен): 1

Как это работает под капотом

Redis обрабатывает команды в одном потоке и по очереди, поэтому SET ... NX атомарен по своей природе: между «проверить отсутствие» и «записать» вклиниться нечему. Lua-скрипт получает ту же гарантию — на время выполнения EVAL сервер не обслуживает другие команды, так что внутри скрипта GET и DEL образуют единую неделимую транзакцию. Срок жизни (EX) реализован через механизм истечения ключей: по таймеру или при обращении Redis сам удаляет просроченный ключ, и лок освобождается. Важная тонкость — это таймаут владения, а не таймаут операции: если ваша работа в принципе может длиться дольше срока, лок придётся периодически продлевать (тем же compare-and-set на свой токен), иначе он протухнет под работающим процессом.

Проблемы одиночного лока и Redlock

Лок на одном узле Redis имеет фундаментальный изъян: если этот узел упадёт сразу после выдачи лока, а реплика ещё не успела получить запись (репликация асинхронна), то после переключения на реплику ключа там не будет — и второй клиент спокойно возьмёт «свободный» лок. Для повышения надёжности Сальваторе Санфилиппо предложил алгоритм Redlock: клиент берёт лок не на одном, а на нескольких независимых мастер-узлах Redis (обычно 5) и считает лок захваченным, только если успел получить его на большинстве (3 из 5) за время заметно меньше срока жизни лока. Падение одного-двух узлов тогда не ломает взаимное исключение.

Redlock — не бесплатное и не бесспорное решение: вокруг него есть известная дискуссия о корректности при рассинхронизации часов и «зависших» процессах. Практический вывод такой: для дешёвых сценариев (не дать крону запуститься дважды) хватает одиночного лока с токеном и TTL; за по-настоящему критичную корректность (деньги, инвентарь) обычно отвечает не лок, а транзакция/уникальное ограничение в основной базе данных, а Redis лишь снижает конкуренцию.

Когда лок реально нужен

ЗадачаНужен распределённый лок?
Инкремент счётчика, добавление в множествоНет — атомарные команды (INCR, SADD)
«Списать, только если хватает» в одном ключеНет — Lua-скрипт с проверкой
Не дать фоновой задаче выполниться дваждыДа — лок с TTL и токеном
Сериализовать вызовы внешнего API с лимитомДа
Денежная проводка как источник истиныСкорее транзакция в БД, лок — вспомогательный

Частые ошибки

  • Лок без TTL. Процесс упал, не сняв ключ — и ресурс заблокирован навсегда. Всегда ставьте EX/PX.
  • Удаление без проверки токена. Голый DEL снимает в том числе чужой, уже перехваченный лок. Снимайте только Lua с compare-and-delete.
  • TTL короче реальной работы. Лок протухает под работающим процессом; либо увеличьте срок, либо продлевайте лок в процессе.
  • Делать лок отдельными SET и EXPIRE. Между ними процесс может упасть — ключ останется без срока. Ставьте срок тем же SET ... EX.
  • Считать лок гарантией корректности денег. Для критичных инвариантов источник истины — транзакция/уникальный индекс в основной БД, а не Redis.

Итоги

  • Распределённый лок — это ключ-флаг в общем Redis; SET key val NX EX 30 — атомарный захват с автосроком.
  • В значение кладите уникальный токен владельца, иначе можно случайно снять чужой лок.
  • Снимайте лок Lua-скриптом «сравнить токен и удалить» — это атомарно и безопасно.
  • Одиночный узел уязвим к фейловеру; Redlock берёт лок на большинстве из нескольких узлов, но имеет нюансы и нужен не всегда.
  • Лок хорош против повторного запуска задач; за денежную корректность отвечает основная БД.
Проверьте себя
1. Зачем в значение ключа-лока записывают уникальный токен владельца, а снимают лок Lua-скриптом?
AЧтобы при снятии удалить ключ только если токен совпал, и не снять чужой лок, перехваченный после истечения TTL
BЧтобы Redis быстрее находил ключ в памяти
CЧтобы лок автоматически продлевался без участия клиента
DЧтобы лок работал без указания срока жизни
2. В чём основная слабость лока на одном узле Redis, которую призван закрыть Redlock?
AОдин узел не умеет команду SET с флагом NX
BПри падении узла и переключении на реплику запись о локе может потеряться из-за асинхронной репликации, и второй клиент возьмёт «свободный» лок
CLua-скрипты не выполняются на одиночном узле
DTTL на одном узле не работает