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.
"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.
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:
// 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:
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:
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 temporalQual 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.