Redis и кэширование

Redis — высокопроизводительное хранилище данных в оперативной памяти, работающее по модели «ключ — значение».

Redis часто используют для:

Упрощённая схема кэширования:

Клиент
   ↓
Приложение
   ↓
Redis
   ↓ при отсутствии данных
База данных

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

Чаще всего роли распределяются так:

Основная база данных — источник истины
Redis                — быстрый временный слой

Например, информация о товаре хранится в PostgreSQL, а часто запрашиваемое представление товара временно сохраняется в Redis.


Основные свойства Redis

Redis поддерживает:

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.

В строке можно хранить:

Запись:

SET user:name "Анна"

Чтение:

GET user:name

Удаление:

DEL user:name

JavaScript:

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:815

JavaScript:

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 30

JavaScript:

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 role

JavaScript:

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 -1

JavaScript:

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 удобен, если:

Hash удобен, если:

Обычный Hash не хранит вложенные JavaScript-объекты как полноценные вложенные структуры. Такие поля придётся отдельно сериализовать.


List

List — упорядоченная последовательность строк.

Элементы могут добавляться в начало или конец:

LPUSH notifications "first"
RPUSH notifications "last"

Получение диапазона:

LRANGE notifications 0 -1

Извлечение первого элемента:

LPOP notifications

Извлечение последнего:

RPOP notifications

JavaScript:

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 queue
await 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 99

JavaScript:

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 cache

JavaScript:

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:users

JavaScript:

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 WITHSCORES

JavaScript:

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:42

JavaScript:

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(),
  );

Для настоящей очереди необходимо дополнительно решить:


Выбор структуры данных

Задача Структура
Кэш JSON-ответа String
Счётчик String с INCR
Плоский объект с отдельными полями Hash
Очередь или последние события List
Уникальные элементы Set
Рейтинг Sorted Set
Отложенные задачи по времени Sorted Set
Пользовательские сессии String или Hash
Временный флаг String с TTL

Не следует выбирать структуру только по привычке. Важно учитывать операции, которые приложение выполняет чаще всего.


Кэширование запросов

Кэширование сохраняет результат дорогой операции, чтобы повторно использовать его без полного вычисления или обращения к основной базе.

Можно кэшировать:

Кэш особенно полезен, когда:

Кэш может быть бесполезен, если:


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}`;

Он может содержать:


Персонализированный кэш

Ответ может зависеть от пользователя:

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 5000

JavaScript:

await redis.set(
  "cache:product:815",
  JSON.stringify(product),
  {
    EX: 300,
  },
);

Просмотр TTL

TTL cache:product:815

Результат:

300 — ключ истечёт примерно через 300 секунд
-1  — ключ существует, но TTL не установлен
-2  — ключ не существует

В миллисекундах:

PTTL cache:product:815

JavaScript:

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

Удалить все связанные списки сложно.

Возможные подходы:


Версионирование группы ключей

Можно хранить версию:

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 100

JavaScript:

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

В результате кэш снова содержит устаревшие данные.

Варианты снижения риска:

Абсолютная согласованность между базой и кэшем требует более сложного дизайна. Для многих кэшей принимается модель 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

Меры:


Cache Avalanche

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

Меры:

Приложение должно заранее определить поведение при недоступности 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

Полезно отслеживать:

При кэшировании особенно важны:

cache_hits_total
cache_misses_total
cache_errors_total
cache_operation_duration_seconds
redis_evicted_keys_total
redis_memory_usage

Если eviction быстро растёт, возможны причины:


Частые ошибки

Кэш без 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 под одним ключом приводит к:

Большой объект лучше разделить или пересмотреть необходимость его кэширования.


Hot key

Hot key — ключ, к которому обращается очень большое число запросов.

Пример:

cache:homepage

Один популярный ключ может создать нагрузку на один узел.

Возможные решения:


Кэширование ошибок на слишком долгий срок

Нежелательно сохранять временную ошибку внешнего API на час:

await redis.set(
  key,
  JSON.stringify({
    error: "service unavailable",
  }),
  {
    EX: 3600,
  },
);

После восстановления сервиса пользователи продолжат получать старую ошибку.

Ошибки и отрицательные результаты обычно требуют короткого и отдельно определённого 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;
  }
}

Абстракция позволяет:

Тестовый кэш:

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 / SUBSCRIBE

Cache-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 subscribers

Pub/Sub подходит для необязательных сигналов, но не гарантирует доставку отключённому подписчику.

Основные правила: