Стратегии кэширования Redis, которые реально работают в продакшене
Cache-aside, write-through, предотвращение cache stampede, стратегии TTL и паттерны инвалидации. Паттерны Redis, которые я использовал в продакшн Node.js-приложениях с реальными примерами кода.
Все тебе говорят «просто добавь Redis», когда твой API тормозит. Никто не рассказывает, что произойдёт через полгода, когда кэш начнёт отдавать устаревшие данные, логика инвалидации будет размазана по 40 файлам, а деплой вызовет cache stampede, который положит твою базу данных сильнее, чем если бы ты вообще не кэшировал.
Я использую Redis в продакшене уже много лет. Не как игрушку, не в туториале — в системах с реальным трафиком, где ошибка в кэшировании означает алерты в 3 часа ночи. Ниже — всё, что я узнал о том, как делать это правильно.
Зачем кэшировать?#
Начнём с очевидного: базы данных медленные по сравнению с памятью. Запрос к PostgreSQL, который выполняется за 15 мс — это быстро по стандартам баз данных. Но если этот запрос выполняется на каждый API-запрос, а ты обрабатываешь 1000 запросов в секунду, это 15 000 мс кумулятивного времени базы данных в секунду. Пул соединений исчерпан. Латенси p99 зашкаливает. Пользователи пялятся на спиннеры.
Redis отдаёт большинство чтений за менее чем 1 мс. Те же данные из кэша превращают 15-миллисекундную операцию в 0.3-миллисекундную. Это не микрооптимизация. Это разница между необходимостью 4 реплик базы данных и нулём.
Но кэширование не бесплатно. Оно добавляет сложность, вносит проблемы консистентности и создаёт совершенно новый класс отказов. Прежде чем кэшировать что-либо, спроси себя:
Когда кэширование помогает:
- Данные читаются гораздо чаще, чем записываются (соотношение 10:1 и выше)
- Базовый запрос тяжёлый (джойны, агрегации, вызовы внешних API)
- Небольшая устаревшесть допустима (каталог товаров, профили пользователей, конфиг)
- Паттерны доступа предсказуемы (одни и те же ключи запрашиваются повторно)
Когда кэширование вредит:
- Данные меняются постоянно и должны быть свежими (котировки акций в реальном времени, живые счета)
- Каждый запрос уникален (поисковые запросы с множеством параметров)
- Датасет крошечный (если всё помещается в память приложения, пропусти Redis)
- У тебя нет операционной зрелости для мониторинга и дебага проблем с кэшем
Фил Карлтон как-то сказал, что в компьютерных науках есть только две сложные вещи: инвалидация кэша и именование. Он был прав насчёт обоих, но именно инвалидация кэша будит тебя по ночам.
Настройка ioredis#
Прежде чем углубиться в паттерны, настроим подключение. Я использую ioredis повсюду — это самый зрелый Redis-клиент для Node.js, с полноценной поддержкой TypeScript, кластерного режима, Sentinel и Lua-скриптов.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;Несколько моментов, на которые стоит обратить внимание. lazyConnect: true означает, что соединение не устанавливается до тех пор, пока ты реально не выполнишь команду, что полезно при тестировании и инициализации. retryStrategy реализует экспоненциальный бэкофф с потолком в 5 секунд — без этого при сбое Redis твоё приложение будет спамить попытками переподключения. А maxRetriesPerRequest: 3 гарантирует, что отдельные команды быстро фейлятся, а не зависают навечно.
Паттерн Cache-Aside#
Это паттерн, который ты будешь использовать в 80% случаев. Его также называют «ленивая загрузка» или «look-aside». Поток простой:
- Приложение получает запрос
- Проверяет Redis на наличие кэшированного значения
- Если найдено (cache hit), возвращает его
- Если не найдено (cache miss), запрашивает базу данных
- Сохраняет результат в Redis
- Возвращает результат
Вот типизированная реализация:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // секунды
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// Шаг 1: Пробуем прочитать из кэша
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Повреждённая запись кэша, удаляем и идём дальше
await redis.del(cacheKey);
}
}
// Шаг 2: Промах кэша — получаем из источника
const result = await fetcher();
// Шаг 3: Сохраняем в кэш (не ждём — fire and forget)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}Использование выглядит так:
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30 минут
);
}Обрати внимание, что я делаю fire-and-forget для вызова redis.set. Это намеренно. Если Redis лежит или тормозит, запрос всё равно выполнится. Кэш — это оптимизация, а не необходимость. Если запись в кэш не удастся, следующий запрос просто снова обратится к базе данных. Не страшно.
Есть тонкий баг во многих реализациях cache-aside, который люди пропускают: кэширование значений null. Если пользователь не существует и ты не кэшируешь этот факт, каждый запрос этого пользователя бьёт по базе. Злоумышленник может использовать это, запрашивая случайные ID пользователей, превращая кэш в бесполезную конструкцию. Всегда кэшируй и отрицательный результат — только с более коротким TTL.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// Более короткий TTL для null-результатов, чтобы ограничить использование памяти,
// но достаточно долгий, чтобы поглотить повторные промахи
ttl: row ? 1800 : 300,
}
);
}Вообще, давай перестрою это, чтобы динамический TTL работал правильно:
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// Кэшируем существующие результаты на 30 мин, null-результаты на 5 мин
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through и Write-Behind#
Cache-aside отлично работает для нагрузок с преобладающим чтением, но у него есть проблема консистентности: если другой сервис или процесс обновит базу данных напрямую, твой кэш будет устаревшим до истечения TTL. Здесь на сцену выходят паттерны write-through и write-behind.
Write-Through#
В write-through каждая запись проходит через слой кэша. Сначала обновляется кэш, затем база данных. Это гарантирует, что кэш всегда согласован с базой данных (при условии, что все записи идут через твоё приложение).
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// Шаг 1: Обновляем базу данных
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// Шаг 2: Немедленно обновляем кэш
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}Ключевое отличие от cache-aside: мы пишем в кэш при каждой записи, а не только при чтении. Это значит, что кэш всегда прогрет для недавно обновлённых данных.
Компромисс: задержка записи увеличивается, потому что каждая запись теперь затрагивает и базу данных, и Redis. Если Redis тормозит, твои записи тормозят. В большинстве приложений чтений намного больше, чем записей, поэтому этот компромисс того стоит.
Write-Behind (Write-Back)#
Write-behind переворачивает всё: записи идут сначала в Redis, а база данных обновляется асинхронно. Это даёт крайне быстрые записи ценой потенциальной потери данных, если Redis упадёт до того, как данные будут персистированы.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// Читаем текущее состояние
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Немедленно обновляем кэш
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Ставим запись в базу в очередь для асинхронной обработки
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}Затем тебе нужен отдельный воркер, дренящий эту очередь:
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// Перекладываем в очередь при ошибке с количеством попыток
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}На практике я редко использую write-behind. Риск потери данных реален — если Redis упадёт до того, как воркер обработает очередь, эти записи пропадут. Используй это только для данных, где eventual consistency действительно приемлема — счётчики просмотров, аналитические события или некритичные пользовательские предпочтения.
Стратегия TTL#
Правильный выбор TTL более нюансирован, чем кажется. Фиксированный TTL в 1 час на всё — легко реализовать и почти всегда неправильно.
Уровни волатильности данных#
Я классифицирую данные на три уровня и назначаю TTL соответственно:
const TTL = {
// Уровень 1: Редко меняется, дорого вычислять
// Примеры: каталог товаров, конфиг сайта, фича-флаги
STATIC: 86400, // 24 часа
// Уровень 2: Меняется иногда, средняя стоимость
// Примеры: профили пользователей, настройки команды, разрешения
MODERATE: 1800, // 30 минут
// Уровень 3: Меняется часто, дёшево вычислять, но вызывается часто
// Примеры: данные ленты, счётчики уведомлений, информация о сессии
VOLATILE: 300, // 5 минут
// Уровень 4: Эфемерные, используются для rate limiting и блокировок
EPHEMERAL: 60, // 1 минута
// Null-результаты: всегда короткоживущие
NOT_FOUND: 120, // 2 минуты
} as const;TTL Jitter: предотвращение Thundering Herd#
Вот сценарий, который меня когда-то укусил: ты деплоишь приложение, кэш пуст, и 10 000 запросов кэшируют одни и те же данные с TTL в 1 час. Через час все 10 000 ключей истекают одновременно. Все 10 000 запросов бьют по базе данных разом. База захлёбывается. Я видел, как такое валило продакшн-инстанс Postgres.
Решение — jitter, добавление случайности к значениям TTL:
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// Вместо: redis.set(key, value, "EX", 3600)
// Используй: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = случайное значение между 3240 и 3960Это распределяет истечение по окну, так что вместо 10 000 ключей, истекающих в одну секунду, они истекают в течение 12-минутного окна. База данных видит постепенный рост трафика, а не обрыв.
Для критических путей я иду дальше и использую 20% jitter:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 секундСкользящее истечение#
Для данных типа сессий, где TTL должен сбрасываться при каждом обращении, используй GETEX (Redis 6.2+):
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX атомарно получает значение И сбрасывает TTL
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}Если ты на более старой версии Redis, используй pipeline:
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}Cache Stampede (Thundering Herd)#
TTL jitter помогает с массовым истечением, но не решает проблему stampede для одного ключа: когда популярный ключ истекает и сотни конкурентных запросов одновременно пытаются его перегенерировать.
Представь: ты кэшируешь ленту главной страницы с TTL в 5 минут. Она истекает. Пятьдесят конкурентных запросов видят промах кэша. Все пятьдесят бьют по базе данных с одним и тем же тяжёлым запросом. Ты, по сути, DDoS-ишь сам себя.
Решение 1: Мьютекс-блокировка#
Только один запрос перегенерирует кэш. Остальные ждут.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Сначала пробуем кэш
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Пытаемся получить блокировку (NX = только если не существует, EX = авто-истечение)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// Мы получили блокировку — получаем и кэшируем
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Снимаем блокировку
await redis.del(lockKey);
}
}
// Другой запрос держит блокировку — ждём и повторяем
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Всё ещё нет кэша — идём напрямую к базе данных
// (обрабатывает случай, когда держатель блокировки упал)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}В снятии блокировки выше есть тонкое состояние гонки. Если держатель блокировки работает дольше 10 секунд (TTL блокировки), другой запрос получает блокировку, а затем первый запрос удаляет блокировку второго запроса. Правильное решение — использовать уникальный токен:
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Lua-скрипт обеспечивает атомарную проверку и удаление
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}По сути, это упрощённый Redlock. Для одиночного инстанса Redis этого достаточно. Для Redis Cluster или Sentinel-конфигураций стоит посмотреть полный алгоритм Redlock — но, честно говоря, для предотвращения cache stampede эта простая версия работает отлично.
Решение 2: Вероятностное раннее истечение#
Это мой любимый подход. Вместо того чтобы ждать истечения ключа, случайным образом перегенерируем его чуть раньше. Идея взята из статьи Ваттани, Кьеричетти и Лёвенштейна.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// Алгоритм XFetch: вероятностная перегенерация по мере приближения истечения
// beta * Math.log(Math.random()) производит отрицательное число,
// которое растёт (становится более отрицательным) по мере приближения к истечению
const beta = 1; // параметр настройки, 1 работает хорошо
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Идём на перегенерацию
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Ставим с дополнительным запасом, чтобы Redis не удалил ключ раньше, чем мы сможем перегенерировать
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}Красота этого подхода: по мере уменьшения оставшегося TTL ключа вероятность перегенерации растёт. При 1000 конкурентных запросов, возможно, один-два инициируют перегенерацию, в то время как остальные продолжат отдавать кэшированные данные. Никаких блокировок, никакой координации, никакого ожидания.
Решение 3: Stale-While-Revalidate#
Отдавай устаревшее значение, пока перегенерируешь в фоне. Это даёт лучшую латентность, потому что ни один запрос не ждёт fetcher.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // сколько данные считаются «свежими»
staleTtl: number; // сколько устаревшие данные могут отдаваться
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// Данные устарели — отдаём их, но запускаем фоновое обновление
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Полный промах кэша — нужно получить синхронно
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// Используем блокировку, чтобы предотвратить множественные фоновые обновления
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}Использование:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 минут свежести
staleTtl: 3600, // отдаём устаревшее до 1 часа во время ревалидации
});Я использую этот паттерн для всего, что обращено к пользователю и где латентность важнее абсолютной свежести. Данные дашбордов, страницы профилей, списки товаров — всё идеальные кандидаты.
Инвалидация кэша#
Фил Карлтон не шутил. Инвалидация — это где кэширование переходит из «простой оптимизации» в «проблему распределённых систем».
Простая инвалидация по ключу#
Самый простой случай: когда обновляешь пользователя, удали его ключ из кэша.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// Инвалидируем кэш
await redis.del(`cache:user:${userId}`);
return user[0];
}Это работает, пока данные пользователя не появляются в других кэшированных результатах. Может быть, они встроены в список членов команды. Может, в результатах поиска. Может, в 14 разных кэшированных API-ответах. Теперь тебе нужно отслеживать, какие ключи кэша содержат какие сущности.
Инвалидация на основе тегов#
Помечай записи кэша тегами содержащихся сущностей, а затем инвалидируй по тегу.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// Сохраняем значение
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Добавляем ключ в множество каждого тега
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Множества тегов живут дольше значений
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}Использование:
// При кэшировании данных команды помечаем их ID всех участников
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// Когда пользователь 42 обновляет профиль, инвалидируем всё, что его содержит
await invalidateByTag("entity:user:42");Событийная инвалидация#
Для более крупных систем используй Redis Pub/Sub для рассылки событий инвалидации:
// Публикатор (в твоём API-сервисе)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Подписчик (в каждом инстансе приложения)
const subscriber = new Redis(/* тот же конфиг */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});Это критически важно в мультиинстансных деплоях. Если у тебя 4 сервера приложения за балансировщиком нагрузки, инвалидация на сервере 1 должна распространиться на все серверы. Pub/Sub обрабатывает это автоматически.
Инвалидация по паттерну (с осторожностью)#
Иногда нужно инвалидировать все ключи, соответствующие паттерну. Никогда не используй KEYS в продакшене. Он блокирует сервер Redis при сканировании всего пространства ключей. С миллионами ключей это может блокировать на секунды — целая вечность в терминах Redis.
Используй SCAN вместо этого:
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// Инвалидируем все кэшированные данные конкретной команды
await invalidateByPattern("cache:team:42:*");SCAN итерирует инкрементально — он никогда не блокирует сервер. Подсказка COUNT предлагает, сколько ключей возвращать за итерацию (это подсказка, а не гарантия). Для больших пространств ключей это единственный безопасный подход.
Тем не менее, инвалидация по паттерну — это code smell. Если ты часто сканируешь, перепроектируй структуру ключей или используй теги. SCAN — это O(N) по пространству ключей и предназначен для операций обслуживания, а не горячих путей.
Структуры данных за пределами строк#
Большинство разработчиков используют Redis как хранилище ключ-значение для JSON-строк. Это как купить швейцарский нож и использовать только открывалку для бутылок. В Redis есть богатые структуры данных, и выбор правильной может устранить целые категории сложности.
Хеши для объектов#
Вместо сериализации всего объекта в JSON, храни его как Redis Hash. Это позволяет читать и обновлять отдельные поля без десериализации всего объекта.
// Сохраняем пользователя как хеш
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// Читаем конкретные поля
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Обновляем одно поле
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Читаем весь хеш как объект
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}Хеши эффективны по памяти для маленьких объектов (Redis использует компактную кодировку ziplist под капотом) и избавляют от накладных расходов на сериализацию/десериализацию. Компромисс: ты теряешь возможность хранить вложенные объекты без их предварительного выравнивания.
Sorted Sets для лидербордов и Rate Limiting#
Sorted Sets — самая недооценённая структура данных в Redis. Каждый элемент имеет оценку, и множество всегда отсортировано по оценке. Это делает их идеальными для лидербордов, ранжирования и rate limiting со скользящим окном.
// Лидерборд
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-indexed в 1-indexed
}Для rate limiting со скользящим окном:
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Удаляем записи за пределами окна
pipeline.zremrangebyscore(key, 0, windowStart);
// Добавляем текущий запрос
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Считаем запросы в окне
pipeline.zcard(key);
// Устанавливаем истечение на весь ключ
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}Это точнее, чем подход с фиксированным окном, и не страдает от проблемы границ, когда всплеск в конце одного окна и в начале следующего фактически удваивает твой лимит.
Списки для очередей#
Redis Lists с LPUSH/BRPOP — отличные легковесные очереди задач:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Продюсер
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// Консьюмер (блокируется до появления задачи)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}Для чего-то более сложного, чем базовая очередь (повторы, dead letter queues, приоритеты, отложенные задачи), используй BullMQ, который построен на Redis, но обрабатывает все краевые случаи.
Множества для уникального трекинга#
Нужно отслеживать уникальных посетителей, дедуплицировать события или проверять принадлежность? Множества — это O(1) для добавления, удаления и проверки принадлежности.
// Трекаем уникальных посетителей за день
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// Автоматическое истечение через 48 часов
await redis.expire(key, 172800);
return isNew === 1; // 1 = новый, 0 = уже существовал
}
// Получаем количество уникальных посетителей
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Проверяем, проголосовал ли пользователь
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}Для очень больших множеств (миллионы элементов) рассмотри HyperLogLog. Он использует всего 12 КБ памяти независимо от кардинальности ценой ~0.81% стандартной ошибки:
// HyperLogLog для приблизительных уникальных подсчётов
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}Сериализация: JSON vs MessagePack#
JSON — выбор по умолчанию для сериализации в Redis. Он читаемый, универсальный и достаточно хорош для большинства случаев. Но для систем с высокой пропускной способностью накладные расходы на сериализацию/десериализацию накапливаются.
Проблема с JSON#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 байт
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse на горячем пути: ~0.02 мс на вызов
// При 10 000 запросах/сек: 200 мс совокупного CPU-времени в секундуАльтернатива — MessagePack#
MessagePack — это бинарный формат сериализации, который компактнее и быстрее JSON:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 байт (на 25% меньше)
const packed = pack(user);
console.log(packed.length); // ~140
// Сохраняем как Buffer
await redis.set("user:123", packed);
// Читаем как Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}Обрати внимание на использование getBuffer вместо get — это критически важно. get возвращает строку и повредит бинарные данные.
Сжатие для больших значений#
Для больших кэшированных значений (API-ответы с сотнями элементов, отрендеренный HTML) добавь сжатие:
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// Сжимаем только если больше 1 КБ (накладные расходы на сжатие не оправданы для маленьких значений)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// Сначала пробуем распаковать
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Не сжато, парсим как обычный JSON
return JSON.parse(raw.toString()) as T;
}
}По моим тестам, gzip обычно уменьшает размер JSON-пейлоада на 70–85%. API-ответ в 50 КБ становится 8 КБ. Это имеет значение, когда ты платишь за память Redis — меньше памяти на ключ означает больше ключей в том же инстансе.
Компромисс: сжатие добавляет 1–3 мс CPU-времени на операцию. Для большинства приложений это пренебрежимо. Для ультранизколатентных путей — пропусти.
Моя рекомендация#
Используй JSON, если профилирование не покажет, что это бутылочное горлышко. Читаемость и удобство дебага JSON в Redis (ты можешь сделать redis-cli GET key и реально прочитать значение) перевешивают выигрыш в производительности от MessagePack для 95% приложений. Добавляй сжатие только для значений больше 1 КБ.
Redis в Next.js#
Next.js имеет свою историю с кэшированием (Data Cache, Full Route Cache и т. д.), но Redis заполняет пробелы, которые встроенное кэширование не закрывает — особенно когда нужно делить кэш между несколькими инстансами или сохранять кэш между деплоями.
Кэширование ответов API Route#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// Проверяем кэш
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Получаем из базы данных
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// Кэшируем на 5 минут с jitter
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}Заголовок X-Cache неоценим для дебага. Когда латентность резко растёт, быстрый curl -I скажет тебе, работает ли кэш.
Хранение сессий#
Next.js с Redis для сессий побеждает JWT для stateful-приложений:
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24 часа
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// Используем GETEX для обновления TTL при каждом доступе (скользящее истечение)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// Уничтожаем все сессии пользователя (полезно для «выйти везде»)
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// Это требует поддержки индекса user->sessions
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}Мидлвар Rate Limiting#
// middleware.ts (или хелпер, используемый мидлваром)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Lua-скрипт для атомарного rate limiting
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}Lua-скрипт здесь важен. Без него последовательность ZREMRANGEBYSCORE + ZADD + ZCARD не атомарна, и при высокой конкурентности счётчик может быть неточным. Lua-скрипты выполняются атомарно в Redis — они не могут быть прерваны другими командами.
Распределённые блокировки для Next.js#
Когда у тебя несколько инстансов Next.js и нужно гарантировать, что только один обрабатывает задачу (например, отправка запланированного письма или запуск джоба очистки):
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// Пытаемся получить блокировку
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Автоматически продлеваем блокировку для долгих задач
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// Снимаем блокировку только если мы всё ещё ею владеем
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// Ждём перед повтором
await new Promise((r) => setTimeout(r, retryDelay));
}
// Не удалось получить блокировку после всех попыток
return null;
}Использование:
// В API route, запускаемом по крону
export async function POST() {
const result = await withLock("daily-report", async () => {
// Только один инстанс выполняет это
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}Интервал продления блокировки ttl/3 важен. Без него, если задача занимает дольше, чем TTL блокировки, блокировка истекает и другой инстанс её перехватывает. Экстендер поддерживает блокировку живой, пока задача выполняется.
Мониторинг и дебаг#
Redis быстр, пока не перестаёт. Когда проблемы возникают, тебе нужна видимость.
Коэффициент попаданий кэша#
Самая важная метрика. Отслеживай в приложении:
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// Сбрасываем метрики ежедневно
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}Здоровый коэффициент попаданий кэша — выше 90%. Если ниже 80%, либо TTL слишком короткие, либо ключи кэша слишком специфичные, либо паттерны доступа более случайные, чем ты думал.
Команда INFO#
Команда INFO — это встроенная панель здоровья Redis:
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
Ключевые метрики для мониторинга:
- used_memory vs maxmemory: приближаешься к лимиту?
- mem_fragmentation_ratio: выше 1.5 означает, что Redis использует значительно больше RSS, чем логической памяти. Подумай о рестарте.
- evicted_keys: если ненулевое и ты не планировал вытеснение, у тебя кончается память.
redis-cli INFO statsСледи за:
- keyspace_hits / keyspace_misses: серверный коэффициент попаданий
- total_commands_processed: пропускная способность
- instantaneous_ops_per_sec: текущая пропускная способность
MONITOR (используй с крайней осторожностью)#
MONITOR транслирует каждую команду, выполняемую на сервере Redis, в реальном времени. Невероятно полезно для дебага и невероятно опасно в продакшене.
# НИКОГДА не оставляй это запущенным в продакшене
# Добавляет значительную нагрузку и может логировать чувствительные данные
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
Я использую MONITOR ровно для двух вещей: дебаг именования ключей при разработке и проверка того, что конкретный путь в коде действительно обращается к Redis как ожидалось. Никогда дольше 30 секунд. Никогда в продакшене, если ты не исчерпал все другие способы дебага.
Уведомления о пространстве ключей#
Хочешь знать, когда ключи истекают или удаляются? Redis может публиковать события:
# Включаем уведомления о пространстве ключей для событий истечения и вытеснения
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* конфиг */);
// Слушаем события истечения ключей
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Проактивно перегенерируем важные ключи
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});Это полезно для проактивного прогрева кэша — вместо того чтобы ждать, пока пользователь триггернет промах кэша, ты перегенерируешь критические записи в момент их истечения.
Анализ памяти#
Когда память Redis неожиданно растёт, нужно найти, какие ключи потребляют больше всего:
# Выборка 10 крупнейших ключей
redis-cli --bigkeys# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields
Для более детального анализа:
# Использование памяти конкретного ключа (в байтах)
redis-cli MEMORY USAGE "cache:search:electronics"// Программный анализ памяти
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// Сортируем по размеру по убыванию
stats.sort((a, b) => b.bytes - a.bytes);
console.log("Top 20 keys by memory usage:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}Политики вытеснения#
Если у твоего инстанса Redis есть лимит maxmemory (и он должен быть), настрой политику вытеснения:
# В redis.conf или через CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruДоступные политики:
- noeviction: возвращает ошибку при заполнении памяти (по умолчанию, худший вариант для кэширования)
- allkeys-lru: вытесняет наименее недавно использованный ключ (лучший универсальный выбор для кэширования)
- allkeys-lfu: вытесняет наименее часто используемый ключ (лучше, если некоторые ключи обращаются всплесками)
- volatile-lru: вытесняет только ключи с установленным TTL (полезно, если смешиваешь кэш и постоянные данные)
- allkeys-random: случайное вытеснение (удивительно неплохо, нулевые накладные расходы)
Для чисто кэшировочных нагрузок allkeys-lfu обычно лучший выбор. Он сохраняет часто запрашиваемые ключи в памяти, даже если к ним давно не обращались.
Собираем всё вместе: продакшн-модуль кэша#
Вот полный модуль кэша, который я использую в продакшене, комбинируя всё, что мы обсудили:
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// Уровни TTL
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// Основной cache-aside с защитой от stampede
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// Проверяем кэш
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// Получаем блокировку для предотвращения stampede
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Другой процесс получает данные — ждём и пробуем снова
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// Сохраняем ассоциации тегов
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// Инвалидация
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// Метрики
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};Использование по всему приложению:
import { cache } from "@/lib/cache";
// Простой cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// С кастомным TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 часов
});
// После обновления продукта
await cache.invalidateByTag("entity:products");
// Проверяем здоровье
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);Типичные ошибки, которые я допускал (чтобы тебе не пришлось)#
1. Не устанавливать maxmemory. Redis с удовольствием использует всю доступную память, пока OC не прибьёт его. Всегда устанавливай лимит.
2. Использовать KEYS в продакшене. Он блокирует сервер. Используй SCAN. Я это усвоил, когда вызов KEYS * из мониторингового скрипта вызвал 3 секунды даунтайма.
3. Кэшировать слишком агрессивно. Не всё нужно кэшировать. Если запрос к базе данных занимает 2 мс и вызывается 10 раз в минуту, кэширование добавляет сложность при ничтожной выгоде.
4. Игнорировать стоимость сериализации. Однажды я закэшировал 2-мегабайтный JSON-блоб и удивлялся, почему чтения из кэша медленные. Накладные расходы на сериализацию были больше, чем запрос к базе данных, который кэш должен был сэкономить.
5. Отсутствие graceful degradation. Когда Redis падает, приложение должно продолжать работать — просто медленнее. Оборачивай каждый вызов кэша в try/catch с фолбэком на базу данных. Никогда не позволяй сбою кэша стать ошибкой для пользователя.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // Полностью обходим кэш
}
}6. Не мониторить вытеснения. Если Redis вытесняет ключи, ты либо недопровизионирован, либо кэшируешь слишком много. В любом случае тебе нужно об этом знать.
7. Использовать один инстанс Redis для кэша и персистентных данных. Используй отдельные инстансы (или хотя бы отдельные базы данных). Политика вытеснения кэша, которая удаляет записи из очереди задач — это плохой день для всех.
Подведём итоги#
Кэширование в Redis — это не сложно, но легко сделать неправильно. Начни с cache-aside, добавь TTL jitter с первого дня, мониторь коэффициент попаданий и сопротивляйся желанию кэшировать всё подряд.
Лучшая стратегия кэширования — та, о которой ты можешь рассуждать в 3 часа ночи, когда что-то сломалось. Держи всё простым, наблюдаемым и помни, что каждое кэшированное значение — это ложь, которую ты рассказал пользователям о состоянии данных. Твоя задача — сделать эту ложь как можно меньше и как можно короче.