Phil Karlton, engenheiro da Netscape em meados dos anos 1990, é frequentemente citado pela frase: "There are only two hard things in Computer Science: cache invalidation and naming things". A frase virou folclore por uma razão — a primeira metade é literalmente verdadeira. Cache acelera sistemas, melhora latência, descarrega banco, reduz custo de infraestrutura. Mas cache mal invalidado mostra dado obsoleto, e dado obsoleto em sistemas de pagamento, estoque, ou autorização vira desde experiência ruim de usuário até problema regulatório.
Cache é cross-cutting clássico quando a decisão de cachear é uniforme. "Toda leitura de produto por ID é cacheada por 5 minutos" é regra que cabe em decorator/aspect, aplicada em um lugar e válida em todos. "Esse repositório aqui cacheia, e esse outro também, mas com políticas diferentes definidas em cada handler" é tangling — política espalhada, drift garantido. A pergunta deste conceito é: como organizar o cache enquanto aspect, e como pensar invalidação para que ela seja escolha consciente, não consequência acidental?
Há três decisões que toda equipe enfrenta cedo: onde cachear (in-memory, distribuído, HTTP, CDN); quando invalidar (TTL, evento, escrita passa pelo cache); e quem responde quando cache e fonte divergem. Este conceito articula as três decisões, mostra a forma de cada cache via aspect em três ecossistemas, e enuncia heurísticas para evitar a armadilha mais cara — usuário lendo dado obsoleto sem que o time saiba.
O foco aqui é cache em nível de aplicação — o que seu código controla via decorator/middleware. Cache em outras camadas (CDN, browser, banco) interage com o seu, mas pertence a outros módulos. Quando uma camada de cache de aplicação é bem desenhada, as outras camadas se ajustam mais facilmente; quando é mal desenhada, nenhuma das outras compensa.
Os padrões de cache — quem escreve, quem lê
Há quatro padrões clássicos. Cada um define quem é responsável por escrever no cache e quem garante a consistência. Reconhecer qual padrão a equipe está usando — mesmo que ninguém tenha articulado — é exercício de senior.
Cache-aside (lazy loading)
O mais comum. A aplicação consulta o cache primeiro; se hit,
retorna; se miss, consulta a fonte (banco), grava no cache, e
retorna. Escritas vão direto à fonte e (opcionalmente)
invalidam a entrada do cache. É o padrão dos
functools.lru_cache, das implementações típicas
de decorator de cache, e da maior parte do uso de Redis.
Vantagens: simples; só os dados acessados ficam no cache; tolera cache inteiro fora (cai para fonte). Desvantagens: a primeira leitura sempre é miss (latência variável); janela entre fonte atualizada e cache invalidado deixa dado obsoleto temporariamente; problema de thundering herd quando uma chave expira durante pico (centenas de concorrentes vão à fonte ao mesmo tempo).
Read-through
Variante onde o próprio cache é responsável por carregar da fonte em caso de miss. A aplicação só fala com o cache; o cache fala com o banco. Algumas bibliotecas de cache (Guava, Caffeine em Java, .NET MemoryCache com factory) implementam read-through nativo. A vantagem é encapsulamento; a desvantagem é acoplar o cache à fonte de dados, o que pode ser difícil em sistemas com múltiplas fontes.
Write-through
A aplicação escreve no cache, e o cache escreve na fonte sincronamente. Garante que cache e fonte ficam consistentes após escrita. O custo é latência maior em escrita (duas operações em vez de uma) e acoplamento similar ao read-through. É padrão menos comum em aplicações; mais comum em sistemas de cache distribuído como middleware (NCache, alguns modos do Hazelcast).
Write-behind (write-back)
A aplicação escreve no cache, e o cache escreve na fonte assincronamente. Latência de escrita melhor, mas há janela onde fonte está desatualizada — e em caso de crash do cache antes do flush, dado é perdido. É padrão para throughput alto onde inconsistência temporária é aceitável. Raramente apropriado para dado financeiro ou auditável.
Refresh-ahead
Variação onde o cache, próximo da expiração, recarrega proativamente em background. Reduz miss rate em chaves "quentes". Mais sofisticado, geralmente combinado com cache-aside como otimização secundária.
Os escopos — onde o cache vive
Aplicação típica tem três níveis de cache que merecem ser entendidos como camadas distintas, cada uma com trade-offs.
In-memory local. O processo da aplicação
mantém um dicionário em memória.
IMemoryCache em .NET, cachetools
em Python, sync.Map ou
github.com/dgraph-io/ristretto em Go. Latência
de microssegundos, sem serialização, sem rede. Custo: cada
réplica do serviço tem seu próprio cache, e mudanças de uma
não chegam à outra automaticamente. Em ambiente com 10
réplicas, isso significa 10 caches divergentes — útil para
dado verdadeiramente imutável (configurações estáticas,
lookup tables), problemático para dado mutável.
Distribuído (Redis, Memcached, Valkey). Cache
compartilhado entre réplicas. Latência maior (1-5ms via
rede), mas consistência entre réplicas. Pode persistir
(Redis com snapshot ou AOF). Padrão dominante em sistemas
web modernos. StackExchange.Redis em .NET,
redis-py em Python, go-redis em
Go. Em 2023, Salvatore Sanfilippo (criador do Redis) saiu
da Redis Inc.; em 2024 o projeto Valkey (fork community-driven)
surgiu — em 2026, a comunidade está dividida e ambos são
escolhas válidas.
HTTP cache (resposta cacheada). Cache no
nível de resposta HTTP, via cabeçalhos
Cache-Control, ETag,
Last-Modified. ASP.NET Core
OutputCache (.NET 7+) e CDN edge caches
(CloudFront, Cloudflare) jogam aqui. Padrão muito eficiente
para conteúdo público; complicado para conteúdo
personalizado por usuário (precisa Vary
cuidadoso, ou desabilitar em rotas autenticadas).
Sistemas maduros usam os três em camadas — local-first (lookup tables), distribuído (dado mutável compartilhado), e HTTP onde aplicável. A organização explícita dessa hierarquia é parte do desenho arquitetural; quando vira "cada lugar cachear como achar melhor", surgem inconsistências.
Invalidação — três estratégias
Cache é dado denormalizado. Quando a fonte muda, o cache fica obsoleto até alguma estratégia de invalidação alinhar. Há três estratégias canônicas, e elas se misturam conforme o sistema.
TTL (time-to-live)
A entrada expira após N segundos/minutos. Simples, robusta, sem necessidade de coordenação com a fonte. A escolha do TTL é a decisão central: TTL curto reduz risco de obsolescência mas aumenta carga na fonte; TTL longo melhora performance mas tolera obsolescência maior. Heurística: TTL deve ser o máximo de obsolescência que o produto aceita. Se "5 minutos" é aceitável, TTL = 5 min; se "qualquer obsolescência é problema", TTL não é a estratégia.
Invalidação por evento
Quando o sistema atualiza a fonte, ele explicitamente
invalida (ou atualiza) a entrada correspondente do cache.
Mais preciso que TTL, mas exige que toda escrita passe pelo
ponto que invalida — esquecer um caminho de escrita produz
cache obsoleto silenciosamente. A regra prática é centralizar
escrita: se há um único Repository.Save(produto)
e o decorator de cache observa esse ponto, a invalidação é
confiável. Se há cinco caminhos de update espalhados, vão
faltar alguns.
Cache stampede e single-flight
Quando uma chave expira e cem requests concorrentes batem na
fonte ao mesmo tempo, a fonte recebe rajada que não devia
receber. A solução é single-flight: uma das
requests é eleita para recarregar; as outras esperam o
resultado dela. Em Go, há
golang.org/x/sync/singleflight diretamente. Em
.NET, padrões com SemaphoreSlim + cache de
Task. Em Python, asyncio.Lock por
chave. Bibliotecas modernas de cache (Caffeine em Java,
HybridCache no .NET 9+, aiocache
em Python) trazem stampede protection embutido.
Cache via decorator — em três ecossistemas
A organização canônica de cache como aspect é decorator
em torno do repositório/service. O método de domínio chama
repo.Obter(id), e o decorator decide se vai à
fonte ou serve do cache. Quem chama nem sabe. Muda-se a
política em um lugar; testes que querem desligar o cache
passam o repositório direto.
// startup
builder.Services.AddHybridCache(o =>
{
o.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1),
};
});
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = "localhost:6379");
// repositório com cache via decorator
public class CachedProdutoRepository : IProdutoRepository
{
private readonly IProdutoRepository _inner;
private readonly HybridCache _cache;
public CachedProdutoRepository(IProdutoRepository inner, HybridCache cache)
=> (_inner, _cache) = (inner, cache);
public async Task<Produto?> ObterAsync(Guid id, CancellationToken ct)
=> await _cache.GetOrCreateAsync(
$"produto:{id}",
async token => await _inner.ObterAsync(id, token),
tags: new[] { "produto", $"produto:{id}" },
cancellationToken: ct);
public async Task SalvarAsync(Produto p, CancellationToken ct)
{
await _inner.SalvarAsync(p, ct);
await _cache.RemoveByTagAsync($"produto:{p.Id}", ct); // invalida
}
}
// composição via Scrutor
services.AddScoped<IProdutoRepository, ProdutoRepository>();
services.Decorate<IProdutoRepository, CachedProdutoRepository>();
HybridCache, lançado em .NET 9 (novembro 2024), unifica
L1 (memória local) e L2 (distribuído) com stampede
protection embutido. Tags permitem invalidação em grupo.
A API GetOrCreateAsync é cache-aside puro
com factory.
from aiocache import cached, Cache
from aiocache.serializers import PickleSerializer
class ProdutoRepository:
@cached(
ttl=300, # 5 min
cache=Cache.REDIS,
endpoint="redis", port=6379,
serializer=PickleSerializer(),
key_builder=lambda f, *args, **kw: f"produto:{kw['id']}",
)
async def obter(self, *, id: UUID) -> Produto | None:
async with self._db.session() as s:
return await s.get(Produto, id)
async def salvar(self, p: Produto) -> None:
async with self._db.session() as s:
s.add(p)
await s.commit()
# invalidação manual
cache = Cache(Cache.REDIS, endpoint="redis", port=6379)
await cache.delete(f"produto:{p.id}")
aiocache integra com Redis via decorator. A invalidação
é manual aqui — delete após
salvar. Para sistemas com muitos caminhos de
update, padrão melhor é envolver o repositório inteiro
em decorator class que centraliza invalidação.
type CachedRepo struct {
inner ProdutoRepo
rdb *redis.Client
sf singleflight.Group
ttl time.Duration
}
func (c *CachedRepo) Obter(ctx context.Context, id uuid.UUID) (*Produto, error) {
key := "produto:" + id.String()
// tenta cache
raw, err := c.rdb.Get(ctx, key).Bytes()
if err == nil {
var p Produto
if json.Unmarshal(raw, &p) == nil { return &p, nil }
}
// miss: single-flight para evitar stampede
v, err, _ := c.sf.Do(key, func() (any, error) {
p, err := c.inner.Obter(ctx, id)
if err != nil { return nil, err }
if p != nil {
data, _ := json.Marshal(p)
c.rdb.Set(ctx, key, data, c.ttl)
}
return p, nil
})
if err != nil { return nil, err }
return v.(*Produto), nil
}
func (c *CachedRepo) Salvar(ctx context.Context, p *Produto) error {
if err := c.inner.Salvar(ctx, p); err != nil { return err }
return c.rdb.Del(ctx, "produto:"+p.ID.String()).Err()
}
Em Go, composição manual mas explícita. singleflight
(lib oficial) protege contra stampede; serialização JSON
escolhida pela inspeção em redis-cli.
HTTP cache — quando o cache é a resposta
ASP.NET Core 7 (novembro 2022) trouxe
OutputCache, uma forma idiomática de cachear a
resposta HTTP inteira em memória, com tag-based
invalidation. É o equivalente moderno ao response caching
mais antigo, com mais controle.
// .NET — OutputCache em endpoint
app.MapGet("/produtos/{id}", async (Guid id, IProdutoRepository repo) =>
{
var p = await repo.ObterAsync(id, default);
return p is null ? Results.NotFound() : Results.Ok(p);
})
.CacheOutput(p => p
.Expire(TimeSpan.FromMinutes(5))
.Tag("produto")
.VaryByRouteValue("id"));
// invalidação por tag em endpoint de update
app.MapPut("/produtos/{id}", async (Guid id, AtualizarProdutoCmd cmd,
IProdutoRepository repo, IOutputCacheStore cache) =>
{
await repo.AtualizarAsync(id, cmd);
await cache.EvictByTagAsync("produto", default);
return Results.NoContent();
});
O ganho é eliminar o handler inteiro em hits — não só o
acesso a banco, mas também serialização JSON, pipeline de
filtros, etc. Custos: cuidado com personalização. Toda
response cacheada que depende de header (Authorization,
Accept-Language) precisa de VaryBy* apropriado.
Cachear response de endpoint autenticado por engano vaza
dado entre usuários — incidente clássico.
Anti-padrões — onde a equipe se machuca
Cache em sistema com escrita não-canalizada. Quando há cinco caminhos de update do mesmo dado (handler direto, job batch, evento de fila, admin tool, migração manual), invalidação por evento vira jogo de gato e rato. A equipe sempre acha um caminho que não chama o invalidador, e o cache fica obsoleto silenciosamente. Conserto: ou centralizar escrita em um repositório único (e o decorator cuida), ou usar TTL como último recurso, ou usar invalidação por mudança em log/CDC (Debezium escutando o banco) — solução de sistemas maduros.
Cache de dado individualizado em endpoint público.
/api/me cacheado por 5 minutos, e dois usuários
diferentes recebem resposta um do outro. O culpado é falta de
Vary ou cache key sem user_id. Em
sistemas autenticados, regra: cache key sempre tem
identificador de usuário, ou cache simplesmente não vai em
endpoint personalizado.
TTL longo demais para o domínio. Estoque cacheado por 30 minutos em e-commerce de alta rotatividade → cliente compra produto que já não tem mais. Solução: TTL precisa ser do tamanho da tolerância do produto a obsolescência, não do tamanho do que economiza em latência "se eu pudesse". Reuniões de produto onde o time articula "essa info pode estar X minutos atrasada" antes de configurar TTL evitam reuniões piores depois.
Cache sem métricas de hit rate. Cache sem
observação é fé. Hit rate baixo significa que o cache está
caro e inútil; hit rate "perfeito" sinaliza dado
praticamente estático que poderia estar em outro lugar (um
blob, uma constante). Métrica mínima:
cache.hits, cache.misses, e taxa
derivada por chave/grupo.
Cache distribuído como SPOF. Aplicação que
crasha ou degrada catastroficamente quando Redis fica fora.
Defesa: cache deve ser opcional — falha graceful
para fonte. HybridCache faz isso por padrão;
em código manual, é decisão arquitetural ("se cache fora,
logamos warn e seguimos").
Cache de endpoint autenticado sem Vary. Um
engenheiro adiciona OutputCache em
/api/me achando que vai melhorar latência;
outro usuário acessa o endpoint depois e recebe a resposta
do primeiro. Em alguns casos, recebe inclusive token JWT
ativo de outro usuário. Esse tipo de bug não dispara em
teste manual (você sempre é o mesmo usuário); aparece em
produção como reclamação aleatória de "vejo perfil de
outra pessoa". Toda introdução de cache em endpoint
autenticado precisa: (1) Vary por header de
identidade, ou (2) cache key explicitamente incluindo
user_id, ou (3) decisão consciente de não
cachear. A revisão de código que aceita cache em endpoint
autenticado sem nada disso é a que paga o incidente.
Cache em escala — duas decisões finais
Em sistemas grandes, duas decisões surgem inevitavelmente.
Primeira: cache compartilhado ou local-first?
Local é mais rápido (microssegundos), distribuído é mais
consistente. HybridCache em .NET, multi-level
cache em Caffeine, e setups de "L1 local + L2 Redis" fazem
o que se chama tiered cache: leitura primeiro em L1,
cai para L2 se miss, escreve em ambos. Invalidação por evento
precisa atravessar todos os L1s — geralmente via pub/sub no
L2 que dispara invalidação local em cada réplica.
Segunda: quando dizer não a cache? Cache não é grátis. Adiciona uma camada de bug potencial, exige observação, exige invalidação correta, custa memória ou infraestrutura. Sistemas que cacheiam sem necessidade adicionam complexidade sem benefício. Heurística: meça antes de cachear. Se a operação custa 5ms e roda 10 RPS, cachear é ganho marginal por ônus alto. Se custa 200ms e roda 1000 RPS, cachear é claramente vitória. Entre os dois, decisão consciente.
Antes de adicionar cache, responda em uma frase: "qual é o tempo máximo de obsolescência aceitável para esse dado?". Se a resposta é "horas", cache via TTL é trivial. Se é "minutos", TTL com cuidado de invalidação ainda funciona. Se é "imediato" — qualquer obsolescência é problema —, você precisa de invalidação por evento confiável, e isso significa canalizar todas as escritas. Se nem isso é viável, cache provavelmente não é a ferramenta certa, ou precisa ser de nível diferente (cache de query no banco em vez de cache de objeto na app, por exemplo).
Por que importa para a sua carreira
Em entrevista de design, cache aparece em quase toda pergunta de sistema com escala — "como você faria essa API responder em P99 de 50ms para 10k RPS?" rapidamente vira discussão de cache. A resposta forte enumera padrões (cache-aside vs write-through), discute camadas (local + distribuído), aborda invalidação (TTL vs evento), menciona stampede e single-flight, e — diferencial real — pergunta sobre a tolerância a obsolescência antes de chutar TTL. Em revisão de código, perguntar "qual o tempo máximo aceitável de obsolescência?" antes de aprovar cache novo é serviço prestado a futuros operadores. Em pos-mortems, "cache mostrou dado obsoleto" é causa raiz tão comum que ter um catálogo de invalidações mapeadas é higiene de senior.
Como praticar
- Cache-aside via decorator nas três linguagens. Implemente em .NET (HybridCache), Python (aiocache + Redis) e Go (manual com singleflight) o mesmo decorator de repositório. Adicione métricas de hit/miss rate. Force miss simultâneo em 100 requests concorrentes para a mesma chave e verifique que a fonte é chamada uma vez só (single-flight funcionando). Esse exercício é o que torna concreta a diferença entre cache "que parece funcionar" e "que realmente protege a fonte".
- Auditoria de invalidação. Pegue um sistema seu com cache. Liste todos os caminhos pelos quais o dado cacheado pode ser modificado: handlers de update, jobs batch, eventos de fila, admin tools, scripts, migrações. Para cada caminho, verifique se ele invalida o cache. Os caminhos que não invalidam são bombas-relógio. Documente e proponha unificação ou TTL como fallback.
- Mensure antes de cachear. Pegue um endpoint que você cogitaria cachear. Antes de adicionar cache, meça: latência atual P50/P95/P99, RPS, custo do banco em CPU. Adicione cache. Meça de novo. Calcule o ganho em latência, a redução de carga no banco, e o aumento em complexidade operacional. Se o ganho não for óbvio, considere remover. Esse é o exercício que separa cache decidido por dado de cache decidido por hábito.
Referências para aprofundar
- livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017).
- livro Web Scalability for Startup Engineers — Artur Ejsmont (McGraw-Hill, 2015).
- livro Redis in Action — Josiah Carlson (Manning, 2013).
- livro High Performance MySQL (4ª ed.) — Silvia Botros, Jeremy Tinley (O'Reilly, 2021).
- artigo Caching at Reddit — Daniel Lewine et al. (reddit engineering blog, 2017).
- artigo Caching Best Practices — AWS Builders' Library.
- artigo Cache Stampede — Wikipedia + Marc Brooker post (AWS, 2020).
- docs HybridCache (Microsoft Learn).
- docs OutputCache em ASP.NET Core.
- docs aiocache e cachetools.
- docs golang.org/x/sync/singleflight.
- vídeo Caches at the Edge — Charity Majors (talk técnico, 2019).