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.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 проходит сетевой 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 есть), вытеснение потока планировщиком ОС, залипший сетевой вызов, «замороженный» контейнер, миграция виртуальной машины или просто медленный запрос в БД. В любой из этих ситуаций два клиента одновременно считают себя держателями блокировки, и оба уверены, что имеют право писать.
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
}
}
}
}
Он уменьшает вероятность, но не устраняет проблему. Если процесс встал целиком (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), не связанных репликацией. Клиент:
- берёт текущее время и последовательно пытается захватить ключ на всех N инстансах с малым таймаутом на каждый;
- считает блокировку взятой, если получил её на большинстве (N/2+1) и суммарно потратил меньше TTL;
- владеет ею TTL минус потраченное время минус поправка на дрейф часов;
- при неуспехе снимает блокировку со всех инстансов, где успел взять.
Смысл: пережить падение отдельных инстансов без Sentinel и без потери блокировки при failover.
Критика Мартина Клеппмана (2016)
Статья «How to do distributed locking» и ответ Санфилиппо по пунктам.
- Разделение целей. Клеппман начинает с вопроса, зачем вообще нужна блокировка: для эффективности (не делать одну работу дважды) или для корректности (двойное выполнение недопустимо). Для эффективности сгодится и одиночный Redis. Для корректности Redlock не подходит, и дальше Клеппман объясняет почему.
- Опора на часы. Redlock считает истечение по времени, предполагая ограниченный дрейф часов. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, переустановка часов администратором. Время на одном узле прыгнуло вперёд, и блокировка «истекает» раньше, чем думает держатель. Если корректность системы зависит от синхронности часов, в асинхронной модели она небезопасна.
- Паузы процесса. Даже с идеальными часами держателя может остановить STW-пауза, своп, вытеснение планировщиком или заморозка контейнера. После паузы он не знает, что блокировку потерял, - и продолжает работать. Никакой алгоритм захвата это не ловит.
- Fencing token как единственное лекарство. Отсюда вывод: нужен монотонный токен,
проверяемый ресурсом. Redlock не выдаёт такого токена: у него N независимых инстансов, и
получить из них согласованно возрастающее число нельзя. А системы с настоящим консенсусом
(ZooKeeper с
zxid, etcd сrevision, Consul) его выдают. - Ответ Санфилиппо. Он возразил, что предположения о часах ограничены и практичны, а про паузы сказал, что fencing token нужен любой блокировке, включая ZooKeeper, так что это не претензия именно к 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/Sub | Redis Streams | Kafka / 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
}
- Сравнивать через
==.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, поэтому читать результат раньше - типичная ошибка.
- Оба дают один 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 останавливает всех клиентов. Скрипты держат короткими и с ограниченным числом операций; никаких «пройтись по всем ключам».
- Все ключи передавай через
KEYS, а не жёстко в тексте. Иначе кластер не сможет определить слот и маршрутизировать скрипт, а на одиночном инстансе его нельзя будет переиспользовать. - Скрипт должен быть детерминированным. В старых версиях случайность и запись в одном скрипте были запрещены из-за репликации по командам; сейчас реплицируется результат (effects replication), но привычка «никакой недетерминированности» остаётся правильной.
- Кэш скриптов не вечен. После рестарта или
SCRIPT FLUSHсервер не знает твой SHA - поэтому голыйEVALSHAбез фолбэка однажды вернётNOSCRIPT.redis.NewScriptделает фолбэк сам, а писатьEvalShaруками без обработкиNOSCRIPT- типичная ошибка. - Redis 7 добавил Functions (
FUNCTION LOAD) - постоянные именованные библиотеки скриптов, которые переживают рестарт и реплицируются.
Вопросы
9SET lock:key <uuid> NX PX 10000 - атомарный
захват одной командой. TTL нужен, чтобы блокировка освободилась, если держатель умер и
defer unlock() выполнять некому. Уникальное значение - чтобы при освобождении
снять свою блокировку, а не чужую, если TTL успел истечь.Почему одной командой
NX означает «установить, только если ключа нет». Проверка и установка
выполняются как одна операция в однопоточном сервере, поэтому гонки между «посмотрел, что
свободно» и «занял» физически не существует. Разделять это на EXISTS +
SET - классическая ошибка.
Зачем TTL
Держатель может умереть в любой момент: OOM-kill, выселение пода, отключение машины. Тогда никакого «освобождения» не будет - выполнять его некому. Без TTL блокировка зависает навсегда, работа не делается, чинится руками. TTL остаётся единственным способом освободить блокировку без участия упавшего процесса.
Зачем уникальное значение
- A взял блокировку с TTL 10 с, работа затянулась на 12.
- На 10-й секунде ключ истёк, B немедленно его взял.
- На 12-й A заканчивает, делает
DEL lock:keyи удаляет блокировку B. - Блокировку берёт 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
}
Гонка на оси времени
- A:
GET lock→ «uuid-A», всё моё. - Между ответом и следующей командой A вытесняют с процессора на 5 мс.
- TTL истекает, B делает
SET NXи получает блокировку со значением «uuid-B». - A просыпается, выполняет
DEL lockи сносит блокировку B.
Проверяя в Go, ты решаешь по данным, которые устарели как минимум на один round-trip. Между
получением ответа и отправкой DEL всегда есть окно: планировщик Go,
планировщик ОС, сеть. Уменьшить его можно, устранить - нет. Корректно только одно:
сравнивать и удалять на сервере, то есть в Lua. Это касается любой конструкции
«прочитал → подумал → записал» в Redis: она либо в Lua, либо через
WATCH/MULTI/EXEC, либо сломана.
Почему это неизбежно
Держателя может остановить что угодно: пауза 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 инстансах с малым таймаутом на каждый.
- Блокировка взята, если получена на большинстве (N/2+1) и суммарно потрачено меньше TTL.
- Эффективный срок владения = TTL − потраченное время − поправка на дрейф часов.
- При неуспехе клиент снимает блокировку со всех инстансов, где успел взять.
Redlock нужен, чтобы пережить падение отдельных инстансов без Sentinel и не потерять блокировку при failover (при обычной репликации блокировка, взятая на мастере, может не доехать до реплики, которую промоутят).
Критика (Kleppmann, 2016)
- Сначала определи цель. Блокировка для эффективности (не делать работу дважды) или для корректности (двойное выполнение недопустимо)? Для эффективности хватит одиночного Redis, Redlock избыточен. Для корректности он не подходит.
- Зависимость от часов. Алгоритм считает истечение по времени и предполагает ограниченный дрейф. Но время может скакнуть: NTP-коррекция, живая миграция ВМ, ручная установка часов. Система, чья корректность зависит от синхронности часов, в асинхронной модели небезопасна.
- Паузы процесса. Даже с идеальными часами держателя останавливает STW-пауза, своп или заморозка контейнера. После паузы он не знает, что потерял блокировку. Никакой алгоритм захвата этого не ловит.
- Нет fencing token. Лечится это только монотонным токеном, который проверяет
ресурс. Redlock не может его выдать: у него N независимых инстансов без общего
согласованного счётчика. Системы с консенсусом выдают: ZooKeeper -
zxid, etcd -revision. - Ответ Санфилиппо. Предположения о часах ограничены и практичны; проблема пауз относится к любой блокировке, включая ZooKeeper, а не только к Redlock. Консенсуса в сообществе нет до сих пор.
«Если блокировка нужна для эффективности, беру простой SET NX PX на одном
Redis с Lua-освобождением. Если для корректности, Redis не подходит в принципе, и я выбираю из
трёх вариантов: fencing token, проверяемый ресурсом; идемпотентная операция; или
механизм в самой БД - уникальный индекс, условный UPDATE,
SELECT ... FOR UPDATE SKIP LOCKED, advisory lock. Последнее обычно и проще,
и надёжнее, потому что блокировка живёт в той же транзакции, что и данные.»
XACK, то есть
нормальная at-least-once очередь в памяти. Kafka/RabbitMQ - когда нужен долгий retention,
replay, много независимых потребителей и настоящая durability.| Pub/Sub | Streams | Kafka / RabbitMQ | |
|---|---|---|---|
| Хранение | нет | лог в памяти + MAXLEN | диск, retention |
| Гарантия | at-most-once | at-least-once (XACK) | at-least-once, у Kafka - транзакции |
| Подтверждения | нет | PEL, XACK, XAUTOCLAIM | offset / ack |
| Replay | невозможен | по id (XRANGE) | перемотка offset |
| Масштаб потребителей | фанаут: все получают всё | consumer group делит | партиции + группы |
Что теряется в pub/sub
- Нет персистентности. Сообщение получают только те, кто подписан прямо сейчас. Подписчик переподключался две секунды, и эти сообщения потеряны, причём он об этом не узнает.
- Нет подтверждений.
PUBLISHвозвращает число подписчиков, которым отправлено, а не число обработавших. - Медленного подписчика отключают. При переполнении
client-output-buffer-limit pubsubRedis рвёт соединение. - В кластере классический 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) или делают операцию устойчивой к повтору.
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, а до него объекты пусты.
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 FLUSHSHA неизвестен серверу. Голый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
- Создавать клиент на каждый запрос. Клиент содержит пул и потокобезопасен: он создаётся один раз на приложение и закрывается при завершении.
- Ошибка кэша роняет запрос. Ошибка Redis должна вести в фолбэк, а не в 500-й ответ пользователю. Иначе кэш из ускорителя превращается в обязательную зависимость.
- Не проверять результат каждой команды в пайплайне. Ошибка
Execдаёт только общий сигнал, а разбирать надо каждую команду. - Забыть таймауты. Дефолтный
ReadTimeoutв 3 секунды (с go-redis v9.22 - 5) для Redis огромен: подвисший инстанс на эти секунды съест весь пул горутин обработчика. Ставь порядка сотен миллисекунд. - Ретраить неидемпотентное. Автоматические ретраи go-redis могут удвоить
INCR. - Не закрывать pub/sub.
PubSubнадо явноClose(), иначе соединение и горутина живут вечно. - Надеяться на контекст. По умолчанию 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 и ожидание пула.