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) | Уникальность, теги, пересечения |
Правило которое мы вывели на практике: если вы делаете GET → JSON.parse → modify → JSON.stringify → SET — вероятно нужен Hash. Если делаете GET → split(',') → includes() — вероятно нужен Set.
Redis — это не "база данных для кэша". Это инструмент для задач которые требуют субмиллисекундного времени отклика с атомарными операциями. Правильный выбор структуры данных экономит и память, и CPU, и сложность кода.
Если вы строите backend с Redis и хотите получить код-ревью или консультацию по архитектуре — команда Aunimeda работает с клиентами по всему Кыргызстану. Пишите в WhatsApp или заходите на aunimeda.com/kg/contact.