7.5Cache stampede, горячие и большие ключи
Разбери, как истечение одного ключа может превратиться в stampede, а одна горячая команда или большой ключ - задержать весь Redis. Сопоставь singleflight, jitter, раннее обновление, разбиение данных и репликацию по их разным целям.
Что такое cache stampede (dog-pile)
Ключ, который читают 5000 раз в секунду, истёк. Пока первый запрос идёт в БД, сотни горутин
одновременно получают nil, одновременно решают «надо сходить в БД» и одновременно
выполняют один и тот же SELECT. На БД обрушивается залп одинаковых запросов, её
латентность растёт, из-за этого запросы идут дольше, а залп за это время успевает вырасти ещё. Классическая
положительная обратная связь: в конце пул соединений исчерпан, а отказы идут каскадом. Причём
падает БД, та самая, которую кэш должен был защищать.
Чем популярнее ключ, тем сильнее удар. Редко читаемый ключ при истечении даст один-два лишних запроса, ключ с главной страницы даст тысячу. Выходит, stampede бьёт ровно туда, где кэш приносил больше всего пользы.
Когда случается
- Истечение популярного ключа: базовый сценарий, он же cache breakdown.
- Холодный старт. Подняли новый под или новый инстанс Redis, восстановились после аварии, а кэш пуст, и весь трафик идёт в БД. Промахиваются уже все ключи сразу (avalanche).
- Деплой со сменой префикса версии. На бумаге операция «безопасная», а на деле она мгновенно сбрасывает весь кэш.
- Синхронное истечение. Прогрели кэш скриптом с одинаковым TTL - и ровно через N минут всё истекает в одну секунду. Самый обидный вариант: его создают своими руками.
- Failover Redis. Реплика стала мастером с отставанием или пустой - эффект тот же, что при холодном старте.
- Массовое вытеснение, когда память упёрлась в
maxmemoryи политика начинает выбрасывать ключи пачками.
Главный приём защиты - singleflight: он «схлопывает» одновременные одинаковые запросы в один.
Решения: пять приёмов от простого к сложному
1. Блокировка на перестроение
Первый, кто получил промах, берёт блокировку и идёт в БД; остальные либо ждут её, либо сразу
отдают устаревшее/деградированное значение. Внутри одного процесса хватает sync.Mutex
на ключ, между процессами нужна распределённая блокировка на Redis (SET lock:key NX PX,
подробности в главе 7.7).
Приём рабочий, но цену стоит назвать сразу. Блокировка на весь кластер добавляет round-trip к Redis на каждый промах, может залипнуть, если держатель упал (лечится TTL), и оставляет вопрос, что делают остальные, пока лидер работает: ждут (и упираются в его латентность и таймауты) или получают ошибку. На практике внутрипроцессный singleflight даёт 95 % пользы за 5 % сложности: при 20 подах залп из 200 запросов схлопывается до 20, а такого БД уже не замечает.
2. Singleflight - стандартный ответ для Go
Singleflight (дословно «один полёт») отвечает за дедупликацию конкурентных
вызовов: из N одновременных запросов одной и той же работы выполняется один, остальные
подписываются на его результат. При stampede не хватает ровно этого. Скорость похода в БД тут
ни при чём - беда в том, что туда ходят двести раз подряд за одним и тем же ответом. Пакет
golang.org/x/sync/singleflight так и работает: первый вызов по ключу выполняет
функцию, остальные с тем же ключом ждут и получают тот же самый результат.
Тут же напрашивается вопрос: почему не обойтись обычным мьютексом? Один общий
sync.Mutex отпадает сразу: он выстроит в очередь запросы к разным ключам
и превратит параллельный сервис в последовательный. Мьютекс на каждый ключ уже ближе к делу,
но за ним тянутся три вещи, и из них состоит почти весь код singleflight.
- Мапу «ключ → мьютекс» кто-то должен чистить, иначе она растёт вместе с числом когда-либо запрошенных ключей. И выбросить запись можно не в любой момент: пока на мьютексе кто-то стоит, удалять его нельзя.
- Мьютекс ничего не возвращает. Каждый, кто дождался очереди, должен сам перепроверить кэш. Забыли перепроверку или значение в кэш не легло (Redis моргнул, запись упала), и все двести запросов всё равно уйдут в БД - только теперь по очереди, а для клиентов это даже хуже залпа. Singleflight раздаёт готовый ответ, забыть тут нечего.
- Ждать мьютекс с таймаутом или по контексту нельзя:
Lock()не умеет отменяться. У singleflight для этого естьDoChan, он возвращает канал.
Если совсем коротко, singleflight устроен как «мьютекс на ключ плюс место, куда лидер кладёт ответ для всех ждущих», и в нём аккуратно решено, сколько живёт эта пара.
import "golang.org/x/sync/singleflight"
type Repo struct {
rdb *redis.Client
db *sql.DB
sf singleflight.Group // один на репозиторий, не создавать на каждый запрос
metrics *repoMetrics // счётчики Prometheus
}
func (r *Repo) GetUser(ctx context.Context, id int64) (User, error) {
key := fmt.Sprintf("user:%d", id)
// 1. Быстрый путь: при попадании в кэш singleflight не нужен.
if b, err := r.rdb.Get(ctx, key).Bytes(); err == nil {
return decodeUser(b)
} else if !errors.Is(err, redis.Nil) {
slog.Warn("cache get failed", "err", err) // ошибка кэша == промах
}
// 2. Медленный путь: только один поход в БД на ключ.
v, err, shared := r.sf.Do(key, func() (any, error) {
// Внутрь не передаём ctx конкретного запроса, см. ловушки ниже.
cctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
u, err := r.loadUserFromDB(cctx, id)
if err != nil {
return nil, err
}
b, _ := encodeUser(u)
ttl := 5*time.Minute + time.Duration(rand.Int63n(int64(60*time.Second))) // jitter
if err := r.rdb.Set(cctx, key, b, ttl).Err(); err != nil {
slog.Warn("cache set failed", "err", err) // не роняем запрос из-за кэша
}
return u, nil
})
if shared {
r.metrics.SingleflightShared.Inc() // по этой метрике видно силу stampede
}
if err != nil {
return User{}, err
}
return v.(User), nil
}
Как singleflight устроен внутри
Реализация занимает примерно сто строк, и внутри всего две вещи: мапа под мьютексом и WaitGroup на каждый ключ.
type call struct {
wg sync.WaitGroup // на нём ждут все дубликаты
val any // результат лидера
err error
dups int // сколько вызовов схлопнулось (отсюда shared)
}
type Group struct {
mu sync.Mutex // защищает только мапу, не вызов fn
m map[string]*call
}
func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok { // полёт уже идёт, присоединяемся
c.dups++
g.mu.Unlock()
c.wg.Wait() // ждём, пока лидер закончит
return c.val, c.err, true
}
c := new(call) // мы лидер
c.wg.Add(1) // поднимаем, пока держим мьютекс
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn() // тяжёлая работа вне мьютекса
g.mu.Lock()
delete(g.m, key) // ключ убираем после вызова
g.mu.Unlock()
c.wg.Done() // будим всех ждущих
return c.val, c.err, c.dups > 0
}
Три момента, которые стоит проговорить вслух:
- Мьютекс защищает только мапу, а не выполнение
fn. Иначе конструкция сериализовала бы запросы к разным ключам и стала бы хуже, чем ничего. wg.Add(1)вызывается доg.mu.Unlock(). Если поднять счётчик после, присоединившийся дубликат может позватьWait()на нулевом WaitGroup и пройти сквозь него, прочитав пустойc.val.- Ключ удаляется из мапы после вызова. Значит, singleflight не кэширует: как только лидер завершился, следующий вызов снова пойдёт в БД. Это дедупликатор конкурентных вызовов, и за слой кэша его часто принимают по ошибке.
Настоящий код в x/sync сверх этого обрабатывает панику (её оборачивают в
panicError и пробрасывают всем ждущим, чтобы они не зависли навсегда), корректно
обрабатывает runtime.Goexit и держит список каналов chans для DoChan.
DoChan и Forget
Do блокирует, и в этом его главная проблема: ждущая горутина не может бросить
ожидание по своему контексту. DoChan возвращает канал, а его уже можно слушать в
select вместе с ctx.Done():
ch := r.sf.DoChan(key, func() (any, error) { return r.loadUserFromDB(bgCtx, id) })
select {
case res := <-ch:
if res.Err != nil {
return User{}, res.Err
}
return res.Val.(User), nil
case <-ctx.Done():
// Наш HTTP-запрос отменили. Лидер доработает в фоне, и остальные
// ждущие получат результат без второго похода в БД.
return User{}, ctx.Err()
}
Forget(key) досрочно убирает ключ из мапы: следующий вызов не присоединится к
текущему полёту, а начнёт свой. Нужен он в двух случаях: лидер завис, и ты не хочешь, чтобы
новые запросы унаследовали его зависание, или результат лидера заведомо устарел (например,
пока он работал, пришла инвалидация). Обычно из этого делают «одноразовый» singleflight:
v, err, _ := g.Do(key, func() (any, error) {
// Отпускаем ключ через 100 мс после старта: те, кто пришёл позже,
// начнут свой полёт, а не будут ждать зависшего лидера.
t := time.AfterFunc(100*time.Millisecond, func() { g.Forget(key) })
defer t.Stop() // иначе таймер отпустит уже следующий полёт
return load(key)
})
- Общая ошибка на всех. Если лидер получил таймаут, ошибку разом получат все 200 ждущих - залп в БД ты убрал, а получил залп 500-х. Лечат ретраем на уровне вызывающего и коротким таймаутом у лидера.
- Контекст лидера решает за всех. Если внутрь
fnпередатьctxпервого запроса и этот клиент отвалится, отменится работа для всех ждущих. Внутрь передают независимый контекст с собственным таймаутом. - Залипание.
Doждёт лидера без ограничения по времени. Один зависший поход в БД без таймаута - и в ожидании копятся все запросы по этому ключу. СпасаютDoChan+selectилиForgetпо таймеру. - Общий мутируемый результат. Все ждущие получают одно и то же значение
any. Если это указатель на структуру или слайс и кто-то его поменяет, поменяется у всех. Возвращай значение или неизменяемую копию.
Об одном ограничении спрашивают почти всегда: singleflight работает внутри одного процесса. При 20 подах 200 запросов схлопнутся до 20, а не до одного. Обычно этого хватает. Если нет, сверху добавляют распределённую блокировку или берут приёмы 3 и 5, которые вообще не дают ключу истечь.
3. Вероятностное раннее обновление (XFetch)
После singleflight одно остаётся нерешённым: кто-то всё равно ждёт. Лидер честно стоит свои 40 мс в БД, и все, кто к нему присоединился, стоят те же 40 мс. На горячем ключе это значит, что раз в TTL сотня пользователей получает ответ в сотню раз медленнее обычного: залпа в БД уже нет, а провал в p99 есть. И чем дороже пересчёт, тем провал глубже. XFetch убирает как раз его.
Идея в том, чтобы не ждать истечения, а обновлять значение заранее и вероятностно. Пока ключ ещё жив, каждый читатель сам бросает монетку: обновить кэш прямо сейчас или отдать то, что есть. Чем ближе истечение и чем дороже обошёлся прошлый пересчёт, тем чаще монетка выпадает в пользу обновления. В итоге обновляет кто-то один и заранее, а остальные по-прежнему получают попадание. Момент, когда ключа нет, просто не наступает - залп невозможен по построению, и сдерживать его не нужно.
Приём описан в работе Vattani, Chierichetti, Lowenstein «Optimal Probabilistic Cache Stampede
Prevention» (VLDB 2015). Вместе со значением хранят delta - сколько заняло его
вычисление - и абсолютный момент истечения expiry. Обновление запускают, если
now - delta * beta * ln(rand()) >= expiry
где rand() ~ U(0,1], ln(rand()) ≤ 0, beta > 0 (обычно 1)
Читается так: -ln(rand()) даёт экспоненциальную случайную величину со средним 1.
Её умножают на стоимость пересчёта, и чем дороже был пересчёт, тем раньше и охотнее начинается
обновление. beta крутит агрессивность: при значении больше единицы обновляемся
раньше и чаще (меньше промахов, больше фоновой нагрузки), при меньшем - позже.
type entry struct {
Value []byte
Delta time.Duration // сколько занял пересчёт в прошлый раз
Expiry time.Time // абсолютный момент истечения
}
// beta = 1.0 по умолчанию
func shouldRefreshEarly(e entry, beta float64) bool {
// -math.Log(u) для u из (0,1] распределён экспоненциально
u := rand.Float64()
if u == 0 {
u = math.SmallestNonzeroFloat64
}
gap := time.Duration(float64(e.Delta) * beta * -math.Log(u))
return time.Now().Add(gap).After(e.Expiry)
}
func (r *Repo) Get(ctx context.Context, id int64) (User, error) {
e, ok := r.readEntry(ctx, id)
if ok && !shouldRefreshEarly(e, 1.0) {
return decodeUser(e.Value) // обычное попадание
}
if ok {
// Значение ещё живое: обновляем в фоне и отвечаем сразу, латентность не страдает.
go r.refresh(context.WithoutCancel(ctx), id)
return decodeUser(e.Value)
}
return r.loadAndStore(ctx, id) // настоящий промах
}
Из всех приёмов только XFetch убирает stampede без блокировок и без координации между
подами: каждый читатель решает локально, а «залпа» не бывает, потому что вероятность
срабатывания мала и растёт плавно. К тому же он сам подстраивается под цену: дешёвые ключи
обновляются впритык к истечению, дорогие - сильно заранее. Минус в том, что нужен свой формат
значения (value + delta + expiry) и обязательный go refresh без привязки к
контексту запроса.
4. Jitter в TTL
Самое дешёвое, что можно сделать, и почти всегда - первое. Одинаковый TTL синхронизирует истечение: прогрели тысячу ключей за секунду, и ровно через 5 минут тысяча ключей истечёт за секунду. Разброс эту синхронизацию ломает:
// По умолчанию хватает ±20 %
func jitter(base time.Duration) time.Duration {
spread := int64(base / 5) // 20 %
return base + time.Duration(rand.Int63n(2*spread)-spread)
}
_ = rdb.Set(ctx, key, val, jitter(5*time.Minute)).Err()
Jitter не решает проблему одного горячего ключа. Он лечит массовое синхронное истечение (avalanche). Эти вещи часто путают, а они разные: против breakdown нужен singleflight, против avalanche - jitter. Отвечать надо обоими.
5. «Вечный» ключ с фоновым обновлением
Радикальный вариант: значение никогда не истекает, а за свежесть отвечает отдельный механизм. Делают это двумя способами.
- Логический TTL внутри значения. В Redis ключ живёт без TTL (или с огромным), а внутри
значения лежит поле
fresh_until. Если читатель видит, что логический срок вышел, он отдаёт устаревшее значение сразу и запускает фоновое обновление под singleflight. БД не ждёт никто и никогда: промахов по этому ключу просто нет. Цена в том, что читатели какое-то время сознательно получают устаревшие данные (это тот самый паттернstale-while-revalidateиз HTTP). - Внешний прогреватель. Отдельный воркер или cron обновляет список известных горячих ключей по расписанию. Это просто и предсказуемо, но заранее надо знать, что горячее, а БД получает постоянную фоновую нагрузку, в том числе от ключей, которые уже никто не читает.
Пропадает главная страховка - TTL. Если обновлятор сломался (упал воркер, изменилась схема, ключ выпал из списка горячих), кэш будет отдавать устаревшее значение бесконечно, и узнаешь ты об этом из жалоб, а не из метрик. Поэтому «вечному» ключу нужен аварийный физический TTL, сильно больше логического (например, логический 1 минута, физический 1 час), и алерт на возраст значения.
Сравнение подходов
| Приём | Что даёт | Промах для клиента | Область действия | Сложность | Когда брать |
|---|---|---|---|---|---|
| Jitter TTL | убирает синхронное истечение пачек | остаётся | - | тривиальная | всегда, по умолчанию для каждого ключа |
| singleflight | 1 поход в БД на ключ на процесс | остаётся, ждут все | процесс | низкая | по умолчанию во всех Go-сервисах |
| Распределённая блокировка | 1 поход в БД на весь кластер | остаётся, ждут или получают stale | кластер | средняя | очень дорогой пересчёт (отчёт, ML-фича) или много подов |
| XFetch | обновление до истечения, залпа нет по построению | почти исчезает | кластер (без координации) | средняя | дорогой пересчёт + высокий и ровный RPS по ключу |
| Вечный ключ + фон stale-while-revalidate |
клиент никогда не ждёт БД | нет | кластер | высокая | витрины, главная страница, конфиги - где stale допустим, а задержка нет |
| Прогрев | убирает холодный старт | нет для прогретых | кластер | средняя | деплой, рестарт Redis, известный список горячих ключей |
Не одним словом. «Базово - jitter в TTL у всех ключей и singleflight в репозитории: это закрывает и синхронное истечение, и залп внутри пода. Для по-настоящему дорогих значений беру stale-while-revalidate: отдаём устаревшее и обновляем фоном, клиент БД не ждёт никогда. И отдельно защищаю саму БД ограничителем конкурентности, чтобы даже при полном сбросе кэша к ней шло не больше N параллельных запросов, а остальные быстро отваливались.» Последний пункт называют редко, хотя он важнее остальных: он превращает отказ в деградацию.
Горячие ключи
Горячим называют ключ, на который приходится непропорционально большая доля обращений: баннер на главной, товар из рассылки, конфиг фичефлагов, счётчик лайков у поста блогера-миллионника. На одиночном Redis это просто нагрузка, а в кластере уже архитектурная проблема.
Чем плох в кластере
В Redis Cluster ключ детерминированно попадает в один хеш-слот
(slot = CRC16(key) mod 16384), слот живёт на одной ноде, а нода
обрабатывает команды одним потоком. Значит:
- Масштабирование не работает. Добавили нод - трафик по горячему ключу не размазался ни на процент: как шёл в один поток, так и идёт.
- Перекос загрузки. Одна нода на 100 % CPU, остальные на 5 %. По средним метрикам кластер «недогружен», хотя на деле он в отказе. Классическая ловушка мониторинга: смотреть надо p99 по каждой ноде отдельно, а не среднее по кластеру.
- Сеть. Значение в 20 КБ при 50 000 запросах в секунду даёт гигабайт в секунду с одного сетевого интерфейса. В NIC упереться проще, чем в CPU.
- Взрывной эффект при потере. Если горячий ключ истёк или вытеснен, получаешь тот самый stampede, только в максимальном масштабе.
Как размазывать
- Локальный кэш поверх Redis - первое и лучшее. Держим значение в памяти процесса
1–3 секунды. При 20 подах и TTL в секунду до Redis доходит 20 запросов в секунду вместо
50 000, то есть в тысячи раз меньше. Платишь окном рассинхрона длиной с локальный TTL, но
для баннера или фичефлага это допустимо ровно всегда. Взять можно
ristretto,otter,hashicorp/golang-lruили просто мапу подRWMutexс полем времени. - Чтение с реплик. В Redis Cluster клиент может читать со слейвов (
READONLY, в go-redis этоRouteRandomly/ReadOnly: true). Нагрузка на чтение делится на число реплик слота. Реплика асинхронна, так что можно прочитать чуть устаревшее значение, но для горячего кэша это обычно не проблема. - Реплики ключа с суффиксом. Пишем N копий (
banner:main#0…banner:main#15), читаем случайную. Разные суффиксы дают разные слоты и разные ноды. Это работает всегда, но платить придётся памятью (×N) и инвалидацией: удалять надо все N копий, пайплайном по нодам: Lua-скрипт в кластере работает только с ключами одного слота. И осторожно: если сделать наоборот и загнать копии в один слот через hash tag, приём теряет смысл целиком. - Client-side caching Redis 6+ (
CLIENT TRACKING, RESP3): сервер сам присылает инвалидацию по ключам, которые клиент читал. По сути это локальный кэш с автоматической инвалидацией - самый аккуратный вариант, если клиент и версия сервера позволяют.
Как найти горячий ключ
redis-cli --hotkeysработает только приmaxmemory-policyсемейства LFU: он опирается на счётчик обращений в объекте.redis-cli monitorпоказывает все команды в реальном времени. Мощно и опасно: сильно грузит сервер, так что включают его на секунды и только под нагрузкой, а оставлять нельзя.OBJECT FREQ keyпри LFU отдаёт счётчик частоты конкретного ключа.- Лучше всего работают метрики на стороне приложения: считай обращения по префиксу ключа прямо в репозитории. От Redis ничего не требуется, всё видно в Grafana, и работает это постоянно.
Большие ключи (big keys)
Большим называют ключ со строкой в мегабайты или с коллекцией (hash, list, set, zset) на сотни тысяч элементов. Опасен он из-за однопоточности: пока идёт любая операция над большим ключом, сервер больше никого не обслуживает.
Чем именно плохи
- Блокировка на чтении.
HGETALLпо хешу из миллиона полей илиLRANGE 0 -1стоят O(N) в одном потоке, а потом сервер ещё собирает ответ на десятки мегабайт. Остальные клиенты всё это время ждут: head-of-line blocking на весь инстанс. - Блокировка на удалении. Самое неочевидное.
DELбольшой коллекции освобождает память поэлементно, и это тоже O(N) в главном потоке. Ключ, который «просто удалили», может остановить сервер на сотни миллисекунд. Истечение TTL у большого ключа делает то же самое, причём удаление случится само и в самый неудобный момент. - Сеть и латентность. Значение в 10 МБ при 100 rps даёт гигабайт в секунду. А на стороне Go такой ответ означает большой буфер в куче, работу GC и рост p99 у всего сервиса.
- Проблемы в кластере. При классическом решардинге ключи переезжают по одному и атомарно,
поэтому большой ключ на время переноса блокирует и источник, и приёмник (с Redis 8.4
слот можно перенести целиком, командой
CLUSTER MIGRATION). Ребалансировка кластера с большими ключами превращается в серию микро-аварий. - Перекос памяти и вытеснение. С ключом на гигабайт шард на 4 ГБ упрётся в
maxmemoryраньше остальных и начнёт вытеснять чужие полезные данные. - Персистентность. Большой ключ раздувает RDB-снимок и AOF-перезапись, а копирование
при записи во время
BGSAVEможет удвоить потребление памяти.
Как искать
# 1. Обзорный проход: SCAN изнутри, безопасно для прода, но всё же I/O.
# Показывает самый большой ключ каждого типа. Учти: у коллекций --bigkeys
# считает число элементов, а не байты (у строк - байты).
redis-cli --bigkeys
# 2. Оценка в байтах (Redis 5+). Точнее, но дороже: считает реальный размер.
redis-cli --memkeys
# 3. Точный размер одного ключа со всеми накладными расходами структуры.
redis-cli MEMORY USAGE user:42:feed
redis-cli MEMORY USAGE user:42:feed SAMPLES 0 # 0 = не сэмплировать, точно
# 4. Число элементов, не вытягивая значение: O(1) для всех типов.
redis-cli STRLEN key ; redis-cli HLEN key ; redis-cli LLEN key
redis-cli SCARD key ; redis-cli ZCARD key ; redis-cli XLEN key
# 5. Свой обход: SCAN курсором + MEMORY USAGE. Даёт полную картину,
# не блокирует сервер, можно гонять по расписанию из джобы.
redis-cli --scan --pattern 'user:*' | head -100000
# 6. Оффлайн-анализ дампа: никакой нагрузки на прод вообще.
rdb --command memory dump.rdb --bytes 10240 # redis-rdb-tools, дампы до Redis 6.x
На «как найдёте большой ключ» многие отвечают KEYS * - и на этом разговор
заканчивается. В правильном ответе обязательно звучит SCAN (курсорный обход, не
блокирует) и уточнение, что --bigkeys у коллекций измеряет количество
элементов, а не байты; байты покажут --memkeys или MEMORY USAGE. Плюсом
будет оффлайн-анализ RDB, который вообще не трогает продовый инстанс.
Что делать
- Удалять через
UNLINK, а неDEL.UNLINKмгновенно отцепляет ключ от пространства имён, а память освобождает фоновый поток (lazy free). Для больших коллекций это разница между «сервер встал на 300 мс» и «не заметили». Заодно включаютlazyfree-lazy-expire yes,lazyfree-lazy-eviction yesиlazyfree-lazy-server-del yes, чтобы то же самое происходило при истечении TTL и при вытеснении. - Не читать целиком. Вместо
HGETALLбериHMGETнужных полей илиHSCANпорциями, вместоLRANGE 0 -1листай постранично черезLRANGE start stop, а вместоSMEMBERSиспользуйSSCANилиSISMEMBER, если нужен только факт наличия. - Разбивать на части (шардировать ключ). Хеш из миллиона полей превращают в 100 хешей
по 10 000:
obj:{42}:part:0…obj:{42}:part:99, номер части считают какhash(field) mod 100. Фигурные скобки здесь задают hash tag: он держит все части в одном слоте, если над ними нужны мультиключевые операции. Если же цель в том, чтобы размазать нагрузку, hash tag не ставят. - Ограничивать рост на входе. Список обрезают:
LPUSH+LTRIM key 0 999одной транзакцией. Stream ограничивают черезXADD ... MAXLEN ~ 10000. Sorted set периодически чистятZREMRANGEBYRANK. Это дешевле, чем потом чинить разросшийся ключ. - Пересмотреть модель. Большой ключ часто выдаёт, что в Redis положили то, что должно лежать в БД или в объектном хранилище: полный фид пользователя, вложение, сериализованный отчёт. В кэше держат идентификаторы и небольшие горячие срезы, а не всё целиком.
Практические пороги, которые не стыдно произнести на собеседовании: для строки до 100 КБ (выше уже повод разобраться, а выше 1 МБ почти всегда ошибка модели), для коллекции до 5–10 тысяч элементов. И правило: если операция над ключом принципиально O(N), а N растёт вместе с пользовательскими данными, это будущая авария, какими бы ни были текущие размеры.
Вопросы
8Механика
От истечения ключа до момента, когда первый читатель положит новое значение, проходит время похода в БД, скажем 40 мс. Всё, что прилетело в это окно, тоже получит промах: в кэше по-прежнему пусто. При 500 rps по ключу это 20 лишних запросов, при 5000 rps - 200. Тут включается обратная связь: БД под залпом отвечает медленнее, окно растёт, в него попадает больше запросов, и залп становится больше. Потом пул соединений исчерпывается, и отказ идёт каскадом.
Когда случается
- Истечение одного популярного ключа (cache breakdown).
- Холодный старт: подняли под, перезапустили Redis, восстановились после аварии.
- Деплой со сменой префикса версии ключей: по сути он мгновенно сбрасывает весь кэш.
- Одинаковый TTL после массового прогрева: тысяча ключей истекает в одну секунду (avalanche).
- Failover Redis на пустую или отставшую реплику.
- Память упёрлась в
maxmemory, и политика начинает вытеснять пачками.
«Stampede бьёт тем сильнее, чем полезнее был ключ: холодные ключи дают один лишний запрос, горячие - тысячу. Поэтому защиту вешают не везде подряд, а на дорогие и часто читаемые значения, и обязательно ставят ограничитель конкурентности к БД, чтобы даже при полном сбросе кэша к ней шло не больше N параллельных запросов.»
| Приём | Что даёт | Область | Когда брать |
|---|---|---|---|
| Jitter TTL | убирает синхронное истечение пачек | - | всегда, для каждого ключа |
| singleflight | 1 запрос в БД на ключ на процесс | процесс | по умолчанию |
| Распределённая блокировка | 1 запрос на весь кластер | кластер | очень дорогой пересчёт, много подов |
| XFetch | обновление до истечения, залпа нет | кластер | дорогой пересчёт + ровный высокий RPS |
| Вечный ключ + фон | клиент никогда не ждёт БД | кластер | витрины и конфиги, где stale допустим |
Порядок внедрения
- Jitter в TTL. Одна строка кода ломает синхронное истечение. Ставят сразу и везде.
- singleflight в репозитории. Ещё десять строк схлопывают залп внутри пода. При 20 подах 200 запросов превращаются в 20 - этого БД уже не замечает.
- Ограничитель конкурентности к БД (семафор на N слотов или размер пула соединений как естественный ограничитель) плюс circuit breaker. Только этот механизм работает и при полном сбросе кэша, и при недоступном Redis.
- И только если пересчёт действительно дорогой, берут stale-while-revalidate или XFetch.
Назвать только singleflight. Он внутрипроцессный: при сотне подов залп сожмётся до сотни запросов, по одному с пода, но не исчезнет, а при полном сбросе кэша (рестарт Redis, смена префикса) не поможет вообще - там промахи по разным ключам, схлопывать нечего. В сильном ответе всегда есть второй уровень: защита самой БД.
key -> *call под мьютексом плюс
sync.WaitGroup в каждом call. Первый вызов становится лидером и
выполняет функцию, остальные находят запись в мапе и блокируются на wg.Wait().
После завершения лидер удаляет ключ из мапы и делает wg.Done() - все ждущие
получают один и тот же результат.type call struct {
wg sync.WaitGroup
val any
err error
dups int // сколько вызовов схлопнулось
}
type Group struct {
mu sync.Mutex // защищает только мапу
m map[string]*call
}
func (g *Group) Do(key string, fn func() (any, error)) (any, error, bool) {
g.mu.Lock()
if g.m == nil { g.m = make(map[string]*call) }
if c, ok := g.m[key]; ok { // полёт уже идёт
c.dups++
g.mu.Unlock()
c.wg.Wait() // ждём лидера
return c.val, c.err, true
}
c := new(call) // мы лидер
c.wg.Add(1) // пока мьютекс ещё взят
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn() // работа вне мьютекса
g.mu.Lock()
delete(g.m, key) // ключ снимаем после вызова
g.mu.Unlock()
c.wg.Done() // будим всех
return c.val, c.err, c.dups > 0
}
Три детали, которые проверяют
- Почему мьютекс не держат во время
fn. Иначе запросы к разным ключам сериализовались бы друг с другом, и конструкция стала бы хуже, чем её отсутствие. Мьютекс охраняет только доступ к мапе, а это микросекунды. - Почему
wg.Add(1)доUnlock. Если поднять счётчик после, присоединившийся дубликат успеет позватьWait()на нулевом WaitGroup, пройти сквозь него и прочитать пустойval. Это гонка на пустом результате. - Почему
deleteпосле вызова, а не после раздачи. Singleflight не кэш: как только лидер закончил, следующий вызов должен начать новый полёт. Схлопываются только конкурентные вызовы.
Настоящая реализация в golang.org/x/sync/singleflight вдобавок ловит панику
(заворачивает её в panicError и пробрасывает всем ждущим, иначе они зависли бы
навсегда), обрабатывает runtime.Goexit и держит слайс каналов для DoChan.
shared bool равен dups > 0 и показывает, что результат
достался кому-то кроме лидера. Из него выходит отличная метрика: доля shared == true
и есть измеренная сила stampede в твоём сервисе. Если она заметная, защиту стоит усиливать,
если нулевая - singleflight можно было и не ставить.
Do ждёт без таймаута и может залипнуть; общий результат мутируемый.
DoChan даёт select с ctx.Done(), Forget
досрочно отпускает ключ.Четыре ловушки
- Общая ошибка. Лидер получил таймаут - ошибку получают все 200 ждущих. Вместо залпа в БД клиент получает залп 500-х. Смягчают коротким таймаутом лидера и ретраем на уровне вызывающего.
- Контекст лидера. Если внутрь
fnпередатьctxпервого запроса, а этот клиент отвалится,ctxотменится, и работа сорвётся у всех ждущих, хотя их запросы живы. Внутрь передают независимый контекст (context.WithoutCancelили новыйcontext.Background()с собственным таймаутом), а отмену обрабатывают снаружи черезDoChan. - Залипание.
Doждёт лидера сколько угодно. Один поход в БД без таймаута - и по этому ключу копятся все запросы сервиса. - Мутируемый общий результат. Все ждущие получают одно и то же значение
any. Если это указатель или слайс и кто-то его изменит, изменится у всех. Возвращай значение или копию.
DoChan - управляемое ожидание
ch := g.DoChan(key, func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return load(ctx, key)
})
select {
case res := <-ch:
if res.Err != nil { return zero, res.Err }
return res.Val.(User), nil
case <-ctx.Done():
// клиент ушёл; лидер доработает, результат достанется остальным
return zero, ctx.Err()
}
Forget - досрочно отпустить ключ
Forget(key) убирает запись из мапы, и следующий вызов не присоединится к
текущему полёту, а начнёт свой. Нужен в двух случаях: лидер завис и не надо, чтобы новые
запросы наследовали его зависание; или пока лидер работал, пришла инвалидация, и его
результат уже устарел. Обычно ключ отпускают через фиксированное время после старта:
v, err, _ := g.Do(key, func() (any, error) {
t := time.AfterFunc(100*time.Millisecond, func() { g.Forget(key) })
defer t.Stop() // лидер уже вернулся - чужой полёт не трогаем
return load(key)
})
Singleflight живёт в памяти одного процесса. Двадцать подов дают двадцать независимых лидеров, то есть двадцать запросов в БД вместо одного. Чтобы схлопнуть «до одного на кластер», нужна распределённая блокировка на Redis, а с ней сразу всплывают её проблемы: TTL, зависший держатель, лишний round-trip. Обычно 20 запросов вместо 200 уже достаточно, и это стоит прямо сказать.
Формула
обновляем, если: now - delta * beta * ln(rand()) >= expiry
delta - сколько занял прошлый пересчёт значения
expiry - абсолютный момент истечения ключа
rand() - равномерное из (0,1], значит ln(rand()) ≤ 0
beta - агрессивность, обычно 1.0
Величина -ln(rand()) распределена экспоненциально со средним 1. Если умножить её на
delta, получится случайное «опережение»: дешёвое значение обновляют впритык к
истечению, дорогое - сильно заранее. При beta > 1 обновляемся раньше и чаще
(меньше промахов, больше фоновой нагрузки на БД), при beta < 1 наоборот.
Формула взята из статьи Vattani, Chierichetti, Lowenstein, VLDB 2015.
type entry struct {
Value []byte
Delta time.Duration
Expiry time.Time
}
func shouldRefreshEarly(e entry, beta float64) bool {
u := rand.Float64()
if u == 0 { u = math.SmallestNonzeroFloat64 }
gap := time.Duration(float64(e.Delta) * beta * -math.Log(u))
return time.Now().Add(gap).After(e.Expiry)
}
// В обработчике: если сработало и значение ещё живое, обновляем в фоне,
// а клиенту сразу отдаём то, что есть.
if ok && shouldRefreshEarly(e, 1.0) {
go r.refresh(context.WithoutCancel(ctx), id)
}
return decode(e.Value)
Плюсы и минусы
- Плюс: работает без координации между процессами и без блокировок, каждый читатель решает сам. Единственный приём, который одинаково хорош на одном поде и на ста.
- Плюс: чувствителен к цене автоматически. Не надо руками подбирать «за сколько до истечения обновлять» для каждого ключа.
- Минус: нужен свой формат значения (value + delta + expiry), то есть обёртка над сериализацией.
- Минус: обновление должно уходить в фон и не зависеть от контекста запроса, иначе ты просто переложил ожидание БД на случайного невезучего клиента.
Не как «стандартное решение», а как ответ на уточняющий вопрос «а если подов сто и singleflight мало». Формулу наизусть знать не обязательно, хватит объяснить идею: вероятность обновления растёт по мере приближения к TTL и масштабируется стоимостью пересчёта. Этим XFetch и отличается от наивного «обновлять за 10 % до истечения», где все поды сработают одновременно и ты получишь тот же залп, только раньше.
Механика
Прогрели кэш скриптом: тысяча ключей записана за секунду с TTL 5 минут. Ровно через 5 минут тысяча ключей истекает за ту же секунду, и вся нагрузка идёт в БД одновременно. Хуже того, после восстановления они снова запишутся почти одновременно - и цикл повторится ещё через 5 минут. На графике нагрузки БД получается устойчивая пила с периодом TTL, и её потом долго ищут.
// Рабочий диапазон: ±20 %
func jitter(base time.Duration) time.Duration {
spread := int64(base / 5)
return base + time.Duration(rand.Int63n(2*spread)-spread)
}
_ = rdb.Set(ctx, key, val, jitter(5*time.Minute)).Err()
Чего jitter НЕ решает
Он не помогает против stampede на одном ключе: у одного ключа один момент истечения, разбрасывать нечего. Против breakdown нужен singleflight или раннее обновление. Это две разные проблемы с разными решениями, и на собеседовании ценят, когда кандидат их разделяет:
| Проблема | Масштаб | Решение |
|---|---|---|
| Breakdown / dog-pile | один горячий ключ | singleflight, блокировка, XFetch, stale-while-revalidate |
| Avalanche | много ключей сразу | jitter TTL, прогрев, ограничитель конкурентности к БД |
Jitter пригодится и за пределами TTL. Та же логика работает в интервалах фоновых задач,
в ретраях (exponential backoff with jitter) и в периодах health-check. Если N
клиентов выполняют действие «через одинаковый интервал», рано или поздно они
синхронизируются и создают залп. Назовёшь этот общий принцип, и ответ станет сильнее.
Почему масштабирование не помогает
slot = CRC16(key) mod 16384. Ключ детерминированно попадает в один слот, слот
живёт на одной ноде, нода однопоточная. Утроили кластер, а трафик по горячему ключу не
сдвинулся ни на процент. Есть и ещё одна ловушка: средний CPU по кластеру выглядит низким,
хотя одна нода в отказе. Смотреть надо p99 и CPU по каждой ноде отдельно.
Способы, по порядку применения
- Локальный кэш поверх Redis (1–3 секунды). Его почти всегда достаточно, и почти всегда с него правильно начинать. При 20 подах и TTL в секунду 50 000 rps превращаются в 20 rps до Redis. Платишь окном рассинхрона длиной с локальный TTL.
- Чтение с реплик. В кластере это
READONLY, в go-redisReadOnly: trueиRouteRandomly. Нагрузка делится на число реплик, но данные бывают чуть более устаревшими (репликация асинхронная). - Реплики ключа с суффиксом. Пишем N копий
key#0..key#N-1, читаем случайную: разные суффиксы дают разные слоты. Работает всегда, но стоит памяти ×N и инвалидации всех копий (пайплайном по нодам: одним Lua-скриптом нельзя, копии в разных слотах). Hash tag здесь ставить нельзя: он загонит все копии обратно в один слот и убьёт смысл приёма. - Client-side caching (
CLIENT TRACKING, RESP3): локальный кэш, который сервер сам инвалидирует. Самый аккуратный вариант, если версия и клиент позволяют.
Как обнаружить
redis-cli --hotkeysработает только приmaxmemory-policyсемейства LFU (опирается на счётчик обращений в объекте).OBJECT FREQ keyпри LFU показывает счётчик частоты конкретного ключа.redis-cli monitorвыводит все команды в реальном времени, но сильно грузит сервер, так что включают его на секунды.- Лучше всего подходят метрики в приложении: считай обращения по префиксу ключа прямо в репозитории. Работает постоянно и ничего не требует от Redis.
Горячий ключ на чтение размазывается репликами. Горячий ключ на запись
(счётчик просмотров вирусного поста) репликами не лечится: писать надо в мастера слота.
Тут шардируют сам счётчик: INCR views:42:shard:N со случайным N от 0 до 15, а сумму
собирают при чтении одним пайплайном. Это готовый ответ на уточняющий
вопрос, который задают почти всегда.
DEL и даже истечение TTL. Ищут через
--bigkeys/--memkeys/MEMORY USAGE, лечат
UNLINK, порционным чтением и разбиением на части.Чем плох
- Чтение целиком блокирует всех.
HGETALLпо миллиону полей илиLRANGE 0 -1занимают единственный поток на O(N), а потом ещё собирается ответ на десятки мегабайт. Итог: head-of-line blocking для всего инстанса. - Удаление тоже блокирует. Самый неочевидный пункт:
DELбольшой коллекции освобождает память поэлементно, это O(N) в главном потоке. То же делает истечение TTL, причём само и в непредсказуемый момент. - Сеть и GC. 10 МБ × 100 rps = гигабайт в секунду; на стороне Go это большая аллокация на каждый запрос и рост p99 у всего сервиса.
- Кластер. Миграция слота переносит ключи по одному и атомарно: большой ключ блокирует и источник, и приёмник на время переноса. Ребалансировка превращается в серию микро-аварий.
- Перекос памяти. Гигабайтный ключ в шарде на 4 ГБ заставит этот шард упереться в
maxmemoryраньше остальных и вытеснять чужие полезные данные. - Персистентность. Раздувает RDB и AOF-перезапись, увеличивает copy-on-write во
время
BGSAVE.
Как найти
redis-cli --bigkeys # обход SCAN-ом; у коллекций элементы, не байты
redis-cli --memkeys # оценка в байтах (Redis 5+), дороже
redis-cli MEMORY USAGE key SAMPLES 0 # точный размер одного ключа
redis-cli STRLEN/HLEN/LLEN/SCARD/ZCARD key # число элементов, O(1)
redis-cli --scan --pattern 'user:*' # курсорный обход, не блокирует
rdb --command memory dump.rdb --bytes 10240 # оффлайн по дампу (до Redis 6.x), без нагрузки
И чего делать нельзя, так это KEYS *. Он проходит всё пространство
ключей в главном потоке, и на миллионе ключей сервер полностью встаёт на сотню миллисекунд и дольше.
Что делать
UNLINKвместоDEL. Отцепляет ключ мгновенно, память освобождает фоновый поток. Заодно включаютlazyfree-lazy-expire,lazyfree-lazy-eviction,lazyfree-lazy-server-del, чтобы то же происходило при истечении и вытеснении.- Не читать целиком:
HMGET/HSCANвместоHGETALL, постраничныйLRANGEвместоLRANGE 0 -1,SISMEMBER/SSCANвместоSMEMBERS. - Разбить на части:
obj:{42}:part:N, гдеN = hash(field) mod 100. Фигурные скобки ставят как hash tag, если части должны лежать в одном слоте для мультиключевых операций. - Ограничить рост на входе:
LPUSH+LTRIM key 0 999одной транзакцией,XADD ... MAXLEN ~ 10000, периодическийZREMRANGEBYRANK. - Пересмотреть модель. Часто большой ключ означает, что в кэш положили то, чему место в БД или объектном хранилище. В Redis держат идентификаторы и небольшие горячие срезы.
Строка - до 100 КБ (выше 1 МБ почти всегда ошибка модели), коллекция - до 5–10 тысяч элементов. И общее правило: если операция принципиально O(N), а N растёт от пользовательских данных, это будущая авария независимо от текущих размеров.
Что стоит запомнить
- singleflight схлопывает конкурентные загрузки одного ключа внутри одного процесса.
- Jitter размазывает сроки истечения разных ключей, а не объединяет запросы к одному ключу.
- Redis hot key и big key требуют разных решений: распределение чтений против уменьшения размера/работы одной команды.
Частые ошибки
- Локальный singleflight на каждой реплике не гарантирует один запрос к БД глобально.
- Не ставь одинаковый TTL на большой набор ключей: синхронное истечение создаёт волну промахов.
- Репликация горячего чтения не помогает командам-записям, которые требуют единого значения.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Посчитай локальный fan-out
Три pod одновременно получают промах по одному user ID; в каждом pod 40 HTTP-запросов, и в каждом singleflight выбирает одного лидера. Сколько загрузок БД будет?
// Each of 3 pods has its own singleflight.Group.
result, err, _ := sf.Do(userKey, func() (any, error) {
return db.LoadUser(ctx, userID)
})
Ответ и разбор
Ровно три загрузки: по одной на каждый pod, остальные локальные запросы присоединятся к лидеру своего singleflight. Это лучше 120 загрузок, но не одна глобальная. Для дальнейшего сокращения нужны распределённая координация или короткий общий cache fill; распределённый lock добавляет свои риски и обычно не обязателен для обычного кэша.
Код-ревью
2. Проверь jitter формулу
Автор хотел добавить только положительный jitter от 0 до 20%, но реализовал формулу ниже. Каков фактический диапазон результата? Покажи вариант для симметричного jitter ±20% и укажи ограничения на base.
func jitter(base time.Duration) time.Duration {
spread := int64(base / 5)
return base + time.Duration(rand.Int63n(spread))
}
Ответ и разбор
При base=100ms текущий код возвращает значения от 100ms включительно до 120ms не включая верхнюю границу: это положительный jitter, не симметричный. Для диапазона примерно [80ms,120ms) используй spread := int64(base / 5) и base + time.Duration(rand.Int63n(2*spread)-spread). Проверь, что base>0 и spread>0, иначе Int63n получит неположительный предел; для очень малых duration задай минимум или верни base без jitter.
Применение
3. Ограничь stampede между pod
У ключа TTL 5 минут. Во всех четырёх pod одновременно истекает запись, а база выдерживает не больше 100 параллельных чтений. Предложи минимум два дополняющих друг друга приёма и укажи границы.
Ответ и разбор
Добавь jitter к TTL, чтобы разные ключи не истекали одновременно, и singleflight на каждом pod, чтобы схлопнуть локальные дубликаты. Для конкретного ключа можно использовать stale-while-revalidate или раннее обновление. Локальная координация всё ещё допускает до четырёх загрузок одного ключа, поэтому общую параллельность к БД ограничь отдельно.
Применение
4. Снизь цену big key
HGETALL большого hash на 8 МБ занимает Redis event loop и резко повышает p99. Опиши, как измерить и изменить доступ, если клиенту нужны 3 поля из 20 000.
Ответ и разбор
Измерь размер ключа и latency команды; замени HGETALL выборочным HMGET для трёх известных полей либо раздели запись на меньшие ключи с понятной группировкой. Это уменьшит ответ и синхронную работу. Если клиенту иногда нужны все поля, делай отдельный ограниченный экспортный путь с пагинацией/scan-подобной обработкой.