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 МБ решает ту же задачу |
| Сложность отладки | «У меня старые данные» - воспроизводится только у одного пользователя, только пока живёт ключ |
Уровни кэширования на пути запроса
На пути к диску запрос пользователя проходит через несколько независимых кэшей. На собеседовании их часто просят перечислить - и кандидат почти всегда вспоминает два-три. Хороший ответ идёт сверху вниз и для каждого уровня называет: что там лежит, кто это инвалидирует и во что обходится промах.
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-аренах и не плодят указатели.
Метрики: 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 даётся дороже предыдущего.
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изINFORedis: рост вытеснений означает, что кэш меньше, чем рабочее множество, и hit rate будет деградировать сам собой.- Возраст отданных данных, если умеешь его считать, прямо меряет окно рассинхрона.
Когда кэш не нужен и когда он вредит
- Нет повторяемости ключей. Каждый запрос уникален (поиск по произвольным фильтрам, выгрузка отчётов по диапазонам). Hit rate будет 5 %, а каждый запрос получит лишний round-trip.
- Источник и так быстрый. Выборка по первичному ключу из прогретого буферного пула занимает 0,2–0,5 мс, столько же, сколько поход в Redis по сети. Выигрыша нет, сложность есть.
- Данные не имеют права быть устаревшими. Баланс перед списанием, остаток товара в момент оформления заказа, права доступа при проверке авторизации, статус блокировки пользователя. Здесь кэш грозит не «немного стухшими данными», а инцидентом безопасности или деньгами.
- Пишут чаще, чем читают. При соотношении 1 read / 1 write кэш инвалидируется быстрее, чем успевает пригодиться: получаем нагрузку и на БД, и на Redis.
- Кэш вместо индекса. Классический анти-паттерн: запрос делает seq scan на 3 секунды, его закрывают кэшем - и живут так, пока однажды кэш не окажется пустым.
- Кэш вместо исправления N+1. Тот же случай: лечат это
IN (...)или джойн, а не «закэшируем каждую из 200 подгрузок».
Отдельный класс проблем: запрашивают ключ, которого нет в БД. Кэш промахивается, идём в
источник, тот возвращает пусто, класть в кэш нечего - и так на каждый запрос. Если так делает бот,
перебирающий 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. Пустой кэш после деплоя отправляет всю нагрузку в БД разом.
- Маскировка. Кэш прячет неоптимизированный запрос до того дня, когда промахнётся.
«Перед тем как ставить кэш, я смотрю на профиль обращений: есть ли перекос по ключам и какое отношение чтений к записям. Если перекоса нет или пишут почти так же часто, как читают, кэш принесёт сложность без выигрыша - и правильнее сначала посмотреть план запроса.»
Сверху вниз
- Клиент / браузер. Управляется заголовками
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 на десятки секунд. Баланс не кэшируют вообще.
Локальный
Обращение к мапе занимает 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, набор перестанет помещаться в памяти, либо база станет узким местом; сравни измеренную стоимость чтений с эксплуатационной ценой.