Pular para o conteúdo
arquitetura

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.

Douglas M. Pereira5 min de leitura
rediscacheperformancebackendnodejsestratégia 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.