Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 10 из 25 ~20 мин +20 XP

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

Распределённая блокировка на Redis отдаёт задачу одному исполнителю из многих: SetNX создаёт ключ, только если тот не существует, а TTL гарантирует, что блокировка умрёт вместе с упавшим процессом. Соберём захват и снятие на go-redis v9 — с токеном владельца, Lua-скриптом и честным разговором о том, где приём ломается.

Содержание урока · 9 разделов

Сценарий, который знакомит с темой почти каждого: ночная задача пересчитывает рейтинги, сервис живёт в трёх репликах 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

GO
func 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, отталкивайтесь от верхней оценки длительности работы с запасом — и помните, что слишком щедрый срок превращает падение владельца в паузу задачи на эти тридцать секунд.

Атомарное снятие: Lua-скрипт вместо трёх команд#

Проверить токен и удалить ключ нужно одной операцией — иначе между проверкой и удалением блокировка уже сменит владельца. В Redis это делается Lua-скриптом: сервер исполняет скрипт как одну атомарную команду, и никакой клиент не вклинится между строками. Скрипт читает значение ключа командой redis.call, сравнивает с аргументом и удаляет ключ только при совпадении — иначе возвращает ноль. В go-redis v9 для этого есть redis.NewScript: объект компилирует скрипт один раз, а Run использует EVALSHA — на сервере кэшируется байткод, и повторные вызовы не гоняют текст скрипта по сети. Аргументы передаются через KEYS — массив ключей — и ARGV — прочие параметры; в нашем случае один ключ блокировки и один токен. Тот же приём понадобится для продления: скрипт проверяет токен и перезаписывает TTL, и это тоже должно быть атомарным. Lua в Redis — минимальный язык без библиотек, и для блокировочных сценариев хватает одной проверки с удалением; полный разбор пайплайнов и скриптов ждёт вас в уроке про пайплайны, здесь — только рабочий минимум.

Снятие владения одной операцией

GO
var 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 здесь абстрактна: это может быть пересчёт рейтингов, рассылка дайджеста, миграция данных — любой фрагмент, который обязан выполниться единожды.

Ночная задача под блокировкой

GO
func 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, о чём в следующей врезке.

Честные границы: 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, чтение результата и удаление ключа.

ok, err := rdb.(ctx, key, token, 30*time.Second).() if err != nil { return err } if ok { defer rdb.(ctx, key).Err() }

Цикл воркера по строкам

Расставьте строки воркера по порядку: от генерации токена до отложенного снятия.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: распределённые блокировки

Термин

SETNX / SetNX

нажмите, чтобы перевернуть
Определение

Записать значение, только если ключа нет. Истина — захват удался и вы владелец; ложь — ключ занят, значение не тронуто.

1 / 6

Блокировки закрыли первый десяток курса: строки, 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Распределённые блокировки: SETNX)

Вопросы и ответы по уроку

Почему нельзя просто DEL после работы?

Del снимает ключ, не спрашивая чей он: если TTL истёк и блокировку взял другой воркер, вы удалите его блокировку и впустите третьего. Правильно — скрипт, который удаляет ключ только при совпадении токена, потому что проверка и удаление должны быть атомарны.

Что делать, если работа длится дольше TTL?

Увеличьте TTL с запасом или добавьте goroutine-смотрителя, который продлевает срок скриптом с проверкой токена, пока работа не закончена. Альтернатива — дробить задачу на короткие куски со своей блокировкой у каждого. Если двойное исполнение недопустимо, полагаться на один TTL нельзя.

Redlock — значит кластер Redis обязателен?

Нет: классический SetNX-паттерн работает с одним Redis и покрывает кроновые задачи и дедупликацию. Redlock — алгоритм для кворума из независимых инстансов, и его корректность предмет спора; для строгих гарантий используйте средства базы, а для удобства в проде — библиотеку redsync.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

JWT25 уроков~12 чпрактика25 уроков уже доступны

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin