Кэш, singleflight и thundering herd

Перед началом: модули 11–14 (shutdown, очереди, БД, пулы). Инструменты: Go 1.22+, Redis или локальный кэш, go test -race. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).

🔥 Инцидент: горячий ключ истёк ровно в час пик

Главная страница читает конфигурацию промо из Redis: ключ promo:main, TTL — час. Ровно на границе TTL ключ исчезает, и все одновременные запросы видят промах. В час пик это 500 запросов в секунду, и каждый идёт в PostgreSQL за одной и той же строкой. База получает 500 одинаковых тяжёлых запросов, пул исчерпан, latency растёт, клиенты ретраят — нагрузка умножается.

Инцидент повторяется каждый час: TTL истёк — штурм, через секунду кэш снова горячий. Это thundering herd (dogpile, cache stampede): проблема не в кэше, а в том, что промах не координируется между читателями.

📖 Теория

15.1 Cache-aside и цена промаха

Схема cache-aside: читаем кэш; при промахе идём в БД, кладём результат с TTL. Hit ratio 99 % звучит отлично, но 1 % промахов на 50 000 rps — это 500 запросов в секунду в БД, и все они приходят одновременно на одном ключе.

Цена промаха = задержка БД + нагрузка на пул + риск, что промах массовый. Кэш не убирает источник истины, он лишь отодвигает обращения к нему; на границе TTL вся отложенная нагрузка возвращается разом.

go
func Get(ctx context.Context, key string) (Value, error) {
    if v, ok := cache.Get(key); ok {
        return v, nil
    }
    v, err := db.Load(ctx, key) // сюда придут все промахи
    if err != nil {
        return Value{}, err
    }
    cache.Set(key, v, ttl)
    return v, nil
}

15.2 Singleflight: один летит, остальные ждут

Идея: на один ключ в один момент времени — один запрос в БД. Первый читатель создаёт «полёт» и выполняет функцию; остальные видят тот же полёт, ждут и получают тот же результат. После завершения запись удаляется — следующая волна снова полетит.

Устройство — около шестидесяти строк: map[key]*call под Mutex, у call — WaitGroup и поля результата. Лидер кладёт call в map, выполняет fn, будит ожидающих и удаляет запись. В бою берите golang.org/x/sync/singleflight — устройство то же, плюс DoChan и обработка паник.

go
v, err, shared := group.Do(key, func() (any, error) {
    return loadFromDB(ctx, key)
})
// shared=true — результат пришёл от чужого полёта

Ловушка: лидер выполняет fn со своим ctx. Если его запрос отменили, ошибку получат все присоединившиеся. Для фоновой загрузки используют отдельный ctx или DoChan, чтобы отмена одного клиента не роняла общий результат.

15.3 Dogpile, догрев и jitter TTL

Singleflight убирает дубли, но не убирает паузу: пока лидер ходит в БД, все ждут. На очень горячем ключе это допустимо, на тёплом — лучше не доводить до промаха.

Приёмы:

  • jitter TTL: ttl × (0,8…1,2) — сроки жизни разных ключей разъезжаются, и они не истекают одной волной.
  • refresh-ahead: за N секунд до истечения ключ обновляет фоновая горутина; читатели всегда видят горячее значение.
  • stale-while-revalidate: отдаём старое значение, обновление идёт асинхронно; свежесть ограничена окном.
  • вероятностное раннее обновление: чем ближе TTL к концу, тем выше шанс, что читатель запустит refresh.
go
ttl := base + time.Duration(rand.Int63n(int64(base/5)))

15.4 Согласованность: RWMutex vs канал-владелец

Локальный кэш — это map; конкурентный доступ к ней нужно упорядочить. Два стиля:

  • sync.RWMutex: читатели идут параллельно, писатель эксклюзивен. Просто, быстро для read-heavy, но writer может ждать долго, а lock contention растёт с числом ядер.
  • Канал-владелец: map принадлежит одной горутине, остальные общаются с ней сообщениями. Нет гонок по определению, удобно для эвикции и фонового обновления, но каждое чтение — сообщение и переключение.

Версионирование: значение несёт version или updatedAt, а запись делается только если версия новее. Иначе медленный ответ БД, пришедший позже, затрёт свежие данные — классическая гонка «last write wins».

15.5 Инвалидация и «кэш навсегда»

TTL — это компромисс, а не инвалидация. Если данные меняются редко и обязаны быть свежими после записи, нужен явный сигнал:

  • удаление после коммита: сначала фиксируем транзакцию, потом удаляем ключ; удаление до коммита оставляет окно, где кэш уже пуст, а БД ещё старая;
  • версия в ключе: user:42:v7 — новая версия даёт новый ключ, старый умирает по TTL; удобно для иммутабельных данных;
  • событие или outbox: изменение публикует событие, кэши его инвалидируют; надёжнее прямого удаления из обработчика.

Данные, которые не меняются никогда (справочники, исторические срезы, контент по хэшу), можно кэшировать «навсегда»: TTL не нужен, но нужен способ убрать мусор при смене версии.

15.6 Компромиссы

  • TTL длинный vs короткий: длинный — выше hit ratio и меньше нагрузки на БД, но больше устаревание; короткий — свежее, но чаще промахи.
  • Локальный кэш vs Redis: локальный без сети и быстрее, но у каждого инстанса свой и память дублируется; Redis общий, но добавляет сетевой hop и точку отказа.
  • Singleflight vs per-key lock: singleflight делит результат и не блокирует последовательные волны; лок проще, но ждёт каждого и легко становится узким местом.
  • Stale-while-revalidate vs строгая свежесть: stale даёт низкую latency ценой окна устаревания; строгая свежесть возвращает нагрузку на источник.
  • Версионные ключи vs удаление: версии не гоняются с читателями, но плодят мусор; удаление точечно, но требует дисциплины и порядка операций.

💻 Практика

  1. Воспроизведите стадо. 500 горутин читают истёкший ключ; счётчик обращений к «БД» растёт до 500. Добавьте singleflight и покажите, что обращение одно, а ответы получили все.
  2. Соберите кэш. RWMutex, TTL с jitter и версия значения. Прогоните zipf-нагрузку и снимите hit ratio, число промахов и пиковую нагрузку на источник.
  3. Добавьте догрев. Refresh-ahead и stale-while-revalidate на медленной БД (200 мс). Сравните p99 и число промахов с обычным TTL.
  4. Запустите сниппет модуля: 5 читателей, 1 обращение к БД, 4 присоединившихся, 5 ответов — «значение» получают все, а второй строкой выводится цена без singleflight: 5 запросов.

🧩 Quiz

1. Как инвалидировать ключ после обновления данных в БД?
2. Когда канал-владелец удобнее RWMutex для локального кэша?
3. Зачем добавлять jitter к TTL?
4. Как singleflight защищает источник?
5. Что такое thundering herd при истечении TTL?

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

Студент должен:

  • Объяснять цену промаха и механизм thundering herd
  • Реализовать singleflight и понимать общий ctx лидера
  • Разводить TTL jitter и не синхронизировать истечение
  • Применять refresh-ahead и stale-while-revalidate
  • Выбирать RWMutex или канал-владельца осознанно
  • Версионировать значения и не терять свежую запись
  • Инвалидировать кэш после коммита, а не до него
  • Ответить на все вопросы Quiz

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

Кэш и singleflight защищают синхронное чтение, но основная нагрузка всё чаще приходит потоком событий из брокера. Дальше — консьюмеры: сколько их запускать, как делить партиции, что делать с ошибками и как не терять порядок при масштабировании.