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

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

junior~25 мин

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

В прошлом уроке мы выбирали, чем передавать события. Теперь паттерны. Первый из них мы уже строили в уроке про блокировки и Lua: SET ... NX PX со случайным токеном и снятие скриптом, который сверяет токен. Там же остались два открытых вопроса. Что делать, если работа идёт дольше срока блокировки? И что будет с данными, если блокировка всё-таки подвела? Сегодня закрываем оба.

Отчёт, который считается долго

Каждую ночь магазин собирает отчёт о продажах за день. Сервис отчётов запущен в трёх копиях, и собирать отчёт должна одна из них. Блокировку берём на 30 секунд. Обычно отчёт готов за 10 секунд.

Но однажды база тормозит, и отчёт считается 45 секунд. На 30-й секунде Redis удаляет ключ блокировки. Вторая копия видит свободную блокировку, берёт её и начинает тот же отчёт. Теперь отчёт считают двое, и кто запишет итог последним, неизвестно.

Можно взять срок побольше, скажем 10 минут. Тогда упавший процесс будет держать блокировку 10 минут, и всё это время отчёт никто не соберёт. Нужен способ держать блокировку столько, сколько идёт работа, но не дольше.

Продление: сторожевой таймер

Вспомни вахтёра с ключом от переговорки из урока 19: через час он забирает ключ сам. Если ты знаешь, что засидишься, раз в двадцать минут подходишь к вахте и говоришь: «Я ещё здесь, продлите мне бронь». Вахтёр продлевает, но только тому, на кого ключ записан.

В коде это сторожевой таймер: пока работа идёт, фоновая задача каждые несколько секунд продлевает срок блокировки. Если процесс упал, таймер умер вместе с ним, продлевать некому, и блокировка истечёт сама.

Продлевать надо с той же проверкой, что и при снятии: «продли, только если в ключе мой токен». Иначе процесс, чью блокировку уже забрали, продлит чужую. Поэтому снова скрипт Lua, только вместо DEL в нём PEXPIRE:

if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
  return 0
end

Проверим на настоящем Redis. Блокировку держит w1, чужой токен ничего не продлевает, свой продлевает:

127.0.0.1:6379> SET lock:report:daily w1-7f3a NX PX 10000
OK
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end" 1 lock:report:daily w2-91bc 10000
(integer) 0
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end" 1 lock:report:daily w1-7f3a 10000
(integer) 1

Продлевают обычно раз в треть срока: при сроке 30 секунд раз в 10 секунд. Тогда одна неудачная попытка из-за сетевой заминки ещё ничего не ломает. А если скрипт вернул 0, блокировка уже чужая, и работу надо бросить: её делает кто-то другой.

Пауза, которую никто не заметил

Сторожевой таймер спасает от долгой работы. Но есть беда хуже: процесс замирает целиком.

В Node.js, Java и Go память чистит сборщик мусора, и иногда он останавливает программу целиком. Обычно на миллисекунды, но под нагрузкой бывает и на секунды. Ещё процесс может замереть, когда виртуальную машину переносят на другой сервер или когда системе не хватает памяти.

Мартин Клеппман разобрал такую историю в статье о распределённых блокировках:

  1. Процесс w1 взял блокировку на 30 секунд и начал отчёт.
  2. w1 замер на 40 секунд. Замер весь процесс, вместе со сторожевым таймером: продлевать некому.
  3. Блокировка истекла. w2 взял её, посчитал отчёт и записал итог 412.
  4. w1 проснулся. Он не знает, что спал. С его точки зрения прошла одна строчка кода, и он записывает свой итог 398 поверх правильного.

Токен при снятии тут не поможет: w1 ничего не снимает, он пишет в базу. Блокировка в Redis не может остановить процесс, который уже прошёл проверку. Значит, защищать данные должно само хранилище.

Номер на пропуске

Представь склад, куда водители привозят товар по пропускам. Пропуска выдают с растущими номерами: 33, 34, 35. Кладовщик записывает номер последнего пропуска, по которому принял товар. Если приходит водитель с пропуском 33, а кладовщик уже принимал по 34, водитель опоздал, и его товар не принимают.

Это fencing-токен, по-русски «ограждающий номер». При каждом захвате блокировки процесс получает следующий номер. В Redis это обычный счётчик:

127.0.0.1:6379> INCR lock:report:daily:fence
(integer) 1
127.0.0.1:6379> INCR lock:report:daily:fence
(integer) 2

Процесс передаёт номер вместе с каждой записью, а база принимает запись, только если номер больше последнего принятого. В PostgreSQL это одно условие в UPDATE. Повторим историю Клеппмана: w1 получил номер 33 и замер, w2 получил 34 и успел записать.

CREATE TABLE reports (day date PRIMARY KEY, total integer NOT NULL, fence bigint NOT NULL DEFAULT 0);
INSERT INTO reports (day, total) VALUES ('2026-10-09', 0);

-- w2 с номером 34
UPDATE reports SET total = 412, fence = 34 WHERE day = '2026-10-09' AND fence < 34;
-- w1 проснулся с номером 33
UPDATE reports SET total = 398, fence = 33 WHERE day = '2026-10-09' AND fence < 33;
SELECT * FROM reports;
UPDATE 1
UPDATE 0
    day     | total | fence
------------+-------+-------
 2026-10-09 |   412 |    34
(1 row)

Запись w2 прошла, запись проснувшегося w1 затронула ноль строк. Правильный итог 412 остался. w1 видит UPDATE 0 и понимает, что опоздал.

Цена приёма в том, что хранилище должно уметь проверять номер. С таблицей в PostgreSQL это просто. С отправкой письма или вызовом чужого API так не выйдет: письмо не умеет отклонять устаревшие номера.

урок из курса

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

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