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

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) // слишком старое, идём синхронно } }
Пользователь получает ответ сразу и видит данные, скажем, пятисекундной давности вместо ожидания в очереди. Для ленты, каталога или счётчиков это обычно приемлемо. Для баланса и остатков на складе — нет.
Что выбрать
Четыре техники не нужно внедрять пачкой. Они отвечают на разные вопросы:
Что болит | С чего начать |
|---|---|
Один горячий ключ, в него бьют все |
|
Массовое одновременное истечение TTL | jitter |
Предсказуемый набор горячих данных, больно после деплоя | refresh ahead и прогрев |
Допустима небольшая устарелость ответа | stale‑while‑revalidate |
Несколько инстансов и очень дорогой промах | координация между инстансами |
Чек‑лист
Ставите кэш — сразу решайте, когда он протухнет. Не «потом добавлю TTL».
Инвалидация по событию плюс TTL как страховка.
singleflightдля дедупликации запросов к горячим ключам, с продуманной семантикой контекста.Jitter там, где TTL проставляются массово.
Прогрев, если после деплоя база складывается.
Фоновое обновление дедуплицируется так же, как основное.
Кэшируйте то, что действительно читают часто: кэш ест память и добавляет ещё одно место, где данные могут разойтись с реальностью.
Кэш ускоряет всё, пока вы следите за сроком годности. Забыли — кормите пользователей плесенью.

