Redis и кэширование
Redis — высокопроизводительное хранилище данных в оперативной памяти, работающее по модели «ключ — значение».
Redis часто используют для:
- кэширования результатов запросов;
- хранения пользовательских сессий;
- счётчиков и ограничений частоты запросов;
- очередей и временных данных;
- рейтингов;
- распределённых блокировок;
- обмена сообщениями через Pub/Sub;
- хранения данных с ограниченным сроком жизни.
Упрощённая схема кэширования:
Клиент
↓
Приложение
↓
Redis
↓ при отсутствии данных
База данныхRedis хранит значения в памяти, поэтому обычно работает значительно быстрее дисковой базы данных. Однако это не означает, что Redis всегда должен заменять основную базу.
Чаще всего роли распределяются так:
Основная база данных — источник истины
Redis — быстрый временный слойНапример, информация о товаре хранится в PostgreSQL, а часто запрашиваемое представление товара временно сохраняется в Redis.
Основные свойства Redis
Redis поддерживает:
- разные структуры данных;
- атомарные операции;
- TTL для автоматического удаления ключей;
- транзакции;
- Lua-скрипты;
- репликацию;
- кластеризацию;
- сохранение данных на диск;
- Pub/Sub;
- потоки сообщений;
- блокирующие операции со списками.
Redis обычно выполняет команды последовательно в основном потоке обработки команд. Благодаря этому отдельная команда является атомарной.
Например:
INCR page:viewsможно безопасно вызывать из нескольких процессов. Redis не потеряет увеличение счётчика из-за обычной гонки чтения и записи.
Небезопасный подход без атомарной операции:
const currentValue = Number(
await redis.get("page:views"),
);
await redis.set(
"page:views",
currentValue + 1,
);Два процесса могут одновременно прочитать одно значение и записать одинаковый результат.
Предпочтительно:
await redis.incr("page:views");Подключение Redis в JavaScript
Для Node.js можно использовать официальный клиент redis.
Установка:
npm install redisСоздание клиента:
import { createClient } from "redis";
const redis = createClient({
url: process.env.REDIS_URL,
});
redis.on("error", (error) => {
console.error("Redis error", error);
});
await redis.connect();Пример строки подключения:
redis://localhost:6379С именем пользователя и паролем:
redis://username:password@redis.example.com:6379Для защищённого TLS-соединения может использоваться схема:
rediss://Секреты подключения нельзя хранить непосредственно в коде:
// Нежелательно
const redis = createClient({
url: "redis://admin:production-password@redis:6379",
});Предпочтительно:
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
throw new Error(
"Переменная REDIS_URL не настроена",
);
}
const redis = createClient({
url: redisUrl,
});Завершение соединения:
await redis.quit();В приложении обычно создаётся один Redis-клиент или небольшой контролируемый набор клиентов, а не новое соединение на каждый HTTP-запрос.
Ключи Redis
Данные в Redis сохраняются под строковыми ключами:
user:42
product:815
session:7c8f1a
cache:product:815
rate-limit:login:192.0.2.10Двоеточие не имеет специального значения для Redis, но помогает визуально разделять части ключа.
Удобное соглашение:
<назначение>:<сущность>:<идентификатор>Примеры:
cache:user:42
cache:product:815
session:abc123
counter:article:81:views
leaderboard:weeklyВерсию формата можно включить в ключ:
cache:v1:product:815После изменения структуры данных приложение может начать использовать:
cache:v2:product:815Старые ключи постепенно исчезнут по TTL, не конфликтуя с новым форматом.
Правила проектирования ключей
Ключи должны быть:
- предсказуемыми;
- достаточно короткими;
- уникальными в своём назначении;
- единообразными;
- независимыми от чувствительных данных;
- пригодными для удаления и поиска по префиксу.
Нежелательно помещать в ключ:
password
access token
полный email
персональные данные
очень длинный URLНежелательный ключ:
cache:user:anna@example.com:full-profile-with-all-settingsПредпочтительно использовать внутренний ID:
cache:user:42:profileСтруктуры данных Redis
Redis — не просто хранилище строк. Он поддерживает несколько структур данных, каждая из которых подходит для определённых сценариев.
Основные структуры:
| Структура | Назначение |
|---|---|
| String | Строка, число, JSON или бинарное значение |
| Hash | Набор полей одного объекта |
| List | Упорядоченная последовательность |
| Set | Неупорядоченное множество уникальных значений |
| Sorted Set | Уникальные значения с числовым рейтингом |
String
String — базовый и наиболее универсальный тип Redis.
В строке можно хранить:
- обычный текст;
- число;
- JSON;
- сериализованный объект;
- бинарные данные;
- флаг;
- идентификатор.
Запись:
SET user:name "Анна"Чтение:
GET user:nameУдаление:
DEL user:nameJavaScript:
await redis.set(
"user:42:name",
"Анна",
);
const name = await redis.get(
"user:42:name",
);
console.log(name);JSON в String
Redis не преобразует JavaScript-объекты автоматически. Объект можно сериализовать в JSON:
const user = {
id: 42,
name: "Анна",
role: "editor",
};
await redis.set(
"cache:user:42",
JSON.stringify(user),
);Чтение:
const cachedValue = await redis.get(
"cache:user:42",
);
const user = cachedValue
? JSON.parse(cachedValue)
: null;Нужно учитывать ошибки повреждённого или устаревшего JSON:
async function readJson(key) {
const value = await redis.get(key);
if (value === null) {
return null;
}
try {
return JSON.parse(value);
} catch (error) {
await redis.del(key);
throw new Error(
`Некорректное JSON-значение в ключе ${key}`,
{
cause: error,
},
);
}
}Числовые строки
Redis может атомарно изменять строку, содержащую число.
Увеличение:
INCR article:42:viewsУвеличение на определённое значение:
INCRBY article:42:views 10Уменьшение:
DECR inventory:product:815JavaScript:
const views = await redis.incr(
"article:42:views",
);
console.log(views);Счётчик с TTL:
const key =
"rate-limit:login:user-42";
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 60);
}Для критичной логики несколько команд следует объединять атомарно, например через транзакцию или Lua-скрипт. Иначе процесс может завершиться после INCR, но до установки TTL.
Условная запись
Записать значение, только если ключа ещё нет:
SET lock:report "token" NX EX 30JavaScript:
const result = await redis.set(
"lock:report",
"unique-owner-token",
{
NX: true,
EX: 30,
},
);
if (result === "OK") {
console.log("Блокировка получена");
}NX означает:
Записать только при отсутствии ключаXX означает:
Записать только при наличии ключаТакие операции могут использоваться для простых блокировок и дедупликации, но корректная распределённая блокировка требует аккуратного управления владельцем и временем жизни.
Нельзя безусловно удалять блокировку:
await redis.del("lock:report");Пока операция выполнялась, TTL мог истечь, а ключ мог получить другой процесс. Удаление должно происходить только в том случае, если значение ключа всё ещё совпадает с токеном текущего владельца.
Hash
Hash — набор полей внутри одного ключа.
Hash удобно использовать для объекта:
user:42
├── name = Анна
├── email = anna@example.com
└── role = editorЗапись нескольких полей:
HSET user:42 name "Анна" email "anna@example.com" role "editor"Получение одного поля:
HGET user:42 nameПолучение всех полей:
HGETALL user:42Удаление поля:
HDEL user:42 roleJavaScript:
await redis.hSet(
"user:42",
{
name: "Анна",
email: "anna@example.com",
role: "editor",
},
);Чтение одного поля:
const role = await redis.hGet(
"user:42",
"role",
);Чтение объекта:
const user = await redis.hGetAll(
"user:42",
);
console.log(user);Результат содержит строковые значения:
{
name: "Анна",
email: "anna@example.com",
role: "editor"
}Числа и логические значения необходимо преобразовывать самостоятельно:
await redis.hSet(
"product:815",
{
name: "Клавиатура",
price: "5900",
available: "1",
},
);
const product =
await redis.hGetAll(
"product:815",
);
const parsedProduct = {
name: product.name,
price: Number(product.price),
available:
product.available === "1",
};Изменение числового поля Hash
HINCRBY product:815 stock -1JavaScript:
const stock =
await redis.hIncrBy(
"product:815",
"stock",
-1,
);Операция изменения одного поля выполняется атомарно.
Hash или JSON String
Один объект можно хранить как JSON:
await redis.set(
"user:42",
JSON.stringify(user),
);Или как Hash:
await redis.hSet(
"user:42",
user,
);JSON String удобен, если:
- объект читается и записывается целиком;
- важна вложенная структура;
- данные просто кэшируются;
- не требуется изменять отдельные поля в Redis.
Hash удобен, если:
- поля читаются отдельно;
- поля изменяются независимо;
- нужны атомарные счётчики;
- объект имеет плоскую структуру.
Обычный Hash не хранит вложенные JavaScript-объекты как полноценные вложенные структуры. Такие поля придётся отдельно сериализовать.
List
List — упорядоченная последовательность строк.
Элементы могут добавляться в начало или конец:
LPUSH notifications "first"
RPUSH notifications "last"Получение диапазона:
LRANGE notifications 0 -1Извлечение первого элемента:
LPOP notificationsИзвлечение последнего:
RPOP notificationsJavaScript:
await redis.rPush(
"notifications:user:42",
JSON.stringify({
type: "order_created",
orderId: 815,
}),
);Получение последних элементов:
const values = await redis.lRange(
"notifications:user:42",
0,
9,
);
const notifications = values.map(
(value) => JSON.parse(value),
);Очередь на List
Простая очередь FIFO:
Producer → RPUSH queue item
Consumer → LPOP queueawait redis.rPush(
"queue:emails",
JSON.stringify({
to: "anna@example.com",
template: "welcome",
}),
);Обработчик:
const rawJob = await redis.lPop(
"queue:emails",
);
if (rawJob) {
const job = JSON.parse(rawJob);
await sendEmail(job);
}При таком подходе обработчик должен постоянно проверять список.
Redis поддерживает блокирующие операции, которые ждут появления элемента:
BLPOP queue:emails 10Однако List не предоставляет всех возможностей полноценной надёжной очереди:
- подтверждения обработки;
- повторные попытки;
- журнал сообщений;
- группы потребителей;
- отслеживание необработанных сообщений.
Для более надёжной обработки можно использовать Redis Streams или специализированную очередь.
Ограничение длины списка
Если список хранит только последние события:
LTRIM notifications:user:42 0 99JavaScript:
await redis
.multi()
.lPush(
"notifications:user:42",
JSON.stringify(notification),
)
.lTrim(
"notifications:user:42",
0,
99,
)
.exec();В списке останутся не более 100 последних записей.
Set
Set — неупорядоченное множество уникальных строк.
Один элемент не может присутствовать в Set несколько раз.
Добавление:
SADD article:42:tags redis cache backendПолучение всех элементов:
SMEMBERS article:42:tagsПроверка присутствия:
SISMEMBER article:42:tags redisУдаление:
SREM article:42:tags cacheJavaScript:
await redis.sAdd(
"article:42:tags",
[
"redis",
"cache",
"backend",
],
);Проверка:
const hasRedisTag =
await redis.sIsMember(
"article:42:tags",
"redis",
);Получение:
const tags = await redis.sMembers(
"article:42:tags",
);Применение Set
Set подходит для:
- уникальных тегов;
- ролей пользователя;
- списка участников;
- просмотренных объектов;
- уникальных посетителей;
- связей между сущностями;
- дедупликации.
Пример уникальных просмотров:
await redis.sAdd(
"article:42:viewers",
request.user.id,
);Количество уникальных пользователей:
const uniqueViewers =
await redis.sCard(
"article:42:viewers",
);Операции над множествами
Пересечение:
SINTER user:42:interests user:43:interestsОбъединение:
SUNION user:42:roles user:43:rolesРазность:
SDIFF all:users blocked:usersJavaScript:
const commonTags =
await redis.sInter([
"user:42:tags",
"user:43:tags",
]);Это удобно для поиска общих категорий, разрешений или признаков.
Sorted Set
Sorted Set, или упорядоченное множество, хранит уникальные значения с числовым score.
Значение Score
user:42 1500
user:17 2350
user:81 1100Элементы сортируются по score.
Добавление:
ZADD leaderboard 1500 user:42
ZADD leaderboard 2350 user:17Получение диапазона:
ZRANGE leaderboard 0 -1 WITHSCORESПолучение в обратном порядке:
ZREVRANGE leaderboard 0 9 WITHSCORESJavaScript:
await redis.zAdd(
"leaderboard:weekly",
[
{
score: 1500,
value: "user:42",
},
{
score: 2350,
value: "user:17",
},
],
);Получение элементов:
const leaders =
await redis.zRangeWithScores(
"leaderboard:weekly",
0,
9,
{
REV: true,
},
);Изменение score
ZINCRBY leaderboard 100 user:42JavaScript:
await redis.zIncrBy(
"leaderboard:weekly",
100,
"user:42",
);Место участника
ZREVRANK leaderboard user:42Ранг начинается с нуля:
const rank = await redis.zRevRank(
"leaderboard:weekly",
"user:42",
);
const humanReadableRank =
rank === null
? null
: rank + 1;Sorted Set для отложенных операций
В score можно хранить время Unix:
const executeAt =
Date.now() + 60_000;
await redis.zAdd(
"jobs:scheduled",
{
score: executeAt,
value: "job-815",
},
);Получение готовых задач:
const readyJobs =
await redis.zRangeByScore(
"jobs:scheduled",
0,
Date.now(),
);Для настоящей очереди необходимо дополнительно решить:
- атомарное извлечение;
- конкуренцию обработчиков;
- подтверждение;
- повторную обработку;
- недоступность worker;
- защиту от потери задания.
Выбор структуры данных
| Задача | Структура |
|---|---|
| Кэш JSON-ответа | String |
| Счётчик | String с INCR |
| Плоский объект с отдельными полями | Hash |
| Очередь или последние события | List |
| Уникальные элементы | Set |
| Рейтинг | Sorted Set |
| Отложенные задачи по времени | Sorted Set |
| Пользовательские сессии | String или Hash |
| Временный флаг | String с TTL |
Не следует выбирать структуру только по привычке. Важно учитывать операции, которые приложение выполняет чаще всего.
Кэширование запросов
Кэширование сохраняет результат дорогой операции, чтобы повторно использовать его без полного вычисления или обращения к основной базе.
Можно кэшировать:
- результат SQL-запроса;
- объект по ID;
- список товаров;
- результат внешнего API;
- итог сложного вычисления;
- HTML-фрагмент;
- права пользователя;
- конфигурацию;
- результат сериализации.
Кэш особенно полезен, когда:
- данные читаются значительно чаще, чем изменяются;
- получение данных занимает заметное время;
- допустима небольшая задержка обновления;
- запрос часто повторяется;
- результат можно безопасно использовать для нескольких запросов.
Кэш может быть бесполезен, если:
- данные почти никогда не запрашиваются повторно;
- они изменяются при каждом запросе;
- результат уникален для каждого пользователя;
- основной запрос и так очень быстрый;
- поддержка корректной инвалидации сложнее самого вычисления.
Cache-Aside
Cache-Aside, или lazy loading, — распространённая стратегия кэширования.
Алгоритм:
1. Приложение проверяет Redis.
2. Если значение найдено — возвращает его.
3. Если значения нет — читает основную базу.
4. Сохраняет результат в Redis.
5. Возвращает результат.Схема:
Запрос
↓
Есть значение в Redis?
├── Да → вернуть кэш
└── Нет
↓
запросить базу
↓
сохранить в Redis
↓
вернуть результатПример Cache-Aside
class ProductService {
constructor({
redis,
productRepository,
}) {
this.redis = redis;
this.productRepository =
productRepository;
}
async getProduct(productId) {
const cacheKey =
`cache:v1:product:${productId}`;
const cachedValue =
await this.redis.get(cacheKey);
if (cachedValue !== null) {
return JSON.parse(cachedValue);
}
const product =
await this.productRepository.findById(
productId,
);
if (!product) {
return null;
}
await this.redis.set(
cacheKey,
JSON.stringify(product),
{
EX: 300,
},
);
return product;
}
}EX: 300 устанавливает TTL на 300 секунд.
Cache hit и cache miss
Если значение найдено:
Cache hitЕсли значение отсутствует:
Cache missПолезные метрики:
cache_requests_total{result="hit"}
cache_requests_total{result="miss"}
cache_operation_duration_seconds
cache_errors_totalКоэффициент попаданий:
hit rate =
cache hits / все обращения к кэшуВысокий hit rate не всегда означает хороший кэш. Например, кэш может успешно возвращать устаревшие данные. Нужно одновременно контролировать корректность, latency и стоимость памяти.
Кэширование отсутствующего значения
Если ресурс отсутствует, одинаковые запросы могут постоянно обращаться к базе:
GET /products/999999Можно ненадолго кэшировать отсутствие:
const NOT_FOUND =
"__NOT_FOUND__";
async function getProduct(productId) {
const key =
`cache:v1:product:${productId}`;
const cached = await redis.get(key);
if (cached === NOT_FOUND) {
return null;
}
if (cached !== null) {
return JSON.parse(cached);
}
const product =
await productRepository.findById(
productId,
);
if (!product) {
await redis.set(
key,
NOT_FOUND,
{
EX: 30,
},
);
return null;
}
await redis.set(
key,
JSON.stringify(product),
{
EX: 300,
},
);
return product;
}Отрицательный результат обычно кэшируют на короткое время, иначе недавно созданный ресурс может долго оставаться «не найденным».
Кэширование списков и запросов
Для запроса:
GET /products?category=books&page=2&limit=20ключ должен однозначно учитывать параметры:
cache:v1:products:category=books:page=2:limit=20Лучше формировать ключ централизованно:
function createProductListCacheKey({
category,
page,
limit,
}) {
return [
"cache",
"v1",
"products",
`category=${category}`,
`page=${page}`,
`limit=${limit}`,
].join(":");
}Если порядок query-параметров не нормализовать, одинаковые запросы могут создать разные ключи:
?page=2&limit=20
?limit=20&page=2Нежелательно использовать исходный URL напрямую:
const key =
`cache:${request.originalUrl}`;Он может содержать:
- уникальные параметры;
- секретные данные;
- очень длинную строку;
- разный порядок одинаковых параметров;
- значения с высокой cardinality.
Персонализированный кэш
Ответ может зависеть от пользователя:
GET /profileНельзя сохранять персональный ответ под общим ключом:
cache:profileИначе один пользователь может получить данные другого.
Ключ должен учитывать владельца:
cache:user:42:profileНо включение пользователя в каждый ключ увеличивает объём кэша. Нужно решить, действительно ли персональный ответ стоит кэшировать.
Другие стратегии кэширования
Read-Through
Приложение обращается к кэширующему слою, а тот сам загружает данные из основной базы при отсутствии.
Application
↓
Cache abstraction
├── Redis hit
└── Redis miss → DatabaseПреимущество — логика кэширования скрыта от бизнес-кода.
Write-Through
При изменении данные сразу записываются и в основное хранилище, и в кэш.
Application
↓
Write service
├── Database
└── RedisПреимущество — кэш обновляется сразу.
Недостатки:
- запись становится сложнее;
- нужно определить порядок операций;
- возможна частичная ошибка;
- кэш может содержать данные, не подтверждённые базой.
Основная база должна оставаться источником истины.
Write-Behind
Изменение сначала записывается в кэш, а затем асинхронно переносится в основную базу.
Application
↓
Redis
↓ асинхронно
DatabaseТакой подход может ускорить запись, но увеличивает риск потери данных и сложность согласованности.
Он подходит только для специально спроектированных сценариев, где допустимы отложенная запись и восстановление после сбоя.
Refresh-Ahead
Кэш обновляется до истечения TTL, если значение активно используется.
Ключ скоро истечёт
↓
Фоновое обновление
↓
Пользователь продолжает получать старое значение
↓
Кэш заменяется новой версиейЭто уменьшает число запросов, которые сталкиваются с cache miss, но усложняет реализацию.
TTL
TTL (Time To Live) — срок жизни ключа.
Установить TTL в секундах:
EXPIRE cache:product:815 300Записать значение сразу с TTL:
SET cache:product:815 "{...}" EX 300В миллисекундах:
SET cache:product:815 "{...}" PX 5000JavaScript:
await redis.set(
"cache:product:815",
JSON.stringify(product),
{
EX: 300,
},
);Просмотр TTL
TTL cache:product:815Результат:
300 — ключ истечёт примерно через 300 секунд
-1 — ключ существует, но TTL не установлен
-2 — ключ не существуетВ миллисекундах:
PTTL cache:product:815JavaScript:
const ttl = await redis.ttl(
"cache:product:815",
);Удаление TTL
PERSIST cache:product:815После этого ключ останется без срока жизни.
Для обычного кэша отсутствие TTL часто является ошибкой: значение может оставаться навсегда и занимать память.
Выбор TTL
TTL зависит от:
- частоты изменения данных;
- допустимой устарелости;
- стоимости получения;
- объёма значения;
- нагрузки на базу;
- важности актуальности;
- механизма инвалидации.
Примеры ориентировочной логики:
| Данные | Возможный TTL |
|---|---|
| Публичный каталог | Несколько минут |
| Настройки приложения | Минуты или часы |
| Профиль пользователя | Короткий TTL с инвалидацией |
| Отрицательный результат | Несколько секунд |
| Одноразовый код | Строгий срок действия |
| Сессия | Срок жизни сессии |
| Результат дорогого отчёта | Минуты или часы |
Конкретные значения должны определяться требованиями приложения, а не копироваться без анализа.
Абсолютный и скользящий TTL
Абсолютный TTL
Ключ истекает через заданное время независимо от использования:
await redis.set(
key,
value,
{
EX: 300,
},
);Скользящий TTL
Срок жизни продлевается при обращении:
const value = await redis.get(key);
if (value !== null) {
await redis.expire(key, 300);
}Скользящий TTL полезен для сессий, но может привести к тому, что активно используемый ключ будет жить почти бесконечно.
Для чувствительных сессий часто задают одновременно:
- период бездействия;
- абсолютный максимальный срок.
TTL jitter
Если множество ключей создаются одновременно с одинаковым TTL, они могут истечь в один момент.
10 000 ключей истекают через 300 секунд
↓
10 000 запросов обращаются к базеЭто называется массовым истечением кэша.
Можно добавить случайное отклонение:
function createTtlWithJitter({
baseSeconds,
jitterSeconds,
}) {
return (
baseSeconds +
Math.floor(
Math.random() *
(jitterSeconds + 1),
)
);
}Использование:
const ttl =
createTtlWithJitter({
baseSeconds: 300,
jitterSeconds: 60,
});
await redis.set(
key,
JSON.stringify(value),
{
EX: ttl,
},
);Теперь ключи истекут в диапазоне от 300 до 360 секунд.
Инвалидация кэша
Инвалидация — удаление или обновление кэшированного значения после изменения исходных данных.
Известная проблема:
Кэш сложно правильно инвалидировать.Если данные изменились в базе, но старое значение осталось в Redis, пользователи будут видеть устаревший результат.
Удаление после изменения
Простая схема:
1. Изменить данные в базе.
2. Удалить соответствующий ключ из Redis.
3. Следующее чтение снова заполнит кэш.Пример:
class ProductService {
constructor({
productRepository,
redis,
}) {
this.productRepository =
productRepository;
this.redis = redis;
}
async updateProduct(
productId,
changes,
) {
const product =
await this.productRepository.update(
productId,
changes,
);
await this.redis.del(
`cache:v1:product:${productId}`,
);
return product;
}
}Сначала изменяется источник истины, затем удаляется кэш.
Нежелательный порядок:
await redis.del(cacheKey);
await productRepository.update(
productId,
changes,
);Между операциями другой запрос может прочитать старые данные из базы и снова записать их в кэш.
Обновление кэша после записи
Можно сразу записать новое значение:
const product =
await productRepository.update(
productId,
changes,
);
await redis.set(
`cache:v1:product:${productId}`,
JSON.stringify(product),
{
EX: 300,
},
);Преимущество — следующий запрос сразу получает новые данные.
Недостаток — нужно убедиться, что кэшируемое представление полностью соответствует результату чтения из базы.
Часто безопаснее удалить ключ и позволить обычному чтению заполнить его заново.
Инвалидация связанных ключей
Изменение товара может затрагивать:
cache:product:815
cache:products:category=books:page=1
cache:products:search=redis
cache:recommendations:user:42Удалить все связанные списки сложно.
Возможные подходы:
- короткий TTL для списков;
- отдельный индекс зависимых ключей;
- версионирование ключей;
- событие об изменении;
- инвалидация по категории;
- отказ от кэширования слишком сложного представления.
Версионирование группы ключей
Можно хранить версию:
products:cache-version = 17Ключ списка:
cache:products:v17:category=books:page=1После изменения каталога версия увеличивается:
await redis.incr(
"products:cache-version",
);Новые запросы используют новую версию, а старые ключи исчезают по TTL.
Преимущество — не нужно искать и удалять все старые ключи.
Недостаток — старые значения временно продолжают занимать память.
Удаление по шаблону
Команда:
KEYS cache:products:*может блокировать Redis при большом количестве ключей. Её не следует использовать в production на крупных наборах данных.
Для итерации используется SCAN:
SCAN 0 MATCH cache:products:* COUNT 100JavaScript:
for await (
const keys
of redis.scanIterator({
MATCH: "cache:products:*",
COUNT: 100,
})
) {
if (keys.length > 0) {
await redis.del(keys);
}
}Даже SCAN и массовое удаление создают нагрузку. Частая инвалидация по шаблону обычно указывает на необходимость другой схемы ключей.
Проблемы согласованности
Кэш и основная база — две разные системы, поэтому между ними возможны расхождения.
Пример гонки:
Запрос A не находит ключ в Redis
Запрос A читает старое значение из базы
Запрос B обновляет базу
Запрос B удаляет ключ
Запрос A записывает старое значение в RedisВ результате кэш снова содержит устаревшие данные.
Варианты снижения риска:
- короткий TTL;
- версионирование;
- обновление кэша после транзакции;
- события изменения;
- блокировка заполнения;
- повторное удаление с небольшой задержкой;
- отказ от кэша для критичных данных;
- проверка версии данных.
Абсолютная согласованность между базой и кэшем требует более сложного дизайна. Для многих кэшей принимается модель eventual consistency — данные становятся согласованными через некоторое время.
Cache Stampede
Cache stampede, или «стадный эффект», возникает, когда популярный ключ истекает и множество запросов одновременно обращается к базе.
Популярный ключ истёк
↓
1000 одновременных cache miss
↓
1000 одинаковых SQL-запросовЗащита блокировкой
Один процесс получает право обновить ключ:
const lockKey =
`lock:cache:product:${productId}`;
const lockToken =
crypto.randomUUID();
const lockAcquired =
await redis.set(
lockKey,
lockToken,
{
NX: true,
EX: 10,
},
);Если блокировка получена, процесс загружает данные и обновляет кэш.
Остальные процессы:
- коротко ждут;
- повторно проверяют кэш;
- возвращают слегка устаревшее значение;
- используют резервный путь.
Правильное освобождение блокировки должно атомарно проверить владельца и удалить ключ. Обычно для этого используется Lua-скрипт:
const releaseLockScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`;
await redis.eval(
releaseLockScript,
{
keys: [lockKey],
arguments: [lockToken],
},
);TTL блокировки должен предотвращать вечную блокировку, если процесс завершился.
Распределённые блокировки требуют осторожности. Для критичной координации недостаточно без анализа скопировать пример с SET NX.
Stale-While-Revalidate
Можно хранить данные дольше логического срока свежести:
{
"value": {
"id": 815,
"name": "Клавиатура"
},
"freshUntil": 1789580000000
}Если данные ещё свежие — они возвращаются сразу.
Если данные устарели, но допустимы для временного использования:
- пользователь получает старое значение;
- один процесс обновляет кэш в фоне.
Это уменьшает резкие нагрузки, но подходит только тогда, когда временная устарелость допустима.
Cache Penetration
Cache penetration возникает, когда запрашиваются отсутствующие ключи, поэтому Redis не помогает и каждый запрос доходит до базы.
GET /products/999999
GET /products/999998
GET /products/999997Меры:
- кратковременное кэширование отсутствия;
- проверка формата ID;
- rate limiting;
- авторизация;
- фильтр допустимых значений;
- вероятностные структуры для очень больших наборов;
- мониторинг аномальных запросов.
Cache Avalanche
Cache avalanche — массовое истечение большого количества ключей или полная недоступность кэша, после чего нагрузка переносится на основную базу.
Меры:
- TTL jitter;
- распределение времени прогрева;
- репликация и отказоустойчивость;
- ограничение параллельных запросов;
- защита основной базы;
- постепенный прогрев;
- circuit breaker;
- резервная деградация;
- контроль времени ожидания Redis.
Приложение должно заранее определить поведение при недоступности Redis.
Для обычного кэша можно обратиться к базе:
try {
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
} catch (error) {
logger.warn(
{
error,
key,
},
"Redis cache is unavailable",
);
}
return productRepository.findById(
productId,
);Но если все экземпляры одновременно переключатся на базу, она может быть перегружена. Нужны ограничения и мониторинг.
Полный пример кэширующего сервиса
class CachedProductService {
constructor({
redis,
productRepository,
logger,
ttlSeconds = 300,
}) {
this.redis = redis;
this.productRepository =
productRepository;
this.logger = logger;
this.ttlSeconds = ttlSeconds;
}
createCacheKey(productId) {
return (
`cache:v1:product:${productId}`
);
}
createTtl() {
const jitter =
Math.floor(
Math.random() * 61,
);
return this.ttlSeconds + jitter;
}
async getById(productId) {
const cacheKey =
this.createCacheKey(productId);
try {
const cachedValue =
await this.redis.get(cacheKey);
if (cachedValue !== null) {
this.logger.debug(
{
cacheKey,
cacheResult: "hit",
},
"Product cache hit",
);
return JSON.parse(
cachedValue,
);
}
this.logger.debug(
{
cacheKey,
cacheRedis:6379Предпочтительно:
Internet
↓
Application
↓ private network
RedisПриложение не должно принимать произвольную Redis-команду от пользователя:
// Критически опасно
await redis.sendCommand(
request.body.command,
);Маршруты API должны выполнять только заранее предусмотренные операции.
Наблюдаемость Redis
Полезно отслеживать:
- использование памяти;
- число ключей;
- hit rate;
- cache miss rate;
- latency команд;
- число соединений;
- число отклонённых соединений;
- eviction;
- истёкшие ключи;
- репликацию;
- состояние persistence;
- загрузку CPU;
- сетевой трафик;
- ошибки клиента;
- переподключения;
- долгие команды.
При кэшировании особенно важны:
cache_hits_total
cache_misses_total
cache_errors_total
cache_operation_duration_seconds
redis_evicted_keys_total
redis_memory_usageЕсли eviction быстро растёт, возможны причины:
- недостаточно памяти;
- слишком большие значения;
- отсутствуют TTL;
- неверная политика памяти;
- слишком много редко используемых ключей;
- один Redis обслуживает несовместимые задачи.
Частые ошибки
Кэш без TTL
Нежелательно:
await redis.set(
cacheKey,
JSON.stringify(value),
);Для временного кэша лучше:
await redis.set(
cacheKey,
JSON.stringify(value),
{
EX: 300,
},
);Redis как единственный источник критичных данных
Если данные нельзя восстановить и они имеют высокую ценность, Redis требует соответствующей настройки persistence, репликации и резервного копирования.
Нельзя считать, что данные автоматически надёжны только потому, что они записаны в Redis.
Новый клиент на каждый запрос
Нежелательно:
app.get("/products/:id", async (
request,
response,
) => {
const redis = createClient();
await redis.connect();
const value = await redis.get(
"some-key",
);
await redis.quit();
response.send(value);
});Создание соединения увеличивает latency и нагрузку.
Предпочтительно переиспользовать подключённый клиент.
Использование KEYS в production
Нежелательно:
KEYS cache:*На большом наборе ключей команда может надолго заблокировать сервер.
Для итерации применяется SCAN, но ещё лучше проектировать схему так, чтобы частый поиск по шаблону не требовался.
Очень большие значения
Хранение огромного JSON под одним ключом приводит к:
- долгой сериализации;
- большой сетевой передаче;
- задержке Redis;
- неэффективной инвалидации;
- резким скачкам памяти.
Большой объект лучше разделить или пересмотреть необходимость его кэширования.
Hot key
Hot key — ключ, к которому обращается очень большое число запросов.
Пример:
cache:homepageОдин популярный ключ может создать нагрузку на один узел.
Возможные решения:
- локальный кэш;
- реплики чтения;
- предварительное вычисление;
- контролируемое дублирование;
- уменьшение частоты чтения;
- stale-while-revalidate;
- анализ архитектуры данных.
Кэширование ошибок на слишком долгий срок
Нежелательно сохранять временную ошибку внешнего API на час:
await redis.set(
key,
JSON.stringify({
error: "service unavailable",
}),
{
EX: 3600,
},
);После восстановления сервиса пользователи продолжат получать старую ошибку.
Ошибки и отрицательные результаты обычно требуют короткого и отдельно определённого TTL.
Отсутствие инвалидации
Если запись в базу изменилась, соответствующие ключи должны:
- удаляться;
- обновляться;
- иметь достаточно короткий TTL;
- переходить на новую версию.
TTL сам по себе не всегда обеспечивает приемлемую актуальность.
Кэширование данных без учёта пользователя
Нежелательно:
cache:profileесли профиль зависит от текущего пользователя.
Нужно:
cache:user:42:profileИли следует отказаться от такого кэша.
Сессии и обычный кэш в одном Redis
Если Redis применяет политику удаления кэшированных ключей, пользовательские сессии также могут быть вытеснены.
Для разных классов данных полезно использовать отдельные экземпляры или чётко спроектированную изоляцию.
Pub/Sub для критических событий
Pub/Sub не хранит сообщение для отключённого подписчика.
Если событие нельзя потерять, нужен механизм с сохранением и подтверждением обработки.
Практическая архитектура
В небольшом приложении:
HTTP Controller
↓
Service
↓
Cache abstraction
├── Redis
└── Repository
↓
DatabaseИнтерфейс кэша:
class Cache {
constructor(redis) {
this.redis = redis;
}
async getJson(key) {
const value =
await this.redis.get(key);
if (value === null) {
return null;
}
return JSON.parse(value);
}
async setJson(
key,
value,
ttlSeconds,
) {
await this.redis.set(
key,
JSON.stringify(value),
{
EX: ttlSeconds,
},
);
}
async delete(key) {
await this.redis.del(key);
}
}Использование:
class GetProduct {
constructor({
cache,
productRepository,
}) {
this.cache = cache;
this.productRepository =
productRepository;
}
async execute(productId) {
const key =
`cache:v1:product:${productId}`;
const cached =
await this.cache.getJson(key);
if (cached) {
return cached;
}
const product =
await this.productRepository.findById(
productId,
);
if (!product) {
return null;
}
await this.cache.setJson(
key,
product,
300,
);
return product;
}
}Абстракция позволяет:
- тестировать сервис без настоящего Redis;
- централизовать сериализацию;
- централизовать метрики;
- менять правила TTL;
- добавлять обработку ошибок;
- заменить реализацию кэша.
Тестовый кэш:
class InMemoryCache {
constructor() {
this.values = new Map();
}
async getJson(key) {
return this.values.get(key) ?? null;
}
async setJson(key, value) {
this.values.set(key, value);
}
async delete(key) {
this.values.delete(key);
}
}Краткая памятка
Основные структуры:
String — значение, JSON, число, флаг
Hash — поля одного объекта
List — упорядоченная последовательность
Set — уникальные неупорядоченные значения
Sorted Set — уникальные значения с числовым scoreТипичные команды:
SET / GET / DEL
INCR / DECR
HSET / HGET / HGETALL
LPUSH / RPUSH / LPOP / LRANGE
SADD / SISMEMBER / SMEMBERS
ZADD / ZINCRBY / ZRANGE / ZREVRANK
EXPIRE / TTL / PTTL
PUBLISH / SUBSCRIBECache-Aside:
Проверить Redis
↓
Cache hit → вернуть значение
↓
Cache miss → прочитать базу
↓
Записать Redis с TTL
↓
Вернуть значениеTTL:
await redis.set(
key,
JSON.stringify(value),
{
EX: 300,
},
);Инвалидация:
1. Изменить основную базу.
2. Удалить или обновить кэш.
3. Следующее чтение заполнит кэш заново.Сессии:
Cookie содержит session ID
Redis содержит данные сессии
TTL определяет срок жизни
Logout удаляет сессиюPub/Sub:
Publisher → Channel → Active subscribersPub/Sub подходит для необязательных сигналов, но не гарантирует доставку отключённому подписчику.
Основные правила:
- используйте Redis как быстрый слой, а не автоматически как источник истины;
- выбирайте структуру данных под необходимые операции;
- задавайте понятную схему ключей;
- добавляйте версию формата кэшируемых данных;
- устанавливайте TTL для временных ключей;
- добавляйте TTL jitter для массовых ключей;
- инвалидируйте кэш после изменения исходных данных;
- учитывайте гонки между чтением, записью и инвалидацией;
- защищайтесь от cache stampede;
- кратковременно кэшируйте отсутствующие данные;
- не используйте
KEYSна большом production-хранилище; - не создавайте новое Redis-соединение на каждый запрос;
- ограничивайте размеры значений;
- не помещайте уникальные и чувствительные данные в ключи;
- разделяйте критичные сессии и необязательный кэш;
- настраивайте политику памяти и eviction осознанно;
- отслеживайте hit rate, latency, память, eviction и ошибки;
- используйте отдельное соединение для Pub/Sub;
- не применяйте Pub/Sub для событий, которые нельзя потерять;
- не открывайте Redis в публичный интернет;
- защищайте подключение аутентификацией, ACL, сетью и TLS;
- заранее определяйте поведение приложения при недоступности Redis.