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