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 для разработчика.

