Estratégias de Cache com Redis Que Realmente Fizeram Diferença

Cache é onde muitos backends conseguem o maior e mais barato ganho — e também onde muitos adquirem os bugs mais confusos. "Coloque Redis na frente do banco" é fácil de dizer e fácil de fazer mal. Isto é o que de fato funcionou para mim sob carga de leitura real, e os modos de falha que aprendi a projetar contra antes que me encontrassem em produção.

Cache-Aside É o Padrão por um Motivo

O padrão que pego primeiro é cache-aside (lazy loading): a aplicação é dona do cache. Numa leitura, checa o Redis; num miss, lê o banco, popula o cache, retorna. Escritas vão para o banco e invalidam (ou atualizam) o cache.

func (s *Service) GetProduct(ctx context.Context, id string) (*Product, error) {
	key := "product:" + id

	// 1. Try the cache
	if b, err := s.rdb.Get(ctx, key).Bytes(); err == nil {
		var p Product
		if json.Unmarshal(b, &p) == nil {
			return &p, nil
		}
	}

	// 2. Miss → source of truth
	p, err := s.repo.GetProduct(ctx, id)
	if err != nil {
		return nil, err
	}

	// 3. Populate for next time (fire-and-forget on cache errors)
	if b, err := json.Marshal(p); err == nil {
		s.rdb.Set(ctx, key, b, ttlWithJitter(10*time.Minute))
	}
	return p, nil
}

Duas coisas ali são deliberadas. Primeiro, um erro de cache nunca faz a requisição falhar — se o Redis está fora, caímos para o banco e continuamos servindo. O cache é uma otimização, não uma dependência; no momento em que ele vira load-bearing você construiu um sistema que cai quando seu cache cai. Segundo, o TTL tem jitter, o que importa mais do que parece.

TTLs com Jitter Previnem Expiração Sincronizada

Se você cacheia um lote de itens com um TTL idêntico de 10 minutos — digamos, tudo carregado na hora do deploy — todos expiram no mesmo segundo, e no segundo seguinte toda requisição é um miss martelando o banco simultaneamente. Espalhar as expirações por uma janela resolve:

func ttlWithJitter(base time.Duration) time.Duration {
	jitter := time.Duration(rand.Int63n(int64(base) / 5)) // up to +20%
	return base + jitter
}

Barato, e transforma um thundering herd sincronizado num fluxo suave de refreshes.

O Stampede Que Derruba o Banco

Jitter lida com muitas chaves expirando juntas. O caso mais desagradável é uma chave quente expirar. Imagine o cache do seu produto mais popular expirando durante o pico de tráfego: centenas de requisições concorrentes todas dão miss de uma vez, e todas rodam a mesma query cara para reconstruir o mesmo valor. O cache não reduziu a carga — ele a agrupou num pico mirado no seu banco.

O conserto é deixar só uma requisição reconstruir enquanto as outras esperam pelo resultado dela. Em Go, singleflight faz exatamente isso sem infraestrutura extra:

import "golang.org/x/sync/singleflight"

var group singleflight.Group

func (s *Service) getProductCoalesced(ctx context.Context, id string) (*Product, error) {
	v, err, _ := group.Do("product:"+id, func() (interface{}, error) {
		return s.repo.GetProduct(ctx, id) // only ONE of N concurrent callers runs this
	})
	if err != nil {
		return nil, err
	}
	return v.(*Product), nil
}

singleflight colapsa N chamadas concorrentes para a mesma chave numa execução e entrega a todos os callers o resultado compartilhado. Sob um miss de chave quente, seu banco vê uma query em vez de centenas. (Isso coalesce por-processo; para um lock verdadeiramente global entre muitas instâncias você usaria um lock de vida curta no Redis, ao custo de mais complexidade — raramente precisei ir tão longe.)

Invalidação: A Parte Difícil

Há uma piada velha de que os dois problemas difíceis da computação são invalidação de cache e dar nome às coisas. A piada está certa sobre o primeiro. As estratégias confortáveis, da pior à melhor em correção:

  • Só TTL — nunca invalidar explicitamente; tolerar staleness até o TTL. O mais simples, e ok quando leituras ligeiramente stale são aceitáveis (descrições de produto, config). A janela de staleness é uma decisão de produto, não técnica — consiga alguém para assinar embaixo de "até 10 minutos stale".
  • Invalidação write-through / write-around — a cada escrita, atualizar ou deletar a chave do cache. Mais fresco, mas agora seu caminho de escrita tem que saber toda chave que um dado alimenta, e se você esquecer uma, ele serve dado stale para sempre sem TTL para salvar.
  • Invalidação orientada a eventos — escritas emitem eventos de mudança; um consumidor invalida as chaves afetadas. Desacopla o caminho de escrita do conhecimento do cache, ao custo de infraestrutura real e frescor eventual (não imediato).

Eu default para só-TTL e só adiciono invalidação explícita para dados onde staleness genuinamente machuca, porque todo caminho de invalidação é uma nova forma de errar.

Trade-offs

Strategy         Freshness         Write-path cost    Failure mode
---------------  ---------------   ----------------   ---------------------------
TTL only         stale ≤ TTL       none               serves stale within window
Write invalidate fresh             couples write→keys stale forever if a key missed
Event-driven     near-fresh        infra + consumer   lag; more moving parts

O enquadramento que me mantém fora de encrenca: um cache é uma aposta de que leituras dominam escritas e que algum staleness é aceitável. Se leituras não dominam, o cache mal ajuda. Se nenhum staleness é aceitável, você não está realmente cacheando — está construindo uma segunda fonte da verdade, e isso é um problema muito mais difícil. Nomeie seu orçamento de staleness, mantenha o cache não-load-bearing, coalesça seus stampedes, e Redis na frente do Postgres é uma das coisas de melhor custo-benefício que você pode fazer a um serviço read-heavy.