Модуль 2. Кэш и распределённые локи: cache-aside, stampede, SET NX PX

Цель модуля: освоить два самых частых применения Redis в Go — кэш-aside (без штампов и с честной инвалидацией) и распределённые локи (SET NX PX + токен), и точно понимать границы: где Redis-лок честен, а где нужен контракт БД.

Перед началом: Модуль 1 (клиент, политики отказа). Инструменты: go-redis/v9, golang.org/x/sync/singleflight, miniredis. Трудоёмкость: ~8–10 ч (теория 4 ч · практика 5 ч).


🔥 Инцидент: «Чёрная пятница: 1 200 соединений на один холодный ключ»

Утро распродажи. Первый запрос после ночи инвалидировал (истёк TTL) ключ catalog:main — дорогая сборка на 300 мс. К обеду трафик вырос в десятки раз, и на каждый промах по этому ключу приходили сотни одновременных запросов: каждый увидел «нет в кэше» и пошёл собирать значение в PostgreSQL. Тысяча двести соединений на одну таблицу, p99 БД в потолке, API под 429 от собственной защиты — из-за одного ключа.

Классический cache stampede: «проверил — нет — посчитал — положил» атомарно не защищает. Через месяц словили второй инцидент из той же оперы: миграция упала, а распределённый лок, взятый без TTL, остался в Redis навсегда — и система замерла в ожидании, который уже никем не будет отпущен. Обе раны лечатся устройством: схлопывание промахов (singleflight), TTL у лока и токен владельца. Разбираем оба приёма до винтиков.

📖 2.1. Cache-aside: скучный паттерн, который ломают деталями

Классический цикл: приложение читает кэш → промах → читает БД → пишет в кэш с TTL → отвечает. Всё состояние — в Redis, БД — источник правды:

go
func (c *UserCache) Get(ctx context.Context, id int) (*User, error) {
    key := fmt.Sprintf("user:%d", id)
    if raw, ok := CacheGet(ctx, c.rdb, key); ok {
        var u User
        if json.Unmarshal(raw, &u) == nil {
            return &u, nil
        }
        // битый JSON — считаем промахом и перезапишем ниже
    }
    u, err := c.db.UserByID(ctx, id)
    if err != nil {
        return nil, err
    }
    raw, _ := json.Marshal(u)
    c.rdb.Set(ctx, key, raw, c.ttl) // TTL с джиттером — §2.2
    return u, nil
}

Гигиена, которую проверяют на ревью:

  • Ключи — неймспейсом: user:{id}, session:{token}, cfg:{name}. Без общего префикса на «всё» — вылов мусора потом стоит недель;
  • TTL обязателен всегда. «Кэш без TTL» = будущий инцидент с рассинхроном;
  • TTL с джиттером: ttl + rand(0..ttl/10) — иначе все ключи, заполненные волной, истекают одновременно (см. §2.2);
  • Отрицательное кэширование: для «нет такого пользователя» кладите короткую метку (TTL 30–60 с) — иначе один сканер долбит БД через ваш несуществующий id.

📖 2.2. Stampede: тысяча запросов на один холодный ключ

Когда горячий ключ истекает, все параллельные запросы одновременно промахиваются и идут в БД — штампedere. Три средства, по возрастанию силы:

  1. Singleflight (golang.org/x/sync/singleflight) — в пределах одного инстанса складывает одинаковые запросы в один вызов БД:
go
var g singleflight.Group

v, err, _ := g.Do(key, func() (any, error) {
    return c.loadFromDB(ctx, id) // один вызов на все ждущие горутины
})
  1. Джиттер TTL — разносит истечения во времени;
  2. Блокировка пересчёта (распределённая): первый добравшийся берёт лок (§2.4) и пересчитывает, остальные ждут или отвечают старым значением. Нужно только если штампедер болезненный даже после 1–2.

Для большинства сервисов достаточно singleflight + jitter — и это решение локально, без координации.


📖 2.3. Инвалидация: TTL — лучший друг, «умная» инвалидация — враг

Правило, которое экономит недели отладки: инвалидация событиями — только если TTL недостаточен. Событийная инвалидация означает распределённый протокол («кто, где и когда должен сбросить ключ») — и каждый участник этого протокола — будущий баг.

Если всё-таки нужны свежее-данных-чем-TTL: после записи в БД публикуйте событие (Streams/Pub-Sub — курс «Redis как брокер») и сбрасывайте ключ в одном месте — в подписчике, а не «там, где вспомнили».


📖 2.4. Распределённые локи: SET NX PX + токен

Лок нужен, когда работу должен делать ровно один инстанс: ночной cron, обработка батча, дедупликация фоновых задач. Минимально честный лок:

go
// Захват: атомарно «нет ключа → поставить с TTL»
token := uuid.NewString()
ok, err := rdb.SetNX(ctx, "lock:report", token, 30*time.Second).Result()
if err != nil || !ok {
    return errLockHeld // кто-то уже работает — выходим
}
defer func() {
    // Освобождение: только свой лок! Сравниваем токен атомарно (Lua).
    rdb.Eval(ctx, luaUnlock, []string{"lock:report"}, token)
}()
// ... работа ...
lua
-- unlock: удалить ключ только если значение совпадает с нашим токеном.
-- Без сравнения воркер, задержавшийся дольше TTL, удалит ЧУЖОЙ лок.
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
end
return 0

Три обязательных элемента и почему:

  • PX (TTL) — лок без TTL превращается в вечный при смерти владельца;
  • Токен + сравнение при release — без него «опоздавший» владелец снимает лок нового владельца;
  • Продление (watchdog/heartbeat), если работа может быть дольше TTL: отдельная горутина продлевает TTL, пока владелец жив. TTL — это таймаут безопасности, а не «сколько займёт работа».

📖 2.5. Честные границы: Redlock и когда лок в Redis не подходит

Знаменитый Redlock (лок в N независимых Redis с кворумом) — предмет длительного спора (Kleppmann vs antirez). Практический вывод для Go-сервиса:

  • Один Redis + Sentinel/failover достаточно для координации и дедупа: при фейловере возможна кратковременная потеря лока → два владельца на мгновение. Если последствие — «задача отработала дважды, но идемпотентна» — это приемлемо;
  • Деньги и необратимые эффекты (списание, отправка) не защищают локом в Redis — защищают контрактом БД: уникальный индекс, UPDATE ... WHERE status='new', идемпотентный ключ операции. Лок может ускорить happy-path, но гарантию даёт только БД;
  • Лок в Redis — это координация лучшего усилия, а не консенсус.

💻 Практика

  1. Кэш профиля: UserCache из Модуля 1 + singleflight + джиттер TTL + отрицательный кэш; тест на miniredis: hit / miss / битый JSON / «DB called once» при 100 параллельных горутинах на холодном ключе (это и есть тест штампедера — убедитесь, что БД-вызовов был один).
  2. Лок планировщика: фоновая задача «раз в минуту» в двух инстансах сервиса; лок SET NX PX 45s + токен + Lua-unlock. В логах каждого инстанса видно: ровно один выполнил работу за тик.
  3. Сломайте нарочно: увеличьте работу до 60 с при TTL 30 с — наблюдайте двойное выполнение; добавьте продление TTL — двойное исчезает.

Артефакт: user_cache.go + scheduler.go с локом + тесты.


🧩 Quiz "Проверь себя"

1. Для списания денег между инстансами достаточно лока в Redis?
2. Зачем TTL у ключа дедупа идемпотентности должен быть ≥ retention источника повторов?
3. Горячий ключ истёк, и 200 параллельных запросов одновременно пошли в БД. Что будет самым эффективным первым средством?
4. Воркер взял лок SET NX PX 30s, работа заняла 45 с, второй воркер взял лок после истечения. Что сделает первый воркер при release без сравнения токена?

✅ Итоги модуля

ТемаГлавное
Cache-asideTTL всегда + джиттер; неймспейс ключей; отрицательный кэш
Stampedesingleflight + jitter — достаточно в 90 % случаев
ИнвалидацияTTL-first; события — одним подписчиком, если без них никак
ЛокSET NX PX + токен + Lua-release; TTL — таймаут безопасности; продление при длинной работе
ГраницыRedis-лок = координация; гарантию денег даёт только БД

🔗 Мостик к Модулю 3

Кэш и локи — «периодические» применения. Дальше — главное блюдо курса: rate limiting. Алгоритмы (fixed window и его клык, sliding, token bucket, GCRA), затем Token Bucket на Redis через Lua — с боевой реализацией, где refill/consume атомарны, часы защищены от скачка, а бакет не протекает памятью.