Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Кэш без штормов: защита от cache stampede и старого контента — быстрее ответы и ниже затраты

Разработка и технологии26 февраля 2026 г.
Кэш снижает задержки и нагрузку на базу, но под пиковыми запросами легко превращается в источник аварий: шторм кэша, массовое истечение TTL и рассинхрон. Разбираем практические приёмы: джиттер TTL, stale-while-revalidate, коалесцирование запросов, отрицательное кэширование и распределённые блокировки. Показываем рабочий пример на Go с Redis, метрики и чек-лист внедрения.
Кэш без штормов: защита от cache stampede и старого контента — быстрее ответы и ниже затраты

Оглавление

  • Зачем бизнесу стабильный кэш
  • Типовые проблемы кэша
    • Шторм кэша (cache stampede)
    • Проникновение кэша и «дырявые» ключи
    • Горячие ключи и массовое истечение TTL
    • Рассинхрон и устаревшие данные
  • Паттерны и решения
    • Джиттер TTL
    • Stale‑while‑revalidate (мягкий срок годности)
    • Коалесцирование запросов (singleflight)
    • Распределённая блокировка в Redis
    • Отрицательное кэширование (Not Found тоже кэшируем)
    • Тёплый старт и фоновые обновления
    • Ограничение запросов на ключ и защита горячих ключей
    • Стратегии записи
    • Деградация при сбое кэша
  • Метрики и алерты для кэша
  • Практика: минимальный, но боевой слой кэширования на Go + Redis
  • Чек-лист внедрения
  • Частые ошибки
  • Когда кэш не нужен
  • Итоги

Зачем бизнесу стабильный кэш

Кэш — это быстрые ответы без покупки лишних серверов. Хороший кэш снижает задержки, разгружает базу, сглаживает пики трафика и уменьшает расходы. Плохой кэш делает наоборот: во время распродажи всё валится из‑за «шторма кэша», пользователи видят старые данные, команда жжёт бюджет на оверхед и расследования.

Ниже — набор практик, которые приносят прямую пользу:

  • предсказуемые p95/p99 задержки во время пиков;
  • меньше инцидентов и ночных правок TTL;
  • снижение нагрузки на БД и сторонние API;
  • гибкая деградация — продажи продолжаются даже при проблемах с кэшем.

Типовые проблемы кэша

Шторм кэша (cache stampede)

Классика: срок хранения ключа истёк, сотни запросов одновременно идут в базу/микросервис за обновлением. Ресурс не выдерживает, начинается лавина сбоев.

Проникновение кэша и «дырявые» ключи

Боты или легитимные запросы часто спрашивают несуществующие сущности. Если 404 не кэшировать, каждый запрос ударит в БД/поисковик.

Горячие ключи и массовое истечение TTL

У популярных ключей TTL часто истекает одновременно (напр. на «00» минут). В момент истечения — пик запросов в источник правды.

Рассинхрон и устаревшие данные

Пишем в БД, забываем обновить кэш — пользователи видят старое. Или наоборот: чистим кэш слишком рано — читаем из БД с повышенной задержкой.

Паттерны и решения

Джиттер TTL

Добавляйте случайный разброс к TTL. В итоге разные ключи и даже однотипные ключи истекают не одновременно — меньше пиков:

  • TTL = базовый_TTL ± 10–20% случайно.

Stale‑while‑revalidate (мягкий срок годности)

Два срока:

  • мягкий (soft TTL): после него можно отдать «слегка устаревшее» значение;
  • жёсткий (hard TTL): после него значение нельзя отдавать вовсе.

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

Коалесцирование запросов (singleflight)

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

Распределённая блокировка в Redis

Поверх singleflight внутри процесса используйте короткую блокировку в Redis (SET NX EX), чтобы и на нескольких репликах сервиса не началась гонка за обновление одного и того же ключа.

Отрицательное кэширование (Not Found тоже кэшируем)

Если сущность не найдена — кэшируйте «пустой» результат коротким TTL (например, 30–60 секунд). Это защищает БД от повторяющихся 404.

Тёплый старт и фоновые обновления

  • Прогревайте кэш для действительно горячих ключей при релизе или по расписанию.
  • Делайте фоновые обновления для самых популярных ключей до истечения soft TTL.

Ограничение запросов на ключ и защита горячих ключей

  • Лимитируйте частоту обновления одного ключа (key-level rate limit).
  • Если ключ супергорячий — шардируйте его (разнесите по нескольким ключам) или добавляйте локальный in‑memory кэш поверх Redis.

Стратегии записи

  • Cache-aside (ленивый): читаем из кэша, при промахе — из БД и записываем в кэш. Просто и надёжно.
  • Write-through: при записи в БД одновременно пишем в кэш. Консистентнее, но дороже.
  • Write-back: пишем сперва в кэш, а в БД — асинхронно. Максимальная скорость и максимальные риски при сбоях. В бизнес‑критичных системах — с оглядкой.

Деградация при сбое кэша

Если Redis умер или сеть глючит — не надо умирать вместе с ним. Примите решение заранее:

  • быстрый обход кэша (fallthrough) и прямой поход в БД с жёсткими лимитами;
  • выключение «тяжёлых» страниц/фич;
  • возврат более грубых данных, но быстро.

Метрики и алерты для кэша

Отслеживайте и алертите по:

  • hit ratio (доля попаданий), отдельно для горячих ключей;
  • p95/p99 задержек «с кэшем» и «без кэша»;
  • QPS к источнику данных во время истечения TTL;
  • доля ответов stale-while-revalidate;
  • ошибки Redis (таймауты, отказ), доля деградаций;
  • распределение размеров значений (чтобы не хранить гигантов).

Простой порог: если hit ratio падает ниже 85–90% у горячей выборки — это повод искать причины (массовые инвалидации, рассинхрон, неверный TTL).

Практика: минимальный, но боевой слой кэширования на Go + Redis

Ниже — готовый пример: cache-aside с джиттером TTL, stale‑while‑revalidate, коалесцированием запросов и отрицательным кэшированием. Код экономит походы в БД и избегает штормов.

Перед запуском поднимите Redis (локально):

docker run --rm -p 6379:6379 redis:7-alpine

Код (Go 1.21+):

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"math/rand"
	"time"

	redis "github.com/redis/go-redis/v9"
	"golang.org/x/sync/singleflight"
)

// Модель домена
type Product struct {
	ID         int64  `json:"id"`
	Name       string `json:"name"`
	PriceCents int64  `json:"price_cents"`
}

// Обёртка для хранения в Redis: поддерживаем soft/hard TTL и «пустые» значения.
type cacheEnvelope struct {
	Data       *Product `json:"data,omitempty"`
	SoftExpire int64    `json:"soft_expire_unix"`
	HardExpire int64    `json:"hard_expire_unix"`
	Nil        bool     `json:"nil,omitempty"`
}

var (
	rdb   *redis.Client
	group singleflight.Group

	// Бизнес‑ошибки
	ErrNotFound = errors.New("not found")
)

// Настройки TTL
const (
	baseHardTTL     = 5 * time.Minute   // жёсткий срок годности
	baseSoftTTL     = 1 * time.Minute   // мягкий срок: после него отдаём stale и фоном обновляем
	negCacheTTL     = 30 * time.Second  // TTL для Not Found
	ttlJitterSpread = 0.20               // ±20% разброс TTL
	lockTTL         = 30 * time.Second  // TTL для распределённой блокировки
)

func main() {
	// Инициализируем Redis
	rdb = redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
	defer func() { _ = rdb.Close() }()

	ctx := context.Background()

	// Пример нагрузки: параллельные запросы одного и того же ключа
	id := int64(42)
	concurrency := 20
	ch := make(chan struct{}, concurrency)
	for i := 0; i < concurrency; i++ {
		ch <- struct{}{}
		go func(i int) {
			defer func() { <-ch }()
			ctxReq, cancel := context.WithTimeout(ctx, 2*time.Second)
			defer cancel()
			p, err := GetProduct(ctxReq, id)
			if err != nil {
				log.Printf("worker %d: error: %v", i, err)
				return
			}
			log.Printf("worker %d: got product: %+v", i, p)
		}(i)
	}
	// дождаться завершения всех горутин
	for i := 0; i < concurrency; i++ {
		ch <- struct{}{}
	}
	log.Println("done")
}

func cacheKeyProduct(id int64) string {
	return fmt.Sprintf("product:%d", id)
}

func lockKeyProduct(id int64) string {
	return fmt.Sprintf("lock:product:%d", id)
}

// GetProduct — чтение с многоуровневой защитой от штормов.
func GetProduct(ctx context.Context, id int64) (*Product, error) {
	key := cacheKeyProduct(id)
	now := time.Now()

	// 1) Пробуем Redis
	bs, err := rdb.Get(ctx, key).Bytes()
	if err == nil {
		env := cacheEnvelope{}
		if e := json.Unmarshal(bs, &env); e == nil {
			// Хард‑TTL истёк — считаем промахом
			if now.Unix() >= env.HardExpire {
				// промах — пойдём загружать ниже
			} else {
				// Not Found — сразу возвращаем
				if env.Nil {
					return nil, ErrNotFound
				}

				// Если soft TTL прошёл — триггерим фоновой рефреш, но возвращаем старые данные быстро
				if now.Unix() >= env.SoftExpire {
					go func() {
						ctxBg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
						defer cancel()
						_ = refreshProduct(ctxBg, id)
					}()
				}
				return env.Data, nil
			}
		}
	} else if err != redis.Nil {
		// Redis недоступен — логируем и деградируем на источник данных
		log.Printf("redis get error: %v", err)
	}

	// 2) Промах: коалесцируем запросы, чтобы один полёт сходил в БД
	v, err, _ := group.Do("load:"+key, func() (any, error) {
		ctxLoad, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
		defer cancel()

		p, e := loadProductFromDB(ctxLoad, id)
		if e != nil {
			if errors.Is(e, ErrNotFound) {
				// Отрицательное кэширование
				_ = saveEnvelope(ctx, key, cacheEnvelope{
					Nil:        true,
					SoftExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
					HardExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
				})
				return nil, ErrNotFound
			}
			return nil, e
		}

		// Сохраняем с мягким/жёстким TTL
		_ = saveEnvelope(ctx, key, cacheEnvelope{
			Data:       p,
			SoftExpire: now.Add(jitter(baseSoftTTL, ttlJitterSpread)).Unix(),
			HardExpire: now.Add(jitter(baseHardTTL, ttlJitterSpread)).Unix(),
		})
		return p, nil
	})
	if err != nil {
		return nil, err
	}
	return v.(*Product), nil
}

// refreshProduct — фоновое обновление с распределённой блокировкой.
func refreshProduct(ctx context.Context, id int64) error {
	lkey := lockKeyProduct(id)
	ok, err := rdb.SetNX(ctx, lkey, "1", lockTTL).Result()
	if err != nil {
		return err
	}
	if !ok {
		return nil // кто-то уже обновляет
	}
	defer func() { _ = rdb.Del(context.Background(), lkey).Err() }()

	p, e := loadProductFromDB(ctx, id)
	now := time.Now()
	key := cacheKeyProduct(id)
	if e != nil {
		if errors.Is(e, ErrNotFound) {
			return saveEnvelope(ctx, key, cacheEnvelope{
				Nil:        true,
				SoftExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
				HardExpire: now.Add(jitter(negCacheTTL, ttlJitterSpread)).Unix(),
			})
		}
		return e
	}
	return saveEnvelope(ctx, key, cacheEnvelope{
		Data:       p,
		SoftExpire: now.Add(jitter(baseSoftTTL, ttlJitterSpread)).Unix(),
		HardExpire: now.Add(jitter(baseHardTTL, ttlJitterSpread)).Unix(),
	})
}

func saveEnvelope(ctx context.Context, key string, env cacheEnvelope) error {
	bs, err := json.Marshal(env)
	if err != nil {
		return err
	}
	// Истекаем по жёсткому сроку
	ttl := time.Until(time.Unix(env.HardExpire, 0))
	if ttl <= 0 {
		// На всякий пожарный, не записываем уже истёкшее
		return nil
	}
	return rdb.Set(ctx, key, bs, ttl).Err()
}

// Имитируем источник правды (БД): быстрый NotFound, небольшая задержка на success.
func loadProductFromDB(ctx context.Context, id int64) (*Product, error) {
	// Для примера: каждый пятый ID отдаёт NotFound
	if id%5 == 0 {
		return nil, ErrNotFound
	}
	select {
	case <-time.After(120 * time.Millisecond):
		return &Product{
			ID:         id,
			Name:       fmt.Sprintf("Product-%d", id),
			PriceCents: 1999,
		}, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

func jitter(d time.Duration, spread float64) time.Duration {
	if spread <= 0 {
		return d
	}
	factor := 1 + (rand.Float64()*2-1)*spread // [1-spread, 1+spread]
	return time.Duration(float64(d) * factor)
}

Что делает код:

  • отдаёт данные из кэша мгновенно, даже если нужно обновить (stale‑while‑revalidate);
  • коалесцирует конкурирующие промахи (singleflight);
  • держит короткую блокировку в Redis, чтобы несколько инстансов не ломанулись одновременно за обновлением;
  • кэширует Not Found отдельным коротким TTL;
  • размывает истечение TTL джиттером, уменьшая пики.

Подключите метрики (время ответа, попадания/промахи, долю stale), и вы почти полностью уберёте «штормы кэша».

Чек-лист внедрения

  • Пронумеруйте горячие ключи: топ-50/100 по QPS и объёму трафика.
  • Включите джиттер TTL минимум для горячей сотни ключей.
  • Добавьте soft/hard TTL для тяжёлых источников (медленный SQL, сторонние API).
  • Включите отрицательное кэширование для 404/пустых результатов.
  • Поверх — singleflight и распределённый лок на ключ.
  • Введите key-level rate limit на обновление одного ключа.
  • Настройте алерты: падение hit ratio, всплеск промахов, рост доли stale.
  • Тёмный запуск: сначала включайте по частям (канареечный процент трафика или набор ключей).

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

  • Один общий TTL без джиттера: истекает всё сразу — привет лавина.
  • Не кэшируют 404: БД «залипает» на повторных Not Found.
  • Большие значения (сотни килобайт) без компрессии: трафик и задержки растут кратно.
  • Сканы ключей через KEYS в проде: стопорите Redis. Используйте SCAN или храните индексы.
  • Нет таймаутов на Redis: при сетевой проблеме сервис зависает.
  • Смешивают кэш и источник правды в одной ошибке: при падении Redis считают, что «всё умерло» — и ошибочно включают анти‑DDoS‑режим.
  • Слишком короткий soft TTL: система постоянно что‑то обновляет и лишний раз трогает источник.

Когда кэш не нужен

  • Набор данных мал, в памяти БД всё и так работает стабильно быстрее, чем цена сложности.
  • Доля записи высока, а требования к консистентности жёсткие — кэш только усложнит жизнь.
  • Сильные ограничения на p99 и минимальную вариативность задержек: сериализация и сеть кэша могут дать нежелательный шум — лучше in‑memory с явной стратегией.

Итоги

Кэш — не просто «поставим Redis и готово». Чтобы кэш действительно экономил деньги и держал SLA, устраните шторм кэша, размажьте TTL, кэшируйте Not Found, коалесцируйте промахи и обновляйте данные фоном. Добавьте метрики, тёплый старт и деградацию при сбоях — и ваши ответы останутся быстрыми даже под пиковыми нагрузками, без лишних серверов и ночных дежурств.


Redisпроизводительностькэш