MÓDULO 05 · CONCEITO 10 DE 14

Cache como cross-cutting

Cache-aside via decorator, OutputCache, functools.lru_cache, wrappers em Go. Por que invalidação é o problema difícil — e por que cache mal organizado é a forma mais ergonômica de transformar consistência forte em eventual sem ter consciência disso.

Tempo de leitura ~22 min Pré-requisito Conceito 03 (decorator) Próximo Auth/Authorization como aspect

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.

C# — HybridCache (.NET 9+)
// 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.

Python — aiocache com decorator
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.

Go — wrapper com singleflight
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").

armadilha em produção

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.

heurística do sênior

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

  1. 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".
  2. 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.
  3. 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

  1. livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017). Cap. 5 (Replication) e seções espalhadas sobre caching como denormalização. Kleppmann conecta cache com modelos de consistência de forma rara.
  2. livro Web Scalability for Startup Engineers — Artur Ejsmont (McGraw-Hill, 2015). Capítulos 6 e 7 cobrem caching em camadas com pragmatismo e exemplos. Para quem quer pensar cache como decisão arquitetural, não como detalhe.
  3. livro Redis in Action — Josiah Carlson (Manning, 2013). Apesar da idade, ainda é o livro mais didático sobre padrões de uso de Redis. Capítulos 4 e 6 são particularmente úteis para cache de aplicação.
  4. livro High Performance MySQL (4ª ed.) — Silvia Botros, Jeremy Tinley (O'Reilly, 2021). Cap. 14 (Application Optimization) traz a perspectiva de cache vista do banco — quando vale, quando vira problema. Útil para sêniores que lidam com banco.
  5. artigo Caching at Reddit — Daniel Lewine et al. (reddit engineering blog, 2017). redditinc.com/blog/caches-at-reddit — Caso real de cache em escala de reddit, com decisões articuladas e armadilhas observadas em produção.
  6. artigo Caching Best Practices — AWS Builders' Library. aws.amazon.com/builders-library/caching — A perspectiva da AWS sobre os trade-offs. Cobre stampede, eviction, sizing.
  7. artigo Cache Stampede — Wikipedia + Marc Brooker post (AWS, 2020). Brooker escreve sobre o tema com a clareza dele em aws.amazon.com/builders-library — cobre single-flight, probabilistic early expiration, e variantes.
  8. docs HybridCache (Microsoft Learn). learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid — Documentação canônica do .NET 9. Mostra integração L1+L2 e migration de IDistributedCache.
  9. docs OutputCache em ASP.NET Core. learn.microsoft.com/en-us/aspnet/core/performance/caching/output — Documentação do output cache moderno, com tag-based invalidation e VaryBy.
  10. docs aiocache e cachetools. aiocache.aio-libs.org e cachetools.readthedocs.io — As duas bibliotecas de referência para cache em Python. aiocache para integração com Redis async; cachetools para cache local com políticas de eviction (LRU, TTL, LFU).
  11. docs golang.org/x/sync/singleflight. pkg.go.dev/golang.org/x/sync/singleflight — Documentação curta, código-fonte legível em meia tarde. Resolve stampede com elegância em ~150 linhas.
  12. vídeo Caches at the Edge — Charity Majors (talk técnico, 2019). YouTube. Charity Majors articula como cache em edge interage com cache em aplicação, e quando cada um é apropriado. Útil para visão sistêmica.