O problema do cache stale
Existe uma frase famosa na computação: "There are only two hard things in Computer Science: cache invalidation and naming things."
É engraçado porque é verdade. Cache stale causa bugs que aparecem de forma intermitente, difíceis de reproduzir e que deixam o sistema em estado inconsistente.
O cenário clássico: usuário atualiza o perfil, vê as informações antigas, fica confuso, chama o suporte.
Cache-aside: o padrão mais comum
No cache-aside (também chamado lazy loading), a aplicação gerencia o cache explicitamente:
@Injectable()
export class UsersService {
async findById(id: string): Promise<User> {
const cacheKey = `user:${id}`;
// 1. Tenta o cache primeiro
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Cache miss: busca no banco
const user = await this.usersRepo.findById(id);
if (!user) throw new NotFoundException();
// 3. Armazena no cache com TTL
await this.redis.setex(cacheKey, 300, JSON.stringify(user));
return user;
}
async update(id: string, data: UpdateUserDto): Promise<User> {
const user = await this.usersRepo.update(id, data);
// 4. Invalida o cache após atualização
await this.redis.del(`user:${id}`);
return user;
}
}Simples, funciona bem para a maioria dos casos. O TTL é a rede de segurança: mesmo que a invalidação falhe, o cache expira sozinho.
Write-through: consistência garantida
No write-through, toda escrita atualiza cache e banco ao mesmo tempo:
async update(id: string, data: UpdateUserDto): Promise<User> {
const user = await this.usersRepo.update(id, data);
// Atualiza o cache com o dado novo, não apenas invalida
await this.redis.setex(`user:${id}`, 300, JSON.stringify(user));
return user;
}Vantagem: o cache nunca fica desatualizado após um write. Desvantagem: se o banco atualizar com sucesso mas o Redis falhar, há inconsistência. Use transações ou aceite o risco (geralmente aceitável com TTL curto).
O problema com invalidação por chave
Quando você tem dados agregados em cache, invalidar por chave individual não é suficiente:
// Esses caches estão relacionados:
`user:${id}` // dados do usuário
`user:${id}:posts` // posts do usuário
`user:${id}:stats` // estatísticas
`feed:${id}` // feed que inclui dados do usuário
`search:results:backend-dev` // resultado de busca que pode incluir esse usuárioSe o usuário atualiza o nome, você precisa invalidar todos esses. Gerenciar isso manualmente é trabalhoso.
Tags de cache: invalidação em grupo
Uma solução é usar tags para agrupar caches relacionados:
@Injectable()
export class TaggedCacheService {
async set(key: string, value: unknown, ttl: number, tags: string[]): Promise<void> {
const pipe = this.redis.pipeline();
pipe.setex(key, ttl, JSON.stringify(value));
// Adiciona a chave em cada set de tags
for (const tag of tags) {
pipe.sadd(`tag:${tag}`, key);
pipe.expire(`tag:${tag}`, ttl + 60); // tag expira um pouco depois da chave
}
await pipe.exec();
}
async invalidateByTag(tag: string): Promise<void> {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length === 0) return;
const pipe = this.redis.pipeline();
for (const key of keys) pipe.del(key);
pipe.del(`tag:${tag}`);
await pipe.exec();
}
}
// Uso:
async cacheUser(user: User): Promise<void> {
await this.taggedCache.set(
`user:${user.id}`,
user,
300,
[`user:${user.id}`, `tenant:${user.tenantId}`, "users"]
);
}
// Invalida tudo relacionado a esse usuário
async onUserUpdate(userId: string): Promise<void> {
await this.taggedCache.invalidateByTag(`user:${userId}`);
}
// Invalida todo o cache de um tenant
async onTenantDelete(tenantId: string): Promise<void> {
await this.taggedCache.invalidateByTag(`tenant:${tenantId}`);
}Invalidação por padrão com SCAN
Para invalidar múltiplas chaves por padrão, use SCAN (nunca KEYS em produção):
async invalidateByPattern(pattern: string): Promise<void> {
let cursor = "0";
const keysToDelete: string[] = [];
// SCAN é não-bloqueante, ao contrário de KEYS
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
keysToDelete.push(...keys);
} while (cursor !== "0");
if (keysToDelete.length === 0) return;
// Deleta em lotes para não sobrecarregar o Redis
const batchSize = 100;
for (let i = 0; i < keysToDelete.length; i += batchSize) {
const batch = keysToDelete.slice(i, i + batchSize);
await this.redis.del(...batch);
}
}
// Uso:
await this.invalidateByPattern(`user:${userId}:*`);Stale-while-revalidate: UX sem travar
Uma abordagem interessante é retornar o dado stale imediatamente e atualizar em background:
async getWithStaleRevalidate(
key: string,
ttl: number,
staleTtl: number,
fetchFn: () => Promise<unknown>
): Promise<unknown> {
const staleKey = `stale:${key}`;
const freshKey = `fresh:${key}`;
// Dado fresco: retorna direto
const fresh = await this.redis.get(freshKey);
if (fresh) return JSON.parse(fresh);
// Dado stale disponível: retorna imediatamente e atualiza em background
const stale = await this.redis.get(staleKey);
if (stale) {
this.revalidateInBackground(key, ttl, staleTtl, fetchFn);
return JSON.parse(stale);
}
// Nenhum dado: busca de forma síncrona
const data = await fetchFn();
await this.set(key, data, ttl, staleTtl);
return data;
}
private async revalidateInBackground(
key: string,
ttl: number,
staleTtl: number,
fetchFn: () => Promise<unknown>
): Promise<void> {
// Não bloqueia o request atual
setImmediate(async () => {
try {
const data = await fetchFn();
await this.set(key, data, ttl, staleTtl);
} catch {
// Falha silenciosa: dado stale ainda é melhor que nada
}
});
}O usuário recebe resposta instantânea com dado levemente desatualizado. O dado fresco chega na próxima requisição.
TTL como rede de segurança
Sempre defina TTL, mesmo quando você tem invalidação explícita. Bugs acontecem. O TTL garante que, no pior caso, o cache stale expira sozinho.
Escolha de TTL por tipo de dado:
| Tipo de dado | TTL sugerido | |---|---| | Sessão de usuário | 15-30 minutos | | Perfil de usuário | 5 minutos | | Listagens paginadas | 1-2 minutos | | Dados de configuração | 10-30 minutos | | Conteúdo estático | 1-24 horas |
Conclusão
Cache invalidation bem feita combina três camadas: invalidação explícita após writes, TTL como segurança, e tags para invalidar grupos de dados relacionados.
O erro mais comum é confiar só na invalidação explícita sem TTL. Quando um bug impede a invalidação, o sistema fica com dados stale indefinidamente. Com TTL, pelo menos o problema se resolve sozinho.