voidlearn
 

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

Разбери ограничения lock через SET NX PX: владелец может пережить TTL, а новый владелец уже начать работу. Сравни lock с fencing token, разберись в pub/sub и Streams, затем проверь, как go-redis ведёт себя при промахе, таймауте и заполненном пуле.

Блокировка на Redis: SET NX PX

Хватает одной команды:

SET lock:job:42 <уникальное-значение> NX PX 10000
  • NX - установить, только если ключа нет. Это и есть атомарный захват: команда либо создаёт ключ и возвращает OK, либо возвращает nil. Однопоточный сервер выполняет проверку и установку как одну операцию, так что гонке между ними взяться неоткуда.
  • PX 10000 - TTL в миллисекундах.
  • В значение кладут случайный UUID держателя.

Зачем TTL

Без TTL блокировка упавшего держателя остаётся навсегда. Процесс убили OOM-killer-ом, под выселили, машина отключилась - defer unlock() не выполнится, потому что выполнять его некому. Ключ висит, работа стоит, чинят руками в три часа ночи. Освободить блокировку без участия упавшего процесса умеет только TTL. Из него же растёт главная проблема таких блокировок, о ней ниже.

Зачем уникальное значение

Чтобы снять свою блокировку, а не чужую. Сценарий: A взял блокировку с TTL 10 секунд, работа затянулась на 12; на десятой секунде ключ истёк, B тут же его взял; на двенадцатой A заканчивает и делает DEL lock:job:42, то есть удаляет блокировку B. Дальше блокировку берёт C, и теперь B и C работают параллельно. Забыл одну деталь, и взаимного исключения больше нет. С уникальным значением блокировку можно снимать с проверкой: «удали, только если там всё ещё моё значение».

Освобождение через Lua

«Проверить и удалить» состоит из двух операций, и между ними ключ может истечь. Классическая гонка check-then-act: GET вернул моё значение, в этот момент TTL истёк, B захватил блокировку, и мой DEL сносит уже его ключ. Атомарность даёт Lua-скрипт: Redis выполняет его целиком, не вклинивая чужие команды.

-- unlock.lua: удалить ключ, только если значение совпадает
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end`)

type Lock struct {
    rdb   *redis.Client
    key   string
    value string        // уникальный идентификатор владения
}

func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) {
    val := uuid.NewString()
    ok, err := rdb.SetNX(ctx, key, val, ttl).Result()   // SET key val NX PX ttl
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, ErrLockBusy                          // занято кем-то другим
    }
    return &Lock{rdb: rdb, key: key, value: val}, nil
}

func (l *Lock) Release(ctx context.Context) error {
    // NewScript.Run сначала пробует EVALSHA, а при NOSCRIPT переходит на EVAL.
    n, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.value).Int64()
    if err != nil {
        return err
    }
    if n == 0 {
        // Ключа нет или он уже не наш: TTL истёк, пока мы работали.
        // Это не безобидно: значит, кто-то мог работать параллельно с нами.
        return ErrLockLost
    }
    return nil
}
Откуда здесь uuid и что изменилось в Go 1.27

uuid.NewString() взят из github.com/google/uuid, который много лет был стандартом де-факто. С Go 1.27 пакет uuid есть в стандартной библиотеке (RFC 9562), и ради одной случайной строки внешняя зависимость больше не нужна: uuid.New().String() даёт то же самое. В версии из stdlib есть uuid.New(), uuid.NewV4(), uuid.NewV7(), uuid.Parse и тип uuid.UUID = [16]byte; метода NewString в ней нет, вместо него пишут New().String(). Для значения блокировки годится любой вариант: от него требуется только неповторимость. А вот для первичного ключа в БД разница есть - там нужен именно NewV7(), потому что он сортируется по времени и не рвёт страницы B-tree-индекса, в отличие от случайного v4.

Почему нельзя GET + DEL, даже с проверкой в Go

Между GET и DEL проходит сетевой round-trip. Это миллисекунды, за которые тебя могли вытеснить с процессора, а ключ мог истечь. Проверка в коде приложения ничего не даёт: решение принимается по устаревшим данным. Атомарной должна быть сама пара «сравнить и удалить». На стороне Redis это либо Lua, либо встроенный compare-and-delete: с Redis 8.4 есть DELEX key IFEQ value, а продлевают через SET key value IFEQ value PX ttl. На версиях до 8.4 остаётся только Lua. С продлением то же самое: TTL продлевают с проверкой значения, иначе продлишь чужую блокировку.

Главная проблема: TTL истёк, а работа идёт

Блокировка с TTL держится ровно TTL - и ни микросекундой больше. А задержать держателя дольше могут пауза GC (в Go редко, но stop-the-world есть), вытеснение потока планировщиком ОС, залипший сетевой вызов, «замороженный» контейнер, миграция виртуальной машины или просто медленный запрос в БД. В любой из этих ситуаций два клиента одновременно считают себя держателями блокировки, и оба уверены, что имеют право писать.

Блокировка истекла, а работа не закончилась: два держателя и что их разнимает t Клиент A Клиент B состояние t1 SET lock NX PX 10000 --> OK, токен 33 лок у A до t1+10 c t2 начал работу (ожидали 3 с) лок у A t3 STW-пауза / потеря сети - 12 с TTL ИСТЁК, лок свободен t4 SET lock NX PX 10000 --> OK, токен 34 лок у B t5 пишет результат, предъявляет токен 34 принято, ресурс запомнил 34 t6 очнулся, «я держу лок», пишет с токеном 33 33 < 34 --> ОТКЛОНЕНО t7 Release: Lua сравнивает значение не наше --> чужой лок цел Без fencing token на t6 запись A прошла бы: результат B затёрт результатом устаревшего A. Никакой TTL, watchdog и Redlock этого не ловят - A физически не может узнать, что он уже не держатель. Пауза может случиться и ПОСЛЕ успешной проверки. Fencing token: монотонно растущее число, выдаваемое при захвате (INCR lock:fence). Ресурс запоминает максимальный виденный токен и отвергает всё меньшее. Корректность обеспечивает РЕСУРС, а не блокировка - в этом весь смысл.
Блокировка даёт оптимизацию, а не гарантию. Держатель не может узнать, что его TTL истёк: пауза может случиться и после проверки. Спасает только проверка монотонного токена на стороне ресурса, который ты защищаешь.

Watchdog - продление TTL

Обычно делают так: пока работа идёт, фоновая горутина периодически продлевает TTL (в Redisson это называется watchdog, отсюда термин). Продлевают тем же скриптом с проверкой значения - иначе продлишь чужую блокировку.

var extendScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end`)

// Запускается сразу после захвата, останавливается через cancel при Release.
func (l *Lock) keepAlive(ctx context.Context, ttl time.Duration) {
    t := time.NewTicker(ttl / 3)      // продлеваем втрое чаще, чем истекает
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-t.C:
            ok, err := extendScript.Run(ctx, l.rdb, []string{l.key}, l.value,
                ttl.Milliseconds()).Int64()
            if err != nil || ok == 0 {
                // Блокировку потеряли, значит работу надо прервать, а не продолжать:
                // параллельно с нами уже может идти другой держатель.
                l.lost()          // закрывает канал, по которому работа отменяется
                return
            }
        }
    }
}
Чего watchdog не решает

Он уменьшает вероятность, но не устраняет проблему. Если процесс встал целиком (STW-пауза, заморозка контейнера, потеря сети), watchdog встал вместе с ним - продлевать некому, и TTL истечёт. Вдобавок у watchdog есть свой сценарий отказа: продление ушло, но ответ потерялся; клиент считает, что потерял блокировку, а она продлена. Watchdog нужен для гигиены, а корректность дают только fencing token или идемпотентность операции.

Fencing token

Идея из статьи Клеппмана. При каждом захвате блокировки выдаётся монотонно растущее число (в Redis это INCR lock:{job:42}:fence: в Cluster фигурные скобки сводят лок и счётчик в один слот, иначе скрипт захвата упадёт с CROSSSLOT). Клиент передаёт этот токен в каждый запрос к защищаемому ресурсу, а ресурс помнит самый большой токен, какой видел, и отвергает всё, что меньше. Тогда «воскресший» старый держатель с токеном 33 физически не сможет испортить работу нового с токеном 34. Оговорка: счётчик в Redis монотонен, пока Redis не теряет записи. После failover с асинхронной репликацией он может откатиться назад, поэтому для строгих гарантий токен берут из хранилища с консенсусом или из той же БД, что защищает ресурс.

-- захват с выдачей токена, одним скриптом
if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
    return redis.call("INCR", KEYS[2])   -- монотонный счётчик поколений
else
    return 0
end
Вывод, которого от тебя ждут

Fencing token переносит ответственность за корректность с блокировки на защищаемый ресурс. И тут же всплывает практическая проблема: ресурс должен уметь проверять токен - условный UPDATE ... WHERE fence <= :token в БД или condition в объектном хранилище. Если ресурс этого не умеет (сторонний API, файловая система, отправка письма), то никакая распределённая блокировка не даст корректности, и решение надо строить на идемпотентности.

Redlock и критика Клеппмана

Алгоритм Redlock Сальваторе Санфилиппо (автор Redis) предложил для блокировки поверх N независимых мастеров Redis (обычно 5), не связанных репликацией. Клиент:

  1. берёт текущее время и последовательно пытается захватить ключ на всех N инстансах с малым таймаутом на каждый;
  2. считает блокировку взятой, если получил её на большинстве (N/2+1) и суммарно потратил меньше TTL;
  3. владеет ею TTL минус потраченное время минус поправка на дрейф часов;
  4. при неуспехе снимает блокировку со всех инстансов, где успел взять.

Смысл: пережить падение отдельных инстансов без Sentinel и без потери блокировки при failover.

Критика Мартина Клеппмана (2016)

Статья «How to do distributed locking» и ответ Санфилиппо по пунктам.

  1. Разделение целей. Клеппман начинает с вопроса, зачем вообще нужна блокировка: для эффективности (не делать одну работу дважды) или для корректности (двойное выполнение недопустимо). Для эффективности сгодится и одиночный Redis. Для корректности Redlock не подходит, и дальше Клеппман объясняет почему.
  2. Опора на часы. Redlock считает истечение по времени, предполагая ограниченный дрейф часов. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, переустановка часов администратором. Время на одном узле прыгнуло вперёд, и блокировка «истекает» раньше, чем думает держатель. Если корректность системы зависит от синхронности часов, в асинхронной модели она небезопасна.
  3. Паузы процесса. Даже с идеальными часами держателя может остановить STW-пауза, своп, вытеснение планировщиком или заморозка контейнера. После паузы он не знает, что блокировку потерял, - и продолжает работать. Никакой алгоритм захвата это не ловит.
  4. Fencing token как единственное лекарство. Отсюда вывод: нужен монотонный токен, проверяемый ресурсом. Redlock не выдаёт такого токена: у него N независимых инстансов, и получить из них согласованно возрастающее число нельзя. А системы с настоящим консенсусом (ZooKeeper с zxid, etcd с revision, Consul) его выдают.
  5. Ответ Санфилиппо. Он возразил, что предположения о часах ограничены и практичны, а про паузы сказал, что fencing token нужен любой блокировке, включая ZooKeeper, так что это не претензия именно к Redlock. Обе стороны частично правы; консенсуса в сообществе нет.
Как ответить про Redlock

«Redlock решает узкую задачу - пережить падение отдельных инстансов Redis. Главную он не решает и решить не может: держатель не может узнать, что его блокировка истекла. Поэтому я сначала спрашиваю, для чего блокировка. Если она нужна для эффективности, беру простой SET NX PX на одном Redis с Lua-освобождением, этого достаточно, и Redlock тут избыточен. Если для корректности, блокировка на Redis не подходит в принципе: нужен либо fencing token, проверяемый ресурсом, либо идемпотентность, либо система с консенсусом (etcd, ZooKeeper, Consul), либо просто уникальный индекс/условный апдейт в самой БД, и чаще всего это самое простое решение.»

Глубже: чаще всего блокировка вообще не нужна

Большинство задач, для которых берут распределённую блокировку, обходятся без неё. «Только один воркер обрабатывает заказ» решается уникальным индексом на order_id в таблице обработок или UPDATE ... WHERE status = 'new' с проверкой числа затронутых строк. Чтобы «не отправить письмо дважды», хватит таблицы идемпотентности с ключом операции. Для «одного экземпляра крона» есть SELECT ... FOR UPDATE SKIP LOCKED или advisory lock в Postgres: там блокировка живёт в той же транзакции, что и данные, и не может «истечь посередине».

Pub/Sub, Streams и полноценный брокер

Передать сообщение через Redis-подобную инфраструктуру можно тремя способами, и главный вопрос ко всем трём один: что происходит с сообщением, если получателя в этот момент нет.

Redis Pub/SubRedis StreamsKafka / RabbitMQ / NATS JS
Хранение нет вообще: сообщение существует только в момент рассылки да, лог в памяти с опциональным MAXLEN; переживает рестарт при AOF/RDB да, на диске, с настраиваемым retention
Гарантия at-most-once: нет подписчика - сообщение потеряно навсегда at-least-once через consumer group и XACK at-least-once, у Kafka - транзакционный exactly-once внутри системы
Подтверждениянет есть: PEL, XACK, XPENDING, XCLAIM/XAUTOCLAIM есть: offset-коммиты, ack/nack
Повторное чтениеневозможно да, по id: XRANGE, XREAD с любого места да, перемотка offset, replay всего лога
Масштабирование потребителей все подписчики получают всё (фанаут) consumer group делит сообщения между потребителями партиции и consumer group, порядок внутри партиции
Порядокв пределах каналастрогий по id строгий внутри партиции
Ценаничего не стоит память под лог, ручное управление PEL и обрезкой отдельный кластер, эксплуатация, задержка

Что именно теряется в pub/sub

  • Нет персистентности. PUBLISH доставляет только тем, кто подписан прямо сейчас. Подписчик отвалился на две секунды, пока переподключался, и сообщений за эти две секунды он не увидит и никогда не узнает об этом.
  • Нет подтверждений. PUBLISH возвращает число подписчиков, которым сообщение отправлено, а не число обработавших. Обработал ли кто-то, отправитель не узнает.
  • Медленный подписчик отбрасывается. Если он не успевает вычитывать, у него растёт output buffer, и когда тот упрётся в client-output-buffer-limit pubsub, Redis рвёт соединение. Для приложения это происходит тихо.
  • В кластере рассылается по всем нодам - это дорого. С Redis 7 есть шардированный SPUBLISH/SSUBSCRIBE, привязанный к слоту канала.

Отсюда единственно корректное применение pub/sub: сигналы-ускорители, потеря которых безопасна. Сюда хорошо ложится инвалидация локальных кэшей: если сигнал не дошёл, ключ всё равно протухнет по TTL через секунду. А бизнес-события («заказ оплачен») на pub/sub гарантированно потеряются, причём тихо и в первый же деплой.

Когда Streams достаточно

  • Умеренный объём, который помещается в память с запасом, и есть MAXLEN.
  • Retention нужен часами, а не неделями: перечитать вчерашний день не потребуется.
  • Redis уже есть в инфраструктуре, и заводить Kafka ради одной очереди задач избыточно.
  • Нужны подтверждения и повторная выдача зависших сообщений - PEL и XAUTOCLAIM это дают.

Когда нужен настоящий брокер

  • Retention днями и неделями, replay. В Redis лог живёт в памяти - терабайт истории там никто держать не станет.
  • Много независимых потребителей одного потока. В Kafka это естественно: каждая consumer group читает свой offset по одному и тому же логу.
  • Строгие требования к durability. Kafka пишет на диск и реплицирует с acks=all; Redis теряет до секунды при everysec и подтверждённые записи при failover.
  • Экосистема: Kafka Connect, Streams/Flink, схемы (Avro/Protobuf + registry), compaction, транзакции. Ничего этого в Redis нет.
  • Сложная маршрутизация уже по части RabbitMQ: exchange, routing key, TTL очередей, DLQ из коробки.
Готовая формулировка

«Pub/sub беру только для сигналов, потеря которых безопасна: инвалидация кэша, уведомление о смене конфига. Streams подходят как нормальная очередь задач, когда объём умеренный и Redis уже есть: там есть consumer group, PEL и XAUTOCLAIM, то есть at-least-once. Kafka нужна, когда требуются долгий retention, replay, много независимых потребителей и настоящая durability. Чаще всего я видел одну ошибку: бизнес-события через pub/sub. Они теряются молча, а замечают это через недели.»

Redis из Go: go-redis

github.com/redis/go-redis/v9 стал стандартом де-факто (второй вариант, rueidis, быстрее за счёт автопайплайнинга по умолчанию, но встречается реже; RESP3 go-redis v9 включает и сам). Клиент потокобезопасен, держит внутри пул соединений и создаётся один на приложение.

rdb := redis.NewClient(&redis.Options{
    Addr:     "redis:6379",
    Password: os.Getenv("REDIS_PASSWORD"),
    DB:       0,

    // --- пул соединений ---
    PoolSize:        50,               // максимум сокетов; по умолчанию 10 * GOMAXPROCS
    MinIdleConns:    10,               // держать прогретыми, чтобы не платить за dial в пике
    MaxIdleConns:    20,
    ConnMaxIdleTime: 30 * time.Minute, // закрывать простаивающие
    ConnMaxLifetime: 0,                // 0 = не пересоздавать по возрасту
    PoolTimeout:     4 * time.Second,  // сколько ждать свободное соединение из пула

    // --- таймауты ---
    DialTimeout:  2 * time.Second,
    ReadTimeout:  500 * time.Millisecond,   // Redis быстрый: секунды тут не нужны
    WriteTimeout: 500 * time.Millisecond,

    // --- ретраи ---
    MaxRetries:      3,
    MinRetryBackoff: 8 * time.Millisecond,
    MaxRetryBackoff: 512 * time.Millisecond,
})

Про пул - то, что спрашивают

  • Одна команда занимает одно соединение на время round-trip. Значит, нужный размер пула ≈ пиковая конкурентность обращений к Redis, а не число ядер. При латентности 0,3 мс одно соединение выдаёт ~3000 команд в секунду - 50 соединений закрывают 150 000 rps.
  • Исчерпание пула даёт не отказ, а ожидание: горутина ждёт свободное соединение до PoolTimeout, потом получает redis: connection pool timeout. Это главный симптом: либо пул мал, либо Redis отвечает медленно (большие ключи, медленные команды). Поэтому метрики rdb.PoolStats() (Timeouts, TotalConns, IdleConns) надо снимать в Prometheus.
  • Блокирующие команды съедают соединение целиком. BLPOP, XREAD BLOCK, подписка pub/sub держат сокет всё время ожидания. Их нельзя гонять через общий пул в большом количестве: заведи для них отдельный клиент или хотя бы помни, что N воркеров навсегда съедят N соединений.
  • MaxRetries и идемпотентность. Ретраи включены по умолчанию. Для GET это безопасно, для INCR уже нет: команда могла выполниться, а ответ потеряться, и повтор посчитает второй раз. Для неидемпотентных операций либо MaxRetries: -1 (выключить), либо операция, устойчивая к повтору.

Контекст и таймауты

Каждая команда принимает ctx, но по умолчанию go-redis v9 его дедлайн на сокет не переносит (ContextTimeoutEnabled: false): контекст учитывается, пока ждёшь соединение из пула и между ретраями, а ответ ждут по ReadTimeout. С ContextTimeoutEnabled: true дедлайн контекста становится дедлайном чтения; оборванное посреди ответа соединение go-redis закрывает, так что массовые таймауты выглядят как шторм переподключений. И в любом случае уже отправленная команда в Redis выполнится. На практике ставят короткий ReadTimeout (порядка сотен миллисекунд) и не рассчитывают, что отмена «остановит работу в Redis».

redis.Nil - не ошибка, а результат

val, err := rdb.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
    // Ключа нет. Это штатная ветка, а не сбой: промах кэша.
    return loadFromDB(ctx, key)
case err != nil:
    // Настоящая ошибка: сеть, таймаут, пул. Тоже обычно ведёт в БД,
    // но с метрикой и логом, иначе не заметишь, что Redis лежит.
    metrics.CacheErrors.Inc()
    return loadFromDB(ctx, key)
default:
    return val, nil
}
Три ошибки вокруг redis.Nil
  • Сравнивать через ==. err == redis.Nil сломается, как только ошибку кто-нибудь обернёт fmt.Errorf("%w"). Всегда errors.Is.
  • Возвращать redis.Nil наверх. Слой кэша обязан транслировать её в доменную ошибку (ErrNotFound) или в промах. Иначе про Redis знает весь сервис.
  • Путать «нет ключа» и «ошибка». redis.Nil означает отсутствие ключа; сетевая ошибка означает, что мы не знаем. Ветки разные: в первой обычный промах, во второй сигнал деградации, и его надо считать в метриках.

Отдельно: redis.Nil прилетает не только из GET. Пустой результат BLPOP по таймауту, ZSCORE несуществующего элемента, HGET отсутствующего поля - всё это redis.Nil. А вот Exists и SetNX возвращают ноль/false без ошибки, и на этом тоже часто путаются.

Пайплайнинг

Пайплайн отправляет N команд одним пакетом и читает N ответов, экономя N−1 round-trip. Это самый дешёвый способ ускорить работу с Redis: при RTT 0,3 мс сто последовательных GET займут 30 мс, а пайплайном - 0,4 мс.

// Pipeline не атомарен: просто пакет команд, между ними могут выполниться чужие.
pipe := rdb.Pipeline()
cmds := make([]*redis.StringCmd, 0, len(ids))
for _, id := range ids {
    cmds = append(cmds, pipe.Get(ctx, keyOf(id)))
}
_, err := pipe.Exec(ctx)
// Exec возвращает ошибку, если упала хотя бы одна команда, включая redis.Nil.
// Результаты всё равно надо разбирать по каждой команде отдельно.
if err != nil && !errors.Is(err, redis.Nil) {
    return nil, err
}
for i, c := range cmds {
    v, err := c.Result()
    if errors.Is(err, redis.Nil) {
        continue                 // ключа нет: промах, не ошибка
    }
    ...
}
// TxPipeline: то же самое, но обёрнуто в MULTI ... EXEC.
// Все команды выполнятся подряд, без чужих между ними.
pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, "counter")
pipe.Expire(ctx, "counter", time.Hour)
_, err := pipe.Exec(ctx)          // MULTI, INCR, EXPIRE, EXEC одним round-trip
fmt.Println(incr.Val())           // 1 - счётчик после первого INCR
// До Exec поля команд пустые: incr.Val() вернул бы 0. Заполняются они
// в момент Exec, поэтому читать результат раньше - типичная ошибка.
Pipeline против TxPipeline: что отвечать
  • Оба дают один round-trip. Разница только в атомарности.
  • Pipeline только пакует команды: между твоими Redis может выполнить чужие.
  • TxPipeline = MULTI/EXEC: команды выполняются подряд как единый блок. Но отката нет: если одна команда упала на этапе выполнения (например, INCR по строке), остальные всё равно выполнятся. Транзакция Redis значит «выполнить подряд», а не «всё или ничего».
  • Условные операции делают через WATCH + MULTI/EXEC (оптимистическая блокировка: EXEC вернёт nil, если наблюдаемый ключ изменился) или, что почти всегда проще и надёжнее, через Lua-скрипт.
  • В кластере пайплайн разбивается по нодам, а TxPipeline требует все ключи в одном слоте, иначе получишь CROSSSLOT.

Lua: EVAL, EVALSHA и redis.NewScript

Lua-скрипт выполняется на сервере атомарно: пока он работает, ни одна другая команда не выполнится. Только так в Redis получается настоящее «прочитать-проверить-записать». EVAL отправляет текст скрипта каждый раз, EVALSHA - только SHA1-хеш, экономя трафик. go-redis прячет оба за одним вызовом:

// Скрипт объявляется один раз как переменная пакета.
var rateLimit = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
    redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
    return 0
end
return 1`)

// Run сначала пробует EVALSHA; если сервер ответил NOSCRIPT
// (перезапуск, SCRIPT FLUSH), сам переключается на EVAL и кеширует скрипт заново.
allowed, err := rateLimit.Run(ctx, rdb,
    []string{"rl:" + userID},                 // KEYS
    int64(time.Minute/time.Millisecond), 100, // ARGV: окно и лимит
).Int64()
Правила работы с Lua в Redis
  • Скрипт блокирует сервер целиком. Исполнение однопоточное, так что долгий цикл в Lua останавливает всех клиентов. Скрипты держат короткими и с ограниченным числом операций; никаких «пройтись по всем ключам».
  • Все ключи передавай через KEYS, а не жёстко в тексте. Иначе кластер не сможет определить слот и маршрутизировать скрипт, а на одиночном инстансе его нельзя будет переиспользовать.
  • Скрипт должен быть детерминированным. В старых версиях случайность и запись в одном скрипте были запрещены из-за репликации по командам; сейчас реплицируется результат (effects replication), но привычка «никакой недетерминированности» остаётся правильной.
  • Кэш скриптов не вечен. После рестарта или SCRIPT FLUSH сервер не знает твой SHA - поэтому голый EVALSHA без фолбэка однажды вернёт NOSCRIPT. redis.NewScript делает фолбэк сам, а писать EvalSha руками без обработки NOSCRIPT - типичная ошибка.
  • Redis 7 добавил Functions (FUNCTION LOAD) - постоянные именованные библиотеки скриптов, которые переживают рестарт и реплицируются.

Вопросы

9
Суть: SET lock:key <uuid> NX PX 10000 - атомарный захват одной командой. TTL нужен, чтобы блокировка освободилась, если держатель умер и defer unlock() выполнять некому. Уникальное значение - чтобы при освобождении снять свою блокировку, а не чужую, если TTL успел истечь.

Почему одной командой

NX означает «установить, только если ключа нет». Проверка и установка выполняются как одна операция в однопоточном сервере, поэтому гонки между «посмотрел, что свободно» и «занял» физически не существует. Разделять это на EXISTS + SET - классическая ошибка.

Зачем TTL

Держатель может умереть в любой момент: OOM-kill, выселение пода, отключение машины. Тогда никакого «освобождения» не будет - выполнять его некому. Без TTL блокировка зависает навсегда, работа не делается, чинится руками. TTL остаётся единственным способом освободить блокировку без участия упавшего процесса.

Зачем уникальное значение

  1. A взял блокировку с TTL 10 с, работа затянулась на 12.
  2. На 10-й секунде ключ истёк, B немедленно его взял.
  3. На 12-й A заканчивает, делает DEL lock:key и удаляет блокировку B.
  4. Блокировку берёт C. Теперь B и C работают параллельно: взаимное исключение сломано.

Уникальный UUID в значении позволяет освобождать с проверкой: «удали, только если там всё ещё моё». Продление TTL тоже идёт с проверкой, иначе продлишь чужую блокировку.

Чем добить

«И сразу оговорюсь: SET NX PX годится как блокировка для эффективности (не делать одну работу дважды), а не для корректности. Гарантии взаимного исключения она не даёт, потому что держатель не может узнать, что его TTL истёк. Если двойное выполнение недопустимо, нужен fencing token или идемпотентность.»

Суть: GET и DEL - две операции, между ними сетевой round-trip. За это время TTL может истечь, блокировку захватит другой, и ваш DEL снесёт его ключ. Проверка в коде Go не помогает - она принимается на устаревших данных. Атомарной должна быть сама пара «сравнить и удалить».

Скрипт

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Redis выполняет Lua-скрипт целиком, не вклинивая чужие команды, поэтому между сравнением и удалением ничего произойти не может.

var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end`)

func (l *Lock) Release(ctx context.Context) error {
    n, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.value).Int64()
    if err != nil {
        return err
    }
    if n == 0 {
        // Ключа уже нет или он не наш: TTL истёк, пока мы работали.
        // Это сигнал, что параллельно с нами кто-то мог работать.
        return ErrLockLost
    }
    return nil
}

Гонка на оси времени

  1. A: GET lock → «uuid-A», всё моё.
  2. Между ответом и следующей командой A вытесняют с процессора на 5 мс.
  3. TTL истекает, B делает SET NX и получает блокировку со значением «uuid-B».
  4. A просыпается, выполняет DEL lock и сносит блокировку B.
Что ответить на «а если проверить в Go после GET?»

Проверяя в Go, ты решаешь по данным, которые устарели как минимум на один round-trip. Между получением ответа и отправкой DEL всегда есть окно: планировщик Go, планировщик ОС, сеть. Уменьшить его можно, устранить - нет. Корректно только одно: сравнивать и удалять на сервере, то есть в Lua. Это касается любой конструкции «прочитал → подумал → записал» в Redis: она либо в Lua, либо через WATCH/MULTI/EXEC, либо сломана.

Суть: два клиента одновременно считают себя держателями. Смягчает watchdog - фоновое продление TTL с проверкой значения. Но по-настоящему проблему решает только fencing token: монотонный номер, который проверяет сам защищаемый ресурс, отвергая всё меньшее.

Почему это неизбежно

Держателя может остановить что угодно: пауза GC, вытеснение планировщиком ОС, своп, заморозка контейнера, залипшая сеть, миграция ВМ. Он не получает уведомления «твой TTL истёк» и не может его получить в принципе: даже если он проверит владение перед записью, пауза может случиться сразу после проверки, до самой записи.

Watchdog - уменьшает вероятность

var extendScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end`)

t := time.NewTicker(ttl / 3)            // продлеваем втрое чаще, чем истекает
for {
    select {
    case <-ctx.Done():
        return
    case <-t.C:
        ok, err := extendScript.Run(ctx, rdb, []string{key}, val, ttl.Milliseconds()).Int64()
        if err != nil || ok == 0 {
            l.lost()   // прервать работу, а не продолжать: рядом уже может идти другой
            return
        }
    }
}

Чего watchdog не решает: если процесс встал целиком, watchdog встал вместе с ним. Плюс у него есть собственный сценарий - продление ушло, ответ потерялся, клиент считает, что блокировку потерял, хотя она продлена.

Fencing token - настоящее решение

При каждом захвате выдаётся монотонно растущее число (INCR lock:key:fence). Клиент передаёт его в каждый запрос к ресурсу; ресурс запоминает максимальный виденный токен и отвергает всё меньшее.

-- захват с выдачей токена
if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
    return redis.call("INCR", KEYS[2])
else
    return 0
end
-- проверка на стороне ресурса, например в Postgres
UPDATE jobs SET result = $1, fence = $2 WHERE id = $3 AND fence <= $2;
-- 0 строк --> наш токен устарел, мы больше не держатель, результат отбрасываем
Что из этого следует

Fencing token переносит ответственность за корректность с блокировки на защищаемый ресурс. На практике это значит: если ресурс не умеет проверять токен (сторонний API, файловая система, отправка письма), то никакая распределённая блокировка не даст корректности, и решение придётся строить на идемпотентности операции.

Суть: Redlock - алгоритм захвата блокировки на N (обычно 5) независимых мастерах Redis: блокировка считается взятой при успехе на большинстве и в пределах TTL. Клеппман показал, что он опирается на предположения о часах и не спасает от пауз процесса, а главное - не выдаёт fencing token, поэтому для корректности не годится.

Как работает Redlock

  1. Клиент запоминает время и последовательно пытается захватить ключ на всех N инстансах с малым таймаутом на каждый.
  2. Блокировка взята, если получена на большинстве (N/2+1) и суммарно потрачено меньше TTL.
  3. Эффективный срок владения = TTL − потраченное время − поправка на дрейф часов.
  4. При неуспехе клиент снимает блокировку со всех инстансов, где успел взять.

Redlock нужен, чтобы пережить падение отдельных инстансов без Sentinel и не потерять блокировку при failover (при обычной репликации блокировка, взятая на мастере, может не доехать до реплики, которую промоутят).

Критика (Kleppmann, 2016)

  1. Сначала определи цель. Блокировка для эффективности (не делать работу дважды) или для корректности (двойное выполнение недопустимо)? Для эффективности хватит одиночного Redis, Redlock избыточен. Для корректности он не подходит.
  2. Зависимость от часов. Алгоритм считает истечение по времени и предполагает ограниченный дрейф. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, ручная установка часов. Система, чья корректность зависит от синхронности часов, в асинхронной модели небезопасна.
  3. Паузы процесса. Даже с идеальными часами держателя останавливает STW-пауза, своп или заморозка контейнера. После паузы он не знает, что потерял блокировку. Никакой алгоритм захвата этого не ловит.
  4. Нет fencing token. Лечится это только монотонным токеном, который проверяет ресурс. Redlock не может его выдать: у него N независимых инстансов без общего согласованного счётчика. Системы с консенсусом выдают: ZooKeeper - zxid, etcd - revision.
  5. Ответ Санфилиппо. Предположения о часах ограничены и практичны; проблема пауз относится к любой блокировке, включая ZooKeeper, а не только к Redlock. Консенсуса в сообществе нет до сих пор.
Правильная позиция

«Если блокировка нужна для эффективности, беру простой SET NX PX на одном Redis с Lua-освобождением. Если для корректности, Redis не подходит в принципе, и я выбираю из трёх вариантов: fencing token, проверяемый ресурсом; идемпотентная операция; или механизм в самой БД - уникальный индекс, условный UPDATE, SELECT ... FOR UPDATE SKIP LOCKED, advisory lock. Последнее обычно и проще, и надёжнее, потому что блокировка живёт в той же транзакции, что и данные.»

Суть: pub/sub - at-most-once без хранения: нет подписчика - сообщение исчезло навсегда. Streams - лог с consumer group, PEL и XACK, то есть нормальная at-least-once очередь в памяти. Kafka/RabbitMQ - когда нужен долгий retention, replay, много независимых потребителей и настоящая durability.
Pub/SubStreamsKafka / RabbitMQ
Хранениенетлог в памяти + MAXLENдиск, retention
Гарантияat-most-onceat-least-once (XACK) at-least-once, у Kafka - транзакции
ПодтверждениянетPEL, XACK, XAUTOCLAIMoffset / ack
Replayневозможенпо id (XRANGE)перемотка offset
Масштаб потребителейфанаут: все получают всё consumer group делитпартиции + группы

Что теряется в pub/sub

  • Нет персистентности. Сообщение получают только те, кто подписан прямо сейчас. Подписчик переподключался две секунды, и эти сообщения потеряны, причём он об этом не узнает.
  • Нет подтверждений. PUBLISH возвращает число подписчиков, которым отправлено, а не число обработавших.
  • Медленного подписчика отключают. При переполнении client-output-buffer-limit pubsub Redis рвёт соединение.
  • В кластере классический pub/sub рассылается по всем нодам; с Redis 7 есть шардированный SPUBLISH/SSUBSCRIBE.

Отсюда единственное корректное применение: сигналы, потеря которых безопасна. Так обычно инвалидируют локальные кэши поверх Redis: не дошло, значит ключ протухнет по TTL через секунду. Бизнес-события через pub/sub гарантированно и тихо теряются.

Когда Streams достаточно, а когда нужен брокер

  • Streams: объём помещается в память с запасом и есть MAXLEN; retention нужен часами; Redis уже есть, а заводить Kafka ради одной очереди избыточно; нужны подтверждения и переприсвоение зависших сообщений (XAUTOCLAIM).
  • Kafka: retention днями и неделями, replay, много независимых consumer group по одному логу, дисковая durability с acks=all, экосистема (Connect, схемы, compaction, транзакции).
  • RabbitMQ: когда нужна сложная маршрутизация - exchange, routing key, DLQ и TTL очередей из коробки.
Ошибка, о которой спрашивают чаще всего

«Сделаем очередь на pub/sub». Она будет работать в тестах и молча терять сообщения на каждом деплое, рестарте Redis и сетевом моргании. Если нужна очередь на Redis, бери Streams с consumer group и XACK либо list с BLMOVE в processing-список (чтобы задача не потерялась при падении воркера). Голый BRPOP тоже теряет задачу, если воркер умер между извлечением и обработкой.

Суть: клиент потокобезопасен и создаётся один на приложение - внутри него пул. Ключевые параметры: PoolSize, MinIdleConns, PoolTimeout, Dial/Read/WriteTimeout, MaxRetries. При исчерпании пула горутина ждёт до PoolTimeout и получает redis: connection pool timeout.
rdb := redis.NewClient(&redis.Options{
    Addr: "redis:6379",

    PoolSize:        50,               // дефолт: 10 * GOMAXPROCS
    MinIdleConns:    10,               // прогретые соединения, чтобы не платить за dial в пике
    ConnMaxIdleTime: 30 * time.Minute,
    ConnMaxLifetime: 0,                // 0 = не пересоздавать по возрасту
    PoolTimeout:     4 * time.Second,  // сколько ждать свободное соединение

    DialTimeout:  2 * time.Second,
    ReadTimeout:  500 * time.Millisecond,   // Redis быстрый, секунды тут не нужны
    WriteTimeout: 500 * time.Millisecond,

    MaxRetries:      3,
    MinRetryBackoff: 8 * time.Millisecond,
    MaxRetryBackoff: 512 * time.Millisecond,
})

Как подбирать PoolSize

Одна команда занимает одно соединение на время round-trip. Значит, нужный размер ≈ пиковая конкурентность обращений к Redis, а не число ядер. При латентности 0,3 мс одно соединение выдаёт около 3000 команд в секунду, то есть 50 соединений закрывают ~150 000 rps. Слишком большой пул тоже вреден: тысячи соединений съедают память и нагружают однопоточный сервер.

Что происходит при исчерпании

  • Горутина ждёт свободное соединение до PoolTimeout (по умолчанию ReadTimeout + 1s), затем получает ошибку пула.
  • Это почти всегда симптом, а не причина: либо пул мал, либо Redis отвечает медленно (большие ключи, медленные команды, блокирующая операция, вытеснение).
  • Мониторить через rdb.PoolStats(): Timeouts, TotalConns, IdleConns, StaleConns. На растущий Timeouts ставят алерт.
Две ловушки
  • Блокирующие команды съедают соединение целиком. BLPOP, XREAD BLOCK, подписка pub/sub держат сокет всё время ожидания. Двадцать воркеров на BLPOP навсегда занимают двадцать соединений. Для них заводят отдельный клиент, чтобы не выесть общий пул.
  • Ретраи и идемпотентность. MaxRetries включён по умолчанию. Для GET это безопасно, для INCR - нет: команда могла выполниться, а ответ потеряться, и повтор посчитает второй раз. Для неидемпотентных операций ретраи выключают (MaxRetries: -1) или делают операцию устойчивой к повтору.
Суть: пайплайн отправляет N команд одним пакетом и читает N ответов - экономит N−1 round-trip. Pipeline просто пакует команды и не атомарен: между ними могут выполниться чужие. TxPipeline оборачивает их в MULTI/EXEC - команды идут подряд как единый блок, но отката нет.

Зачем

При RTT 0,3 мс сто последовательных GET занимают 30 мс, и 99 % из них уходит на ожидание сети. Пайплайн обходится одним round-trip, около 0,4 мс. Это самый дешёвый способ ускорить работу с Redis, и его почти всегда забывают.

pipe := rdb.Pipeline()
cmds := make([]*redis.StringCmd, 0, len(ids))
for _, id := range ids {
    cmds = append(cmds, pipe.Get(ctx, keyOf(id)))
}
_, err := pipe.Exec(ctx)
// Exec вернёт ошибку, если упала хотя бы одна команда, включая redis.Nil.
if err != nil && !errors.Is(err, redis.Nil) {
    return nil, err
}
for _, c := range cmds {
    v, err := c.Result()
    if errors.Is(err, redis.Nil) {
        continue          // просто нет ключа
    }
    ...
}
pipe := rdb.TxPipeline()          // MULTI ... EXEC
incr := pipe.Incr(ctx, "counter")
pipe.Expire(ctx, "counter", time.Hour)
_, err := pipe.Exec(ctx)
fmt.Println(incr.Val())           // 1 - счётчик после первого INCR
                                  // (до Exec было бы 0: результаты приезжают в Exec)

Разница по пунктам

  • Оба дают один round-trip - в этом они одинаковы.
  • Pipeline просто собирает пакет команд, и Redis может выполнить между ними чужие.
  • TxPipeline = MULTI/EXEC: выполняются подряд, без чужих между ними. Но отката нет: если одна команда упала на этапе выполнения (например, INCR по строке), остальные всё равно выполнятся. В Redis транзакция означает «выполнить подряд», а не «всё или ничего».
  • Условную логику строят на WATCH + MULTI/EXEC (оптимистическая блокировка: EXEC вернёт nil, если наблюдаемый ключ изменился) или, что проще и надёжнее, на Lua-скрипте.
  • В кластере Pipeline разбивается по нодам автоматически, а TxPipeline требует все ключи в одном слоте - иначе CROSSSLOT.
Ошибка при разборе результатов

Многие проверяют только ошибку Exec и считают, что всё прошло. На деле Exec вернёт ошибку, если упала любая из команд, включая безобидный redis.Nil от отсутствующего ключа. Результат каждой команды надо разбирать отдельно, а ошибку Exec проверять, пропуская redis.Nil. Ещё одна ловушка: значения из команд доступны только после Exec, а до него объекты пусты.

Суть: Lua выполняется на сервере атомарно - это единственный способ получить настоящее «прочитать-проверить-записать». EVAL шлёт текст скрипта каждый раз, EVALSHA - только SHA1, экономя трафик. Если сервер потерял кэш скриптов, он вернёт NOSCRIPT, и нужен фолбэк на EVAL; redis.NewScript(...).Run() делает это автоматически.
// Объявляется один раз, на уровне пакета.
var rateLimit = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
    redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
    return 0
end
return 1`)

// Run: сначала EVALSHA, при NOSCRIPT сам вызывает EVAL и снова кеширует скрипт.
allowed, err := rateLimit.Run(ctx, rdb,
    []string{"rl:" + userID},                  // KEYS
    int64(time.Minute/time.Millisecond), 100,  // ARGV
).Int64()

Правила

  • Скрипт блокирует весь сервер. Redis однопоточный, и пока крутится долгий цикл в Lua, стоят все клиенты. Скрипты держат короткими, без обходов по всем ключам. Есть busy-reply-threshold и SCRIPT KILL, но это уже тушение пожара.
  • Ключи только через KEYS. Если зашить имя ключа в текст, кластер не сможет определить слот и правильно маршрутизировать вызов.
  • Все ключи скрипта должны лежать в одном слоте (hash tag), иначе CROSSSLOT.
  • Детерминированность. В старых версиях случайность вместе с записью запрещалась из-за репликации по командам; сейчас реплицируются эффекты, но привычка остаётся правильной.
  • Кэш скриптов не вечен. После рестарта или SCRIPT FLUSH SHA неизвестен серверу. Голый EvalSha без обработки NOSCRIPT однажды сломается - это классическая ошибка.
  • Redis 7 Functions (FUNCTION LOAD): именованные библиотеки, которые переживают рестарт и реплицируются. Их стоит упомянуть как современную альтернативу.

Где Lua незаменима

  • Освобождение и продление распределённой блокировки с проверкой значения.
  • Rate limiter: INCR + PEXPIRE + сравнение с лимитом одной атомарной операцией.
  • Условная запись в кэш: «положи, только если версия в значении новее текущей».
  • Инвалидация по тегу: прочитать set имён и удалить их одним вызовом.
Суть: redis.Nil - это не ошибка, а результат «ключа нет». Её проверяют через errors.Is, отличают от настоящих ошибок сети и не выпускают за пределы слоя кэша. Обе ветки - промах и ошибка - ведут в БД, но считаются разными метриками.
val, err := rdb.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
    // Ключа нет: штатный промах.
    metrics.CacheMiss.Inc()
    return loadFromDB(ctx, key)
case err != nil:
    // Настоящая ошибка: сеть, таймаут, пул. Мы не знаем, есть ключ или нет.
    metrics.CacheErrors.Inc()
    slog.Warn("cache unavailable", "err", err)
    return loadFromDB(ctx, key)     // деградация, а не отказ
default:
    metrics.CacheHit.Inc()
    return val, nil
}

Ошибки вокруг redis.Nil

  • err == redis.Nil. Сломается, как только кто-нибудь обернёт ошибку через fmt.Errorf("%w", err). Всегда errors.Is.
  • Возвращать redis.Nil наверх. Слой кэша обязан транслировать её в доменную ErrNotFound или просто в промах - иначе про Redis знает весь сервис, включая HTTP-обработчики.
  • Путать «нет ключа» и «не знаю». redis.Nil даёт определённый ответ, а сетевая ошибка оставляет неопределённость. Кэшировать вторую как «не найдено» нельзя.
  • Считать, что redis.Nil бывает только у GET. Её возвращают BLPOP по таймауту, ZSCORE отсутствующего элемента, HGET отсутствующего поля, LPOP пустого списка. А вот Exists и SetNX отдают 0/false без ошибки, что тоже сбивает с толку.

Остальные типовые ошибки go-redis

  1. Создавать клиент на каждый запрос. Клиент содержит пул и потокобезопасен: он создаётся один раз на приложение и закрывается при завершении.
  2. Ошибка кэша роняет запрос. Ошибка Redis должна вести в фолбэк, а не в 500-й ответ пользователю. Иначе кэш из ускорителя превращается в обязательную зависимость.
  3. Не проверять результат каждой команды в пайплайне. Ошибка Exec даёт только общий сигнал, а разбирать надо каждую команду.
  4. Забыть таймауты. Дефолтный ReadTimeout в 3 секунды (с go-redis v9.22 - 5) для Redis огромен: подвисший инстанс на эти секунды съест весь пул горутин обработчика. Ставь порядка сотен миллисекунд.
  5. Ретраить неидемпотентное. Автоматические ретраи go-redis могут удвоить INCR.
  6. Не закрывать pub/sub. PubSub надо явно Close(), иначе соединение и горутина живут вечно.
  7. Надеяться на контекст. По умолчанию go-redis v9 дедлайн ctx на сокет не переносит: ответ ждут по ReadTimeout, пока не включишь ContextTimeoutEnabled. А уже отправленная команда в Redis выполнится в любом случае.
Правило, которое стоит произнести

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

Что стоит запомнить

  • Случайное значение владельца и Lua unlock не дают удалить чужой lock после истечения TTL.
  • Fencing token защищает ресурс только когда сам ресурс отвергает устаревшие токены.
  • Pub/Sub теряет события для отключённого подписчика, Streams хранят записи и поддерживают consumer groups.

Частые ошибки

  • Redis lock без fencing token не гарантирует защиту долгой операции после истечения TTL.
  • Pipeline экономит round trips, но не изолирует последовательность команд от других клиентов.
  • redis.Nil означает отсутствие ключа, а timeout/connection error означает неопределённый исход запроса.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Пойми гонку после TTL

Разбери последовательность команд: SET lock token-A NX PX 3000; A начинает запись, которая занимает 5 секунд; TTL истекает; SET lock token-B NX PX 3000. Может ли owner-check Lua при Release A сделать саму запись безопасной?

SET lock token-A NX PX 3000
...work takes 5000 ms...
SET lock token-B NX PX 3000
-- A runs owner-check Lua and finishes late
Ответ и разбор

Нет. Lua не удалит lock B, потому что значение владельца другое, но A уже мог продолжать критическую работу параллельно с B после истечения TTL. Нужен fencing token, который растёт при каждом захвате и проверяется хранилищем/ресурсом; отмена A при потере lease также полезна, но одна проверка на стороне Redis не остановит уже начатую работу.

Код-ревью

2. Исправь освобождение lock

Код вызывает DEL key в Release без проверки owner token. Найди сценарий, когда это удалит чужой lock, и предложи атомарный фикс.

func (l *Lock) Release(ctx context.Context) error {
    return l.rdb.Del(ctx, l.key).Err()
}
Ответ и разбор

A держал ключ, его TTL истёк, B захватил ключ. Поздний Release A удалит lock B. Используй Lua: сравнить GET с уникальным token A и выполнить DEL только при совпадении в одном скрипте. Даже это защищает владение ключом, но не fencing критической операции.

Применение

3. Выбери Pub/Sub или Stream

Оповещения о смене статуса можно потерять, если клиент был отключён; задания на обработку должны быть восстановимы после отключения consumer. Выбери Redis-механизм для каждого случая.

Ответ и разбор

Для best-effort уведомлений подходит Pub/Sub: подписчик получает только опубликованное во время соединения. Для восстанавливаемой обработки используй Stream с consumer group, pending tracking и ack. В обоих случаях обработчик должен учитывать дубли и иметь политику retention/повторной доставки.

Применение

4. Настрой таймауты клиента

Redis обычно отвечает за 1 мс, но сеть иногда задерживает ответ на 2 секунды. В конфиге клиента указаны ReadTimeout=0 и PoolTimeout=0; общий HTTP deadline равен 3 секундам. Сверь смысл нулей в go-redis и предложи явный бюджет ожидания.

Ответ и разбор

Нулевые ReadTimeout/PoolTimeout в go-redis выбирают defaults; они не означают бесконечное ожидание. Официальный options source сейчас нормализует ReadTimeout в 5 секунд и PoolTimeout в ReadTimeout + 1 секунду. При HTTP deadline 3 секунды это может превысить бюджет вызова. Задай таймауты явно ниже deadline, оставив время на fallback; конкретные значения выбери по latency distribution. Передавай request context и метриками разделяй сетевой timeout и ожидание пула.