Cache com Redis: Estratégias e Padrões para Aplicações de Alto Desempenho
Domine Redis em produção: cache aside, write-through, TTL, invalidação, cache de sessões, rate limiting e evitando os erros clássicos de cache.
Por que cache é a ferramenta de performance mais poderosa disponível
Uma query SQL que demora 80ms processada 1.000 vezes por segundo pode ser substituída por um GET Redis que demora 0,3ms. O resultado: menos carga no banco, menor latência para o usuário e custo de infraestrutura muito mais baixo.
Redis é um store em memória, single-threaded, que processa 100.000+ operações por segundo com latência abaixo de 1ms.
Padrões fundamentais de cache
Cache Aside (Lazy Loading)
O mais comum. A aplicação checa o cache antes de ir ao banco:
async function getUserById(id: string): Promise<User | null> {
const cacheKey = `user:${id}`
// 1. Checar cache
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
// 2. Cache miss: buscar no banco
const user = await db.users.findUnique({ where: { id } })
if (!user) return null
// 3. Armazenar no cache com TTL
await redis.setex(cacheKey, 300, JSON.stringify(user)) // 5 minutos
return user
}
// Invalidar quando o usuário é atualizado
async function updateUser(id: string, data: Partial<User>) {
await db.users.update({ where: { id }, data })
await redis.del(`user:${id}`)
}
Vantagem: simples, resiliente (se o Redis cair, busca no banco).
Desvantagem: primeiro request sempre é lento (cache miss).
Write-Through
Escreve simultaneamente no banco e no cache:
async function updateProduct(id: string, data: Partial<Product>) {
const updated = await db.products.update({ where: { id }, data })
// Sempre manter o cache atualizado
await redis.setex(`product:${id}`, 600, JSON.stringify(updated))
return updated
}
Vantagem: cache sempre fresco.
Desvantagem: custo de escrita mais alto; dados pouco lidos ficam no cache desnecessariamente.
Cache de resultados de queries complexas
async function getTopProductsByCategory(categoryId: string, limit = 10) {
const cacheKey = `top-products:${categoryId}:${limit}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
// Query pesada: joins, agregações, ordenação
const products = await db.$queryRaw`
SELECT p.*, AVG(r.rating) as avg_rating, COUNT(o.id) as order_count
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
LEFT JOIN order_items oi ON oi.product_id = p.id
WHERE p.category_id = ${categoryId}
GROUP BY p.id
ORDER BY order_count DESC
LIMIT ${limit}
`
// TTL longo: dados mudam raramente
await redis.setex(cacheKey, 3600, JSON.stringify(products)) // 1 hora
return products
}
Evitando problemas clássicos
Cache Stampede (Thundering Herd)
O que acontece: TTL expira, 100 requests chegam ao mesmo tempo, todos vão ao banco.
Solução com lock otimista:
async function getDataWithLock(key: string, fetchFn: () => Promise<any>) {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const lockKey = `lock:${key}`
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10) // lock por 10s
if (!acquired) {
// Outro worker está buscando; esperar e tentar o cache novamente
await new Promise(r => setTimeout(r, 100))
return getDataWithLock(key, fetchFn)
}
try {
const data = await fetchFn()
await redis.setex(key, 300, JSON.stringify(data))
return data
} finally {
await redis.del(lockKey)
}
}
Stale-While-Revalidate
Retorna dado expirado imediatamente enquanto revalida em background:
async function staleWhileRevalidate<T>(
key: string,
ttl: number,
fetchFn: () => Promise<T>
): Promise<T> {
const staleKey = `stale:${key}`
const [fresh, stale] = await Promise.all([
redis.get(key),
redis.get(staleKey),
])
if (fresh) return JSON.parse(fresh)
if (stale) {
// Revalidar em background sem bloquear o usuário
setImmediate(async () => {
const data = await fetchFn()
await redis.setex(key, ttl, JSON.stringify(data))
await redis.setex(staleKey, ttl * 10, JSON.stringify(data))
})
return JSON.parse(stale)
}
const data = await fetchFn()
await Promise.all([
redis.setex(key, ttl, JSON.stringify(data)),
redis.setex(staleKey, ttl * 10, JSON.stringify(data)),
])
return data
}
Rate limiting com Redis
async function checkRateLimit(
key: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const now = Date.now()
const windowStart = now - (windowSeconds * 1000)
const pipeline = redis.pipeline()
pipeline.zremrangebyscore(key, 0, windowStart) // remove entradas antigas
pipeline.zadd(key, now, `${now}-${Math.random()}`) // adiciona entrada atual
pipeline.zcard(key) // conta no window
pipeline.expire(key, windowSeconds)
const results = await pipeline.exec()
const count = results![2][1] as number
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
resetAt: Math.ceil((now + windowSeconds * 1000) / 1000),
}
}
Monitoramento Redis
# Latência em tempo real
redis-cli --latency -i 1
# Monitorar comandos em tempo real (cuidado em produção — alto volume)
redis-cli MONITOR
# Estatísticas de hit/miss
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# keyspace_hits:4891234
# keyspace_misses:124562
# Hit rate: 4891234 / (4891234 + 124562) = 97.5%
# Memória
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human"
# Chaves mais acessadas (Redis 4+)
redis-cli --hotkeys
Hit rate alvo: > 90%. Abaixo disso, o cache não está sendo efetivo.
Eviction Policy
Quando o Redis atinge o limite de memória:
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru # Remove menos recentemente usados
# Outras políticas comuns:
# volatile-lru: LRU apenas em chaves com TTL
# allkeys-lfu: LFU (Least Frequently Used) — melhor para skewed access patterns
Para a maioria dos caches de aplicação, allkeys-lru é o mais seguro.
