Распределённые блокировки и атомарность через Lua
Когда несколько процессов борются за один ресурс, нужна распределённая блокировка. Redis даёт для неё простой и быстрый инструмент — но с подводными камнями.
Блокировка без срока жизни — это блокировка навсегда, если владелец упал. Атомарность из двух команд — это не атомарность. Эти два правила определяют правильную блокировку на Redis.
Распределённая блокировка нужна, когда несколько экземпляров приложения должны по очереди обращаться к общему ресурсу: не отправить письмо дважды, не запустить задачу параллельно. Redis отлично подходит для базовых блокировок благодаря атомарному SET NX EX.
Базовая блокировка: SET NX EX
# Захватить блокировку: установить, ТОЛЬКО если ключа нет, с TTL
SET lock:report "<уникальный-токен>" NX EX 30
# OK -- блокировка наша на 30 секунд
# nil -- кто-то уже держит блокировку
Опция NX означает «установить, только если ключа нет» — это атомарный захват. EX 30 критичен: если владелец упадёт, блокировка сама снимется через 30 секунд и не зависнет навсегда.
Почему важна атомарность снятия
Снимать блокировку наивным DEL lock опасно: за время работы ваш TTL мог истечь, блокировку захватил другой, и вы удалите чужую блокировку. Правильно — удалять, только если токен ваш. Но «проверить токен, затем удалить» — это две команды, между которыми может вклиниться кто-то ещё. Решение — Lua-скрипт, который Redis выполняет атомарно целиком.
# Снять блокировку атомарно: удалить, только если токен наш
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1]
then return redis.call('DEL', KEYS[1])
else return 0 end" 1 lock:report "<наш-токен>"
Зачем вообще Lua
Lua-скрипты выполняются в Redis атомарно: никакая другая команда не вклинится в середину скрипта. Это даёт то, что не может транзакция MULTI/EXEC — условную логику: прочитать значение, принять решение, записать — всё неделимо.
Почему "проверить и удалить" нужно делать в Lua
Без Lua (две команды): В Lua (один скрипт):
GET lock -> "мой-токен" if GET == токен
(тут TTL истёк, другой then DEL
захватил блокировку!) else 0
DEL lock -> удалили ЧУЖУЮ (всё атомарно, гонки нет)
Демонстрация: блокировка с проверкой владельца на Python
import threading, uuid
# Имитируем хранилище блокировок Redis
store = {}
store_lock = threading.Lock() # имитирует атомарность Redis
def set_nx(key, token):
# как SET key token NX: установить, только если ключа нет
with store_lock:
if key in store:
return False
store[key] = token
return True
def unlock_safe(key, token):
# как Lua: удалить, только если токен наш (атомарно)
with store_lock:
if store.get(key) == token:
del store[key]
return True
return False
results = []
def worker(name):
token = str(uuid.uuid4())
if set_nx("lock:report", token):
results.append(f"{name}: захватил блокировку")
# ... критическая секция ...
unlock_safe("lock:report", token)
results.append(f"{name}: снял свою блокировку")
else:
results.append(f"{name}: блокировка занята, пропускаю")
# Три воркера борются за одну блокировку одновременно
threads = [threading.Thread(target=worker, args=(f"w{i}",)) for i in range(3)]
for t in threads: t.start()
for t in threads: t.join()
for r in results: print(r)
print("\nРовно один воркер захватил блокировку. Снятие — только своей.")
Только один воркер получил блокировку (атомарный SET NX), и снять её может лишь владелец по токену. Это безопасная распределённая блокировка в миниатюре.
Как работает под капотом
Атомарность SET NX EX возможна потому, что это одна команда — Redis либо целиком создаёт ключ с TTL, либо нет, без промежуточных состояний. Lua-скрипты Redis выполняет как единую команду в своём однопоточном цикле: пока скрипт работает, сервер не обслуживает другие запросы. Это даёт атомарную условную логику. Для большей надёжности при распределённых системах существует алгоритм Redlock (несколько независимых Redis), но для большинства задач хватает одного инстанса с SET NX EX + Lua-снятие.
Частые ошибки
- Блокировка без TTL. Владелец упал — блокировка зависла навсегда.
- Снятие чужой блокировки. Простой
DELбез проверки токена удаляет блокировку, которую уже перехватил другой. - Слишком короткий TTL. Истёк во время работы — критическую секцию выполняют двое.
Best practices
- Захватывайте блокировку через
SET key token NX EX ttlс уникальным токеном. - Снимайте блокировку Lua-скриптом с проверкой токена, а не голым
DEL. - Подбирайте TTL длиннее ожидаемой работы; для критичных систем рассмотрите Redlock.
Итог: Распределённая блокировка на Redis — это SET NX EX с уникальным токеном и TTL. Снимать её нужно атомарно через Lua с проверкой владельца. Lua даёт атомарную условную логику, недоступную транзакциям. TTL спасает от зависших блокировок.