Перейти до вмісту
·31 хв читання

Стратегії кешування Redis, які реально працюють у продакшені

Cache-aside, write-through, запобігання cache stampede, стратегії TTL та патерни інвалідації. Патерни Redis, які я використовував у продакшн Node.js-застосунках з реальними прикладами коду.

Поділитися:X / TwitterLinkedIn

Усі кажуть тобі "просто додай 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-скриптів.

typescript
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." Потік простий:

  1. Застосунок отримує запит
  2. Перевіряє Redis на наявність кешованого значення
  3. Якщо знайдено (cache hit), повертає його
  4. Якщо не знайдено (cache miss), запитує базу даних
  5. Зберігає результат у Redis
  6. Повертає результат

Ось типізована реалізація:

typescript
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;
}

Використання виглядає так:

typescript
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.

typescript
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 працював правильно:

typescript
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 кожен запис проходить через шар кешу. Кеш оновлюється першим, потім база даних. Це гарантує, що кеш завжди консистентний з базою (за умови, що записи завжди проходять через твій застосунок).

typescript
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 впаде до того, як дані будуть збережені.

typescript
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;
}

Потім у тебе буде окремий воркер, що обробляє цю чергу:

typescript
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 відповідно:

typescript
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:

typescript
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:

typescript
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 seconds

Sliding Expiry#

Для даних типу сесій, де TTL має скидатися при кожному доступі, використовуй GETEX (Redis 6.2+):

typescript
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:

typescript
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#

Тільки один запит регенерує кеш. Всі інші чекають.

typescript
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 лока), інший запит захоплює лок, а потім перший запит видаляє лок другого запиту. Правильне виправлення — використовувати унікальний токен:

typescript
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: Імовірнісне раннє закінчення#

Це мій улюблений підхід. Замість очікування закінчення ключа, випадково регенеруємо його трохи раніше. Ідея з статті Ваттані, К'єрікетті та Лоуенштейна.

typescript
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.

typescript
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);
    });
}

Використання:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 minutes fresh
  staleTtl: 3600,    // serve stale for up to 1 hour while revalidating
});

Я використовую цей патерн для всього, що бачить користувач, де затримка важливіша за абсолютну свіжість. Дані дашборду, сторінки профілів, списки продуктів — все ідеальні кандидати.

Інвалідація кешу#

Філ Карлтон не жартував. Інвалідація — це де кешування переходить від "простої оптимізації" до "проблеми розподілених систем."

Проста інвалідація за ключем#

Найпростіший випадок: коли оновлюєш користувача, видали його ключ кешу.

typescript
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-відповідях. Тепер тобі потрібно відстежувати, які ключі кешу містять які сутності.

Інвалідація за тегами#

Тегуй записи кешу сутностями, які вони містять, потім інвалідуй за тегом.

typescript
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;
}

Використання:

typescript
// 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 для розсилки подій інвалідації:

typescript
// 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:

typescript
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. Це дає змогу читати й оновлювати окремі поля без десеріалізації всього об'єкта.

typescript
// 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 з ковзним вікном.

typescript
// 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 з ковзним вікном:

typescript
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 створюють чудові легковагі черги задач:

typescript
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) для додавання, видалення та перевірки належності.

typescript
// 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% стандартної похибки:

typescript
// 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#

typescript
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:

bash
npm install msgpackr
typescript
import { 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) додай компресію:

typescript
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#

typescript
// 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-застосунків:

typescript
// 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#

typescript
// 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 або запуск задачі очищення):

typescript
// 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;
}

Використання:

typescript
// 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#

Найважливіша метрика. Відстежуй її в своєму застосунку:

typescript
// 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:

bash
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, у тебе закінчилася пам'ять.
bash
redis-cli INFO stats

Слідкуй за:

  • keyspace_hits / keyspace_misses: Hit rate на рівні сервера
  • total_commands_processed: Пропускна здатність
  • instantaneous_ops_per_sec: Поточна пропускна здатність

MONITOR (використовуй з надзвичайною обережністю)#

MONITOR стрімить кожну команду, виконану на сервері Redis в реальному часі. Неймовірно корисний для дебагу і неймовірно небезпечний у продакшені.

bash
# NEVER leave this running in production
# It adds significant overhead and can log sensitive data
redis-cli MONITOR
1614556800.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 може публікувати події:

bash
# Enable keyspace notifications for expired and evicted events
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const 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 несподівано зростає, потрібно знайти, які ключі споживають найбільше:

bash
# 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

Для більш детального аналізу:

bash
# Memory usage of a specific key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// 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 (а має), налаштуй політику евікції:

bash
# 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 зазвичай найкращий вибір. Він тримає часто використовувані ключі в пам'яті, навіть якщо вони не були доступні нещодавно.

Збираємо все разом: продакшн модуль кешу#

Ось повний модуль кешу, який я використовую в продакшені, що поєднує все обговорене:

typescript
// 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,
};

Використання по всьому застосунку:

typescript
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, що відкатується до бази. Ніколи не дозволяй збою кешу стати помилкою для користувача.

typescript
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 і стримуй бажання кешувати все.

Найкраща стратегія кешування — та, про яку ти можеш розмірковувати о третій ночі, коли щось зламалося. Тримай її простою, тримай її спостережуваною, і пам'ятай, що кожне кешоване значення — це брехня, яку ти розповів своїм користувачам про стан твоїх даних — твоя робота полягає в тому, щоб тримати цю брехню якомога маленькою і короткотривалою.

Схожі записи