Содержание урока · 9 разделов
- SETNX: захват блокировки через SetNX
- Токен и TTL: почему блокировка не навсегда
- Атомарное снятие: Lua-скрипт вместо трёх команд
- Полный цикл воркера: захват, работа, снятие
- Честные границы: redsync, Redlock и альтернативы
- Способы синхронизации: что когда брать
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сценарий, который знакомит с темой почти каждого: ночная задача пересчитывает рейтинги, сервис живёт в трёх репликах Kubernetes — и утром три раза отправляются письма, три раза списываются бонусы, в логах три идентичных прохода. sync.Mutex здесь бессилен: он синхронизирует горутины внутри одного процесса, а три пода — три процесса с отдельной памятью, о чём мы говорили в уроке про счётчики. Нужен внешний источник правды, до которого дотягиваются все инстансы, — и Redis идеален по цене: одна команда захвата, один ключ, TTL в комплекте. В этом уроке соберём блокировку по шагам: SetNX с токеном владельца, атомарное снятие Lua-скриптом, полный цикл воркера — и разберём два классических фейла: снятие чужой блокировки без токена и продление работы дольше срока жизни ключа. Отдельно договоримся о честности: блокировка Redis — кооперативная, и у неё есть границы, о которых стоит знать до продакшина, а не после инцидента.
SETNX: захват блокировки через SetNX#
Идея умещается в одну команду: SET key value NX — записать значение, только если ключа ещё нет. Кто первый вызвал — тот и владелец: команда вернула истину, ключ создан. Все остальные в этот момент получают ложь и понимают, что работу ведёт кто-то другой. В go-redis v9 это rdb.SetNX(ctx, key, value, ttl) — метод возвращает булево значение захвата; под капотом это SET с флагами NX и сроком, без отдельного Expire. У блокировки два обязательных атрибута, и оба будут в коде. Первый — TTL: блокировка без срока жизни превращает упавший процесс в вечного владельца, и задача останавливается до ручного DEL — это самая дорогая ошибка темы. Второй — значение: случайный токен, уникальный для каждого владельца, а не константа вроде locked. Токен — метка, по которой владелец узнаёт свою блокировку при снятии; зачем это нужно, покажет врезка с фейлом. Ключ именуют по задаче: lock:recalc-ratings, lock:mail-digest — один ключ на одну критическую секцию, и никак иначе.
Захват: SetNX с токеном и TTL
GOfunc acquireLock(ctx context.Context, task string) (string, bool, error) {
key := "lock:" + task
token := uuid.NewString() // метка владельца
ok, err := rdb.SetNX(ctx, key, token, 30*time.Second).Result()
if err != nil {
return "", false, err
}
return token, ok, nil
}SetNX объединяет захват и срок: ключ lock:recalc создаётся только при отсутствии и живёт тридцать секунд. Значение — случайный токен из пакета github.com/google/uuid; он понадобится при снятии как доказательство владения. Возвращаем пару токен и ok: неудачный захват — штатный исход, а не ошибка, поэтому ложь передаётся значением, а не err.
Токен и TTL: почему блокировка не навсегда#
TTL решает проблему мёртвого владельца. Процесс взял блокировку и умер — упал, убит OOM-киллером, потерял сеть: без срока ключ lock висел бы вечно, и задача никогда больше не выполнялась бы. С TTL через тридцать секунд Redis сам выкинет ключ, и следующий кандидат захватит работу. Обратная сторона срока: если работа длится дольше TTL, блокировка истечёт под работающим владельцем — к этому вернёмся во врезке, это второй классический фейл. Токен решает проблему чужой руки: значение ключа — случайная строка, известная только владельцу, и снятие разрешено только тому, кто назовёт её. Константа в значении не различает владельцев: два воркера могут написать одно и то же locked, и тогда проверка значения ничего не даёт. TTL и токен работают в паре: срок гарантирует, что блокировка освободится, токен гарантирует, что освободить её раньше срока сможет только законный хозяин. Выбирая TTL, отталкивайтесь от верхней оценки длительности работы с запасом — и помните, что слишком щедрый срок превращает падение владельца в паузу задачи на эти тридцать секунд.
Разбор инцидента: воркер A работает дольше TTL, ключ истекает, воркер B захватывает блокировку и начинает свою работу. A заканчивает и добросовестно вызывает DEL — и снимает блокировку B. В окно врывается воркер C: теперь B и C работают одновременно. Классика, от которой защищает токен: снимать ключ разрешено, только если в нём лежит ваш токен. Но Get, сравнение и DEL — три команды с окном между ними, за которое всё меняется; атомарно это умеет только Lua-скрипт из следующего блока.
Атомарное снятие: Lua-скрипт вместо трёх команд#
Проверить токен и удалить ключ нужно одной операцией — иначе между проверкой и удалением блокировка уже сменит владельца. В Redis это делается Lua-скриптом: сервер исполняет скрипт как одну атомарную команду, и никакой клиент не вклинится между строками. Скрипт читает значение ключа командой redis.call, сравнивает с аргументом и удаляет ключ только при совпадении — иначе возвращает ноль. В go-redis v9 для этого есть redis.NewScript: объект компилирует скрипт один раз, а Run использует EVALSHA — на сервере кэшируется байткод, и повторные вызовы не гоняют текст скрипта по сети. Аргументы передаются через KEYS — массив ключей — и ARGV — прочие параметры; в нашем случае один ключ блокировки и один токен. Тот же приём понадобится для продления: скрипт проверяет токен и перезаписывает TTL, и это тоже должно быть атомарным. Lua в Redis — минимальный язык без библиотек, и для блокировочных сценариев хватает одной проверки с удалением; полный разбор пайплайнов и скриптов ждёт вас в уроке про пайплайны, здесь — только рабочий минимум.
Снятие владения одной операцией
GOvar releaseScript = redis.NewScript(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end",
)
func releaseLock(ctx context.Context, task, token string) error {
return releaseScript.Run(ctx, rdb, []string{"lock:" + task}, token).Err()
}Скрипт сравнивает значение ключа с токеном владельца и удаляет ключ только при совпадении — чужую блокировку он не тронет и вернёт ноль. Всё, что внутри, исполняется на сервере одной операцией, без окон между проверкой и удалением. Run отдаёт EVALSHA с фолбэком на EVAL, поэтому после первого вызова текст скрипта по сети не ходит.
Полный цикл воркера: захват, работа, снятие#
Соберём всё в сценарий ночной задачи и обратим внимание на два решения. Первое: неудачный захват — не ошибка. Инстанс, который не смог взять lock:recalc-ratings, просто завершается с нулём: работа уже ведётся другим, и это ожидаемое поведение в кластере из трёх реплик. Ошибкой считается только сетевой сбой при обращении к Redis — и её нельзя путать с отказом захвата. Второе: снятие в defer. Блокировка должна освобождаться при любом исходе работы — успехе, ошибке, панике; defer с releaseLock гарантирует это ровно так же, как defer f.Close() в обычном Go. Токен передаётся в снятие из переменной захвата: попытка снять блокировку чужим токеном вернёт ноль и не тронет чужой ключ. Обратите внимание на тайминг: TTL в тридцать секунд должен перекрывать саму работу с запасом — если recalc может жить минуту, срок надо увеличивать или дробить работу. Функция recalc здесь абстрактна: это может быть пересчёт рейтингов, рассылка дайджеста, миграция данных — любой фрагмент, который обязан выполниться единожды.
Ночная задача под блокировкой
GOfunc runNightlyRecalc(ctx context.Context) error {
const task = "recalc-ratings"
token, ok, err := acquireLock(ctx, task)
if err != nil {
return err // сеть — это ошибка
}
if !ok {
return nil // работу ведёт другой инстанс — это норма
}
defer releaseLock(ctx, task, token) // снятие при любом исходе
return recalc(ctx) // сама работа
}Три исхода разделены честно: сетевая ошибка возвращается наверх, отказ захвата — тихий ноль, а получив блокировку, воркер снимает её в defer с собственным токеном. Так задача в трёхрепличном кластере выполняется ровно один раз за запуск — при условии, что работа укладывается в TTL, о чём в следующей врезке.
Блокировка истекла, а recalc ещё крутится: второй инстанс захватывает освободившийся ключ и работает параллельно с первым. TTL — это ставка на верхнюю границу работы, и ошибиться в неё легко при первом выкате. Средства: щедрый TTL с запасом на пики, goroutine-смотритель, продлевающий срок скриптом с проверкой токена, или разбиение задачи на куски, каждый со своей блокировкой. Помните: блокировка Redis — кооперативная, она честна только к тем, кто её проверяет.
Честные границы: redsync, Redlock и альтернативы#
SetNX-блокировка, которую мы собрали, — рабочий каркас, и в сотнях проектов она выглядит именно так. Но продакшен добавляет требования, и для них есть готовая библиотека: github.com/go-redsync/redsync оборачивает те же команды в API с повторами, продлением и встроенным Lua. Разговор о Redlock — алгоритме блокировки на кластере из независимых Redis — я оставлю честным: он предмет многолетнего спора о корректности, и строить на нём строгие гарантии не стоит. Ключевое ограничение для любой блокировки Redis: это кооперативный механизм. Она защищает от честных конкурентов, но не от системного сбоя — при фейловере мастера ключ может потеряться, и строгую единственность она не гарантирует. Там, где цена двойного исполнения — деньги, ставьте на проверяемые средства: транзакции и уникальные ограничения базы, advisory-блокировки PostgreSQL — как это делается на практике, покажет курс по GORM. А если нужны гарантии строже — fencing-токены и разбор из статьи Клеппмана по запросу «How to do distributed locking» — обязательное чтение перед тем, как вешать на Redis платежи. Для ночных задач и дедупликации кронов каркас из этого урока — норма.
Способы синхронизации: что когда брать#
| Инструмент | Радиус действия | Когда брать |
|---|---|---|
| sync.Mutex | Один процесс | Гонки горутин внутри сервиса — без сети и Redis |
| SetNX + TTL + Lua | Все инстансы через Redis | Крон или задача, которую ведёт один инстанс |
| redsync | Кластер Redis | Прод-блокировки: продление, повторные попытки, API |
| Транзакции и advisory-локи БД | Одна база данных | Двойное исполнение стоит денег — нужны строгие гарантии |
Проверка: захват занятого ключа
Ключ lock:recalc уже существует со значением abc. Что вернёт rdb.SetNX(ctx, "lock:recalc", "xyz", time.Minute).Result()?
Проверка: зачем токен в значении
Зачем в значении блокировки случайный токен, а не константа locked?
Два захвата подряд
Прочитайте код и выберите, что он выведет:
ok1, _ := rdb.SetNX(ctx, "lock:recalc", "aaa", time.Minute).Result()
ok2, _ := rdb.SetNX(ctx, "lock:recalc", "bbb", time.Minute).Result()
fmt.Println(ok1, ok2)Захват после снятия
Прочитайте код и выберите, что он выведет:
rdb.SetNX(ctx, "lock:mail", "aaa", time.Minute)
rdb.Del(ctx, "lock:mail")
ok, _ := rdb.SetNX(ctx, "lock:mail", "bbb", time.Minute).Result()
fmt.Println(ok)Соберите захват и снятие
Допишите захват блокировки с TTL, чтение результата и удаление ключа.
Цикл воркера по строкам
Расставьте строки воркера по порядку: от генерации токена до отложенного снятия.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: распределённые блокировки
Блокировки закрыли первый десяток курса: строки, TTL, кэш, хэши, счётчики и синхронизация — фундамент, на котором строятся типовые Redis-сервисы. Дальше данные перестают быть одиночками: следующий урок — про списки и очереди задач, где LPush и RPop соединяют продюсера и потребителя. Сессии веб-приложения соберут хэши и TTL во вход с выходом в уроке про сессии, а ограничение частоты запросов станет оконными счётчиками из урока про rate limiter. Проверьте себя практикой: весь код урока запускается локальным Go с Redis в Docker — песочница сетевых соединений не открывает, а две реплики для гонки легко сымитировать двумя процессами go run.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- SetNX создаёт ключ только при отсутствии: истина — вы владелец, ложь — работу уже ведут.
- Два обязательных атрибута блокировки — TTL против мёртвых владельцев и случайный токен как метка владения.
- Снятие чужой блокировки без проверки токена — классический инцидент; Get и Del по отдельности не атомарны.
- Lua-скрипт сравнивает токен и удаляет ключ одной операцией: redis.NewScript в v9 кэширует его через EVALSHA.
- TTL короче работы — двое внутри: щедрый срок, смотритель с продлением или дробление задачи.
- Блокировка Redis кооперативна: для строгих гарантий — транзакции и ограничения базы, для прод-удобства — redsync.
Домашнее задание
Соберите acquireLock, releaseLock с Lua-скриптом и runNightlyRecalc, запустите локальным Go с Redis в Docker. Имитируйте гонку: два процесса go run одновременно — убедитесь, что работу выполнил один, а второй вышел с нулём. Затем уменьшите TTL до одной секунды и добавьте в recalc sleep на две: воспроизведите двойное исполнение и почините продлением TTL или увеличением срока. Токен проверьте в redis-cli через GET lock:recalc-ratings.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя просто DEL после работы?
Del снимает ключ, не спрашивая чей он: если TTL истёк и блокировку взял другой воркер, вы удалите его блокировку и впустите третьего. Правильно — скрипт, который удаляет ключ только при совпадении токена, потому что проверка и удаление должны быть атомарны.
Что делать, если работа длится дольше TTL?
Увеличьте TTL с запасом или добавьте goroutine-смотрителя, который продлевает срок скриптом с проверкой токена, пока работа не закончена. Альтернатива — дробить задачу на короткие куски со своей блокировкой у каждого. Если двойное исполнение недопустимо, полагаться на один TTL нельзя.
Redlock — значит кластер Redis обязателен?
Нет: классический SetNX-паттерн работает с одним Redis и покрывает кроновые задачи и дедупликацию. Redlock — алгоритм для кворума из независимых инстансов, и его корректность предмет спора; для строгих гарантий используйте средства базы, а для удобства в проде — библиотеку redsync.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Списки: LPush, RPop и очереди задач
- Множества: SAdd, SInter и уникальность
- Отсортированные множества: лидерборды
- Pub/Sub: рассылка событий в реальном времени
- Счётчики: INCR и атомарный рост
- Хэши: HSet, HGet и профили пользователей
- Кэш структур: JSON-сериализация в Redis
- Кэширование результатов запросов
- Все курсы по Go
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin