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

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

в базу идём только тогда, когда в Redis ключа нет приложение Redis user:42ключа нет user:42живёт ещё 300 c база GET user:42 GET user:42 GET user:42 GET user:42 тяжёлый запрос тяжёлый запрос SET user:42 EX 300 SET user:42 EX 300 промах: ключа нет — читаем базу и кладём ответ в кэш попадание: ответ из памяти, база не участвует и снова попадание — пока ключ жив, база отдыхает срок жизни вышел — Redis убрал ключ следующий читатель снова платит за поход в базу походов в базу: 0 1 2

Cache-aside: приложение сначала спрашивает Redis и идёт в базу только на промахе, а результат кладёт обратно с ограниченным сроком жизни. Пока ключ жив, база не участвует вовсе; когда срок вышел, Redis убирает ключ, и следующий читатель снова оплачивает поход в базу. Отсюда две настройки, которые решают всё: что кладём в кэш и на сколько.

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

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

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

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

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

  1. Нужны данные → проверяем Redis.
  2. Попадание (cache hit): данные есть — возвращаем их, в базу не идём.
  3. Промах (cache miss): данных нет → идём в базу, кладём результат в Redis, возвращаем клиенту.

Механика видна и без Redis: карта вместо кэша, отметка времени вместо срока жизни ключа.

живой пример

import java.util.HashMap;
import java.util.Map;

public class CacheAside {
    record Entry(String value, long expiresAt) {}

    static final Map<Long, Entry> cache = new HashMap<>();
    static int dbCalls = 0;

    static String getUser(long id, long now) {
        Entry hit = cache.get(id);
        if (hit != null && hit.expiresAt() > now) {
            System.out.println(now + " мс: попадание — " + hit.value());
            return hit.value();
        }
        dbCalls++;
        String value = "Иванов";
        cache.put(id, new Entry(value, now + 300));
        System.out.println(now + " мс: промах — идём в базу и кладём ключ на 300 мс");
        return value;
    }

    public static void main(String[] args) {
        for (long now : new long[] {0, 100, 250, 400, 500}) {
            getUser(42, now);
        }
        System.out.println("походов в базу: " + dbCalls);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Плюс — простота: 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, а затем синхронно в базу (или наоборот — но всегда оба хранилища обновляются в одной транзакции логически).

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

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

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

Write-behind (write-back)

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

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

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

Разберём их подробнее, потому что в такой краткости они выглядят взаимозаменяемыми, а это не так.

Сквозная запись (write-through). Приложение пишет в кэш, а кэш синхронно пишет в базу; для вызывающего это одна операция. Плюс: кэш всегда согласован с базой, промахов после записи не бывает. Минусы два, и оба существенные. Запись становится медленнее на время обращения к кэшу, а главное — кэш заполняется всем подряд, включая данные, которые никто не прочитает; на большом потоке записи это вытесняет из памяти действительно горячие ключи. Поэтому сквозную запись берут там, где записанное почти наверняка будет прочитано сразу: профиль пользователя, корзина, настройки.

Отложенная запись (write-behind). Приложение пишет только в кэш, а в базу изменения уходят пачкой через какое-то время. Плюс — запись становится очень быстрой и база получает укрупнённый поток вместо потока мелких операций; это единственный способ выдержать десятки тысяч записей в секунду на счётчиках. Минус ровно один и решающий: потеря данных при падении. Между записью в кэш и записью в базу данные живут только в памяти, и перезапуск Redis без сохранения на диск унесёт их. Поэтому отложенную запись применяют для того, что не жалко потерять частично: счётчики просмотров, метрики, «последний раз видели», — и никогда для денег и заказов.

Реализуют отложенную запись почти всегда не «умным кэшем», а явно: приложение пишет в Redis, отдельное задание раз в N секунд собирает накопленное (HGETALL по накопителю, INCRBY в базу) и очищает. Так видно, где данные, и понятно, что теряется при сбое.

Как выбирать. Чтение много раз чаще записи, данные переживают промах — ленивое кэширование. Записанное сразу читают — сквозная запись. Поток записей огромен, а точность не критична — отложенная. Во всех остальных случаях — ленивое кэширование, оно проще и предсказуемее.

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

TTL (Time To Live) — время жизни ключа. По истечении срока Redis сразу перестаёт отдавать ключ, а память освобождает позже: либо когда к ключу снова обратятся, либо фоновой чисткой, которая выборочно проверяет ключи со сроком.

живой пример

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

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

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Как выбрать TTL

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

Инвалидация

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

живой пример

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

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Сложнее, когда удалить надо пачку ключей по шаблону. Здесь легко уронить сервер:

живой пример

# так не надо: KEYS проходит всё пространство ключей за один раз
# и на это время Redis не отвечает никому
KEYS user:*

# так надо: SCAN отдаёт ключи порциями, между порциями сервер работает
SCAN 0 MATCH user:* COUNT 100
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Штурм кэша: толпа за одним ключом

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

Это и есть штурм кэша (cache stampede): один популярный ключ истёк, и вся толпа читателей одновременно ломится за ним в базу. Рядом живёт похожая беда — лавина, когда разом истекает множество разных ключей, например потому, что их залили в кэш одной пачкой с одинаковым сроком жизни. Симптом один и тот же, всплеск нагрузки на базу, а лечится по-разному: от штурма спасает блокировка на пересчёт, от лавины — случайная добавка к сроку жизни, чтобы ключи истекали вразнобой.

Толпу видно на восьми читателях, которые пришли за одним истёкшим ключом.

без замка ключ истёк 8 читателей 8 запросов в базу под замком ключ истёк 8 читателей 1 запрос, 7 ждут

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

живой пример

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public class Stampede {
    static final AtomicInteger dbCalls = new AtomicInteger();

    static String heavyQuery() {
        dbCalls.incrementAndGet();
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "топ товаров";
    }

    static int crowd(boolean underLock) throws InterruptedException {
        Map<String, String> cache = new ConcurrentHashMap<>();
        dbCalls.set(0);
        CountDownLatch start = new CountDownLatch(1);
        Thread[] readers = new Thread[8];
        for (int i = 0; i < readers.length; i++) {
            readers[i] = new Thread(() -> {
                try {
                    start.await();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                if (underLock) {
                    cache.computeIfAbsent("popular", key -> heavyQuery());
                } else if (cache.get("popular") == null) {
                    cache.put("popular", heavyQuery());
                }
            });
            readers[i].start();
        }
        start.countDown();
        for (Thread reader : readers) {
            reader.join();
        }
        return dbCalls.get();
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("восемь читателей приходят за одним истёкшим ключом");
        System.out.println("каждый сам идёт в базу: запросов " + crowd(false));
        System.out.println("пересчёт под замком:  запросов " + crowd(true));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Восемь запросов вместо одного; при тысяче читателей будет тысяча. CountDownLatch здесь не для красоты: он отпускает всех читателей разом, иначе число зависело бы от того, как их разложил планировщик, и от запуска к запуску прыгало. computeIfAbsent сыграл роль замка, но замка внутреннего: он держит потоки только одного процесса. Процессов в проде много, поэтому замок нужен общий — его и держит Redis.

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

При промахе один поток берёт блокировку (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();
    }
}
// если не получили блокировку — вернуть устаревшее значение или подождать
return redisTemplate.opsForValue().get("popular:result");

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

Защита: jitter в TTL

Jitter — случайный разброс срока жизни: ключи из одной пачки протухают не разом, а вразнобой.

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

Когда Redis недоступен

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

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

Короткие таймауты. Обращение к кэшу должно укладываться в единицы миллисекунд; таймаут в секунду означает, что при зависшем Redis каждый запрос ждёт секунду, и сервис ложится по пулам потоков. Ставят таймаут соединения и команды в десятки миллисекунд — кэш либо отвечает быстро, либо не нужен.

Обработчик ошибок кэша. В Spring это CacheErrorHandler: он перехватывает ошибки чтения и записи в кэш и позволяет их проглотить, записав в журнал и метрику.

@Configuration
class CacheConfig implements CachingConfigurer {
    @Override
    public CacheErrorHandler errorHandler() {
        return new SimpleCacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
                log.warn("кэш {} недоступен, идём в базу: {}", cache.getName(), e.toString());
            }
            @Override
            public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) {
                log.warn("кэш {} не принял запись: {}", cache.getName(), e.toString());
            }
        };
    }
}

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

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

Два дополнения к этому разделу, которые закрывают тему.

Готовый ответ в Spring. @Cacheable(sync = true) делает ровно то, что код выше: при промахе значение вычисляет один поток, остальные ждут его результата. Оговорка важная: синхронизация работает внутри одной виртуальной машины — на трёх экземплярах сервиса вычислений будет три, а не одно. Для полной защиты нужен распределённый замок в самом Redis (SET key NX EX), и его берут, когда вычисление действительно дорогое, — разбор в статье про Redis за пределами кэша.

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

Практический вариант того же приёма попроще — «отдать устаревшее и обновить в фоне»: значение хранится с запасом по сроку жизни (скажем, срок 10 минут, а считается устаревшим через 8); запрос, попавший в интервал между «устарело» и «истекло», отдаёт старое значение и запускает фоновое обновление. Пользователь не ждёт никогда, база получает один запрос вместо толпы.

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

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

Три уровня:

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

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

Слово «почти» здесь важное. Между тем, как из кэша удалили ключ, и тем, как изменение зафиксировалось в базе, есть короткое окно. Если в него влезет чтение, оно возьмёт из базы ещё старое значение и положит его в кэш — на весь срок жизни. Поэтому чистить кэш надёжнее не внутри транзакции, а после её фиксации: Spring умеет это через обёртку TransactionAwareCacheManagerProxy.

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

10:00.000 запись: DEL ключа 10:00.001 чтение: промах 10:00.002 чтение: старое из базы 10:00.003 запись: COMMIT 10:00.004 чтение: SET старого

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

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

Пробивание кэша: запросы к несуществующим данным

Со штурмом и лавиной часто путают третью проблему — пробивание (cache penetration): запросы приходят к данным, которых не существует. Кэш никогда не наполнится по такому ключу — каждый запрос гарантированно проходит сквозь него в базу. Типичный сценарий — бот перебирает идентификаторы товаров или пользователей: миллион запросов к несуществующим записям.

Защиты две. Первая — кэшировать отрицательный результат: по ключу кладётся маркер «такого нет» с коротким TTL, и повторные запросы отбиваются кэшем. Вторая, для больших масштабов, — фильтр Блума перед кэшем: компактная вероятностная структура, которая точно говорит «такого ключа нет» (и лишь иногда ошибочно говорит «возможно, есть»); заведомо несуществующие запросы разворачиваются ещё до кэша.

Имена ключей и их версии

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

Форма. Ключ строят из частей через двоеточие, от общего к частному: shop:product:42:card, shop:user:17:cart. Первая часть — пространство имён сервиса (чтобы в общем Redis не пересечься с соседями), дальше сущность, идентификатор и, если нужно, вид представления. По такому ключу видно, чьи это данные и что в них лежит, а обслуживающий скрипт может обойти нужный префикс курсорным SCAN.

Целиком или по полям. Объект кладут целиком (одна строка с сериализованным значением), когда его всегда читают полностью, — это одна команда и один разбор. По полям (хэш) — когда читают и обновляют части: счётчик просмотров у товара меняется постоянно, а описание нет, и перезаписывать из-за счётчика весь документ незачем.

Версия формата в ключе. Самое ценное правило и самое часто пропущенное. Когда меняется структура значения (добавили поле, сменили сериализацию), старые записи в кэше становятся несовместимыми с новым кодом — и это выстреливает на выкате, когда новая версия читает старый формат и падает на разборе. Лечится версией в ключе: shop:product:v3:42. Новая версия приложения пишет и читает v3, старые ключи v2 никто не трогает, и они уходят сами по сроку жизни. Никакого «сбросить весь кэш» при выкате не нужно — а вот «сбросить весь кэш» командой FLUSHDB на живой системе означает лавину промахов и поход всех запросов в базу разом.

Как измерить, что кэш работает

Кэш без метрик — это вера. Минимальный набор, который ставят сразу.

Коэффициент попаданий. В Redis это keyspace_hits и keyspace_misses из INFO stats, отношение считают за интервал: hits / (hits + misses). Что считать нормой, зависит от сценария: для кэша горячих карточек 90–95 % и выше — хорошо, 60–70 % — сигнал, что либо срок жизни слишком короткий, либо кэшируется не то, либо ключей слишком много и они вытесняются. Низкий коэффициент при высоком evicted_keys — это прямой диагноз: не хватает памяти.

Промахи на стороне приложения. Счётчики Redis считают все обращения вместе; чтобы понять, какой именно кэш плох, нужны метрики на уровне приложения. В Spring это готовые метрики кэша (cache.gets с тегом результата) — они разложены по именам кэшей, и в них сразу видно, какой из них не работает.

Время ответа с кэшем и без. Смысл кэша — не в проценте попаданий, а в сэкономленном времени. Если запрос к базе стоит 3 мс, а обращение к Redis по сети — 0,5 мс, выигрыш есть, но небольшой; кэшировать имеет смысл то, что стоит десятки и сотни миллисекунд.

Признак, что кэш не работает вовсе. Коэффициент попаданий около нуля при нормальном трафике почти всегда означает не «данные не повторяются», а ошибку: ключ включает что-то уникальное (метку времени, идентификатор запроса), срок жизни в секундах вместо минут или самовызов метода мимо прокси в Spring.

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

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

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

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

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

Коротко

  • Cache-aside — основной паттерн: проверяем Redis, при промахе идём в базу и кладём результат в кэш.
  • Write-through — запись сразу в оба хранилища; актуальность гарантирована, но запись медленнее.
  • Write-behind — запись асинхронная; быстро, но с риском потери при сбое.
  • TTL задаёт время жизни ключа; инвалидация (DEL / @CacheEvict) сбрасывает его явно при изменении данных. Пачку ключей вычищают через SCAN, а не KEYS.
  • Штурм кэша — толпа за одним истёкшим горячим ключом; лечится @Cacheable(sync = true) (только внутри одной машины), распределённым замком или ранним обновлением в фоне с отдачей ещё живого значения. Лавина — много ключей истекли разом; лечится случайной добавкой к сроку жизни.
  • Пробивание — запросы к тому, чего в базе нет; спасает кэш отрицательного ответа или фильтр Блума.
  • Кэш не заменяет транзакции — данные в Redis и базе всегда расходятся хотя бы на мгновение; выбирайте стратегию под допустимое окно рассогласования.
  • Недоступный Redis не должен ронять запросы: короткие таймауты, CacheErrorHandler на проглатывание ошибок, размыкатель — и проверка нагрузочным тестом, что база переживёт работу без кэша.
  • Ключи строят из частей через двоеточие и с версией формата (product:v3:42) — тогда выкат нового формата не требует сброса кэша, который сам по себе кладёт базу лавиной промахов.
  • Кэш без метрик — вера: считают коэффициент попаданий (keyspace_hits/keyspace_misses, метрики кэшей в приложении) и выигрыш во времени; ноль попаданий обычно означает уникальный ключ или самовызов мимо прокси.

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