В прошлом уроке мы выбирали, чем передавать события. Теперь паттерны. Первый из них мы уже строили в уроке про блокировки и 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 память чистит сборщик мусора, и иногда он останавливает программу целиком. Обычно на миллисекунды, но под нагрузкой бывает и на секунды. Ещё процесс может замереть, когда виртуальную машину переносят на другой сервер или когда системе не хватает памяти.
Мартин Клеппман разобрал такую историю в статье о распределённых блокировках:
- Процесс
w1взял блокировку на 30 секунд и начал отчёт. w1замер на 40 секунд. Замер весь процесс, вместе со сторожевым таймером: продлевать некому.- Блокировка истекла.
w2взял её, посчитал отчёт и записал итог 412. 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 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.