Voltar para Artigos
Back-end9 min de leitura

A Dor de Cabeça do Cache: Estratégias de Invalidação com Redis

As quatro estratégias de invalidação: TTL passivo, invalidação ativa por evento, invalidação por tag e write-through. SCAN ao invés de KEYS em produção, e consistência eventual vs forte no cache.

12 de agosto de 2026

"Existem apenas duas coisas difíceis na Ciência da Computação: invalidar cache e nomear coisas." — Phil Karlton. O cache resolve performance, mas cria um problema de consistência: quando o dado muda no banco, o cache ainda tem a versão antiga. Usuários veem dados desatualizados. O desafio é saber quando e como invalidar sem criar um cache inútil ou inconsistente.

Este artigo cobre as quatro estratégias principais de invalidação e quando aplicar cada uma, com implementação prática usando Redis e ioredis.

Estratégia 1: TTL Passivo (Time-To-Live)

A escolha da estratégia de invalidação depende do nível de consistência que o negócio exige. TTL passivo aceita que dados podem estar desatualizados por até N segundos — aceitável para páginas de produto, listagens públicas. Invalidação ativa por evento garante que a atualização seja vista imediatamente — essencial para dados do próprio usuário. Cache por tag permite invalidar grupos de chaves relacionadas com uma única operação. A regra geral: quanto mais consistente, mais complexo de implementar e mais rápido o cache expira.

ICacheProvider.ts + RedisCacheProvider.ts
export interface ICacheProvider {
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
  get<T>(key: string): Promise<T | null>;
  del(key: string): Promise<void>;
  delByPattern(pattern: string): Promise<number>;
}

export class RedisCacheProvider implements ICacheProvider {
  constructor(private client: Redis) {}

  async set<T>(key: string, value: T, ttlSeconds = 3600): Promise<void> {
    await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds);
  }

  async get<T>(key: string): Promise<T | null> {
    const data = await this.client.get(key);
    return data ? (JSON.parse(data) as T) : null;
  }

  async del(key: string): Promise<void> {
    await this.client.del(key);
  }

  // SCAN ao invés de KEYS — nunca use KEYS em produção!
  // KEYS bloqueia o Redis enquanto varre todas as chaves
  // SCAN é não-bloqueante e seguro para produção
  async delByPattern(pattern: string): Promise<number> {
    let cursor = '0';
    let deletedCount = 0;

    do {
      const [nextCursor, keys] = await this.client.scan(
        cursor,
        'MATCH', pattern,
        'COUNT', 100  // Processa 100 chaves por iteração
      );
      cursor = nextCursor;

      if (keys.length > 0) {
        // Pipeline: envia todos os DEL de uma vez (reduz round-trips)
        const pipeline = this.client.pipeline();
        keys.forEach((key) => pipeline.del(key));
        await pipeline.exec();
        deletedCount += keys.length;
      }
    } while (cursor !== '0');

    return deletedCount;
  }
}

Nunca use `KEYS pattern` em produção. O comando KEYS é O(N) e bloqueia o event loop do Redis enquanto varre o keyspace inteiro. Em produção com milhões de chaves, isso pode travar o Redis por segundos. Use sempre SCAN com cursor — ele é O(1) por chamada e não bloqueia.

Estratégia 2: Invalidação Ativa por Evento

Quando um dado é modificado, invalide proativamente o cache relacionado. Use uma convenção de nomes hierárquica para poder deletar grupos de chaves relacionadas:

Convenção de nomes de chaves
// Padrão: entidade:id:operacao:parametros
// Exemplos:
// products:list                    — lista geral de produtos
// products:list:category:10        — lista filtrada por categoria 10
// products:detail:uuid-123         — detalhe do produto uuid-123
// users:uuid-456:orders:list       — pedidos do usuário uuid-456
// users:uuid-456:orders:detail:ord-789 — pedido específico do usuário

// Invalidação por evento:
class UpdateProductUseCase {
  async execute(productId: string, data: UpdateProductDTO): Promise<Product> {
    const product = await this.productsRepo.update(productId, data);

    // Invalida todas as chaves relacionadas a este produto
    await Promise.all([
      this.cache.del(`products:detail:${productId}`),
      this.cache.delByPattern('products:list*'), // Todas as listagens
    ]);

    return product;
  }
}

Estratégia 3: Invalidação por Tag (Cache Tagging)

Para sistemas com relacionamentos complexos, use tags: cada cache key é associada a tags, e quando você invalida uma tag, todas as keys associadas são deletadas:

TaggedCacheProvider.ts
export class TaggedCacheProvider {
  constructor(private client: Redis) {}

  // Salva o valor E registra a key em cada tag
  async setWithTags<T>(
    key: string,
    value: T,
    tags: Array<string>,
    ttlSeconds = 3600
  ): Promise<void> {
    const pipeline = this.client.pipeline();

    // Salva o valor
    pipeline.set(key, JSON.stringify(value), 'EX', ttlSeconds);

    // Registra a key em cada tag (Set do Redis)
    // A tag expira um pouco depois do valor para garantir cleanup
    for (const tag of tags) {
      pipeline.sadd(`tag:${tag}`, key);
      pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
    }

    await pipeline.exec();
  }

  // Invalida todas as keys associadas a uma tag
  async invalidateTag(tag: string): Promise<void> {
    const tagKey = `tag:${tag}`;
    const keys = await this.client.smembers(tagKey);

    if (keys.length === 0) return;

    const pipeline = this.client.pipeline();
    keys.forEach((key) => pipeline.del(key));
    pipeline.del(tagKey); // Remove a tag também
    await pipeline.exec();
  }
}

// Uso:
// Armazenar a lista de produtos com tags de categoria e marca
await cache.setWithTags(
  `products:list:category:${categoryId}`,
  products,
  [`category:${categoryId}`, 'products'],
  1800
);

// Quando uma categoria é atualizada, invalida tudo relacionado a ela
await cache.invalidateTag(`category:${categoryId}`);

Estratégia 4: Write-Through Cache

Em vez de invalidar e esperar o próximo GET recriar o cache, o Write-Through atualiza o cache junto com o banco — ideal para dados que mudam com frequência mas são lidos muito mais do que escritos:

Write-through em Use Case
class UpdateUserProfileUseCase {
  async execute(userId: string, data: UpdateProfileDTO): Promise<User> {
    const user = await this.usersRepo.save({ id: userId, ...data });

    // Write-Through: atualiza o cache imediatamente
    // Não espera o próximo GET para recriar — consistência imediata
    await this.cache.set(
      `users:detail:${userId}`,
      user,
      3600
    );

    // Ainda invalida listagens (estrutura de dados diferente)
    await this.cache.delByPattern('users:list*');

    return user;
  }
}

// Trade-offs:
// Write-Through:  zero inconsistência, maior complexidade no write
// Invalidar e recriar: mais simples, mas inconsistente por 1 GET
// TTL puro:       mais simples de tudo, aceita inconsistência temporal

Qual Estratégia Usar?

  • TTL passivo — para dados que podem ser levemente desatualizados (catálogos de produtos, rankings, dashboards analíticos). Simples, zero manutenção.
  • Invalidação ativa — para dados que não podem estar desatualizados (carrinho, estoque em tempo real, agendamentos). Mais complexo, consistência forte.
  • Invalidação por tag — para dados com relacionamentos complexos onde uma mudança afeta múltiplos caches. Melhor escalabilidade da invalidação.
  • Write-through — para dados acessados muito frequentemente logo após a escrita (perfil de usuário recém atualizado).

Conclusão

Cache sem invalidação é tecnicamente um bug a espera de acontecer. A escolha da estratégia depende do nível de consistência exigido pelo negócio: catálogos toleram dados desatualizados por minutos (TTL), estoque e agendamentos não toleram nem segundos (invalidação ativa). O SCAN ao invés de KEYS é a diferença entre um Redis seguro e um que pode parar sua aplicação inteira em produção.