← назад к разделу

Redis используют прежде всего как кэш: он держит горячие данные в памяти, чтобы приложение не ходило в базу при каждом запросе. Но «поставить Redis и закэшировать всё подряд» — не стратегия. Разные данные требуют разных подходов к чтению, записи и протуханию.

Зачем вообще нужен кэш

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

Короткая формула: кэш эффективен там, где одни и те же данные читают часто, а меняют редко.

Примеры: профиль пользователя, каталог товаров, результаты дорогого SQL-запроса, конфигурация приложения.

Cache-aside (ленивое кэширование)

Cache-aside — самый распространённый паттерн. Приложение само управляет кэшем:

  1. Нужны данные → проверяем Redis.
  2. Попадание (cache hit): данные есть — возвращаем их, в базу не идём.
  3. Промах (cache miss): данных нет → идём в базу, кладём результат в Redis, возвращаем клиенту.
Клиент → App → Redis: GET user:42
             ← (nil) — промах
         App → PostgreSQL: SELECT * FROM users WHERE id = 42
             ← данные
         App → Redis: SET user:42 <данные> EX 300
         App → Клиент: ответ

Плюс — простота: Redis не знает о базе, база не знает о Redis. Данные попадают в кэш только по реальному спросу.

Минус — холодный старт: после рестарта Redis или первой загрузки все запросы идут в базу, пока кэш не прогреется.

Cache-aside в Spring Boot

Самый удобный способ — аннотация @Cacheable из Spring Cache:

@Cacheable(value = "users", key = "#id")
public UserDto getUser(long id) {
    return userRepository.findById(id)
            .map(userMapper::toDto)
            .orElseThrow();
}

При промахе Spring сам вызывает метод и кладёт результат в Redis. При повторном вызове с тем же id метод не выполняется — возвращается кэшированное значение.

Для сброса отдельной записи используют @CacheEvict:

@CacheEvict(value = "users", key = "#id")
public void updateUser(long id, UserUpdateRequest req) {
    // обновляем в базе; запись в кэше будет удалена
}

Write-through

При write-through запись идёт сначала в Redis, а затем синхронно в базу (или наоборот — но всегда оба хранилища обновляются в одной транзакции логически).

Клиент → App → Redis: SET user:42 <новые данные>
         App → PostgreSQL: UPDATE users ...
         App → Клиент: OK

Плюс — кэш всегда актуален, промахов после записи нет.

Минус — запись медленнее: нужно подождать оба хранилища. Подходит там, где важна согласованность и объём записей невысок.

Write-behind (write-back)

Write-behind — запись сначала идёт в Redis, а в базу — асинхронно, с задержкой. Приложение получает быстрый ответ; фоновый процесс сбрасывает накопленные изменения в базу батчами.

Плюс — максимальная скорость записи.

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

TTL и протухание

TTL (Time To Live) — время жизни ключа. По истечении Redis удаляет его автоматически.

# установить ключ с TTL 5 минут
SET user:42 "..." EX 300

# посмотреть, сколько осталось
TTL user:42
# → 247

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

Как выбрать TTL

  • Данные меняются редко (конфигурация, справочник) — TTL от 10 минут до нескольких часов.
  • Данные меняются часто (корзина, сессия) — TTL 1-5 минут или явный сброс через @CacheEvict.
  • Данные реального времени (баланс, статус заказа) — осторожно с кэшированием или очень короткий TTL.

Инвалидация

Инвалидация — явное удаление ключа до истечения TTL, когда данные изменились. Это надёжнее, чем ждать протухания.

# удалить конкретный ключ
DEL user:42

# удалить по шаблону (осторожно на больших базах — блокирует Redis)
# лучше использовать SCAN + DEL в цикле
SCAN 0 MATCH user:* COUNT 100

В Spring: @CacheEvict после изменения сущности.

Cache Stampede — лавина промахов

Представьте: в кэше хранится результат тяжёлого запроса (1 секунда к базе). Миллион пользователей читают его каждую минуту. TTL истёк — и одновременно тысяча запросов приходят в приложение, видят промах и бегут в базу. База падает под нагрузкой.

Это и есть cache stampede (лавина промахов на горячем ключе).

diagram

Защита: блокировка при промахе

При промахе один поток берёт блокировку (SETNX или Redisson RLock) и пересчитывает значение. Остальные ждут или возвращают чуть устаревшее значение.

// Redisson — простой вариант с блокировкой
RLock lock = redissonClient.getLock("lock:popular:result");
if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {
    try {
        // перепроверяем кэш под блокировкой — вдруг уже заполнили
        String cached = redisTemplate.opsForValue().get("popular:result");
        if (cached != null) return cached;
        String result = expensiveQuery();
        redisTemplate.opsForValue().set("popular:result", result, 5, TimeUnit.MINUTES);
        return result;
    } finally {
        lock.unlock();
    }
}
// если не получили блокировку — вернуть устаревшее значение или подождать

Защита: jitter в TTL

Если тысяча ключей с одинаковым TTL протухнут одновременно — получим тысячу одновременных промахов. Jitter (случайный разброс) рассыпает их во времени:

// базовый TTL 5 минут + случайный сдвиг до 60 секунд
long ttl = 300 + ThreadLocalRandom.current().nextLong(60);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);

Простая и эффективная защита от «одновременного взрыва» ключей.

Согласованность кэша и базы данных

Кэш — это копия данных из базы. Копии расходятся. Вопрос не «расходятся ли» — а «насколько долго допустимо расхождение».

Три уровня:

СтратегияСогласованностьСкоростьСложность
Только TTLслабаямаксимальнаяминимальная
TTL + @CacheEvict при записисильнаяхорошаясредняя
Write-throughсильнаянижесредняя

Для большинства задач достаточно cache-aside + @CacheEvict: данные актуальны сразу после изменения, кэш читается быстро.

Полная строгая согласованность (без любого окна рассогласования) требует транзакций между Redis и базой — это сложно и редко оправдано.

Что кэшировать, а что — нет

Кэшировать стоит:

  • Редко меняющиеся справочники (категории, настройки).
  • Результаты дорогих запросов (агрегации, JOIN нескольких таблиц).
  • Профили пользователей, которые читают тысячи раз между обновлениями.
  • HTML-фрагменты или JSON-ответы целиком, если содержимое одинаково для всех.

Кэшировать не стоит:

  • Данные, критичные к точности в реальном времени (финансовый баланс, медицинские показатели).
  • Уникальные данные на каждого пользователя при большом числе пользователей — кэш не успеет заполниться, а памяти уйдёт много.
  • Мелкие запросы, которые база и так отдаёт за доли миллисекунды — накладные расходы на Redis превысят выигрыш.

Коротко

  • Cache-aside — основной паттерн: проверяем Redis, при промахе идём в базу и кладём результат в кэш.
  • Write-through — запись сразу в оба хранилища; актуальность гарантирована, но запись медленнее.
  • Write-behind — запись асинхронная; быстро, но с риском потери при сбое.
  • TTL задаёт время жизни ключа; инвалидация (DEL / @CacheEvict) сбрасывает его явно при изменении данных.
  • Cache stampede — лавина промахов на истёкшем горячем ключе; лечится блокировкой при пересчёте и jitter в TTL.
  • Кэш не заменяет транзакции — данные в Redis и базе всегда расходятся хотя бы на мгновение; выбирайте стратегию под допустимое окно рассогласования.

Что почитать дальше

  • Основы Redis: структуры данных и команды
  • Структуры данных Redis: строки, хэши, множества, списки
  • Redis в Spring Boot: @Cacheable, RedisTemplate, конфигурация