lenec ru

← все посты

Кэширование в Redis: стратегии, TTL, eviction policies и cache stampede

19K

Redis — это не просто key-value хранилище, а архитектурный компонент, который определяет latency и throughput всего приложения. Неправильная стратегия кэширования приводит к cache stampede, низкому hit rate и перегрузке базы данных. Разбираем три паттерна кэширования, eviction policies, решения проблемы thundering herd и интеграцию с Node.js через ioredis.

Стратегии кэширования: cache-aside, write-through, write-behind

Cache-Aside (Lazy Loading)

Самый распространённый паттерн. Приложение сначала проверяет кэш, при промахе читает из базы и записывает в кэш:

async function getUser(userId) {
  const cached = await redis.get(`user:${userId}`);
  if (cached) {
    return JSON.parse(cached);
  }

  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600);
  return user;
}

Плюсы: кэш заполняется только востребованными данными, простая логика. Минусы: первый запрос всегда медленный (cold start), возможен cache stampede при истечении популярного ключа.

Write-Through

При записи данные синхронно сохраняются и в базу, и в кэш. Чтение всегда идёт из кэша:

async function updateUser(userId, data) {
  await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, userId]);
  await redis.set(`user:${userId}`, JSON.stringify(data), 'EX', 3600);
  return data;
}

Плюсы: кэш всегда актуален, нет stale data. Минусы: каждая запись медленнее (два сетевых вызова), кэш может заполниться редко используемыми данными.

Write-Behind (Write-Back)

Запись идёт только в кэш, синхронизация с базой происходит асинхронно (батчами или по таймеру):

async function updateUserAsync(userId, data) {
  await redis.set(`user:${userId}`, JSON.stringify(data), 'EX', 3600);
  await redis.lpush('dirty:users', userId); // Очередь на синхронизацию
  return data;
}

// Worker процесс
setInterval(async () => {
  const batch = await redis.lrange('dirty:users', 0, 99);
  if (batch.length === 0) return;

  const users = await Promise.all(batch.map(id => redis.get(`user:${id}`)));
  await db.batchUpdate(users);
  await redis.ltrim('dirty:users', batch.length, -1);
}, 5000);

Плюсы: минимальная latency записи, батчинг снижает нагрузку на базу. Минусы: риск потери данных при падении Redis, сложность реализации, eventual consistency.

Выбор стратегии: cache-aside для read-heavy нагрузки (90%+ чтений), write-through для строгой консистентности (финансы, инвентарь), write-behind для write-heavy систем с допустимой eventual consistency (метрики, логи).

TTL и eviction policies

Redis хранит данные в памяти. Когда память заканчивается, срабатывает eviction policy — алгоритм вытеснения ключей. Настраивается через maxmemory-policy:

noeviction

Запрещает запись при достижении maxmemory. Возвращает ошибку OOM. Используется, когда потеря данных недопустима (например, Redis как primary storage для сессий).

allkeys-lru

Вытесняет наименее недавно использованные (Least Recently Used) ключи из всего keyspace. Подходит для общего кэша, где все ключи равноценны.

volatile-lru

Вытесняет LRU-ключи только среди тех, у которых установлен TTL. Если ключей с TTL нет — ведёт себя как noeviction. Используется, когда часть данных критична (без TTL), а часть — кэш (с TTL).

allkeys-lfu

Вытесняет наименее часто использованные (Least Frequently Used) ключи. LFU учитывает частоту обращений, а не только время последнего доступа. Лучше для workload с долгоживущими популярными ключами.

volatile-ttl

Вытесняет ключи с наименьшим оставшимся TTL. Полезно, когда нужно освободить место для свежих данных, а старые можно удалить раньше срока.

allkeys-random / volatile-random

Случайное вытеснение. Используется редко, в основном для тестирования или когда нет чёткого паттерна доступа.

Рекомендации: для кэша API-ответов — allkeys-lru, для микса кэша и персистентных данных — volatile-lru, для leaderboard/счётчиков с неравномерным доступом — allkeys-lfu.

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

Cache Stampede Problem и решения

Cache stampede (thundering herd) — ситуация, когда популярный ключ истекает, и тысячи одновременных запросов видят cache miss. Все они одновременно обращаются к базе данных, вызывая перегрузку.

Пример: ключ с 10 000 запросов в секунду истекает. В течение миллисекунд приходит 100+ запросов, все видят промах, все идут в базу. База падает, latency растёт, каскадный сбой.

Решение 1: Distributed Lock (Mutex)

Только один процесс получает право обновить кэш. Остальные ждут или возвращают stale data:

const crypto = require('crypto');

async function getWithLock(key, fetcher, ttl = 300) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  const token = crypto.randomBytes(8).toString('hex');

  // Пытаемся захватить блокировку (NX = set if not exists, PX = TTL в мс)
  const acquired = await redis.set(lockKey, token, 'NX', 'PX', 10000);

  if (acquired === 'OK') {
    try {
      const data = await fetcher();
      await redis.set(key, JSON.stringify(data), 'EX', ttl);
      return data;
    } finally {
      // Освобождаем блокировку только если мы её владельцы (через Lua-скрипт)
      await redis.eval(
        `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`,
        1,
        lockKey,
        token
      );
    }
  }

  // Не получили блокировку — ждём и повторяем
  await new Promise(resolve => setTimeout(resolve, 50));
  return getWithLock(key, fetcher, ttl);
}

Плюсы: гарантированно один запрос к базе, детерминированное поведение. Минусы: contention на блокировке при высокой нагрузке, риск deadlock если процесс упал с блокировкой (решается через PX timeout).

Решение 2: Probabilistic Early Expiration (X-Fetch)

Вместо жёсткого истечения TTL, каждый запрос с некоторой вероятностью обновляет кэш до истечения. Вероятность растёт по мере приближения к expiry:

async function getWithPER(key, fetcher, ttl = 300, beta = 1.0) {
  const raw = await redis.get(key);

  if (!raw) {
    // Hard miss — обновляем сразу
    return refreshCache(key, fetcher, ttl);
  }

  const cached = JSON.parse(raw);
  const now = Date.now();
  const remainingTTL = cached.expiry - now;

  // Формула X-Fetch: вероятность обновления растёт при приближении к expiry
  const probabilityFactor = -cached.delta * beta * Math.log(Math.random());

  if (probabilityFactor >= remainingTTL) {
    // "Повезло" — обновляем кэш, остальные продолжают читать stale data
    return refreshCache(key, fetcher, ttl);
  }

  return cached.data;
}

async function refreshCache(key, fetcher, ttl) {
  const start = Date.now();
  const data = await fetcher();
  const delta = Date.now() - start;

  const entry = {
    data,
    delta, // Время генерации (для формулы)
    expiry: Date.now() + ttl * 1000
  };

  // Физический TTL в Redis ставим выше логического (grace period)
  await redis.set(key, JSON.stringify(entry), 'EX', Math.ceil(ttl * 1.2));
  return data;
}

Плюсы: нет блокировок, нет contention, latency остаётся плоской. Минусы: возможна кратковременная stale data, сложнее отлаживать.

Когда что использовать: mutex для критичных данных (цены, остатки), PER для всего остального (контент, конфигурация, leaderboard). Можно комбинировать: PER для нормальной работы, mutex как fallback при hard miss.

Кэширование в Node.js с ioredis

ioredis — production-ready клиент с поддержкой cluster, sentinel, pipelines и Lua-скриптов:

const Redis = require('ioredis');

const redis = new Redis({
  host: 'localhost',
  port: 6379,
  retryStrategy: (times) => Math.min(times * 50, 2000),
  maxRetriesPerRequest: 3
});

// Wrapper с метриками
class CacheService {
  constructor(redis) {
    this.redis = redis;
    this.hits = 0;
    this.misses = 0;
  }

  async get(key, fetcher, ttl = 300) {
    const cached = await this.redis.get(key);

    if (cached) {
      this.hits++;
      return JSON.parse(cached);
    }

    this.misses++;
    const data = await fetcher();
    await this.redis.set(key, JSON.stringify(data), 'EX', ttl);
    return data;
  }

  getHitRate() {
    const total = this.hits + this.misses;
    return total === 0 ? 0 : (this.hits / total * 100).toFixed(2);
  }
}

const cache = new CacheService(redis);

// Использование
app.get('/api/products/:id', async (req, res) => {
  const product = await cache.get(
    `product:${req.params.id}`,
    () => db.getProduct(req.params.id),
    600
  );
  res.json(product);
});

Pipeline для батчинга

Вместо N round-trips делаем один:

async function getMultipleUsers(userIds) {
  const pipeline = redis.pipeline();
  userIds.forEach(id => pipeline.get(`user:${id}`));
  
  const results = await pipeline.exec();
  return results.map(([err, data]) => data ? JSON.parse(data) : null);
}

Lua-скрипты для атомарности

Increment с TTL (атомарно):

const incrWithTTL = redis.defineCommand('incrWithTTL', {
  numberOfKeys: 1,
  lua: `
    local current = redis.call('incr', KEYS[1])
    if current == 1 then
      redis.call('expire', KEYS[1], ARGV[1])
    end
    return current
  `
});

await redis.incrWithTTL('rate:user:123', 60); // Rate limiting

Мониторинг hit rate

Hit rate — главная метрика эффективности кэша. Формула: hits / (hits + misses) * 100%. Целевое значение зависит от workload:

  • >95% — отлично для read-heavy API
  • 80–95% — норма для смешанной нагрузки
  • <80% — проблема: короткий TTL, неправильные ключи или eviction policy

Redis предоставляет встроенную статистику через INFO stats:

redis-cli INFO stats | grep keyspace
# keyspace_hits:1523421
# keyspace_misses:87234

Экспорт метрик в Prometheus:

const client = require('prom-client');

const cacheHits = new client.Counter({
  name: 'cache_hits_total',
  help: 'Total cache hits'
});

const cacheMisses = new client.Counter({
  name: 'cache_misses_total',
  help: 'Total cache misses'
});

const cacheLatency = new client.Histogram({
  name: 'cache_operation_duration_seconds',
  help: 'Cache operation latency',
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1]
});

async function getWithMetrics(key, fetcher, ttl) {
  const end = cacheLatency.startTimer();
  const cached = await redis.get(key);

  if (cached) {
    cacheHits.inc();
    end();
    return JSON.parse(cached);
  }

  cacheMisses.inc();
  const data = await fetcher();
  await redis.set(key, JSON.stringify(data), 'EX', ttl);
  end();
  return data;
}

Алерты в Grafana:

  • Hit rate < 80% в течение 5 минут
  • P99 latency > 10ms
  • Evicted keys > 1000/sec (признак нехватки памяти)

Подводные камни

  • Сериализация больших объектов. JSON.stringify на объекте 10MB занимает ~50ms. Используйте MessagePack или храните только ID, а данные — в отдельных ключах.
  • Hotkey problem. Один ключ с миллионами запросов в секунду создаёт bottleneck. Решение: client-side caching (Redis 6+) или репликация ключа (key:1, key:2, ... key:N, выбор через hash(request_id) % N).
  • Забытые ключи без TTL. Всегда ставьте TTL, даже на "вечные" данные (например, 30 дней). Иначе при изменении схемы старые ключи останутся навсегда.
  • Eviction во время пика. Если Redis вытесняет ключи под нагрузкой, hit rate падает, база перегружается. Мониторьте evicted_keys и увеличивайте maxmemory заранее.
  • Stale data после обновления. При cache-aside обновление в базе не инвалидирует кэш. Либо явно удаляйте ключ (DEL), либо используйте write-through.

Чек-лист production-кэширования

  1. Выберите стратегию: cache-aside для read-heavy, write-through для strict consistency.
  2. Настройте maxmemory-policy: allkeys-lru для общего кэша, volatile-lru для микса.
  3. Защититесь от cache stampede: mutex для критичных данных, PER для остального.
  4. Всегда ставьте TTL. Даже на "постоянные" данные.
  5. Мониторьте hit rate, evicted keys, latency. Алерт на hit rate < 80%.
  6. Используйте pipeline для батчинга, Lua для атомарных операций.
  7. Тестируйте поведение при падении Redis (graceful degradation).

Итого

Redis-кэширование — это архитектурное решение, а не "просто добавить redis.set". Cache-aside подходит для большинства случаев, но требует защиты от stampede через mutex или probabilistic early expiration. Eviction policy определяет, какие данные выживут при нехватке памяти — выбирайте LRU для равномерного доступа, LFU для hotkey-workload. Мониторьте hit rate как главную метрику: если он ниже 80%, кэш не работает. Правильная стратегия кэширования снижает latency в 10–100 раз и разгружает базу данных на порядки.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.