---
title: "Распределённые блокировки: SETNX"
description: "Распределённая блокировка на Redis в Go: SetNX, токен и TTL, атомарное снятие Lua-скриптом в go-redis v9 и фейлы без токена — с примерами кода."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/10-raspredelennye-blokirovki/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 10 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/10-raspredelennye-blokirovki/*

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

Сценарий, который знакомит с темой почти каждого: ночная задача пересчитывает рейтинги, сервис живёт в трёх репликах Kubernetes — и утром три раза отправляются письма, три раза списываются бонусы, в логах три идентичных прохода. sync.Mutex здесь бессилен: он синхронизирует горутины внутри одного процесса, а три пода — три процесса с отдельной памятью, о чём мы говорили в уроке про [счётчики](/courses/go-redis/lessons/09-schetchiki-i-incr/). Нужен внешний источник правды, до которого дотягиваются все инстансы, — и 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, отталкивайтесь от верхней оценки длительности работы с запасом — и помните, что слишком щедрый срок превращает падение владельца в паузу задачи на эти тридцать секунд.

> **DEL без токена снимает чужую блокировку.** Разбор инцидента: воркер 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 — минимальный язык без библиотек, и для блокировочных сценариев хватает одной проверки с удалением; полный разбор пайплайнов и скриптов ждёт вас в уроке про [пайплайны](/courses/go-redis/lessons/16-pipy-lineinye/), здесь — только рабочий минимум.

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

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

> **TTL короче работы — внутри двое.** Блокировка истекла, а recalc ещё крутится: второй инстанс захватывает освободившийся ключ и работает параллельно с первым. TTL — это ставка на верхнюю границу работы, и ошибиться в неё легко при первом выкате. Средства: щедрый TTL с запасом на пики, goroutine-смотритель, продлевающий срок скриптом с проверкой токена, или разбиение задачи на куски, каждый со своей блокировкой. Помните: блокировка Redis — кооперативная, она честна только к тем, кто её проверяет.

### Честные границы: redsync, Redlock и альтернативы

SetNX-блокировка, которую мы собрали, — рабочий каркас, и в сотнях проектов она выглядит именно так. Но продакшен добавляет требования, и для них есть готовая библиотека: github.com/go-redsync/redsync оборачивает те же команды в API с повторами, продлением и встроенным Lua. Разговор о Redlock — алгоритме блокировки на кластере из независимых Redis — я оставлю честным: он предмет многолетнего спора о корректности, и строить на нём строгие гарантии не стоит. Ключевое ограничение для любой блокировки Redis: это кооперативный механизм. Она защищает от честных конкурентов, но не от системного сбоя — при фейловере мастера ключ может потеряться, и строгую единственность она не гарантирует. Там, где цена двойного исполнения — деньги, ставьте на проверяемые средства: транзакции и уникальные ограничения базы, advisory-блокировки PostgreSQL — как это делается на практике, покажет курс по [GORM](/courses/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()?

- ✅ false и ошибку nil: ключ не создан, старое значение не тронуто
- true: значение перезапишется на xyz
- Ошибку: ключ занят
- Панику

NX означает «только если не существует»: при занятом ключе SetNX возвращает false, не меняя значения и не продлевая срок. Именно на этом булевом различении держится весь механизм: истина — ты владелец, ложь — работу ведёт кто-то другой.

### Проверка: зачем токен в значении

**Вопрос:** Зачем в значении блокировки случайный токен, а не константа locked?

- ✅ Чтобы при снятии отличить свою блокировку от чужой
- Чтобы Redis различал блокировки по типу значения
- Чтобы ключ не попал в maxmemory eviction
- Токен ускоряет EVALSHA

Токен — доказательство владения: скрипт снятия сравнивает значение ключа с токеном и удаляет только своё. С константой воркер A удалит ключ воркера B, потому что значения совпадут — и в критическую секцию войдёт третий. TTL от мёртвых владельцев, токен — от чужих рук.

### Два захвата подряд

```go
ok1, _ := rdb.SetNX(ctx, "lock:recalc", "aaa", time.Minute).Result()
ok2, _ := rdb.SetNX(ctx, "lock:recalc", "bbb", time.Minute).Result()

fmt.Println(ok1, ok2)
```

**Что выведет программа?**

- ✅ true false
- true true
- false false
- false true

Первый SetNX создаёт ключ и возвращает истину; второй видит существующий ключ, возвращает ложь и не меняет значение — внутри так и останется aaa. Перезапись значения при захвате сломала бы владение: токен первого владельца был бы стёрт.

### Захват после снятия

```go
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)
```

**Что выведет программа?**

- ✅ true
- false
- ошибку not an integer
- панику

Del удалил ключ, и следующему SetNX нечего встретить — захват проходит с новым токеном bbb. Так выглядит штатная смена владельца после снятия; проблема возникает лишь когда Del делает не владелец — о чём врезка с Lua-скриптом выше.

### Соберите захват и снятие

Допишите захват блокировки с TTL, чтение результата и удаление ключа.

```go
ok, err := rdb.___(ctx, key, token, 30*time.Second).___()

if err != nil {
        return err
}

if ok {
        defer rdb.___(ctx, key).Err()
}
```

Подсказка: Захват с флагом NX делает SetNX, из команды достаётся Result, а снятие — это Del по ключу.

Правильные ответы в пропусках: `SetNX`, `Result`, `Del`

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

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

Правильный порядок строк:

1. `token := uuid.NewString()`
2. `ok, err := rdb.SetNX(ctx, key, token, time.Minute).Result()`
3. `if !ok {`
4. `    return ErrLocked // работу ведёт кто-то другой`
5. `}`
6. `defer releaseLock(ctx, key, token)`

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

- **SETNX / SetNX** — Записать значение, только если ключа нет. Истина — захват удался и вы владелец; ложь — ключ занят, значение не тронуто.
- **TTL у блокировки** — Обязательный срок жизни: упавший владелец освобождает ключ сам. Без TTL мёртвый процесс блокирует задачу навсегда.
- **Токен владельца** — Случайное значение ключа, известное только владельцу. Снятие разрешено при совпадении токена — иначе удалите чужую блокировку.
- **Lua-снятие** — Скрипт сравнивает значение с токеном и удаляет ключ атомарно. Три команды Get, сравнение, Del имеют окно между собой — и оно ломает владение.
- **redsync** — Библиотека github.com/go-redsync/redsync: готовые распределённые блокировки с продлением, повторами и Lua — над теми же командами Redis.
- **Границы блокировки Redis** — Кооперативная и best-effort: защищает от честных конкурентов, не от фейловера мастера. Для денег — транзакции и ограничения базы.

Блокировки закрыли первый десяток курса: строки, TTL, кэш, хэши, счётчики и синхронизация — фундамент, на котором строятся типовые Redis-сервисы. Дальше данные перестают быть одиночками: следующий урок — про [списки и очереди задач](/courses/go-redis/lessons/11-spiski/), где LPush и RPop соединяют продюсера и потребителя. Сессии веб-приложения соберут хэши и TTL во вход с выходом в уроке про [сессии](/courses/go-redis/lessons/21-sessii-web/), а ограничение частоты запросов станет оконными счётчиками из урока про [rate limiter](/courses/go-redis/lessons/22-rate-limiter/). Проверьте себя практикой: весь код урока запускается локальным Go с Redis в Docker — песочница сетевых соединений не открывает, а две реплики для гонки легко сымитировать двумя процессами go run.

## Коротко: что запомнить

- 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.
