О насБлогКонтакты
Backend17 апреля 2026 г. 9 мин 4

Redis: структуры данных для реальных задач — с примерами кода

AunimedaAunimeda
📋 Содержание

Redis: структуры данных для реальных задач — с примерами кода

Redis часто используют как "просто кэш" — поставил SET key value EX 300 и забыл. Но за этим скрывается инструмент с пятью принципиально разными структурами данных, каждая из которых решает свой класс задач. Мы в Aunimeda прошли путь от "кладём всё в строки" до осознанного выбора структуры под задачу — и разница в потреблении памяти и скорости оказалась существенной.

Вот что реально работает на production.


1. String + INCR/EXPIRE: Rate Limiter за 15 строк кода

Самый частый вопрос: как ограничить количество запросов с одного IP? Покупать SaaS-решение за $50/месяц или писать сложную логику с базой данных — не нужно. Redis делает это атомарно.

Идея: для каждого IP храним счётчик с TTL. Если счётчик превысил лимит — блокируем.

import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: 6379,
  lazyConnect: true,
});

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetInSeconds: number;
}

async function checkRateLimit(
  ip: string,
  limit: number = 100,
  windowSeconds: number = 60
): Promise<RateLimitResult> {
  const key = `ratelimit:${ip}`;

  // Lua script для атомарной операции — важно!
  // Без Lua между INCR и EXPIRE есть race condition
  const script = `
    local current = redis.call('INCR', KEYS[1])
    if current == 1 then
      redis.call('EXPIRE', KEYS[1], ARGV[1])
    end
    local ttl = redis.call('TTL', KEYS[1])
    return {current, ttl}
  `;

  const [count, ttl] = await redis.eval(
    script, 1, key, windowSeconds.toString()
  ) as [number, number];

  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetInSeconds: ttl,
  };
}

// Express middleware
export const rateLimitMiddleware = async (req, res, next) => {
  const ip = req.ip || req.connection.remoteAddress;
  const result = await checkRateLimit(ip, 100, 60);

  res.setHeader('X-RateLimit-Remaining', result.remaining);
  res.setHeader('X-RateLimit-Reset', result.resetInSeconds);

  if (!result.allowed) {
    return res.status(429).json({
      error: 'Too Many Requests',
      retryAfter: result.resetInSeconds,
    });
  }
  next();
};

Почему Lua? Без него между INCR и EXPIRE есть race condition: если два запроса одновременно создают ключ, второй перезапишет TTL — и у вас будет "вечный" счётчик. Lua-скрипт выполняется атомарно на сервере Redis.

Потребление памяти: один ключ String занимает ~50-60 байт. При 50 000 уникальных IP за 60 секунд — ~3 МБ. Очень дёшево.


2. Hash: кэширование объектов с частичными обновлениями

Типичная ошибка — кэшировать объект пользователя как JSON-строку:

// ❌ Плохо: JSON строка
await redis.set(`user:${id}`, JSON.stringify(user));

// Каждое обновление требует: GET → parse → modify → stringify → SET
// При конкурентных обновлениях — race condition

Hash позволяет обновлять отдельные поля атомарно:

// ✅ Хорошо: Hash
async function cacheUser(user: User): Promise<void> {
  const key = `user:${user.id}`;
  
  await redis.hset(key, {
    id: user.id,
    name: user.name,
    email: user.email,
    // Сериализуем вложенные объекты в JSON-поля
    settings: JSON.stringify(user.settings),
    updatedAt: Date.now().toString(),
  });
  
  await redis.expire(key, 3600); // TTL 1 час
}

async function getUser(id: string): Promise<User | null> {
  const data = await redis.hgetall(`user:${id}`);
  if (!data || !data.id) return null;
  
  return {
    ...data,
    settings: JSON.parse(data.settings || '{}'),
    updatedAt: Number(data.updatedAt),
  } as User;
}

// Обновляем только счётчик онлайн-активности — без перезаписи всего объекта
async function updateLastSeen(userId: string): Promise<void> {
  await redis.hset(`user:${userId}`, 'lastSeen', Date.now().toString());
  // Продлеваем TTL при активности
  await redis.expire(`user:${userId}`, 3600);
}

// Атомарный инкремент поля
async function incrementLoginCount(userId: string): Promise<number> {
  return redis.hincrby(`user:${userId}`, 'loginCount', 1);
}

Почему Hash эффективнее JSON-строки для небольших объектов?

Redis оптимизирует Hash с малым количеством полей через ziplist / listpack (до 128 полей, значения до 64 байт) — это компактное бинарное представление без накладных расходов на хэш-таблицу. Объект пользователя с 8 полями в виде Hash занимает ~150-180 байт против ~200-250 байт для JSON-строки с той же информацией. На миллионе пользователей это ~70 МБ разницы.

Главное преимущество — атомарные частичные обновления. HSET user:123 lastSeen 1234567890 не требует чтения и перезаписи всего объекта.


3. List как job queue: надёжный паттерн с BLPOP

List в Redis — это двусвязный список. Комбинация RPUSH (добавить в хвост) + BLPOP (забрать с головы с блокировкой) даёт нам очередь задач без брокера сообщений.

// Producer: добавляем задачу в очередь
async function enqueueJob(
  queue: string,
  job: object
): Promise<void> {
  const payload = JSON.stringify({
    id: crypto.randomUUID(),
    data: job,
    createdAt: Date.now(),
    attempts: 0,
  });
  
  await redis.rpush(`queue:${queue}`, payload);
}

// Consumer: reliable queue паттерн с резервной очередью
async function processJobs(queue: string): Promise<void> {
  const mainQueue = `queue:${queue}`;
  const processingQueue = `queue:${queue}:processing`;

  console.log(`Worker started for queue: ${queue}`);

  while (true) {
    // BLMOVE атомарно перемещает элемент из основной очереди
    // в processing очередь — защита от потери задачи при краше воркера
    // Блокируется на 5 секунд если очередь пуста
    const payload = await redis.blmove(
      mainQueue,
      processingQueue,
      'LEFT',
      'RIGHT',
      5
    );

    if (!payload) continue; // timeout, очередь пуста

    let job;
    try {
      job = JSON.parse(payload);
      
      // Обрабатываем задачу
      await handleJob(job);
      
      // Успех: удаляем из processing очереди
      await redis.lrem(processingQueue, 1, payload);
      
    } catch (error) {
      console.error(`Job failed:`, error);
      
      job.attempts = (job.attempts || 0) + 1;
      
      if (job.attempts < 3) {
        // Retry: возвращаем в основную очередь
        await redis.rpush(mainQueue, JSON.stringify(job));
      } else {
        // Dead letter queue
        await redis.rpush(`queue:${queue}:dead`, JSON.stringify(job));
      }
      
      // Удаляем из processing
      await redis.lrem(processingQueue, 1, payload);
    }
  }
}

// Восстановление после краша воркера: при старте переносим задачи
// из processing обратно в основную очередь
async function recoverStuckJobs(queue: string): Promise<void> {
  const processingQueue = `queue:${queue}:processing`;
  let job;
  
  while ((job = await redis.rpop(processingQueue)) !== null) {
    await redis.lpush(`queue:${queue}`, job);
    console.log('Recovered stuck job:', JSON.parse(job).id);
  }
}

Почему не просто LPOP? Обычный LPOP делает busy-wait — ваш воркер молотит CPU в цикле. BLPOP/BLMOVE блокируются на уровне Redis и просыпаются только при появлении данных. На VPS в Бишкеке с 2 CPU это критично — разница между 100% CPU и 0.1%.

Потребление памяти: List хранит элементы компактно. 10 000 задач по 200 байт = ~2 МБ + ~50 байт оверхед на элемент = ~2.5 МБ итого.


4. Sorted Set: лидерборды и scheduled tasks

Sorted Set — это Set где каждый элемент имеет числовой score. Элементы хранятся отсортированными. Это делает Sorted Set идеальным для рейтингов и задач по расписанию.

// === ЛИДЕРБОРД ===

// Обновить очки пользователя
async function addScore(
  leaderboard: string,
  userId: string,
  points: number
): Promise<void> {
  // ZINCRBY атомарно прибавляет к существующему score
  await redis.zincrby(`leaderboard:${leaderboard}`, points, userId);
}

// Получить топ-10 с очками
async function getTop10(leaderboard: string): Promise<Array<{userId: string, score: number}>> {
  // ZREVRANGE: от наибольшего к наименьшему, WITHSCORES
  const result = await redis.zrevrange(
    `leaderboard:${leaderboard}`, 
    0, 9, 
    'WITHSCORES'
  );
  
  const top = [];
  for (let i = 0; i < result.length; i += 2) {
    top.push({
      userId: result[i],
      score: parseFloat(result[i + 1]),
    });
  }
  return top;
}

// Позиция конкретного пользователя
async function getUserRank(
  leaderboard: string, 
  userId: string
): Promise<number | null> {
  const rank = await redis.zrevrank(`leaderboard:${leaderboard}`, userId);
  return rank !== null ? rank + 1 : null; // 0-indexed → 1-indexed
}

// === SCHEDULED TASKS (score = timestamp выполнения) ===

async function scheduleTask(
  taskId: string,
  payload: object,
  runAt: Date
): Promise<void> {
  const score = runAt.getTime(); // Unix timestamp в миллисекундах
  await redis.zadd('scheduled_tasks', score, taskId);
  await redis.hset(`task:${taskId}`, { payload: JSON.stringify(payload) });
}

// Воркер: каждые 5 секунд забираем задачи, время которых пришло
async function scheduledTaskWorker(): Promise<void> {
  setInterval(async () => {
    const now = Date.now();
    
    // ZRANGEBYSCORE: все задачи с score <= now
    // ZRANGEBYSCORE + ZREM не атомарно — используем Lua
    const script = `
      local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 10)
      if #tasks > 0 then
        redis.call('ZREM', KEYS[1], unpack(tasks))
      end
      return tasks
    `;
    
    const dueTasks = await redis.eval(
      script, 1, 'scheduled_tasks', now.toString()
    ) as string[];
    
    for (const taskId of dueTasks) {
      const taskData = await redis.hget(`task:${taskId}`, 'payload');
      if (taskData) {
        await processScheduledTask(taskId, JSON.parse(taskData));
        await redis.del(`task:${taskId}`);
      }
    }
  }, 5000);
}

Потребление памяти: Sorted Set использует skiplist + hashtable. При малом количестве элементов (< 128) Redis переключается на ziplist. Миллион записей лидерборда займёт ~100 МБ — сравните с аналогичным запросом ORDER BY score DESC к PostgreSQL который может читать всю таблицу.


5. Set: уникальные значения без дубликатов

Set гарантирует уникальность — идеально для списков онлайн-пользователей, тегов, visited URLs и похожих задач.

// Онлайн-пользователи (с TTL через отдельный ключ)
async function userCameOnline(userId: string): Promise<void> {
  await redis.sadd('online_users', userId);
  // Обновляем heartbeat в отдельном ключе
  await redis.setex(`heartbeat:${userId}`, 30, '1');
}

async function userWentOffline(userId: string): Promise<void> {
  await redis.srem('online_users', userId);
  await redis.del(`heartbeat:${userId}`);
}

async function getOnlineCount(): Promise<number> {
  return redis.scard('online_users');
}

async function isUserOnline(userId: string): Promise<boolean> {
  // Проверяем heartbeat, а не Set (точнее при таймаутах)
  return (await redis.exists(`heartbeat:${userId}`)) === 1;
}

// Теги: пересечение и объединение
async function getArticlesByTag(tag: string): Promise<string[]> {
  return redis.smembers(`tag:${tag}:articles`);
}

async function getArticlesWithAllTags(tags: string[]): Promise<string[]> {
  const keys = tags.map(t => `tag:${t}:articles`);
  // SINTER: статьи у которых ЕСТЬ ВСЕ теги
  return redis.sinter(...keys);
}

async function getArticlesWithAnyTag(tags: string[]): Promise<string[]> {
  const keys = tags.map(t => `tag:${t}:articles`);
  // SUNION: статьи у которых есть ХОТЬ ОДИН тег
  return redis.sunion(...keys);
}

// Дедупликация событий (например, email-нотификаций)
async function shouldSendNotification(
  userId: string,
  eventType: string
): Promise<boolean> {
  const key = `notif_sent:${userId}:${eventType}`;
  // SETNX + EXPIRE: атомарно занимаем слот
  const set = await redis.set(key, '1', 'EX', 300, 'NX');
  return set === 'OK'; // null если ключ уже существовал
}

Потребление памяти Set vs List: Set с 10 000 строковых элементов ~800 КБ. Аналогичный List — ~600 КБ, но без гарантии уникальности. Sorted Set с теми же элементами (score=0) — ~1.2 МБ. Выбирайте исходя из задачи, не из объёма.


Итоговая таблица: когда что использовать

Структура Операция Сложность Сценарий
String GET/SET/INCR O(1) Счётчики, флаги, кэш строк
Hash HGET/HSET/HINCRBY O(1) Объекты с частичными апдейтами
List LPUSH/RPOP/BLPOP O(1) Очереди, логи, стеки
Sorted Set ZADD/ZRANGE/ZINCRBY O(log N) Рейтинги, расписания
Set SADD/SREM/SINTER O(1)/O(N) Уникальность, теги, пересечения

Правило которое мы вывели на практике: если вы делаете GETJSON.parsemodifyJSON.stringifySET — вероятно нужен Hash. Если делаете GETsplit(',')includes() — вероятно нужен Set.

Redis — это не "база данных для кэша". Это инструмент для задач которые требуют субмиллисекундного времени отклика с атомарными операциями. Правильный выбор структуры данных экономит и память, и CPU, и сложность кода.


Если вы строите backend с Redis и хотите получить код-ревью или консультацию по архитектуре — команда Aunimeda работает с клиентами по всему Кыргызстану. Пишите в WhatsApp или заходите на aunimeda.com/kg/contact.

Читайте также

PostgreSQL на VPS: от 10 до 1000 запросов в секунду без смены железаaunimeda
Backend

PostgreSQL на VPS: от 10 до 1000 запросов в секунду без смены железа

Практическое руководство по настройке PostgreSQL на VPS с 4 CPU и 8 ГБ RAM: правильные параметры postgresql.conf с расчётами, частичные и covering индексы, настройка autovacuum, PgBouncer для connection pooling. Реальные цифры: 23 мс → 1.2 мс на запрос.

Telegram Bot: FSM, middleware и продвинутые техники разработкиaunimeda
Backend

Telegram Bot: FSM, middleware и продвинутые техники разработки

Продвинутое руководство по Telegram Bot API: Finite State Machine для многошаговых диалогов, middleware pipeline с anti-spam и авторизацией, webhook vs polling, inline keyboard с компактной сериализацией данных, обработка медиа через file_id кэш. Полный код на Telegraf.

Как перейти с PHP 5.6 на PHP 7.0 без поломки приложения (2016)aunimeda
Backend

Как перейти с PHP 5.6 на PHP 7.0 без поломки приложения (2016)

PHP 7.0 давал в 2 раза более высокую производительность по сравнению с PHP 5.6. Но миграция ломала старый код: mysql_* функции удалены, изменилась обработка ошибок, несовместимые изменения в типах. Мы мигрировали 4 проекта без простоя — вот точный чеклист.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Получить консультацию Все статьи