Redis используют прежде всего как кэш: он держит горячие данные в памяти, чтобы приложение не ходило в базу при каждом запросе. Но «поставить Redis и закэшировать всё подряд» — не стратегия. Разные данные требуют разных подходов к чтению, записи и протуханию.
Cache-aside: приложение сначала спрашивает Redis и идёт в базу только на промахе, а результат кладёт обратно с ограниченным сроком жизни. Пока ключ жив, база не участвует вовсе; когда срок вышел, Redis убирает ключ, и следующий читатель снова оплачивает поход в базу. Отсюда две настройки, которые решают всё: что кладём в кэш и на сколько.
Зачем вообще нужен кэш
Без кэша каждый запрос к популярной странице или часто читаемой записи означает запрос к базе данных. База справляется с тысячами запросов в секунду — но не с десятками тысяч одинаковых. Кэш берёт на себя повторяющиеся чтения: база отдыхает, ответ приходит быстрее.
Короткая формула: кэш эффективен там, где одни и те же данные читают часто, а меняют редко.
Cache-aside (ленивое кэширование)
Cache-aside — самый распространённый паттерн. Приложение само управляет кэшем:
- Нужны данные → проверяем Redis.
- Попадание (cache hit): данные есть — возвращаем их, в базу не идём.
- Промах (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): один популярный ключ истёк, и вся толпа читателей одновременно ломится за ним в базу. Рядом живёт похожая беда — лавина, когда разом истекает множество разных ключей, например потому, что их залили в кэш одной пачкой с одинаковым сроком жизни. Симптом один и тот же, всплеск нагрузки на базу, а лечится по-разному: от штурма спасает блокировка на пересчёт, от лавины — случайная добавка к сроку жизни, чтобы ключи истекали вразнобой.
Толпу видно на восьми читателях, которые пришли за одним истёкшим ключом.
Один истёкший ключ и восемь читателей: без замка в базу уходят восемь запросов, под замком один, а остальные ждут его результат.
живой пример
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.
Только это смягчение, а не решение. Окно сдвигается, но не закрывается: читатель, успевший сходить в базу до фиксации, положит прочитанное там старое значение в кэш уже после сброса — и оно переживёт сброс. Поэтому срок жизни у ключа обязателен в любом случае, а там, где цена устаревшего значения высока, ключ удаляют дважды: сразу после фиксации и ещё раз через небольшую паузу, чтобы добить как раз такого опоздавшего.
Окно между сбросом ключа и фиксацией записи: смотрите на две выделенные отметки, читатель взял из базы старое значение и вернул его в кэш уже после сброса.
Полная строгая согласованность (без любого окна рассогласования) требует транзакций между 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, метрики кэшей в приложении) и выигрыш во времени; ноль попаданий обычно означает уникальный ключ или самовызов мимо прокси.
Что почитать дальше
- Основы Redis: структуры данных и команды — с чего начинается Redis.
- Структуры данных Redis: строки, хэши, множества, списки — что класть в кэш кроме строк.
- Redis не только для кэша: блокировки, очереди, Pub/Sub — тот самый общий замок.
- Redis в Spring Boot:
@Cacheable,RedisTemplate— как включить это в приложении.