Раздел 37 · Redis с нуля

Распределённые блокировки и атомарность через Lua

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы научили Redis переживать перезапуск. Теперь другая беда. В магазине три копии сервиса заказов, и каждая раз в минуту проверяет неоплаченные заказы. Если две копии одновременно возьмутся за заказ 15, покупатель получит два письма «напоминаем об оплате», а склад дважды снимет резерв. Нужно, чтобы за одно дело в каждый момент брался только один процесс. Сегодня строим такую защиту на Redis и знакомимся со скриптами Lua.

Ключ от переговорки

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

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

Ключ на крючке это распределённая блокировка, а вахта это Redis. «Распределённая» значит, что процессы живут на разных серверах и договариваются через общее хранилище.

Взять блокировку: SET NX PX

Помнишь SET ... NX из урока про строки? Он записывает ключ, только если ключа ещё нет. Это и есть «взять ключ с крючка, если он там есть». Добавляем срок жизни в миллисекундах, PX 30000, это вахтёр, который заберёт ключ через 30 секунд:

127.0.0.1:6379> SET lock:order:15 w1-7f3a NX PX 30000
OK
127.0.0.1:6379> SET lock:order:15 w2-91bc NX PX 30000
(nil)
127.0.0.1:6379> GET lock:order:15
"w1-7f3a"

Первый процесс получил OK и работает с заказом. Второй получил (nil): блокировка занята, заказ 15 он пропускает. Всё в одной команде: проверить и записать отдельными GET и SET нельзя, между ними успеет вклиниться другой процесс. Мы обсуждали эту ловушку в уроке про счётчики.

Значение w1-7f3a это токен: случайная строка, которую процесс придумывает при каждом захвате. Зачем она, видно на снятии блокировки.

Срок жизни обязателен. Без него процесс, который упал посреди работы, оставит блокировку навсегда, и заказ 15 больше никто не обработает.

Снять блокировку: почему не DEL

Самое простое снятие это DEL lock:order:15. Посмотри, что может пойти не так:

  1. Процесс w1 взял блокировку на 30 секунд.
  2. w1 завис: долгий запрос в базу, пауза сборщика мусора. Прошло 30 секунд.
  3. Redis удалил ключ по сроку. Процесс w2 взял блокировку со своим токеном w2-91bc.
  4. w1 проснулся, закончил работу и сделал DEL lock:order:15.
  5. Удалена блокировка w2. Теперь процесс w3 тоже может её взять, и с заказом работают двое.

Это шаг 4 из истории с переговоркой: вернул чужой ключ. Поэтому снимать надо так: «удали ключ, только если в нём мой токен». Двумя командами нельзя:

GET lock:order:15     # вижу свой токен
                      # ...здесь срок истекает, и блокировку берёт w2
DEL lock:order:15     # удаляю чужую

Между GET и DEL есть щель. Нужна одна команда, которая сравнит и удалит за раз. Такой встроенной команды в Redis 7 нет, зато в Redis можно отправить свою маленькую программу. В свежих версиях Redis 8 появилась DELEX lock:book:1 IFEQ токен, которая делает ровно это, но Redis 7 и Valkey ещё долго будут вокруг, а приём со скриптом работает везде.

урок из курса

Урок из курса «Redis с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.