Модуль 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, БД — источник правды:
gofunc (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. Три средства, по возрастанию силы:
- Singleflight (
golang.org/x/sync/singleflight) — в пределах одного инстанса складывает одинаковые запросы в один вызов БД:
govar g singleflight.Group v, err, _ := g.Do(key, func() (any, error) { return c.loadFromDB(ctx, id) // один вызов на все ждущие горутины })
- Джиттер TTL — разносит истечения во времени;
- Блокировка пересчёта (распределённая): первый добравшийся берёт лок (§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 — это координация лучшего усилия, а не консенсус.
💻 Практика
- Кэш профиля:
UserCacheиз Модуля 1 + singleflight + джиттер TTL + отрицательный кэш; тест на miniredis: hit / miss / битый JSON / «DB called once» при 100 параллельных горутинах на холодном ключе (это и есть тест штампедера — убедитесь, что БД-вызовов был один). - Лок планировщика: фоновая задача «раз в минуту» в двух инстансах
сервиса; лок
SET NX PX 45s+ токен + Lua-unlock. В логах каждого инстанса видно: ровно один выполнил работу за тик. - Сломайте нарочно: увеличьте работу до 60 с при TTL 30 с — наблюдайте двойное выполнение; добавьте продление TTL — двойное исчезает.
Артефакт: user_cache.go + scheduler.go с локом + тесты.
🧩 Quiz "Проверь себя"
✅ Итоги модуля
| Тема | Главное |
|---|---|
| Cache-aside | TTL всегда + джиттер; неймспейс ключей; отрицательный кэш |
| Stampede | singleflight + 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 атомарны, часы защищены от скачка, а бакет не протекает памятью.