Распределённые блокировки
Как заставить десятки процессов на разных серверах по очереди трогать один ресурс, когда у них нет общей памяти — только 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 берёт лок на большинстве из нескольких узлов, но имеет нюансы и нужен не всегда.
- Лок хорош против повторного запуска задач; за денежную корректность отвечает основная БД.