Eliminando o "Efeito Manada" com Cache Stale-While-Revalidate no NestJS
O cache é a primeira linha de defesa de qualquer sistema de alta performance. A estratégia mais comum é o TTL (Time-to-Live) rígido: quando o tempo expira, o dado some. O problema acontece exatamente no milissegundo seguinte: se o dado não existe mais e 500 requisições chegam simultaneamente, todas elas baterão no seu banco de dados ao mesmo tempo para recriar o cache. Isso é conhecido como Thundering Herd Problem (ou Cache Stampede). O banco sofre um pico de CPU, a latência sobe para todos e o sistema corre risco de instabilidade.
O que é o Thundering Herd (Efeito Manada)?
Ocorre quando um item de cache muito acessado expira e múltiplas threads ou processos tentam regenerar esse mesmo item simultaneamente no banco de dados. O resultado é um gargalo imediato na camada de persistência. A solução elegante para isso é a estratégia Stale-While-Revalidate.
O Conceito
A ideia é simples: nós permitimos que o sistema entregue um dado "velho" (stale) por um curto período, enquanto uma tarefa em background busca o dado novo. O usuário recebe a resposta instantaneamente (do cache), e o sistema se atualiza sem bloquear a requisição.
O Passo a Passo do Fluxo:
- A requisição chega: O cliente pede um recurso (ex: Detalhes do Produto).
- Verificação do Cache: O sistema busca no Redis.
- Decisão de Frescor:
- Se o dado é recente (dentro do tempo de frescor): Retorna imediatamente.
- Se o dado está "vencido" (stale) mas existe: Retorna o dado antigo imediatamente para o usuário não esperar.
- Revalidação Assíncrona: O sistema dispara, em background (sem travar a resposta enviada), uma consulta ao banco de dados.
- Atualização: O banco retorna o dado novo, e o Redis é atualizado para a próxima requisição.
Implementação Prática: NestJS + Redis
Diferente do cache padrão onde o Redis apaga a chave quando o TTL expira, aqui nós precisamos manter o dado lá. Por isso, usamos um "TTL Lógico" dentro do objeto JSON e um "TTL Físico" maior no Redis para limpeza de lixo. Vamos implementar um CacheService customizado (gerei com IA o código de exemplo, mas validei rsrs)
Pré-requisitos:
Instale o cliente Redis: npm install ioredis
import { Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class SmartCacheService {
private readonly redis: Redis;
private readonly logger = new Logger(SmartCacheService.name);
constructor() {
// Em produção, use variáveis de ambiente
this.redis = new Redis({ host: 'localhost', port: 6379 });
}
async getOrSet<T>(
key: string,
fetchData: () => Promise<T>,
freshTtlSeconds: number, // Tempo que consideramos o dado "novo"
maxStaleSeconds: number, // Tempo máximo que aceitamos servir dado velho
): Promise<T> {
const cachedValue = await this.redis.get(key);
if (cachedValue) {
const entry = JSON.parse(cachedValue);
const ageSeconds = (Date.now() - entry.lastUpdated) / 1000;
// Cenário 1: Dado está fresco. Retorna direto.
if (ageSeconds < freshTtlSeconds) {
return entry.data;
}
// Cenário 2: Dado está "Stale" (vencido), mas aceitável.
if (ageSeconds < maxStaleSeconds) {
this.logger.log(`[Background] Revalidating key: ${key}`);
// IMPORTANTE: Não usamos 'await' aqui. Isso roda em background.
this.revalidateAndCache(key, fetchData, maxStaleSeconds).catch(err =>
this.logger.error(`Failed to revalidate ${key}`, err),
);
// Retorna o dado antigo instantaneamente para o usuário
return entry.data;
}
}
// Cenário 3: Não existe cache ou expirou totalmente (maxStale).
// O usuário terá que esperar (Blocking fetch).
return this.revalidateAndCache(key, fetchData, maxStaleSeconds);
}
private async revalidateAndCache<T>(
key: string,
fetchData: () => Promise<T>,
redisTtl: number,
): Promise<T> {
const data = await fetchData();
const payload = {
data,
lastUpdated: Date.now(),
};
// Salvamos no Redis com o TTL máximo físico
await this.redis.set(key, JSON.stringify(payload), 'EX', redisTtl);
return data;
}
}
Como usar no seu Controller/Service
Agora, ao buscar dados pesados, a experiência do usuário se mantém extremamente rápida, mesmo durante atualizações.
@Injectable()
export class DashboardService {
constructor(
private readonly smartCache: SmartCacheService,
private readonly repository: DatabaseRepository
) {}
async getComplexDashboard(userId: string) {
const key = `dashboard:${userId}`;
return this.smartCache.getOrSet(
key,
() => this.repository.runHeavyQuery(userId), // Função de fetch
60, // Dado é fresco por 1 minuto
3600 // Mantemos o dado no Redis por 1 hora
);
}
}
Conclusão
O uso de Stale-While-Revalidate muda a percepção de performance da aplicação. Em vez de penalizar o usuário azarado que fez a requisição no momento exato da expiração do cache, você penaliza o servidor (assincronamente), mantendo a latência da API consistentemente baixa. É uma troca justa onde a UX vence.