Voltar para o blog
RedisCacheBackendPerformance

Cache invalidation com Redis: os problemas reais e como lidar

6 de junho de 20269 min de leiturapor Sávio Araújo

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ário

Se 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.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech