Стратегії кешування Redis, які реально працюють у продакшені
Cache-aside, write-through, запобігання cache stampede, стратегії TTL та патерни інвалідації. Патерни Redis, які я використовував у продакшн Node.js-застосунках з реальними прикладами коду.
Усі кажуть тобі "просто додай Redis", коли твій API гальмує. Ніхто не розповідає, що буде через пів року, коли твій кеш віддає застарілі дані, логіка інвалідації розкидана по 40 файлах, а деплой спричиняє cache stampede, який кладе базу даних ще сильніше, ніж якби ти взагалі не кешував.
Я працюю з Redis у продакшені роками. Не як з іграшкою, не в туторіалі — у системах, що обслуговують реальний трафік, де помилка в кешуванні означає алерти в пейджері о третій ночі. Далі — все, що я навчився про те, як робити це правильно.
Навіщо кешувати?#
Почнімо з очевидного: бази даних повільні порівняно з пам'яттю. 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% часу. Його також називають "lazy loading" або "look-aside." Потік простий:
- Застосунок отримує запит
- Перевіряє Redis на наявність кешованого значення
- Якщо знайдено (cache hit), повертає його
- Якщо не знайдено (cache miss), запитує базу даних
- Зберігає результат у Redis
- Повертає результат
Ось типізована реалізація:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // seconds
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}`;
// Step 1: Try to read from cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Corrupted cache entry, delete it and fall through
await redis.del(cacheKey);
}
}
// Step 2: Cache miss — fetch from source
const result = await fetcher();
// Step 3: Store in cache (don't await — 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 minutes
);
}Зверни увагу, що я виконую redis.set у режимі fire-and-forget. Це навмисно. Якщо 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;
},
{
// Shorter TTL for null results to limit memory usage
// but long enough to absorb repeated misses
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;
// Cache exists results for 30 min, null results for 5 min
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> {
// Step 1: Update the database
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];
// Step 2: Update the cache immediately
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}`;
// Read current state
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Update cache immediately
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Queue database write for async processing
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) {
// Re-queue on failure with retry count
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 = {
// Tier 1: Rarely changes, expensive to compute
// Examples: product catalog, site config, feature flags
STATIC: 86400, // 24 hours
// Tier 2: Changes occasionally, moderate cost
// Examples: user profiles, team settings, permissions
MODERATE: 1800, // 30 minutes
// Tier 3: Changes frequently, cheap to compute but called often
// Examples: feed data, notification counts, session info
VOLATILE: 300, // 5 minutes
// Tier 4: Ephemeral, used for rate limiting and locks
EPHEMERAL: 60, // 1 minute
// Null results: always short-lived
NOT_FOUND: 120, // 2 minutes
} 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));
}
// Instead of: redis.set(key, value, "EX", 3600)
// Use: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = random value between 3240 and 3960Це розподіляє закінчення терміну по вікну, тож замість 10 000 ключів, що закінчуються в одну секунду, вони закінчуються протягом 12-хвилинного вікна. База бачить поступове зростання трафіку, а не обрив.
Для критичних шляхів я йду далі і використовую 20% jitter:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 secondsSliding Expiry#
Для даних типу сесій, де TTL має скидатися при кожному доступі, використовуй GETEX (Redis 6.2+):
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX atomically gets the value AND resets the 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 хвилин. Термін закінчується. П'ятдесят одночасних запитів бачать cache miss. Всі п'ятдесят б'ють по базі тим самим дорогим запитом. Ти фактично DDoS'иш сам себе.
Рішення 1: Mutex Lock#
Тільки один запит регенерує кеш. Всі інші чекають.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Try to acquire lock (NX = only if not exists, EX = auto-expire)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// We got the lock — fetch and cache
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Release lock
await redis.del(lockKey);
}
}
// Another request holds the lock — wait and retry
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Still no cache — fall through to database
// (this handles the case where the lock holder failed)
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 script ensures atomic check-and-delete
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 — але чесно, для запобігання 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 algorithm: probabilistically regenerate as expiry approaches
// beta * Math.log(Math.random()) produces a negative number
// that grows larger (more negative) as expiry approaches
const beta = 1; // tuning parameter, 1 works well
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Fall through to regenerate
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Set with extra buffer so Redis doesn't expire before we can regenerate
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; // how long the data is "fresh"
staleTtl: number; // how long stale data can be served
}
): 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) {
// Data is stale — serve it but trigger background refresh
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Complete cache miss — must fetch synchronously
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 {
// Use a lock to prevent multiple background refreshes
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 minutes fresh
staleTtl: 3600, // serve stale for up to 1 hour while revalidating
});Я використовую цей патерн для всього, що бачить користувач, де затримка важливіша за абсолютну свіжість. Дані дашборду, сторінки профілів, списки продуктів — все ідеальні кандидати.
Інвалідація кешу#
Філ Карлтон не жартував. Інвалідація — це де кешування переходить від "простої оптимізації" до "проблеми розподілених систем."
Проста інвалідація за ключем#
Найпростіший випадок: коли оновлюєш користувача, видали його ключ кешу.
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]
);
// Invalidate the cache
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();
// Store the value
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Add the key to each tag's set
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets live longer than values
}
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;
}Використання:
// When caching team data, tag it with all member IDs
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// When user 42 updates their profile, invalidate everything that contains them
await invalidateByTag("entity:user:42");Event-Driven інвалідація#
Для більших систем використовуй Redis Pub/Sub для розсилки подій інвалідації:
// Publisher (in your API service)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Subscriber (in each app instance)
const subscriber = new Redis(/* same config */);
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;
}
// Invalidate all cached data for a specific team
await invalidateByPattern("cache:team:42:*");SCAN ітерує інкрементально — вона ніколи не блокує сервер. Підказка COUNT пропонує, скільки ключів повертати за ітерацію (це підказка, а не гарантія). Для великих просторів ключів це єдиний безпечний підхід.
Тим не менш, інвалідація за патерном — це code smell. Якщо ти часто скануєш, переробляй структуру ключів або використовуй теги. SCAN має складність O(N) по простору ключів і призначений для операцій обслуговування, а не для гарячих шляхів.
Структури даних за межами рядків#
Більшість розробників ставляться до Redis як до сховища ключ-значення для JSON-рядків. Це як купити швейцарський ніж і використовувати тільки відкривачку для пляшок. Redis має багаті структури даних, і вибір правильної може усунути цілі категорії складності.
Хеші для об'єктів#
Замість серіалізації цілого об'єкта як JSON, зберігай його як Redis Hash. Це дає змогу читати й оновлювати окремі поля без десеріалізації всього об'єкта.
// Store user as a 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);
}
// Read specific fields
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Update a single field
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Read entire hash as object
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 з ковзним вікном.
// Leaderboard
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 to 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();
// Remove entries outside the window
pipeline.zremrangebyscore(key, 0, windowStart);
// Add current request
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set expiry on the whole 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),
};
}Це точніше за підхід з фіксованим вікном і не страждає від проблеми межі, де сплеск наприкінці одного вікна та на початку наступного фактично подвоює твій rate limit.
Списки для черг#
Redis Lists з LPUSH/BRPOP створюють чудові легковагі черги задач:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Producer
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;
}
// Consumer (blocks until a job is available)
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 і обробляє всі граничні випадки.
Sets для унікального трекінгу#
Потрібно відстежувати унікальних відвідувачів, дедуплікувати події чи перевіряти належність? Sets мають O(1) для додавання, видалення та перевірки належності.
// Track unique visitors per day
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);
// Auto-expire after 48 hours
await redis.expire(key, 172800);
return isNew === 1; // 1 = new member, 0 = already existed
}
// Get unique visitor count
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Check if user has already performed an action
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}Для дуже великих множин (мільйони членів) розглянь HyperLogLog натомість. Він використовує лише 12 КБ пам'яті незалежно від кардинальності, ціною ~0.81% стандартної похибки:
// HyperLogLog for approximate unique counts
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 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse on a hot path: ~0.02ms per call
// At 10,000 requests/sec: 200ms total CPU time per secondАльтернатива MessagePack#
MessagePack — це бінарний формат серіалізації, менший і швидший за JSON:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 bytes (25% smaller)
const packed = pack(user);
console.log(packed.length); // ~140
// Store as Buffer
await redis.set("user:123", packed);
// Read as 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);
// Only compress if larger than 1KB (compression overhead isn't worth it for small values)
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 {
// Try to decompress first
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Not compressed, parse as regular JSON
return JSON.parse(raw.toString()) as T;
}
}За моїми тестами, gzip-компресія зазвичай зменшує розмір JSON-пейлоаду на 70-85%. API-відповідь на 50 КБ стає 8 КБ. Це важливо, коли ти платиш за пам'ять Redis — менше пам'яті на ключ означає більше ключів у тому ж інстансі.
Компроміс: компресія додає 1-3 мс процесорного часу на операцію. Для більшості застосунків це незначно. Для шляхів з ультранизькою затримкою — пропускай.
Моя рекомендація#
Використовуй 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}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Fetch from database
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// Cache for 5 minutes with 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 hours
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}`;
// Use GETEX to refresh TTL on every access (sliding expiry)
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}`);
}
// Destroy all sessions for a user (useful for "logout everywhere")
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// This requires maintaining a user->sessions index
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#
// middleware.ts (or a helper used by middleware)
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 script for atomic 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 і потрібно гарантувати, що тільки один обробляє задачу (як відправка запланованого email або запуск задачі очищення):
// 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}`;
// Try to acquire lock
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Extend lock automatically for long-running tasks
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 {
// Release lock only if we still own it
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);
}
}
// Wait before retrying
await new Promise((r) => setTimeout(r, retryDelay));
}
// Could not acquire lock after all retries
return null;
}Використання:
// In a cron-triggered API route
export async function POST() {
const result = await withLock("daily-report", async () => {
// Only one instance runs this
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 лока, лок закінчується і інший інстанс захоплює його. Extender тримає лок активним, поки задача працює.
Моніторинг та дебаг#
Redis швидкий, поки не перестане бути швидким. Коли виникають проблеми, тобі потрібна видимість.
Cache Hit Ratio#
Найважливіша метрика. Відстежуй її в своєму застосунку:
// 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,
};
}
// Reset metrics daily
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}Здоровий cache hit ratio — вище 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: Якщо це ненульове і ти не планував eviction, у тебе закінчилася пам'ять.
redis-cli INFO statsСлідкуй за:
- keyspace_hits / keyspace_misses: Hit rate на рівні сервера
- total_commands_processed: Пропускна здатність
- instantaneous_ops_per_sec: Поточна пропускна здатність
MONITOR (використовуй з надзвичайною обережністю)#
MONITOR стрімить кожну команду, виконану на сервері Redis в реальному часі. Неймовірно корисний для дебагу і неймовірно небезпечний у продакшені.
# NEVER leave this running in production
# It adds significant overhead and can log sensitive data
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 секунд. Ніколи у продакшені, якщо ти ще не вичерпав інші опції дебагу.
Keyspace Notifications#
Хочеш знати, коли ключі закінчуються або видаляються? Redis може публікувати події:
# Enable keyspace notifications for expired and evicted events
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* config */);
// Listen for key expiration events
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Proactively regenerate important keys
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});Це корисно для проактивного прогрівання кешу — замість очікування, поки користувач спричинить cache miss, ти регенеруєш критичні записи в момент їх закінчення.
Аналіз пам'яті#
Коли пам'ять Redis несподівано зростає, потрібно знайти, які ключі споживають найбільше:
# Sample 10 largest keys
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
Для більш детального аналізу:
# Memory usage of a specific key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"// Programmatic memory analysis
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");
// Sort by size descending
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`);
}
}Eviction Policies#
Якщо твій інстанс Redis має ліміт maxmemory (а має), налаштуй політику евікції:
# In redis.conf or via 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 tiers
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));
}
// Core cache-aside with stampede protection
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}`;
// Check cache
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();
// Acquire lock to prevent stampede
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Another process is fetching — wait briefly and retry cache
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);
// Store tag associations
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);
}
}
// Invalidation
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;
}
// Metrics
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";
// Simple cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// With custom TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 hours
});
// After updating a product
await cache.invalidateByTag("entity:products");
// Check health
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);Типові помилки, які я допускав (щоб тобі не довелося)#
1. Не встановлювати maxmemory. Redis із задоволенням використовуватиме всю доступну пам'ять, поки ОС не вб'є його. Завжди встановлюй ліміт.
2. Використовувати KEYS у продакшені. Вона блокує сервер. Використовуй SCAN. Я навчився цього, коли виклик KEYS * з моніторингового скрипта спричинив 3 секунди даунтайму.
3. Кешувати занадто агресивно. Не все потрібно кешувати. Якщо твій запит до бази займає 2 мс і викликається 10 разів на хвилину, кешування додає складності при мізерній вигоді.
4. Ігнорувати витрати на серіалізацію. Якось я закешував JSON-блоб на 2 МБ і дивувався, чому читання кешу повільне. Накладні витрати на серіалізацію були більші за запит до бази, який це мало економити.
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(); // Bypass cache entirely
}
}6. Не моніторити evictions. Якщо Redis евіктує ключі, ти або недопровізіонований, або кешуєш забагато. В будь-якому випадку, тобі потрібно про це знати.
7. Ділити інстанс Redis між кешуванням і персистентними даними. Використовуй окремі інстанси (або хоча б окремі бази даних). Політика евікції кешу, що видаляє записи твоєї черги задач — це поганий день для всіх.
Підсумок#
Кешування Redis не складне, але легко зробити неправильно. Починай з cache-aside, додавай TTL jitter з першого дня, моніть свій hit rate і стримуй бажання кешувати все.
Найкраща стратегія кешування — та, про яку ти можеш розмірковувати о третій ночі, коли щось зламалося. Тримай її простою, тримай її спостережуваною, і пам'ятай, що кожне кешоване значення — це брехня, яку ти розповів своїм користувачам про стан твоїх даних — твоя робота полягає в тому, щоб тримати цю брехню якомога маленькою і короткотривалою.