voidlearn
 

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: он «схлопывает» одновременные одинаковые запросы в один.

Истёк один популярный ключ: что происходит без защиты и с singleflight БЕЗ ЗАЩИТЫ - dog-pile TTL истёк время гор. 1 hit 0.3 мс GET nil SELECT ... 40 мс SET гор. 2 GET nil SELECT ... тот же SET гор. 3 GET nil SELECT ... тот же SET ... и ещё 197 горутин с тем же самым запросом Postgres 200 идентичных SELECT почти разом пул соединений исчерпан, латентность растёт --> лавина С SINGLEFLIGHT TTL истёк время гор. 1 GET nil лидер: SELECT 40 мс SET гор. 2 GET nil wg.Wait() - спит, БД не трогает гор. 3 GET nil wg.Wait() ... 199 горутин ждут на одном WaitGroup, дублей запроса нет результат раздаётся всем ждущим Postgres ровно 1 SELECT вместо 200 Латентность ждущих та же, что у лидера: 40 мс. Singleflight не ускоряет - он убирает дубли работы.
Схлопывание запросов. 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)
})
Четыре ловушки singleflight
  • Общая ошибка на всех. Если лидер получил таймаут, ошибку разом получат все 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, известный список горячих ключей
Как отвечать на «как боретесь со stampede»

Не одним словом. «Базово - 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, только в максимальном масштабе.
Горячий ключ упирается в один поток одной ноды - сколько нод ни добавь БЫЛО: один ключ banner:main 50 000 rps CRC16 mod 16384 slot 3137 нода A CPU 100 % p99 растёт нода B CPU 4 % нода C CPU 5 % Средний CPU по кластеру: 36 % - метрика говорит «всё хорошо». Фактически нода A в отказе, а масштабирование кластера не помогает: ключ один, слот один, поток один. СТАЛО: реплики ключа + локальный кэш banner:main#0…#15 случайный суффикс на каждом чтении нода A CPU ~30 % #3 #7 #10 #14 нода B CPU ~55 % #1 #2 #5 ... нода C CPU ~30 % #0 #4 #8 #13 16 копий --> 16 слотов --> нагрузка размазана, но не поровну. Цена: x16 памяти и инвалидация всех копий (16 UNLINK пайплайном по нодам). Дешевле - локальный кэш на 1-2 секунды: до Redis доходит 1 rps с пода. Порядок выбора: сначала локальный кэш поверх (почти всегда достаточно), затем чтение с реплик, и только потом реплики ключа с суффиксом.
Размазывание горячего ключа. Локальный кэш срезает нагрузку у источника и стоит дёшево. Реплики ключа с суффиксом полностью распределяют его по слотам, но умножают память и усложняют инвалидацию.

Как размазывать

  1. Локальный кэш поверх Redis - первое и лучшее. Держим значение в памяти процесса 1–3 секунды. При 20 подах и TTL в секунду до Redis доходит 20 запросов в секунду вместо 50 000, то есть в тысячи раз меньше. Платишь окном рассинхрона длиной с локальный TTL, но для баннера или фичефлага это допустимо ровно всегда. Взять можно ristretto, otter, hashicorp/golang-lru или просто мапу под RWMutex с полем времени.
  2. Чтение с реплик. В Redis Cluster клиент может читать со слейвов (READONLY, в go-redis это RouteRandomly / ReadOnly: true). Нагрузка на чтение делится на число реплик слота. Реплика асинхронна, так что можно прочитать чуть устаревшее значение, но для горячего кэша это обычно не проблема.
  3. Реплики ключа с суффиксом. Пишем N копий (banner:main#0 … banner:main#15), читаем случайную. Разные суффиксы дают разные слоты и разные ноды. Это работает всегда, но платить придётся памятью (×N) и инвалидацией: удалять надо все N копий, пайплайном по нодам: Lua-скрипт в кластере работает только с ключами одного слота. И осторожно: если сделать наоборот и загнать копии в один слот через hash tag, приём теряет смысл целиком.
  4. 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, который вообще не трогает продовый инстанс.

Что делать

  1. Удалять через UNLINK, а не DEL. UNLINK мгновенно отцепляет ключ от пространства имён, а память освобождает фоновый поток (lazy free). Для больших коллекций это разница между «сервер встал на 300 мс» и «не заметили». Заодно включают lazyfree-lazy-expire yes, lazyfree-lazy-eviction yes и lazyfree-lazy-server-del yes, чтобы то же самое происходило при истечении TTL и при вытеснении.
  2. Не читать целиком. Вместо HGETALL бери HMGET нужных полей или HSCAN порциями, вместо LRANGE 0 -1 листай постранично через LRANGE start stop, а вместо SMEMBERS используй SSCAN или SISMEMBER, если нужен только факт наличия.
  3. Разбивать на части (шардировать ключ). Хеш из миллиона полей превращают в 100 хешей по 10 000: obj:{42}:part:0 … obj:{42}:part:99, номер части считают как hash(field) mod 100. Фигурные скобки здесь задают hash tag: он держит все части в одном слоте, если над ними нужны мультиключевые операции. Если же цель в том, чтобы размазать нагрузку, hash tag не ставят.
  4. Ограничивать рост на входе. Список обрезают: LPUSH + LTRIM key 0 999 одной транзакцией. Stream ограничивают через XADD ... MAXLEN ~ 10000. Sorted set периодически чистят ZREMRANGEBYRANK. Это дешевле, чем потом чинить разросшийся ключ.
  5. Пересмотреть модель. Большой ключ часто выдаёт, что в 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 (по умолчанию в Go), распределённая блокировка (дорогой пересчёт), XFetch (вероятностное раннее обновление), вечный ключ со stale-while-revalidate (клиент не ждёт БД никогда). Плюс отдельно - защита самой БД ограничителем конкурентности.
ПриёмЧто даётОбластьКогда брать
Jitter TTLубирает синхронное истечение пачек- всегда, для каждого ключа
singleflight1 запрос в БД на ключ на процесспроцесс по умолчанию
Распределённая блокировка1 запрос на весь кластеркластер очень дорогой пересчёт, много подов
XFetchобновление до истечения, залпа неткластер дорогой пересчёт + ровный высокий RPS
Вечный ключ + фонклиент никогда не ждёт БДкластер витрины и конфиги, где stale допустим

Порядок внедрения

  1. Jitter в TTL. Одна строка кода ломает синхронное истечение. Ставят сразу и везде.
  2. singleflight в репозитории. Ещё десять строк схлопывают залп внутри пода. При 20 подах 200 запросов превращаются в 20 - этого БД уже не замечает.
  3. Ограничитель конкурентности к БД (семафор на N слотов или размер пула соединений как естественный ограничитель) плюс circuit breaker. Только этот механизм работает и при полном сбросе кэша, и при недоступном Redis.
  4. И только если пересчёт действительно дорогой, берут 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 досрочно отпускает ключ.

Четыре ловушки

  1. Общая ошибка. Лидер получил таймаут - ошибку получают все 200 ждущих. Вместо залпа в БД клиент получает залп 500-х. Смягчают коротким таймаутом лидера и ретраем на уровне вызывающего.
  2. Контекст лидера. Если внутрь fn передать ctx первого запроса, а этот клиент отвалится, ctx отменится, и работа сорвётся у всех ждущих, хотя их запросы живы. Внутрь передают независимый контекст (context.WithoutCancel или новый context.Background() с собственным таймаутом), а отмену обрабатывают снаружи через DoChan.
  3. Залипание. Do ждёт лидера сколько угодно. Один поход в БД без таймаута - и по этому ключу копятся все запросы сервиса.
  4. Мутируемый общий результат. Все ждущие получают одно и то же значение 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 уже достаточно, и это стоит прямо сказать.

Суть: вместо ожидания истечения каждый читатель с небольшой вероятностью решает обновить значение заранее. Вероятность растёт по мере приближения к TTL и тем быстрее, чем дороже был прошлый пересчёт. Залп невозможен по построению: обновление запускают единицы читателей, остальные получают попадание.

Формула

обновляем, если:   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 синхронизирует истечение. Ключи, залитые одной пачкой, истекают одной пачкой - и вы получаете avalanche ровно через TTL после каждого прогрева. Jitter в ±10–20 % размазывает истечение во времени и стоит одну строку кода.

Механика

Прогрели кэш скриптом: тысяча ключей записана за секунду с 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 по каждой ноде отдельно.

Способы, по порядку применения

  1. Локальный кэш поверх Redis (1–3 секунды). Его почти всегда достаточно, и почти всегда с него правильно начинать. При 20 подах и TTL в секунду 50 000 rps превращаются в 20 rps до Redis. Платишь окном рассинхрона длиной с локальный TTL.
  2. Чтение с реплик. В кластере это READONLY, в go-redis ReadOnly: true и RouteRandomly. Нагрузка делится на число реплик, но данные бывают чуть более устаревшими (репликация асинхронная).
  3. Реплики ключа с суффиксом. Пишем N копий key#0..key#N-1, читаем случайную: разные суффиксы дают разные слоты. Работает всегда, но стоит памяти ×N и инвалидации всех копий (пайплайном по нодам: одним Lua-скриптом нельзя, копии в разных слотах). Hash tag здесь ставить нельзя: он загонит все копии обратно в один слот и убьёт смысл приёма.
  4. 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, а сумму собирают при чтении одним пайплайном. Это готовый ответ на уточняющий вопрос, который задают почти всегда.

Суть: строка в мегабайты или коллекция в сотни тысяч элементов. Опасна потому, что Redis однопоточный: любая O(N)-операция над таким ключом останавливает весь сервер - включая обычный 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 *. Он проходит всё пространство ключей в главном потоке, и на миллионе ключей сервер полностью встаёт на сотню миллисекунд и дольше.

Что делать

  1. UNLINK вместо DEL. Отцепляет ключ мгновенно, память освобождает фоновый поток. Заодно включают lazyfree-lazy-expire, lazyfree-lazy-eviction, lazyfree-lazy-server-del, чтобы то же происходило при истечении и вытеснении.
  2. Не читать целиком: HMGET/HSCAN вместо HGETALL, постраничный LRANGE вместо LRANGE 0 -1, SISMEMBER/SSCAN вместо SMEMBERS.
  3. Разбить на части: obj:{42}:part:N, где N = hash(field) mod 100. Фигурные скобки ставят как hash tag, если части должны лежать в одном слоте для мультиключевых операций.
  4. Ограничить рост на входе: LPUSH + LTRIM key 0 999 одной транзакцией, XADD ... MAXLEN ~ 10000, периодический ZREMRANGEBYRANK.
  5. Пересмотреть модель. Часто большой ключ означает, что в кэш положили то, чему место в БД или объектном хранилище. В 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-подобной обработкой.