Entregando cache da forma correta

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.