Основы кэширования в Redis: шаблоны, подводные камни и темы для собеседований
Кэширование с использованием Redis в Node.js: модель кэша-асайд, TTL, защита от атак типа «стампид», политики удаления данных, а также вопросы, связанные с проектированием систем, которые возникают при обсуждении этих тем.
Источники: Redis Isn’t Just a Cache: The Concepts Every Backend Developer Should Know
Зачем нам кэш, если у нас уже есть база данных?
Представьте конечную точку в вашем приложении, выглядящую так:
GET /products/123
Клиент обращается к этому маршруту, и ваш сервер запрашивает данные у MongoDB:
API → MongoDB → Product
База данных возвращает запись о продукте, и всё работает нормально.
Теперь представьте, что 10 000 человек одновременно запрашивают тот же продукт.
Вам придётся тысячи раз выполнять точно такие же запросы и обработки:
Request
↓
Database
↓
Same Product
Ваша база данных постоянно выполняет одинаковую работу для одинаковых запросов.
Разве не было бы лучше, если бы ответ можно было просто сохранить где-то?
Именно для этого и существует кэширование.
Что такое кэширование?
Кэш — это более быстрый уровень хранения, который временно сохраняет часто запрашиваемые данные, чтобы приложению не приходилось каждый раз обращаться к более медленному основному хранилищу данных.
Вместо обычного порядка действий:
Request → Database → Response
мы вводим ускорение:
Request
↓
Cache
↓
Hit? ── Yes → Response
│
No
↓
Database
↓
Cache
↓
Response
Цель не в том, чтобы полностью избавиться от базы данных.
Цель — сократить количество операций с базой данных, которые не нужно выполнять дважды.
Успешный поиск в кэше против неудачного
Эти два понятия встречаются постоянно, поэтому стоит знать их наизусть.
Успешный поиск в кэше
Это происходит, когда искомые данные уже находятся в кэше.
Request
↓
Redis
↓
Found ✅
↓
Response
Неудачный поиск в кэше
Это происходит, когда в кэше нет нужных данных.
Request
↓
Redis
↓
Not Found ❌
↓
Database
↓
Store in Redis
↓
Response
Полезным показателем здесь является коэффициент успешных поисков в кэше:
Cache Hit Ratio =
Cache Hits / Total Cache Requests
Более высокое соотношение обычно указывает на то, что больше запросов обрабатывается непосредственно из кэша, а не из основной базы данных.
Однако большое число не означает автоматически успех. Если кэш заполнен устаревшими или менее важными записями, высокое соотношение успешных запросов не делает его полезным.
Как же здесь применяется Redis?
Redis — это хранилище данных в оперативной памяти, часто используемое для кэширования, хотя его возможности значительно выходят за рамки этого одного случая применения.
Он предоставляет ряд структур данных, включая:
- Строки
- Хэши
- Списки
- Множества
- Отсортированные множества
- Стримы
- JSON
- А также другие специализированные типы
Для простого кэша его можно рассматривать следующим образом:
Key Value
────────────────────────────
product:123 Product data
user:42 User data
Поскольку Redis хранит активные данные в памяти, операции чтения из кэша могут быть чрезвычайно быстрыми.
Redis в приложении Node.js
Чтобы использовать клиент node-redis, его устанавливают следующим образом:
npm install redis
Настройка базового подключения требует всего нескольких строк кода:
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
После этого хранение и извлечение значений происходит очень просто:
await redis.set("user:42", "Hamza");
const user = await redis.get("user:42");
Официальная документация Redis в настоящее время рекомендует node-redis в качестве клиента для проектов Node.js.
Однако знание команд SET и GET помогает лишь отчасти.
Настоящий вопрос, на который стоит ответить, звучит так:
Как на самом деле спроектировать приложение с использованием Redis?
Самый важный паттерн: Cache-Aside
Если существует один шаблон, который каждый разработчик бэкенда должен изучить в первую очередь, это Cache-Aside.
В этом шаблоне само приложение отвечает за управление кэшем.
Request
↓
Check Redis
↓
┌───────────────┐
│ Cache Hit? │
└───────────────┘
↓ Yes ↓ No
Response Database
↓
Redis
↓
Response
Упрощённая версия на коде выглядит так:
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);const product = await Product.findById(id);await redis.set(key, JSON.stringify(product), {
EX: 300
});
Параметр EX: 300 устанавливает TTL в 300 секунд для записи в кэше.
Cache-Aside, пожалуй, является наиболее распространённым шаблоном кэширования с использованием Redis.
TTL: Кэш не должен существовать вечно
Предположим, вы сделали кэш следующего содержимого:
product:123
Что должно произойти, если позже обновится цена товара?
Без установки срока истечения пользователи могут бесконечно видеть старую цену.
Вот почему существует понятие TTL, или Time To Live.
Product stored
↓
TTL = 300 seconds
↓
Expires
↓
Next request → Database → Fresh cache
TTL устанавливает предел того, как долго могут подаваться устаревшие данные.
Тем не менее старайтесь не выбирать чрезмерно большое значение TTL.
Правильное значение полностью зависит от того, насколько устаревшие данные может позволить себе ваше приложение.
Аннулирование кэша: знаменитая сложная проблема
Рассмотрим следующую ситуацию:
Database:
price = ₹799
Redis:
price = ₹799
Затем база данных обновляется:
Database:
price = ₹899
Но Redis по-прежнему хранит:
price = ₹799
На этом этапе ваше приложение подает несогласованные данные в зависимости от источника чтения.
Распространенным решением является очистка кэша каждый раз, когда меняются основные данные:
await Product.findByIdAndUpdate(id, update);
await redis.del(`product:${id}`);
После этого следующий запрос проходит по этому пути:
Redis → Miss
↓
Database → ₹899
↓
Redis → Store fresh value
Для систем, построенных на принципе кэша в стороне, простое удаление соответствующей записи кэша при каждой записи обычно проще и безопаснее, чем попытка постоянно синхронизировать кэш с базой данных.
Три проблемы кэша, о которых стоит знать
Эти темы отлично подходят для обсуждения на собеседованиях.
1. Атака на кэш
Представьте, что срок действия часто используемого ключа истекает:
product:123 → EXPIRED
Сразу после этого поступает 1 000 запросов одновременно.
Каждый из них не находится в кэше.
Каждый из них направляется непосредственно в базу данных.
1000 requests
↓
1000 DB queries 💥
Кэш предназначался для снижения нагрузки на базу данных, но сам факт истечения срока действия вызвал всплеск нагрузки.
Типичные способы защиты от этого включают:
- Блокировку или ограничение количества одновременных запросов, чтобы только один запрос обновлял кэш, пока остальные ждут
В собственной документации Redis прямо описаны стратегии защиты от атак на «горячие» ключи.
2. Проникновение в кэш
Что происходит, когда клиенты продолжают запрашивать данные, которых просто нет?
GET /users/999999
Redis возвращает сообщение об отсутствии данных.
База данных возвращает сообщение «не найдено».
Следующий идентичный запрос повторяет точно тот же цикл.
Злонамеренный пользователь или неисправный клиент могут воспользоваться этим, чтобы обрушить базу данных бесполезными запросами.
Полезной мерой противодействия является отрицательный кэширование — кэширование информации об отсутствии данных:
user:999999 → NOT_FOUND
TTL → short
Благодаря этому механизму повторные запросы к одной и той же отсутствующей записи могут обрабатываться непосредственно в Redis, без необходимости обращения к базе данных каждый раз.
3. Аваланш кэша
Теперь представьте себе большую группу ключей в кэше, все из которых истекают примерно в один и тот же момент.
Key A → expired
Key B → expired
Key C → expired
Key D → expired
↓
Massive DB traffic
Такая ситуация называется аваланшем кэша.
Типичным способом снижения риска является избегание присваивания одинаковых времен истечения срока хранения большим группам ключей — например, путем добавления небольшого случайного отклонения к каждому TTL.
Исключение из кэша: что происходит, когда у Redis заканчивается память?
Память Redis не является бесконечной.
Как только использование памяти достигает установленного лимита, Redis начинает удалять ключи в соответствии с заданным правилом удаления. В состав Redis входит несколько таких режимов удаления, которые позволяют удалять наименее часто используемые записи во всем пространстве имён ключей или только среди ключей, у которых указан срок истечения, удалять наименее часто используемые записи в одном из этих двух диапазонов, удалять записи в зависимости от того, какие ключи ближе всего к истечению срока, или полностью отказываться принимать новые записи при полной загрузке памяти.
LRU по сути означает:
Удалять то, с чем давно не работали.
LFU по сути означает:
Удалять то, к чему редко обращаются.
Выбор наиболее подходящего режима зависит от ваших конкретных паттернов доступа.
Redis — это не просто кэш
Этот вопрос часто возникает во время интервью.
Поскольку Redis предоставляет несколько встроенных структур данных, он может использоваться для задач, выходящих далеко за рамки кэширования:
Strings → Caching
Hashes → Objects / sessions
Lists → Queues
Sets → Unique collections
Sorted Sets → Leaderboards
Streams → Event processing
Следовательно, утверждение о том, что:
"Redis — это база данных для кэширования"
описывает лишь часть сути.
Более точное описание звучит так:
Redis — это хранилище структур данных в оперативной памяти, обычно используемое для кэширования, но также способное обрабатывать сессии, очереди, топ-листы, ограничение скорости запросов, систему обмена сообщениями типа pub/sub и многое другое.
В официальной документации Redis по типам данных рассматриваются эти структуры вместе с другими специализированными типами, которые он поддерживает.
А что насчет сохранения данных в Redis?
Поскольку Redis находится в памяти, приводит ли перезагрузка к полной потере данных?
Не обязательно.
Redis предлагает несколько стратегий сохранения данных:
- RDB — периодические снимки состояния данных в определенный момент времени
- AOF — журнал, в котором записываются все операции записи
- RDB + AOF — комбинированный подход
- Отсутствие сохранения данных вовсе
Если Redis используется только в качестве кэша, потеря его содержимого при перезагрузке часто не имеет значения, поскольку приложение может просто восстановить данные из основной базы данных.
Но если Redis хранит данные, которые имеют важное значение сами по себе, настройки сохранения данных становятся гораздо более значимой проблемой.
Распространенная ловушка на собеседовании
«Почему бы просто не хранить все в Redis?»
Потому что память Redis — это ограниченный и относительно дорогой ресурс по сравнению с традиционными носителями постоянного хранения.
Это означает, что кэширование не следует проводить бездумно.
Хорошие кандидаты на хранение в кэше обладают следующими особенностями:
- Они запрашиваются часто
- Их получение или обработка требует значительных ресурсов
- Допускается определённая устарелость данных
- Они оправдывают затраты памяти на их хранение в доступном месте
Рассматривайте Redis как слоь для улучшения производительности, работающую параллельно с вашей системой, а не как полную замену основной базе данных.
Локальный кэш против распределённого кэша
Представьте приложение Node.js, работающее на трёх отдельных серверах:
Server A → Cache A
Server B → Cache B
Server C → Cache C
Каждый из этих серверов создаёт собственный независимый кэш.
Пользователь может попасть на сервер A и получить данные из кэша, а затем — на сервер B и не найти эти же данные в кэше.
Введение общего экземпляра Redis меняет ситуацию:
┌→ Server A ─┐
Client ──────┼→ Server B ─┼→ Redis
└→ Server C ─┘
Теперь каждый экземпляр приложения использует один и тот же общий кэш.
Это преимущество общего состояния является одной из главных причин, по которым Redis особенно хорошо справляется при горизонтальном масштабировании приложения.
Более сложная ситуация на собеседовании
Предположим, что на собеседовании вас спросят об этой задаче:
"Разработайте стратегию кэширования для API продукта с высоким трафиком."
Слабый ответ ограничивается следующим:
"Я буду использовать Redis."
Более содержательный ответ включает обоснование:
Request
↓
Redis
↙ ↘
Hit Miss
↓ ↓
Response Database
↓
Redis
↓
Response
Затем описываются каждое из этих решений по проектированию:
1. Шаблон кэширования
Следует использовать подход cache-aside.
2. TTL
Значение должно определяться в зависимости от того, насколько долго данные продукта могут оставаться устаревшими.
3. Аннулирование кэша
Соответствующий ключ следует удалять каждый раз при обновлении основных данных.
4. Защита от спам-запросов
Используйте техники блокировки, объединения запросов или досрочного обновления.
5. Выгон из памяти
Выберите подходящую политику LRU или LFU для данной нагрузки.
6. Проектирование ключей
product:{id}
user:{id}
7. Мониторинг
Следите за:
- Коэффициентом успешных запросов
- Коэффициентом неудачных запросов
- Задержкой
- Использованием памяти
- Количеством операций выгон из памяти
- Популярными ключами
- Ошибками Redis
Такой пошаговый подход позволяет дать гораздо более убедительный ответ по проектированию системы, чем простое утверждение о том, что "Redis быстр.
Краткий обзор вопросов для собеседования
Зачем вообще использовать кэширование?
Оно снижает задержку и освобождает основное хранилище данных от необходимости постоянно обрабатывать дорогостоящие запросы.
Почему именно Redis?
Он обеспечивает быстрый доступ из памяти, поддержку истечения срока действия, универсальные структуры данных и функции, разработанные для распределенных систем.
Что означает подход cache-aside?
Приложение сначала ищет данные в кэше; если их там нет, оно запрашивает базу данных, а затем записывает полученный результат обратно в кэш.
Что означает TTL?
Это срок существования записи в кэше до ее автоматического истечения.
Что означает аннулирование кэша?
Это очистка или обновление записей в кэше каждый раз, когда меняются исходные данные.
Что такое атака типа cache stampede?
Это ситуация, когда большое количество запросов одновременно не находит данных в кэше и все они обращаются к базе данных одновременно.
LRU против LFU?
LRU удаляет ту запись, которая использовалась реже всего. LFU удаляет ту запись, которая использовалась в целом реже всего.
Заслуживает ли каждый запрос кэширования?
Совсем нет. Принимайте решение, исходя из того, как часто доступны данные, насколько дорогостоящим является их получение, насколько допустима старость данных и сколько памяти у вас свободно.
Модель мышления, которую стоит сохранить
Не старайтесь сразу запоминать команды Redis. Вместо этого сосредоточьтесь на основной архитектуре:
┌─────────────┐
Request ─────→│ Redis │
└──────┬──────┘
│
Cache Hit?
↙ ↘
YES NO
↓ ↓
Response Database
↓
Redis
↓
Response
Затем рассмотрите четыре вопроса:
Что должно находиться в кэше?
Как долго оно должно там оставаться?
Когда его необходимо аннулировать?
Что произойдет, если кэш выйдет из строя или истечет срок действия при высокой нагрузке?
Работа с этими вопросами — это то, что отличает поверхностные знания о кэшировании от мышления уровня производственных систем.
Заключение
Кэширование — это не просто:
"Разместите Redis перед MongoDB."
Эффективная стратегия кэширования в основном сводится к компромиссам.
Вы делаете обмен:
Памятью на скорость
и, иногда:
Свежестью данных на производительность
Redis предоставляет механизмы, но именно логика вашего приложения определяет, как они будут использоваться.
Если вы готовитесь к собеседованиям, не ограничивайтесь ответом:
"Redis — это база данных в памяти."
Будьте готовы подробно рассказать о:
методе кэширования в сторону, TTL, аннулировании данных, коэффициенте успешных запросов, процедуре удаления данных из кэша, явлении стампид, проникновении в систему, эффекте лавины, распределенном кэшировании и согласованности данных.
Как только вы поймете, как эти концепции взаимосвязаны, вы уже не просто знаете Redis.
Теперь вы понимаете, как системы с высокой нагрузкой защищают свои базы данных, сохраняя при этом высокую скорость.