voidlearn
 

7.1Зачем кэш и уровни кэширования

Оцени кэш через выигрыш и цену: он уменьшает часть обращений к источнику, но добавляет память, сетевой путь и риск устаревших данных. Сопоставь L1, Redis и базу по латентности, общей видимости и последствиям промаха.

Что такое кэш на самом деле

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

  • Временна́я локальность. Если ключ запросили сейчас, скорее всего, его запросят ещё раз в ближайшие секунды. Так ведут себя лента, карточка популярного товара, профиль автора, настройки фичефлагов.
  • Перекос распределения. Обращения к ключам почти никогда не бывают равномерными: распределение Zipf-подобное, и 1 % ключей даёт 50–90 % трафика. Поэтому кэша на 2 ГБ и хватает на таблицу в 2 ТБ.

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

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

Истечение TTL ты запланировал сам, а вытеснение случается, когда кончилась память: когда угодно и с чем угодно, в том числе с ключом, который ты положил секунду назад. Отсюда правило, к которому всё в итоге сведётся: код обязан работать корректно, даже если любой ключ пропадёт в любой момент.

Формулировка, которая сразу поднимает уровень ответа

«Кэшем мы осознанно меняем консистентность на латентность и пропускную способность. Мы соглашаемся какое-то время отдавать устаревшие данные, а взамен получаем ответ за доли миллисекунды и снимаем нагрузку с источника. Дальше остаётся один вопрос: какое окно рассинхрона бизнес готов терпеть для этих конкретных данных.»

Чем платим

ЦенаВ чём проявляется
Устаревшие данныеПользователь поменял имя - а в ленте старое ещё TTL секунд. Для профиля терпимо, для остатка на складе перед списанием - нет
Дублирование состоянияПоявляется вторая копия правды, которую надо согласовывать. Все баги инвалидации растут отсюда
Новая точка отказаRedis лёг - и если код не умеет работать «мимо кэша», лежит весь сервис. Кэш обязан быть опциональным
Холодный стартПосле рестарта или деплоя кэш пуст, и вся нагрузка одномоментно уходит в БД, которая на неё не рассчитана
StampedeМассовое истечение TTL или падение кэша даёт всплеск одинаковых запросов в источник (глава 7.5)
Маскировка проблемКэш прячет запрос без индекса. Пока hit rate высокий, всё «летает»; в день, когда кэш промахнулся, БД ложится
Память и деньгиКластер Redis на 64 ГБ стоит ощутимо. Иногда индекс на 200 МБ решает ту же задачу
Сложность отладки«У меня старые данные» - воспроизводится только у одного пользователя, только пока живёт ключ

Уровни кэширования на пути запроса

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

Путь одного запроса: сверху вниз. Попадание - ответ сразу, промах - спускаемся уровнем ниже. промах спускается ниже Уровень Что там лежит Чем инвалидируется Ответ Браузер / клиент HTTP-ответы, статика, данные в SPA Cache-Control, ETag, max-age 0 мс CDN / edge статика, публичные GET, картинки TTL + purge по API 5-30 мс Обратный прокси целые HTTP-ответы, nginx proxy_cache proxy_cache_valid, PURGE 1-5 мс In-process приложения готовые Go-структуры, без сериализации короткий TTL, LRU по размеру 50-500 нс Redis / Memcached общее для всех подов состояние TTL, DEL по событию 0,2-1 мс Буферы БД страницы таблиц и индексов, план запроса вытеснение самой СУБД 0,1-1 мс Диск - источник правды сами данные, единственная настоящая копия не кэш, инвалидации нет 5-50 мс Каждый уровень независим и имеет свою инвалидацию. Баг «пользователь видит старое» может жить на любом из них, поэтому первый вопрос при разборе - на каком именно уровне застряла копия. Цифры - порядки величин, не константы.
Лестница кэшей. Чем выше уровень, где случилось попадание, тем дешевле ответ - и тем больше риск отдать устаревшее. Промах на верхнем уровне обходится в полную стоимость всех нижних.
Про кэш БД забывают, а он тоже кэш

shared_buffers в PostgreSQL и буферный пул InnoDB работают как полноценные кэши страниц. Поэтому «повторный запрос выполнился за 0,3 мс вместо 40» часто говорит не о хорошем плане, а о прогретом буфере. Мерять оптимизацию надо EXPLAIN (ANALYZE, BUFFERS), сравнивая shared read с shared hit, иначе будешь «оптимизировать» кэш, а не запрос.

Локальный in-process кэш против распределённого

С этой развилки проектирование и начинается. Локальный кэш живёт внутри процесса: обычная мапа под RWMutex или готовая библиотека (ristretto, otter, golang-lru). Распределённый работает отдельным сервисом, общим для всех реплик.

Локальный (in-process)Распределённый (Redis)
Латентностьдесятки-сотни наносекунд, обращение к памяти0,2–1 мс: сеть + сериализация
Сериализациянет вообще - лежит готовый объектобязательна: JSON, msgpack, protobuf; часто дороже самой сети
Согласованность между репликаминет: у каждого пода своя копия и свой момент истеченияодна копия на всех
Инвалидациясложно: нужен broadcast (pub/sub) на все подыодин DEL
Объёмограничен heap пода, давит на GCдесятки-сотни ГБ, отдельный бюджет памяти
Холодный старткаждый рестарт/деплой - пустой кэш у нового подапереживает деплой приложения
Стоимость промахаидём в Redis или сразу в БДидём в БД
Отказневозможен отдельно от процессаотдельная точка отказа, нужен fallback
Влияние на GCпрямое: миллион живых объектов удлиняет разметкунулевое: память вне процесса
Когда братьмаленькие горячие справочники, конфиги, фичефлаги, результаты, которые не жалко рассинхронить на секундусессии, всё, что должно быть одинаковым у всех подов, большие объёмы

В нагруженных сервисах их комбинируют: L1 держат локально на 1–5 секунд, L2 в Redis на минуты. Локальный слой снимает с Redis «горячий хвост» (тот самый 1 % ключей с половиной трафика) и заодно решает проблему горячего ключа в кластере.

// Двухуровневый кэш: L1 в памяти на 2 с, L2 в Redis на 5 мин.
type TwoLevel struct {
    l1  *ristretto.Cache // локальный, TTL 2 с
    rdb *redis.Client
    db  *sql.DB
}

func (c *TwoLevel) User(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:v1:%d", id)

    if v, ok := c.l1.Get(key); ok {            // ~100 нс, без сети
        return v.(*User), nil
    }
    b, err := c.rdb.Get(ctx, key).Bytes()      // ~0,4 мс
    if err == nil {
        u := new(User)
        if json.Unmarshal(b, u) == nil {
            c.l1.SetWithTTL(key, u, 1, 2*time.Second)
            return u, nil
        }
    }
    if err != nil && err != redis.Nil {
        // Redis недоступен: логируем и идём в БД, а не падаем.
        metrics.CacheErrors.Inc()
    }
    u, err := loadFromDB(ctx, c.db, id)        // ~10 мс
    if err != nil {
        return nil, err
    }
    b, _ = json.Marshal(u)
    c.rdb.Set(ctx, key, b, 5*time.Minute)
    c.l1.SetWithTTL(key, u, 1, 2*time.Second)
    return u, nil
}
Главная ловушка локального кэша

Ты кладёшь в локальный кэш указатель, а не копию. Двадцать горутин получат один и тот же *User, и стоит одной его поменять, как ты получаешь гонку данных и порчу кэша сразу у всех. Правило: в in-process кэше лежат только иммутабельные значения. Либо кладёшь копию при записи, либо возвращаешь копию при чтении, либо прямо пишешь в коде «этот объект менять нельзя». Вторая ловушка в GC: миллион структур с указателями внутри означает миллион объектов, которые сборщик обходит на каждом цикле. Поэтому серьёзные библиотеки хранят значения в []byte-аренах и не плодят указатели.

Локальный кэш: три пода - три независимые копии и три разных момента истечения под A name = Иван под B name = Пётр под C name = Иван PostgreSQL name = Пётр рассинхрон Пользователь переименовался. Под B сходил в БД после записи, A и C - до. Балансировщик раскидывает запросы по подам, и клиент видит то новое имя, то старое - при каждом обновлении страницы. Распределённый кэш: копия одна под A под B под C Redis name = Пётр Все поды видят одно значение. Цена - сеть в каждом запросе и отдельный сервис, который может упасть.
Почему локальный кэш нельзя ставить бездумно. Он размножает состояние по числу реплик. Для фичефлагов с TTL в секунду это нормально, а для данных, которые пользователь только что изменил своими руками, нет.

Метрики: hit rate и что он на самом деле означает

Считают прежде всего hit rate:

hit_rate = hits / (hits + misses)

На собеседовании часто спрашивают, какой hit rate считается хорошим. Ответ: сам по себе hit rate ничего не значит, значение имеют три производных числа.

1. Эффективная латентность

Промах обходится дороже прямого обращения к БД: сначала мы сходили в кэш за nil и только потом пошли в источник. Формула:

L = h * L_hit + (1 - h) * (L_hit + L_source)

Возьмём L_hit = 0,3 мс и L_source = 20 мс: 90 % попаданий дают 2,3 мс, а 99 % уже 0,5 мс, то есть ответ быстрее почти впятеро. Сама латентность падает по h линейно: каждый процент снимает те же 0,2 мс. Нелинеен выигрыш в разах: при 99 % промахов вдесятеро меньше, чем при 90 %, и каждый следующий процент hit rate даётся дороже предыдущего.

Средняя латентность при L_hit = 0,3 мс и L_source = 20 мс Внизу - сколько запросов в секунду долетит до БД при входящих 10 000 rps. Промахи, а не попадания, определяют нагрузку. 20,3 мс 10,3 мс 4,3 мс 2,3 мс 1,3 мс 0,5 мс 0,32 мс hit 0 % 50 % 80 % 90 % 95 % 99 % 99,9 % в БД, rps 10 000 5 000 2 000 1 000 500 100 10 Рост hit rate с 90 % до 99 % убирает 900 запросов в секунду из БД - это чаще всего и есть настоящая цель кэша. При этом p99 приложения остаётся равным латентности промаха: хвост распределения кэш не лечит, он лечит середину.
Нелинейность hit rate. Слева почти чистая БД, справа почти чистый кэш. Между 90 % и 99 % на глаз «почти одинаково», а нагрузка на источник отличается в десять раз.

2. Абсолютное число промахов в секунду

В БД попадает именно оно, а не проценты. Hit rate 99 % при 10 000 rps пропускает в источник 100 rps; тот же 99 % при 1 000 000 rps пропускает уже 10 000 rps, и БД от такого ляжет. Поэтому в алертах держат misses_per_second, а не только процент.

3. Хвост латентности

С кэшем медиана падает почти до L_hit, а p99 остаётся равным латентности источника - этот 1 % запросов туда и уходит. Если SLA сформулирован по p99, кэш с hit rate 95 % его почти не улучшит. Аргумент сильный, а в разговоре его почти никто не приводит.

Что мерить в проде
  • cache_hits / cache_misses заводят счётчиками с меткой имени кэша, а не одним на весь сервис.
  • cache_errors считают отдельно от промахов: недоступность Redis не должна выглядеть как холодный кэш.
  • Гистограмма латентности раздельно для hit и miss - иначе один график усреднит две разные популяции.
  • evicted_keys и used_memory из INFO Redis: рост вытеснений означает, что кэш меньше, чем рабочее множество, и hit rate будет деградировать сам собой.
  • Возраст отданных данных, если умеешь его считать, прямо меряет окно рассинхрона.

Когда кэш не нужен и когда он вредит

  • Нет повторяемости ключей. Каждый запрос уникален (поиск по произвольным фильтрам, выгрузка отчётов по диапазонам). Hit rate будет 5 %, а каждый запрос получит лишний round-trip.
  • Источник и так быстрый. Выборка по первичному ключу из прогретого буферного пула занимает 0,2–0,5 мс, столько же, сколько поход в Redis по сети. Выигрыша нет, сложность есть.
  • Данные не имеют права быть устаревшими. Баланс перед списанием, остаток товара в момент оформления заказа, права доступа при проверке авторизации, статус блокировки пользователя. Здесь кэш грозит не «немного стухшими данными», а инцидентом безопасности или деньгами.
  • Пишут чаще, чем читают. При соотношении 1 read / 1 write кэш инвалидируется быстрее, чем успевает пригодиться: получаем нагрузку и на БД, и на Redis.
  • Кэш вместо индекса. Классический анти-паттерн: запрос делает seq scan на 3 секунды, его закрывают кэшем - и живут так, пока однажды кэш не окажется пустым.
  • Кэш вместо исправления N+1. Тот же случай: лечат это IN (...) или джойн, а не «закэшируем каждую из 200 подгрузок».
Cache penetration - промах, которого не должно быть

Отдельный класс проблем: запрашивают ключ, которого нет в БД. Кэш промахивается, идём в источник, тот возвращает пусто, класть в кэш нечего - и так на каждый запрос. Если так делает бот, перебирающий id, кэш вообще перестаёт защищать. Лечится это negative caching: кладём маркер «нет такого» с коротким TTL (30–60 с). Для больших пространств ключей перед кэшем ставят фильтр Блума: он отвечает «точно нет» без похода в БД.

Глубже, чем спросят: почему кэш вообще работает

Оптимальную политику вытеснения описывает алгоритм Белади: выкидывать тот элемент, который понадобится позже всех. Реализовать его нельзя, для этого надо знать будущее, но он служит верхней границей: любую реальную политику (LRU, LFU, ARC, W-TinyLFU) сравнивают именно с ним. LRU ставит на то, что недавнее прошлое предсказывает ближайшее будущее, LFU ставит на устойчивость частоты во времени. Ломаются обе ставки на разных нагрузках: LRU не переживает скан (одна выгрузка всей таблицы вымывает весь кэш), а LFU держит «вчерашних чемпионов», которых уже никто не спрашивает. Отсюда гибриды: allkeys-lfu в Redis с затуханием счётчика (lfu-decay-time) или W-TinyLFU в otter; ristretto пускает в кэш по TinyLFU, а вытесняет по случайной выборке.

Вопросы

6
Суть: кэш - это размен консистентности на латентность и на снятие нагрузки с источника. Платим устаревшими данными, второй копией правды, новой точкой отказа и целым классом багов инвалидации.

Работает кэш за счёт двух свойств реальной нагрузки: временно́й локальности (что запросили сейчас, запросят снова) и сильного перекоса в распределении обращений - обычно 1 % ключей даёт больше половины трафика. Поэтому небольшой кэш и закрывает огромную таблицу.

Что мы получаем

  • Латентность: 0,3 мс из Redis вместо 10–40 мс из БД, а из локального кэша вообще сотни наносекунд.
  • Пропускную способность: БД перестаёт быть узким местом, её можно не масштабировать.
  • Защиту источника от пиков: всплеск трафика на популярный контент не доходит до БД.
  • Деньги: реплика Postgres дороже, чем нода Redis той же ёмкости.

Чем платим

  • Окно рассинхрона. Сколько-то секунд пользователи видят старые данные. Это надо явно согласовать с продуктом, а не решать в коде молча.
  • Вторая копия правды. Инвалидация порождает баги, которые воспроизводятся раз в неделю у одного пользователя.
  • Точка отказа. Если сервис не умеет работать при недоступном кэше, ты просто добавил в цепочку ещё один компонент, который может её порвать.
  • Холодный старт и stampede. Пустой кэш после деплоя отправляет всю нагрузку в БД разом.
  • Маскировка. Кэш прячет неоптимизированный запрос до того дня, когда промахнётся.
Чем добить ответ

«Перед тем как ставить кэш, я смотрю на профиль обращений: есть ли перекос по ключам и какое отношение чтений к записям. Если перекоса нет или пишут почти так же часто, как читают, кэш принесёт сложность без выигрыша - и правильнее сначала посмотреть план запроса.»

Суть: на пути запроса стоит целая лестница независимых кэшей - браузер, CDN, обратный прокси, in-process, распределённый, буферы БД. У каждого своя инвалидация, и устаревшие данные могут застрять на любом.

Сверху вниз

  • Клиент / браузер. Управляется заголовками Cache-Control, ETag, Last-Modified. Кэш самый дешёвый: запрос вообще не уходит в сеть. И самый опасный: инвалидировать его нельзя, ты уже отдал ответ, поэтому для изменчивых данных ставят no-store или очень короткий max-age, а для статики «вечный» TTL плюс хеш в имени файла.
  • CDN / edge. Тот же HTTP-кэш, но управляемый: есть API для purge. Хорош для статики и публичных GET-ответов, бесполезен для персонализированных.
  • Обратный прокси (nginx proxy_cache, Varnish). Кэширует готовые HTTP-ответы внутри твоего периметра. Хорош тем, что не требует ни строчки в приложении.
  • Приложение, in-process. Мапа или LRU внутри Go-процесса. Нет ни сети, ни сериализации. Но у каждой реплики своя копия.
  • Распределённый кэш (Redis, Memcached). Хранит общее для всех подов состояние: сессии, результаты тяжёлых запросов, счётчики.
  • Уровень БД. Буферный пул (shared_buffers, InnoDB buffer pool), кэш планов и prepared statements, материализованные представления. Кэшем это называют реже, но ведёт себя оно ровно так же.
  • ОС и железо. Page cache файловой системы, кэш контроллера диска.
Практическое правило

Кэшировать надо как можно ближе к пользователю, но ровно настолько близко, насколько позволяет требуемая свежесть. Статику кладут на CDN навсегда, публичный список на прокси на минуту, персональную ленту в Redis на десятки секунд. Баланс не кэшируют вообще.

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

Локальный

Обращение к мапе занимает 50–500 наносекунд, и сериализации нет: в кэше лежит готовая Go-структура. Никакой сети, никакой отдельной точки отказа. Минусы: у каждого пода свой кэш и свой момент истечения, поэтому пользователь при обновлении страницы может увидеть то новое значение, то старое. Для инвалидации нужна рассылка на все поды (обычно через Redis pub/sub), и атомарной она не будет. Плюс объём ограничен heap-ом, а миллион живых объектов удлиняет цикл GC.

Распределённый

Копия одна, и один DEL инвалидирует её для всех; объём измеряется десятками гигабайт и не влияет на GC приложения. Платишь сетевым round-trip и сериализацией, которая часто дороже самой сети: json.Marshal большой структуры порой занимает больше времени, чем поход в Redis.

Как отвечать

«Локальный я беру для маленького, редко меняющегося и одинакового для всех: справочники, фичефлаги, конфиги, курсы валют. С TTL в единицы секунд, чтобы рассинхрон был заведомо незаметен. Redis нужен для всего, что должно быть согласовано между репликами, и для больших объёмов. Если нагрузка высокая, ставлю оба: L1 локальный на 1–5 секунд поверх L2 в Redis. L1 снимает с Redis горячий хвост и заодно решает проблему горячего ключа в кластере.»

На что ловят

«А что ты кладёшь в локальный кэш: значение или указатель?» Если указатель, то все горутины получают один объект, и любая мутация даёт гонку плюс порчу кэша для всех остальных. В in-process кэше должны лежать только иммутабельные значения.

Суть: hits/(hits+misses). Универсального «хорошего» значения нет - важны абсолютное число промахов в секунду и то, что делает с латентностью именно твой источник.

Эффективная средняя латентность считается как L = L_hit + (1-h) * L_source, потому что промах стоит поход в кэш плюс поход в источник. При L_hit = 0,3 мс, L_source = 20 мс hit rate 90 % даёт 2,3 мс, а 99 % уже 0,5 мс. Последние девять процентов ускоряют ответ почти впятеро: промахов становится вдесятеро меньше.

Почему процент сам по себе бесполезен

  • 99 % при 10 000 rps пропускают в БД 100 запросов в секунду, ерунда. 99 % при миллионе rps пропускают уже 10 000 rps, и БД ляжет. В алертах должно быть misses_per_second.
  • Если источник отвечает за 0,3 мс, то и hit rate 50 % никого не беспокоит.
  • Высокий hit rate бывает и у бесполезного кэша: если кэшируется только один горячий ключ, процент будет 99, а тяжёлые запросы всё равно уходят в БД.

Что ещё важно проговорить

Кэш почти не улучшает p99. Медиана падает до латентности кэша, а хвост остаётся равным латентности источника - туда как раз и уходят промахи. Если SLA сформулирован по p99, кэш с hit rate 95 % его практически не сдвинет, и надо либо чинить источник, либо доводить hit rate до 99,9 %, либо ставить refresh-ahead, чтобы промахов на пользовательском пути не было вообще.

Ориентиры из практики: для кэша объектов по идентификатору нормально 90–99 %; ниже 70 % уже повод проверить, есть ли вообще перекос в ключах; ниже 50 % кэш обычно только добавляет round-trip. Отдельно смотрят evicted_keys: если ключи вытесняются по maxmemory, а не по TTL, значит рабочее множество больше кэша и hit rate будет деградировать.

Суть: когда нет повторяемости ключей, когда источник и так быстрый, когда данные не имеют права быть устаревшими, и когда кэшем закрывают отсутствующий индекс.
  • Нет локальности. Аналитика по произвольным диапазонам, поиск по свободным фильтрам: каждый ключ уникален, hit rate около нуля, а лишний round-trip платят все.
  • Источник быстрее или сопоставим. Чтение по первичному ключу из прогретого буферного пула занимает те же 0,3 мс, что и Redis по сети. Кэш добавит только сложность и рассинхрон.
  • Данные критичны к свежести. Баланс перед списанием, остаток товара при оформлении, проверка прав и статуса блокировки. Здесь устаревшее значение означает не «чуть-чуть неточно», а списанные лишние деньги или доступ у уволенного сотрудника. Такие чтения идут в БД, при необходимости под SELECT ... FOR UPDATE.
  • Запись сравнима с чтением по частоте. Ключ инвалидируется раньше, чем его успевают прочитать: платим и за БД, и за Redis.
  • Кэш как замена оптимизации. Медленный запрос надо чинить планом и индексом. Кэш поверх него превращается в отложенный инцидент: рванёт он в момент, когда кэш окажется пуст, то есть при рестарте, деплое или всплеске новых ключей.
  • Данные слишком большие. Мегабайтный JSON в Redis ради одного поля тратит сеть и блокирует однопоточный сервер (см. большие ключи в 7.5).
Ответ, который производит впечатление

«Пока не понятно, откуда взялась проблема производительности, кэш как её решение ставить нельзя. Сначала EXPLAIN (ANALYZE, BUFFERS) и профиль, потом индекс или переписанный запрос, и только потом кэш - уже не чтобы починить, а чтобы снять нагрузку. Иначе без кэша сервис перестаёт работать, а значит, мы построили систему, которая не переживает собственный рестарт.»

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

Penetration - «пробой»

Запрашивают ключ, которого нет в БД. Кэш промахивается, источник возвращает пусто, класть нечего, и каждый следующий такой запрос снова идёт в БД. Если кто-то перебирает идентификаторы, кэш просто перестаёт существовать как защита.

Лечение:

  • Negative caching - кладём маркер «нет такого» с коротким TTL (30–60 с). TTL короткий, чтобы вновь созданная сущность быстро стала видна.
  • Фильтр Блума перед кэшем для больших пространств ключей: даёт достоверное «точно нет» и вероятностное «может быть», при этом занимает биты на ключ.
  • Проверка формата идентификатора на входе отсекает мусорный перебор без похода в БД.
const missMarker = "\x00miss"

v, err := rdb.Get(ctx, key).Result()
switch {
case err == nil && v == missMarker:
    return nil, ErrNotFound            // ключа точно нет, в БД не идём
case err == nil:
    return decode(v)
case err == redis.Nil:
    u, err := loadFromDB(ctx, id)
    if errors.Is(err, sql.ErrNoRows) {
        rdb.Set(ctx, key, missMarker, 60*time.Second)
        return nil, ErrNotFound
    }
    ...
}

Breakdown - «прорыв» одного горячего ключа

У очень популярного ключа истёк TTL, и все запросы к нему одновременно ушли в БД. Это частный случай stampede, подробно он разобран в главе 7.5. Лечится singleflight, «вечным» ключом с фоновым обновлением или вероятностным ранним обновлением.

Avalanche - «лавина»

Протухло сразу много ключей (кэш прогрели одним циклом с одинаковым TTL) или упал сам Redis. В БД приходит вся нагрузка целиком. Лечение: jitter в TTL, чтобы истечения размазались во времени; circuit breaker и лимит одновременных запросов к БД; деградированный ответ вместо полного отказа.

Термины разные в разных школах

В англоязычной литературе чаще говорят cache stampede / dog-pile и thundering herd для всей группы. Разделение на penetration / breakdown / avalanche пришло из китайских инженерных статей и часто встречается в вопросах на российских собеседованиях. Полезно знать оба словаря и уметь их связать: «breakdown и avalanche - частные случаи stampede, а penetration стоит отдельно, потому что там кэшировать буквально нечего».

Что стоит запомнить

  • Кэш уменьшает нагрузку только на запросы, которые попадают в него; низкий hit rate может усилить общую стоимость.
  • Локальный кэш быстрый, но отдельный для каждой реплики и потенциально расходится между ними.
  • Отрицательный результат тоже можно кэшировать, но с коротким TTL и отдельным маркером.

Частые ошибки

  • Не считай высокий hit rate доказательством корректности или общей скорости: смотри на нагрузку источника и хвостовую латентность.
  • Не используй интерфейсный nil как маркер отсутствующего значения, если nil может быть валидным результатом.
  • При падении Redis не отправляй неограниченный поток промахов в БД.

Самопроверка

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

Чтение кода

1. Рассчитай эффект hit rate

Из 10 000 запросов в секунду hit rate равен 90%. Сколько запросов в секунду всё ещё доходит до источника? Что произойдёт с этим числом, если hit rate упадёт до 80% при том же трафике?

if b, err := rdb.Get(ctx, key).Bytes(); err == nil { return decode(b) }
return loadFromDB(ctx, id)
Ответ и разбор

При 90% промахов 10%, то есть 1 000 запросов/с, доходят до источника. При 80% промахов это уже 2 000 запросов/с: нагрузка на БД удваивается, хотя общий трафик не изменился. Поэтому смотри на абсолютный поток промахов, особенно во время роста рабочего набора.

Код-ревью

2. Проверь L1 в многорепличном сервисе

Сервис запускается в трёх репликах. Одна обновила пользователя в БД и удалила ключ Redis, но вторая продолжила отдавать старое значение из локального L1. Предложи конкретные способы ограничить расхождение и объясни компромисс.

func (c *TwoLevel) User(ctx context.Context, id int64) (User, error) {
    if v, ok := c.l1.Get(key(id)); ok { return v.(User), nil }
    b, err := c.rdb.Get(ctx, key(id)).Bytes()
    if err == nil { return decodeAndFillL1(b) }
    return c.loadFromDB(ctx, id)
}
Ответ и разбор

Удаление общего Redis-ключа не очищает память других pod. Можно убрать L1 для данных, которым нужна свежесть, задать короткий TTL и принять окно устаревания, либо рассылать invalidation-события с обработкой пропусков и периодической сверкой. Для строгого read-your-writes направляй конкретное чтение в источник или используй версию данных. In-process кэш всегда локален каждой реплике.

Применение

3. Спроектируй negative cache для отсутствующего ID

Сканирование случайных customer ID создаёт много одинаковых запросов в БД. Сущность может появиться позже после регистрации. Как кэшировать NotFound, чтобы снизить нагрузку и не скрывать новую запись надолго?

Ответ и разбор

Кэшируй явный маркер miss отдельным коротким TTL, например 30-60 секунд, а не пустую строку, которая может быть валидным значением. При создании customer удали отрицательный ключ после commit. В ключе держи версию схемы/сущности, а для ошибок Redis отличай miss от timeout, чтобы не принимать сбой кэша за NotFound.

Применение

4. Выбери, нужен ли Redis

Каталог содержит 200 товаров, редко меняется и целиком помещается в память каждого pod. Запросы идут 20 раз в секунду. Сравни локальный TTL-кэш и Redis и назови, какие факты могут изменить выбор.

Ответ и разбор

Локальный кэш может быть проще: набор мал, обновления редкие, а каждый pod может загрузить каталог самостоятельно. Redis добавит сеть и отдельную зависимость. Выбор изменится, если нужна согласованная инвалидация между pod, набор перестанет помещаться в памяти, либо база станет узким местом; сравни измеренную стоимость чтений с эксплуатационной ценой.