Паттерн использования Redis как распределённого кэша для ответов API. Применим к любому бэкенду с несколькими процессами/инстансами.
Двухуровневый кэш (L1 + L2)
| Слой | Хранилище | Латентность | Scope | Когда использовать |
|---|---|---|---|---|
| L1 | In-process (Map, LRU) | ~0ms | Один процесс | Горячие данные, справочники |
| L2 | Redis | ~2-5ms | Все процессы/инстансы | Дорогие запросы, shared state |
L1 проверяется первым. Промах L1 → L2. Промах L2 → вычисление + запись в оба слоя.
Преимущество двухуровневой схемы: при горизонтальном масштабировании (несколько worker-ов или Pod-ов) L2 обеспечивает единый кэш, а L1 снимает нагрузку с Redis для самых частых запросов.
Cache-aside с inflight deduplication
Базовый паттерн — cache-aside: проверь кэш → промах → вычисли → запиши. Проблема: при одновременных запросах к одному ключу все параллельно идут в БД (cache stampede).
Решение — inflight Map: пока factory выполняется, все последующие запросы к тому же ключу получают тот же Promise:
inflight = Map<key, Promise>
cached(key, ttl, factory):
hit = redis.GET(key) // L2 lookup
if hit → return parse(hit)
if inflight.has(key) // кто-то уже считает
return inflight.get(key) // ждём тот же результат
promise = factory()
.then(value → redis.SET(key, value, EX ttl))
.finally(→ inflight.delete(key))
inflight.set(key, promise)
return promise
Ограничение: inflight dedup работает per-process. При нескольких инстансах возможен параллельный вызов factory для одного ключа. Для кластерного решения нужен distributed lock (Redlock), но при малом масштабе это избыточно.
Graceful degradation
Redis как кэш должен быть опциональным — приложение работает без него, просто медленнее. Все Redis-операции оборачиваются в try/catch и не прерывают основной flow:
GETfailed → вызвать factory напрямуюSETfailed → вернуть результат без записи в кэшDEL(инвалидация) failed → TTL удалит ключ позже
При недоступности Redis каждый запрос идёт в БД. L1 in-memory смягчает удар для горячих данных.
Проектирование ключей
Ключ должен включать все параметры, влияющие на ответ:
{entity}:{scope}:{filter1}:{filter2}
leaders:region42:month
routes:all:rating:false
Правила:
- Scope isolation — если API фильтрует по региону/tenant, ключ должен это отражать. Запрос без фильтра → отдельный ключ (например
all) - Whitelist параметров — не включать произвольные пользовательские значения в ключ. Иначе открытое пространство ключей, которое невозможно полностью инвалидировать. Кэшировать только для известного набора значений, остальные — мимо кэша
- Per-user данные отдельно — кэшировать shared часть ответа, per-user данные (is_member, my_rank) мёрджить после cache hit через дешёвые запросы
Обработка corrupt данных
JSON.parse() может упасть на повреждённых данных в кэше. Важно изолировать эту ошибку от ошибок соединения:
try:
hit = redis.GET(key) // connection error → catch outer
if hit:
try:
return parse(hit) // parse error → catch inner
catch:
redis.DEL(key) // удалить битый ключ
increment error(op=parse)
catch:
increment error(op=get) // connection failure — другая метрика
Без изоляции corrupt entry выглядит как “Redis down” в метриках — ложная картина.
Инвалидация: race condition с inflight
При вызове invalidate(key) нужно также очищать inflight Map. Иначе возможен race:
- Запрос A: cache miss → factory запущен (5 секунд)
- Мутация:
invalidate(key)→ удаляет из Redis - Запрос A factory завершается → записывает устаревшие данные обратно в Redis
Решение: invalidate() должен удалять ключ и из Redis, и из inflight Map.
Observability
Минимальный набор метрик:
| Метрика | Тип | Labels | Зачем |
|---|---|---|---|
cache_hits_total | Counter | prefix | Hit rate = hits/(hits+misses) |
cache_misses_total | Counter | prefix | Эффективность по типу данных |
cache_errors_total | Counter | op (get/set/del/parse) | Отличить connection errors от data corruption |
cache_operation_duration_seconds | Histogram | op | p95 латентность, обнаружение деградации |
Алерты:
- Cache down — нет hits и misses при наличии предыдущей активности (не путать с “Redis не настроен”)
- Low hit rate — < 30% за 30 минут (кэш неэффективен)
- High error rate — > 0.1/s за 5 минут
- High latency — p95 > 100ms
Методология выбора TTL
Два режима определяют подход:
| Режим | TTL | Обоснование |
|---|---|---|
| С explicit инвалидацией | Длинный (1-2ч) | TTL — safety net. Данные обновляются мгновенно через invalidate(). Длинный TTL уменьшает нагрузку на БД |
| Только TTL (без инвалидации) | Короткий (5-30м) | TTL — единственная защита от stale data. Баланс между свежестью и нагрузкой |
Для данных с инвалидацией по событию TTL можно безопасно увеличивать — он сработает только если инвалидация была пропущена.
Связанные заметки
- Инвалидация кэша — стратегии TTL и по событию
- Оценка эффективности кэша
- Старт с холодным кэшем — cache stampede
- Алгоритм вытеснения кэша — LRU, LFU
- Конфигурация Redis