golang

Кэш — это холодильник: TTL, инвалидация и cache stampede

  • пятница, 14 августа 2026 г. в 00:00:20
https://habr.com/ru/articles/1070106/

Объясню кэш через холодильник, а потом покажу, где эта аналогия ломается, и что с этим делают в проде.

Статья для тех, кто кэш уже ставил, но не разбирался, почему он иногда роняет сервис вместо того, чтобы его ускорять.

Холодильник

Захотел есть — открыл холодильник, взял. Пара секунд. Это кэш: нужное лежит рядом.

Холодильник пустой — идёшь в магазин. Одеться, дойти, очередь, обратно. Это запрос в базу: дальше, медленнее и дороже.

Смысл кэша ровно в этом: то, что нужно часто, держим поближе.

func (s *Service) GetUser(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)

    if u, ok := s.cache.Get(key); ok {
        return u, nil // взяли из холодильника
    }

    u, err := s.repo.FindByID(ctx, id) // сходили в магазин
    if err != nil {
        return nil, err
    }

    s.cache.Set(key, u, 5*time.Minute)
    return u, nil
}

Просрочка

Открываешь контейнер, а там уже зародилась новая форма жизни.

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

Два самых распространённых способа выбросить просрочку — TTL и инвалидация по событию. Обычно их используют вместе.

По времени (TTL). Ставим сроку годности запись: протухло — идём за свежим.

s.cache.Set(key, u, 5*time.Minute)

Просто и надёжно, но между изменением данных и истечением TTL пользователь видит старое.

По событию. Данные изменились — сразу выкидываем ключ.

func (s *Service) UpdateUser(ctx context.Context, u *User) error {
    if err := s.repo.Update(ctx, u); err != nil {
        return err
    }
    s.cache.Delete(fmt.Sprintf("user:%d", u.ID))
    return nil
}

Точнее, но легко забыть. Особенно когда данные меняются из трёх мест, а про четвёртое вы узнаете из бага. Поэтому TTL обычно оставляют как страховку поверх событийной инвалидации.

А теперь то, ради чего статья

Аналогия с холодильником работает, пока в квартире один человек. В проде людей тысячи.

Посчитаем. Один промах приводит к запросу в базу, который занимает 40 мс. На горячий ключ в этот момент приходит 500 запросов. Все 500 не находят данных в кэше, все 500 идут в базу, все 500 получают одинаковый результат и записывают его обратно.

Вместо одного SQL‑запроса база получает пятьсот одинаковых. Вся семья одновременно ломанулась в магазин за одним батоном.

Это cache stampede (он же thundering herd). Неприятен он тем, что срабатывает в момент пиковой нагрузки: чем популярнее ключ, тем больше запросов упрётся в базу разом. Кэш, который ставили ради разгрузки базы, в пик её и роняет.

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

Дальше — четыре техники, которые применяют на практике.

1. В магазин идёт один (singleflight)

Кто первым обнаружил, что данных нет, тот и идёт в базу. Остальные ждут его результата.

В Go для этого есть golang.org/x/sync/singleflight:

import "golang.org/x/sync/singleflight"

type Service struct {
    cache Cache
    repo  Repo
    sf    singleflight.Group
}

func (s *Service) GetUser(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)

    if u, ok := s.cache.Get(key); ok {
        return u, nil
    }

    v, err, _ := s.sf.Do(key, func() (any, error) {
        // Проверяем кэш ещё раз, уже внутри группы.
        // Без этого следующая группа запросов, пришедшая сразу после
        // завершения предыдущей, повторно сходит в базу.
        if u, ok := s.cache.Get(key); ok {
            return u, nil
        }

        u, err := s.repo.FindByID(ctx, id)
        if err != nil {
            return nil, err
        }
        s.cache.Set(key, u, 5*time.Minute)
        return u, nil
    })
    if err != nil {
        return nil, err
    }
    return v.(*User), nil
}

Две оговорки, без которых этот пример опасно уносить в сервис.

Про context. Замыкание захватывает ctx того запроса, который вошёл в группу первым. Если этот запрос отменят или у него истечёт дедлайн, поход в базу оборвётся у всех, кто ждал результата. Варианты: выполнять загрузку с отдельным контекстом и собственным таймаутом, либо использовать DoChan и делать select по контексту вызывающего, чтобы каждый ждущий мог отвалиться по своему дедлайну. Семантику ожидания и отмены стоит продумать явно, а не унаследовать случайно.

Про масштаб. singleflight дедуплицирует запросы в пределах одного процесса. При пяти подах в базу уйдёт до пяти запросов вместо пятисот — обычно этого достаточно. Если даже один запрос на инстанс слишком дорог, можно рассматривать координацию между инстансами, например distributed lock или lease в Redis. Но это отдельный набор компромиссов и failure modes, и заходить туда стоит осознанно.

2. Разбросать сроки годности (jitter)

Если вы прогрели тысячу ключей одним проходом и всем поставили ровно 5 минут, через 5 минут они протухнут разом. Толпа соберётся сама собой.

func ttlWithJitter(base time.Duration) time.Duration {
    // ±10% от базового TTL
    delta := time.Duration(rand.Int63n(int64(base/5))) - base/10
    return base + delta
}

s.cache.Set(key, u, ttlWithJitter(5*time.Minute))

Одна функция, а пик размазывается по времени.

3. Обновлять заранее (refresh ahead) и прогрев

Не ждать, пока полка опустеет, а пополнять её заранее. Например, при чтении: если до истечения TTL осталось меньше 20%, отдаём текущее значение и в фоне идём за свежим.

Тут сразу всплывает та же проблема на новом витке: что мешает пятистам запросам запустить пятьсот фоновых обновлений? Ничего. Поэтому фоновый refresh нужно дедуплицировать ровно так же — тем же singleflight по ключу или флагом «обновление уже идёт». Иначе вы просто перенесли stampede из основного пути в фоновый.

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

4. Отдавать слегка полежавшее (stale‑while‑revalidate)

Пока один бежит в магазин, остальным отдаём то, что есть, даже если оно чуть просрочено.

Механика строится на двух сроках: мягком (пора обновить) и жёстком (отдавать больше нельзя). Ниже упрощённый псевдокод: он показывает алгоритм, а не готовую библиотеку, поэтому GetEntry, refreshInBackground и fetchAndCache оставлены за кадром.

type entry struct {
    val       *User
    softUntil time.Time // до этого момента считаем свежим
    hardUntil time.Time // после этого отдавать нельзя
}

func (s *Service) GetUserSWR(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)
    now := time.Now()

    e, ok := s.cache.GetEntry(key)
    switch {
    case ok && now.Before(e.softUntil):
        return e.val, nil // свежее, отдаём как есть

    case ok && now.Before(e.hardUntil):
        s.refreshInBackground(key, id) // внутри singleflight по key
        return e.val, nil // отдаём полежавшее, но мгновенно

    default:
        return s.fetchAndCache(ctx, key, id) // слишком старое, идём синхронно
    }
}

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

Что выбрать

Четыре техники не нужно внедрять пачкой. Они отвечают на разные вопросы:

Что болит

С чего начать

Один горячий ключ, в него бьют все

singleflight

Массовое одновременное истечение TTL

jitter

Предсказуемый набор горячих данных, больно после деплоя

refresh ahead и прогрев

Допустима небольшая устарелость ответа

stale‑while‑revalidate

Несколько инстансов и очень дорогой промах

координация между инстансами

Чек‑лист

  1. Ставите кэш — сразу решайте, когда он протухнет. Не «потом добавлю TTL».

  2. Инвалидация по событию плюс TTL как страховка.

  3. singleflight для дедупликации запросов к горячим ключам, с продуманной семантикой контекста.

  4. Jitter там, где TTL проставляются массово.

  5. Прогрев, если после деплоя база складывается.

  6. Фоновое обновление дедуплицируется так же, как основное.

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

Кэш ускоряет всё, пока вы следите за сроком годности. Забыли — кормите пользователей плесенью.