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

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 проставляются массово.
-
Прогрев, если после деплоя база складывается.
-
Фоновое обновление дедуплицируется так же, как основное.
-
Кэшируйте то, что действительно читают часто: кэш ест память и добавляет ещё одно место, где данные могут разойтись с реальностью.
Кэш ускоряет всё, пока вы следите за сроком годности. Забыли — кормите пользователей плесенью.
Автор: dixmod
