Você implementou um endpoint que busca a timeline de uma conversa, viu que a query era pesada demais pra rodar em todo request, então injetou um cache de memória no controller, configurou expiração de cinco minutos e seguiu a vida.
Nos testes e na homologação tudo se comporta como esperado: o primeiro request paga o custo, os seguintes voltam instantaneamente e o gráfico de latência fica bonito.
Aí o serviço entra em produção em um cluster Kubernetes com três réplicas, porque a empresa cresceu, porque o time finalmente migrou o monolito velho para um ambiente que escala horizontalmente, ou simplesmente porque o Tech Lead não quis mais depender de uma única instância.
E começam a chegar relatos estranhos:
o atendente atualiza a tela e vê a mensagem nova
atualiza de novo, e a mensagem some
atualiza pela terceira vez, e ela volta
Não é bug de UI, não é race condition no banco, não é nada que apareça nos logs do request específico que o suporte mandou.
Logo você entende que o problema é o cache de memória. Este que durante anos foi invisível e funcional, virou a primeira coisa que precisava sair do caminho.
A história é específica de um stack (no nosso caso, .NET com IMemoryCache virando IDistributedCache apontado pro Redis), porém o problema é completamente independente de linguagem: qualquer aplicação que use cache em processo, seja em Node com um Map global ou um lru-cache, em Python com functools.lru_cache ou um dicionário de módulo, em Java com ConcurrentHashMap ou Caffeine, em Go com sync.Map ou um cache em struct, vai encontrar exatamente o mesmo bug ao passar de uma instância única para múltiplas réplicas.
Este artigo é sobre por que ele funcionava antes, por que ele quebra agora, e por que a resposta nem sempre é "trocar cache local por Redis", embora muitas vezes seja.
Por que Memory Cache funcionava antes
IMemoryCache é a abstração padrão do .NET pra cache local, ou seja, o dicionário vive dentro da memória do próprio worker que está atendendo o request. Independentemente de qual stack estejamos falando, há equivalentes utilizados para o mesmo fim.
[HttpGet("conversations/{id}/timeline")]
public async Task<IActionResult> GetTimeline(
Guid id,
[FromServices] IMemoryCache cache,
[FromServices] ITimelineRepository repository)
{
var key = $"timeline:{id}";
if (cache.TryGetValue(key, out var cached))
return Ok(cached);
var timeline = await repository.LoadAsync(id);
cache.Set(key, timeline, TimeSpan.FromMinutes(5));
return Ok(timeline);
}
Quando o aplicativo roda como uma única instância (um App Service single-instance, uma VM, um container solitário), todos os requests passam pelo mesmo processo, leem do mesmo dicionário e a consistência é óbvia: se o request A escreveu uma chave, o request B vai ler aquela chave, porque os dois estão no mesmo lugar.
Esse padrão funcionava por uma combinação de coisas: o monolito era stateful por acidente (todos os requests passavam pelo mesmo processo), o load balancer não existia ou era irrelevante e o cache nunca precisou ser confiável porque na prática nunca foi distribuído. Funcionava porque a topologia escondia o problema, não porque o código estava correto.
Cache local não é "errado". Há usos muito úteis para ele e não é pra sair jogando tudo no Redis e correr o risco de aumentar consideravelmente o preço de uso dessa ferramenta. Mas como bons arquitetos de software, devemos decidir onde é útil utilizá-lo e onde é necessário distribuir o cache
O que quebra quando escala horizontalmente?
Quando o mesmo serviço passa a rodar como N réplicas atrás de um load balancer, dois fatos novos entram em cena.
cada pod tem sua própria memória, então o cache do pod A é literalmente um objeto diferente do cache do pod B, sem comunicação entre eles.
o roteamento de request, por padrão, distribui carga entre os pods sem nenhuma afinidade (a menos que você configure sticky session, o que traz outros problemas nesse sentido), ou seja, o próximo request do mesmo usuário pode cair em qualquer pod.
A consequência é que o ciclo "lê do cache, cache miss, popula o cache, responde" passa a acontecer N vezes (uma por pod), o que já é desperdício. Mas pior do que isso, quando o estado muda (uma nova mensagem chega na conversa, por exemplo) e o código invalida a chave do cache, ele só invalida no pod que recebeu o request de escrita, deixando os outros N pods servindo dados velhos até o expiration natural.
Se o seu serviço vai rodar com mais de uma réplica, qualquer estado em memória que afeta a resposta visível ao usuário (cache, contador, rate limiter, sessão) é um bug latente. Não é uma questão de "se", é uma questão de quando o tráfego vai expor a divergência.
A solução padrão: tirar o cache de memória
A leitura do problema já sugere o caminho da solução: se a inconsistência aparece porque cada pod tem seu próprio cache, a saída é parar de guardar o cache dentro do pod e passar a guardar num lugar único, externo, que todos os pods enxergam. Em vez de cada réplica manter seu próprio dicionário em memória, todas as réplicas falam com um servidor de cache compartilhado, que vira a única fonte de verdade pra aquela camada.
O servidor mais usado pra esse papel é o Redis, um banco de dados que utiliza a memória RAM de onde está hospedado, otimizado pra operações simples (get, set com TTL, delete, incrementos, listas, conjuntos) e com latência absurdamente baixa, principalmente quando roda na mesma rede do serviço que o consome.
Não é a única opção (Memcached, Hazelcast, NCache, ou caches gerenciados pelo provedor de nuvem como Azure Cache for Redis e AWS ElastiCache resolvem o mesmo problema), mas é o padrão de fato na maioria das stacks modernas, ao ponto de "vou colocar um Redis" virar quase sinônimo de "vou colocar um cache distribuído".
Na prática, o que muda no código é a abstração que você injeta: em vez de IMemoryCache (cache local, em processo), você usa IDistributedCache (cache externo, com implementação plugada pra Redis via Microsoft.Extensions.Caching.StackExchangeRedis). O contrato da interface é parecido o suficiente pra que a tradução, no primeiro olhar, pareça quase mecânica:
public async Task<TimelineDto> ExecuteAsync(
Guid conversationId,
CancellationToken cancellationToken)
{
var key = BuildKey(conversationId);
var cached = await _cache.GetStringAsync(key, cancellationToken);
if (cached is not null)
{
return JsonSerializer.Deserialize<TimelineDto>(cached)!;
}
var timeline = await _databaseRepository.FetchAsync(conversationId, cancellationToken);
await _cache.SetStringAsync(
key,
JsonSerializer.Serialize(timeline),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
return timeline;
}
private static string BuildKey(Guid conversationId)
=> $"conversations:{conversationId}:timeline";
A configuração no Program.cs (ou no equivalente da sua linguagem) é igualmente direta:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
Com isso, todos os pods passam a ler e escrever no mesmo Redis: quando o pod A escreve a chave conversations:123:timeline, o pod B consegue ler exatamente o mesmo valor, e quando o pod A invalida a chave, ela some pra todo mundo ao mesmo tempo. A inconsistência intermitente desaparece, porque ela só existia enquanto o estado vivia escondido dentro de cada processo.
Redis é um processo separado que roda em algum lugar (um container no mesmo cluster Kubernetes, um serviço gerenciado da nuvem, uma VM dedicada). Ele precisa de connection string, secret pra autenticação, e idealmente alguma estratégia de alta disponibilidade (Redis Sentinel, Redis Cluster, ou o modo HA do serviço gerenciado). Não é "instalar uma biblioteca"; é adicionar uma dependência de infra ao seu sistema.
ATENÇÃO: Quando o Redis fica fora do ar, todos os requests que dependem dele falham ou ficam lentos. Em cache local, perder o cache só significava recalcular; em cache distribuído, perder o Redis pode significar derrubar a feature inteira se não houver fallback. Vale planejar o comportamento de degradação antes do primeiro incidente.
Esse é o caminho clássico, ensinado em qualquer tutorial sério de aplicação cloud-native, e na grande maioria das vezes é exatamente o que você deve fazer. Mas existe uma pergunta anterior que, se feita honestamente, evita você arrastar Redis pra dentro de features que nem precisam dele.
A pergunta que vem antes do Redis
A reação natural quando se entende o problema é fazer a troca imediata do cache local pelo distribuído e seguir a vida. Funciona, resolve a inconsistência e é a recomendação padrão pra cenários multi-réplica em praticamente qualquer stack.
Mas antes de fazer essa troca imediatamente, vale uma pergunta que economiza código, dependência e custo operacional: o cache ainda é necessário?
Boa parte dos caches locais espalhados num monolito legado são oportunistas, ou seja, foram colocados ali porque na época a query embaixo era cara, ou porque o ORM antigo não tinha change tracking decente, ou simplesmente porque o desenvolvedor preferiu garantir uma camada extra.
Quando você migra a feature pra uma stack moderna, com queries mais enxutas, índices revisados e um modelo de dados mais alinhado ao caso de uso, é comum descobrir que a query da nova versão já está rápida o suficiente pra dispensar o cache de cara.
Antes de migrar cache local pra cache distribuído, meça a feature sem cache. Se a latência da query na nova versão está dentro do esperado, não introduza Redis só porque o legado tinha cache. Migração mecânica imediata (quando você só migra a lógica sem analisar melhorias) é justamente onde nascem dependências fantasma.
Essa avaliação não é gratuita: você precisa olhar a query, entender o padrão de acesso (quantas vezes por minuto, quantos usuários simultâneos, qual o custo no banco), e só então decidir. Mas a economia de não arrastar uma dependência adicional pra uma feature que não precisa dela compensa o tempo gasto na medição.
Quando o cache distribuído é o caminho certo
Quando a evidência indica que o cache continua sendo necessário (a query é cara, o padrão de acesso é repetitivo, o hot path aparece em produção), aí sim o caminho é cache distribuído apontado pra um serviço externo, geralmente Redis, compartilhado entre os pods. A consistência entre réplicas para de ser um acidente do acaso pra virar uma garantia da arquitetura.
Manter Redis como dependência mesmo em features pequenas não é exagero, desde que o cluster já exista no ambiente. O custo marginal de mais uma chave é desprezível; o custo de introduzir um cache compartilhado só na primeira feature que precisa é alto, porque envolve infra, secret, observabilidade e processo de deploy.
Armadilhas que sobrevivem à migração
Trocar cache local por cache distribuído resolve o problema da inconsistência entre pods, mas herda armadilhas próprias do cache compartilhado, das quais três merecem atenção redobrada em qualquer stack.
cache stampede: situação em que uma chave expira e dezenas de requests simultâneos batem no banco ao mesmo tempo pra repopular, derrubando o serviço quando a query é cara. Em cache local, o impacto é limitado ao pod, mas em cache compartilhado, o impacto é global, porque todos os pods vão fazer cache miss ao mesmo tempo. A defesa clássica é um lock distribuído na repopulação (Redis tem
SET NXjustamente pra isso), ou alguma variação de stale-while-revalidate que mantenha o valor velho enquanto o novo é calculado. Vale escolher a estratégia conscientemente, não esperar o incidente acontecer.serialização: cache distribuído armazena bytes, então tudo precisa virar JSON ou algum formato binário. Objetos com referência cíclica, tipos polimórficos sem discriminador ou campos de data sem timezone explícito são fontes inesgotáveis de bug, e bug de serialização raramente aparece em homologação porque os dados de teste costumam ser bem-comportados. Esse problema é especialmente capcioso em linguagens dinâmicas (Python, Node, Ruby), onde a estrutura do objeto serializado pode mudar entre deploys sem que haja um compilador para te avisar.
invalidação cross-feature: quando duas features escrevem em entidades que se sobrepõem (por exemplo, "atualizar uma mensagem" mexe na timeline da conversa e no resumo do contato), você precisa decidir explicitamente quem invalida o quê, sob pena de servir dados inconsistentes entre telas. Em cache local, esse problema era escondido pela curta vida útil do processo; em cache compartilhado, ele vira responsabilidade clara de quem está escrevendo.
Cache não é compensação pra modelo de dados ruim. Se você está cacheando uma view porque a join embaixo é insustentável, o trabalho real é arrumar a join, não esticar o expiration.
Concluindo
A pergunta que importa, antes de escolher a tecnologia, é se o cache continua resolvendo um problema real depois que o resto da arquitetura mudou.
Quando continua, cache distribuído é a resposta certa. Quando não continua, código a menos é a resposta melhor.
Adicionalmente, sobre os cuidados com o uso do Redis, recomendo o seguinte artigo, feito pelo Milton Câmara, parceiro de profissão e de empresa :) Redis além do tutorial. Com os problemas que ninguém te conta
Gostou do artigo? Comente abaixo sobre o que ele te fez pensar e que práticas você deseja aplicar. Além disso, comente sobre o que faltou no artigo que é informação importante sobre o assunto
Top comments (0)