Кеширование

Предпосылки: String, Hash, eviction, архитектура кэширования (уровни кэша, когерентность, инвалидация).

Cluster | Rate limiting

Страница товара загружается 400 мс: PostgreSQL выполняет JOIN по трём таблицам, рендер шаблона, сериализация. Те же данные из Redis возвращаются за 1 мс. Архитектура кэширования описывает общую модель: слои кэша, инвалидацию, trade-off между свежестью и скоростью. Здесь — конкретные паттерны реализации в Redis: как обрабатывать промахи, как избежать stampede, как обновлять кэш при записи.

Cache-aside (lazy loading)

Самый распространённый паттерн кеширования. Приложение сначала ищет данные в Redis. При попадании (cache hit) — возвращает из кеша. При промахе (cache miss) — загружает из основного хранилища (PostgreSQL, API), записывает в Redis с TTL и возвращает клиенту.

GET article:42:html
-- → данные (cache hit) → вернуть клиенту
-- → nil (cache miss) → загрузить из PostgreSQL
--                     → SET article:42:html "<html>..." EX 300
--                     → вернуть клиенту

Преимущества: в кеш попадают только запрашиваемые данные (не нужно прогревать заранее), промах не приводит к ошибке (просто медленнее). Недостаток: первый запрос к каждому ключу всегда медленный (cache miss).

Cache stampede (thundering herd)

Cache-aside работает хорошо при равномерном трафике, но создаёт проблему при истечении TTL популярного ключа. Когда TTL популярного ключа истекает, все одновременные запросы обнаруживают промах и обращаются к PostgreSQL. Если за секунду приходит 1000 запросов — 1000 одинаковых тяжёлых запросов к базе одновременно. При тяжёлом запросе (join, агрегация) это может перегрузить PostgreSQL.

Решение 1: блокировка на перестроение

Первый запрос, обнаруживший промах, захватывает блокировку (SET lock:article:42 1 NX EX 30). Если блокировка получена — выполняет запрос к PostgreSQL и обновляет кеш. Остальные запросы, не получившие блокировку, ждут с retry (sleep + повторная попытка чтения из кеша) или возвращают stale-данные.

-- попытка захватить блокировку:
SET lock:article:42 1 NX EX 30
-- → OK (мы первые — перестраиваем кеш)
-- → nil (кто-то уже перестраивает — ждём)

Подходит для редких, очень тяжёлых запросов. Недостаток: retry добавляет латентность.

Решение 2: упреждающее обновление (early expiration)

Кеш с TTL 300 секунд. Когда оставшийся TTL падает ниже порога (например, 60 секунд), приложение запускает фоновое обновление кеша, но продолжает возвращать текущие (немного устаревшие) данные. К моменту истечения TTL новые данные уже в кеше — массового промаха не происходит.

0 сек ────────── 240 сек ────────── 300 сек
│                │                  │
  обычная работа    фоновое обновление    TTL

Подходит для популярных данных с предсказуемым TTL.

Блокировка или раннее обновление? Блокировка проще в реализации, но добавляет латентность (ожидающие клиенты делают retry). Early expiration не добавляет латентности, но требует логики определения «скоро истечёт» и фонового обновления. Для критичных систем оба подхода комбинируют: early expiration как основной механизм, блокировка как страховка.

Write-through и write-behind

Cache-aside обновляет кеш при чтении. Но если пользователь изменил профиль и тут же открыл его — cache-aside покажет старые данные до следующего промаха или инвалидации. Альтернативные стратегии обновляют кеш при записи, исключая этот эффект.

Write-through: при записи в основное хранилище одновременно обновляется и кеш. Преимущество — кеш всегда актуален. Недостаток — запись становится медленнее (два хранилища вместо одного).

Write-behind (write-back): запись идёт сначала в Redis, затем асинхронно синхронизируется с основным хранилищем. Быстрая запись, но риск потери данных при падении Redis до синхронизации. Подходит для метрик, счётчиков, некритичных данных.

Инвалидация кеша

Независимо от стратегии записи, данные в кеше со временем устаревают. Два подхода к поддержанию актуальности кеша. TTL-based: данные устаревают через фиксированное время. Просто, но допускает окно неактуальности. Event-based: при изменении данных в PostgreSQL приложение явно удаляет или обновляет соответствующий ключ в Redis (DEL article:42:html после UPDATE в PostgreSQL). Точнее, но сложнее — нужно отслеживать все пути изменения данных.

На практике комбинируют оба: event-based инвалидация для обычных случаев, TTL как страховка на случай пропущенного события.

См. также

Sources


Cluster | Rate limiting