Поделиться
Поделиться

Redis — один из тех инструментов, которые разработчики используют с первого дня, но редко используют правильно. Типичная история: добавили кеш, стало чуть быстрее, в production внезапно закончилась память, и начались хаотичные вытеснения ключей. Или добавили pub/sub для уведомлений, а потом обнаружили, что сообщения теряются при падении подписчика.

Рассмотрим, как строить надёжные системы на Redis.

Структуры данных: выбираем правильную

Каждая структура Redis оптимизирована для своей задачи. Хранить всё в строках — значит упускать большую часть возможностей.

| Структура | Команды | Когда использовать | |---|---|---| | String | GET/SET/INCR | Простой кеш, счётчики, флаги | | Hash | HGET/HSET/HGETALL | Объекты с полями (профили, настройки) | | List | LPUSH/RPOP/LRANGE | Очереди FIFO, лента событий | | Set | SADD/SMEMBERS/SINTER | Уникальные теги, множества, пересечения | | Sorted Set | ZADD/ZRANGE/ZRANGEBYSCORE | Лидерборды, приоритетные очереди | | Stream | XADD/XREAD/XGROUP | Надёжные очереди с consumer groups | | Bitmap | SETBIT/BITCOUNT | DAU, A/B тесты, флаги по user_id | | HyperLogLog | PFADD/PFCOUNT | Уникальные посетители (с погрешностью 0.81%) |

Паттерны кеширования

Cache-Aside (Lazy Loading)

Самый распространённый паттерн. Приложение сначала проверяет кеш, при промахе читает из БД и записывает в кеш.

import { Redis } from "ioredis";
import { db } from "./database";

const redis = new Redis({ host: "localhost", port: 6379 });

async function getUserById(userId: string): Promise<User | null> {
  const cacheKey = `user:${userId}`;
  
  // 1. Проверяем кеш
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached) as User;
  }
  
  // 2. Cache miss: читаем из БД
  const user = await db.users.findUnique({ where: { id: userId } });
  if (!user) return null;
  
  // 3. Записываем в кеш с TTL
  await redis.setex(cacheKey, 3600, JSON.stringify(user)); // 1 час
  
  return user;
}

// Инвалидация при обновлении
async function updateUser(userId: string, data: Partial<User>): Promise<User> {
  const user = await db.users.update({ where: { id: userId }, data });
  await redis.del(`user:${userId}`); // инвалидируем
  return user;
}

Недостатки: при массовом промахе («холодный старт» или flush) все запросы летят в БД одновременно — это называется Cache Stampede. Решение — блокировка через SET NX:

async function getUserWithLock(userId: string): Promise<User | null> {
  const cacheKey = `user:${userId}`;
  const lockKey = `lock:user:${userId}`;
  
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
  
  // Пытаемся захватить блокировку на 5 секунд
  const lock = await redis.set(lockKey, "1", "EX", 5, "NX");
  
  if (lock) {
    // Мы первые — читаем из БД
    const user = await db.users.findUnique({ where: { id: userId } });
    if (user) {
      await redis.setex(cacheKey, 3600, JSON.stringify(user));
    }
    await redis.del(lockKey);
    return user;
  } else {
    // Ждём, пока другой поток заполнит кеш
    await new Promise(r => setTimeout(r, 100));
    const retried = await redis.get(cacheKey);
    return retried ? JSON.parse(retried) : null;
  }
}

Write-Through

Запись в кеш и БД происходит одновременно. Данные всегда актуальны, но каждый write медленнее.

async function saveUserProfile(userId: string, profile: UserProfile): Promise<void> {
  const pipeline = redis.pipeline();
  
  // Атомарно обновляем несколько ключей
  pipeline.hset(`user:profile:${userId}`,
    "name", profile.name,
    "email", profile.email,
    "locale", profile.locale
  );
  pipeline.expire(`user:profile:${userId}`, 86400);
  
  // Выполняем Redis и DB параллельно
  await Promise.all([
    pipeline.exec(),
    db.userProfiles.upsert({
      where: { userId },
      create: { userId, ...profile },
      update: profile
    })
  ]);
}

TTL-стратегия

Выбор TTL напрямую влияет на hit rate и нагрузку на БД.

| Тип данных | Рекомендуемый TTL | Причина | |---|---|---| | Профиль пользователя | 1–4 часа | Меняется редко, стоит перечитать | | Список товаров | 5–15 минут | Часто меняется цена/наличие | | Сессия | 30 минут–24 часа | По требованию безопасности | | Счётчики рейтингов | 10 минут | Допускаем небольшое устаревание | | Конфигурация | 60 минут | Меняется при деплое |

Добавляйте случайный jitter к TTL, чтобы избежать одновременного истечения множества ключей:

function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
  const jitter = Math.floor(baseTtl * jitterPercent * Math.random());
  return baseTtl + jitter;
}

await redis.setex(key, ttlWithJitter(3600), value); // 3600 ± 360 секунд

Хранение сессий

Redis — стандартный выбор для горизонтально масштабируемых сессий.

import session from "express-session";
import RedisStore from "connect-redis";

const sessionStore = new RedisStore({
  client: redis,
  prefix: "sess:",
  ttl: 1800  // 30 минут в секундах
});

app.use(session({
  store: sessionStore,
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    maxAge: 30 * 60 * 1000
  }
}));

BullMQ: надёжные очереди задач

List-based очереди хороши для простых случаев, но не имеют retry, dead letter queue и мониторинга. BullMQ строится поверх Redis Streams и решает эти проблемы.

import { Queue, Worker, QueueEvents } from "bullmq";

const connection = { host: "localhost", port: 6379 };

// Продюсер — добавляем задачу
const emailQueue = new Queue("emails", { connection });

await emailQueue.add(
  "send-welcome",
  { userId: "123", template: "welcome" },
  {
    attempts: 3,
    backoff: { type: "exponential", delay: 2000 },
    removeOnComplete: { count: 1000 },
    removeOnFail: false  // храним упавшие задачи для анализа
  }
);

// Воркер — обрабатываем
const worker = new Worker(
  "emails",
  async (job) => {
    const { userId, template } = job.data;
    await sendEmail(userId, template);
    return { sentAt: new Date().toISOString() };
  },
  {
    connection,
    concurrency: 5,   // 5 параллельных задач
    limiter: {
      max: 100,
      duration: 60000  // не более 100 писем в минуту
    }
  }
);

worker.on("failed", (job, err) => {
  console.error(`Job ${job?.id} failed:`, err.message);
});

// Мониторинг событий
const queueEvents = new QueueEvents("emails", { connection });
queueEvents.on("completed", ({ jobId, returnvalue }) => {
  console.log(`Job ${jobId} done:`, returnvalue);
});

Pub/Sub для уведомлений

Pub/Sub Redis подходит для real-time уведомлений, но важно понимать ограничение: сообщения не хранятся. Если подписчик офлайн — он пропустит сообщение.

// Публикатор
async function notifyUserUpdate(userId: string, event: object): Promise<void> {
  const channel = `user:${userId}:updates`;
  const message = JSON.stringify({ ...event, ts: Date.now() });
  await redis.publish(channel, message);
}

// Подписчик (отдельное соединение!)
const subscriber = redis.duplicate();

await subscriber.subscribe("user:*:updates", (err) => {
  if (err) throw err;
});

subscriber.on("pmessage", (pattern, channel, message) => {
  const userId = channel.split(":")[1];
  const event = JSON.parse(message);
  websocketServer.emit(`user-${userId}`, event);
});

Для гарантированной доставки используйте Redis Streams (XADD/XREAD с consumer groups) — они хранят историю и поддерживают подтверждения.

Политики вытеснения (Eviction Policies)

Когда Redis заполняет выделенную память, он начинает удалять ключи согласно политике:

| Политика | Поведение | Применение | |---|---|---| | noeviction | Ошибка на запись | Очереди, данные нельзя терять | | allkeys-lru | Удаляет давно неиспользуемые | Кеш общего назначения | | volatile-lru | LRU только среди ключей с TTL | Смешанное использование | | allkeys-lfu | Удаляет редко используемые | Кеш с разной частотой обращений | | volatile-ttl | Удаляет с наименьшим TTL | Когда хочется предсказуемость |

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
maxmemory-samples 10  # Точнее LRU, но дороже (default 5)

Sentinel vs Cluster

Redis Sentinel

Для большинства приложений достаточно одного primary + реплик с автоматическим failover через Sentinel.

# docker-compose.yml (упрощённо)
services:
  redis-primary:
    image: redis:7-alpine
    command: redis-server --appendonly yes

  redis-replica:
    image: redis:7-alpine
    command: redis-server --replicaof redis-primary 6379

  sentinel:
    image: redis:7-alpine
    command: >
      redis-sentinel /etc/sentinel.conf
    volumes:
      - ./sentinel.conf:/etc/sentinel.conf
# sentinel.conf
sentinel monitor mymaster redis-primary 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 10000
sentinel parallel-syncs mymaster 1

Redis Cluster

Выбирайте Cluster только если:

  • Набор данных > 50–100 ГБ
  • Нужно горизонтальное масштабирование записей
  • Готовы к ограничениям: нет multi-key операций между слотами, нет MGET для разных ключей
import { Cluster } from "ioredis";

const cluster = new Cluster(
  [
    { host: "node1", port: 6379 },
    { host: "node2", port: 6379 },
    { host: "node3", port: 6379 }
  ],
  {
    redisOptions: { password: process.env.REDIS_PASSWORD },
    // Используем хэш-теги для co-location связанных ключей
    // {user:123}:profile и {user:123}:sessions попадут в один слот
    enableReadyCheck: true
  }
);

Итог

Redis — не просто кеш. Это платформа для хранения состояния, очередей и pub/sub. Ключ к надёжной работе: выбирайте структуры данных под задачу, настраивайте eviction policy осознанно, используйте BullMQ вместо самодельных очередей и не забывайте про jitter в TTL.

Если вы строите микросервисную архитектуру, посмотрите статью Микросервисы: когда разбивать монолит — там описано, как Redis вписывается в async-коммуникацию между сервисами. Про DevOps-настройку Redis в Docker — Docker Compose для разработчика.