Cache com Redis, HybridCache e output caching: responder sem ir ao banco

Cache com Redis, HybridCache e output caching: responder sem ir ao banco

Onde guardar respostas prontas da Livraria e quando jogá-las fora: cache HTTP com ETag e 304, IMemoryCache e seus limites, Redis e Valkey, HybridCache em duas camadas com tags, invalidação a cada escrita, output caching e o caminho da venda que nunca passa pelo cache.
Linguagem C#

• • 19 min de leitura

O catálogo da Livraria é lido centenas de vezes para cada vez que é alterado. Este artigo mostra onde guardar respostas prontas, quanto tempo elas podem valer e, principalmente, como jogá-las fora na hora certa.

Onde o cache pode morar

Entre o cliente e o banco há vários lugares onde uma resposta pode ser guardada, cada um com alcance e custo diferentes:

Cliente ──► CDN / proxy ──► [Output cache] ──► Endpoint ──► [Cache de dados] ──► Banco
  │             │                 │                               │
cache HTTP   cache HTTP      resposta HTTP                  objetos/DTOs
do navegador compartilhado   inteira pronta                 (memória / Redis)
  • Cache HTTP no cliente e em intermediários, controlado por cabeçalhos (Cache-Control, ETag). É o mais barato, porque a requisição nem chega ao servidor ou chega e volta sem corpo, mas o servidor não controla quando essas cópias expiram.
  • Output cache: o servidor guarda a resposta HTTP inteira (status, cabeçalhos, corpo) e a devolve sem executar o endpoint.
  • Cache de dados: o endpoint roda, mas pega os objetos de um cache em vez de consultar o banco. É o mais flexível, porque o mesmo dado serve a vários endpoints.

Todos compartilham o mesmo dilema, resumido na frase atribuída a Phil Karlton: "Há só duas coisas difíceis em Ciência da Computação: invalidação de cache e dar nomes às coisas." Cache é uma cópia, e toda cópia pode estar desatualizada. A pergunta de projeto não é "como cachear", é "quanto atraso cada dado tolera, e quem avisa quando ele muda".

Para a Livraria:

Dado Tolera atraso? Estratégia
Título, autor, gêneros minutos, mudam raramente cache com invalidação na escrita
Preço pouco, mas é conferido na venda cache curto + invalidação
Estoque exibido segundos cache curtíssimo ou nenhum
Estoque na venda nunca sempre do banco, com update condicional
Pedidos do cliente não, e é dado pessoal sem cache compartilhado

A última linha da parte de estoque é a mais importante do artigo. O ServicoDeVendas nunca lê do cache. A garantia de não vender o último exemplar duas vezes vem do UPDATE ... WHERE Estoque >= @quantidade de "ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection", e nenhum cache fica no caminho dela. O cache pode, no máximo, mostrar "1 disponível" quando já não há. Aí o cliente tenta comprar, recebe 409, e o sistema continua correto. Cache em leitura é uma otimização. No caminho de escrita, seria um bug.

Cache HTTP: o ETag que já temos

Em "A API da Livraria" o GET /produtos/{id} passou a devolver um ETag para a concorrência otimista. O mesmo cabeçalho serve para revalidação. O cliente guarda a resposta e, na próxima vez, pergunta "mudou desde esta versão?" com If-None-Match. Se não mudou, o servidor responde 304 Not Modified, sem corpo:

static async Task<Results<Ok<ProdutoResposta>, NotFound, StatusCodeHttpResult>> Obter(
    int id,
    CatalogoEmCache catalogo,
    HttpRequest requisicao,
    HttpResponse resposta,
    CancellationToken ct)
{
    var corpo = await catalogo.ObterAsync(id, ct);
    if (corpo is null)
        return TypedResults.NotFound();

    var etag = Etags.De(corpo);
    resposta.Headers.ETag = etag;
    resposta.Headers.CacheControl = "no-cache";

    if (requisicao.Headers.IfNoneMatch.ToString() == etag)
        return TypedResults.StatusCode(StatusCodes.Status304NotModified);

    return TypedResults.Ok(corpo);
}

O nome no-cache confunde muita gente. Ele não significa "não guarde". Significa "guarde, mas pergunte ao servidor antes de reutilizar". Combinado com o ETag, o cliente sempre recebe a versão atual e só baixa o corpo quando algo mudou. Quem quer proibir o armazenamento usa no-store, e é o que as respostas de /pedidos devem declarar, por conterem dados pessoais. max-age=60 diria "reutilize por 60 segundos sem perguntar", o que é mais rápido mas aceita até um minuto de dado velho. A escolha é a tabela acima em forma de cabeçalho. Para respostas autenticadas que podem ser guardadas, private impede que um proxy compartilhado sirva a resposta de um usuário a outro.

Em uma chamada que termina em 304, o servidor ainda precisa montar o DTO para calcular o ETag. Economizamos a rede, mas não o banco. Para economizar o banco, o DTO precisa vir de um cache de dados.

Cache em memória e seus limites

O .NET tem IMemoryCache (AddMemoryCache()), um dicionário com expiração dentro do processo:

var corpo = await memoria.GetOrCreateAsync($"produto:{id}", async entrada =>
{
    entrada.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
    return (await repositorio.ObterAsync(id, ct))?.ParaResposta();
});

É o cache mais rápido possível, porque é só um acesso a memória, sem serialização. Tem três limites que aparecem assim que a API sai do computador do desenvolvedor:

  1. Várias instâncias, várias verdades. Com três réplicas atrás de um balanceador, um PUT na réplica A invalida o cache de A, e B e C continuam servindo o preço antigo até expirar. O mesmo cliente vê preços diferentes a cada F5.
  2. Reinício zera tudo. Cada deploy começa com o cache frio, e o banco leva o impacto de uma vez.
  3. Estouro de cache (cache stampede). Quando uma chave popular expira, cem requisições simultâneas encontram o cache vazio e fazem cem consultas iguais ao banco. O GetOrCreateAsync do IMemoryCache não coordena essas chamadas.

Os dois primeiros pedem um cache fora do processo, compartilhado pelas réplicas. O terceiro pede coordenação. O .NET resolve os dois com Redis e HybridCache.

Redis

Redis é um servidor de estruturas de dados em memória: strings, hashes, listas, conjuntos, conjuntos ordenados e streams, com operações atômicas e expiração por chave. Responde em frações de milissegundo e é o cache distribuído mais usado do mercado.

Um pouco de contexto de licenças, porque ele afeta a escolha da imagem. Em 2024 a Redis Ltd. trocou a licença BSD por licenças que restringem a oferta como serviço (RSAL e SSPL), e a Linux Foundation criou o Valkey, um fork compatível com a versão BSD, adotado por vários provedores de nuvem. Em 2025 o Redis 8 voltou a oferecer uma opção de código aberto, a AGPLv3. Para uma aplicação que usa Redis como cache, as duas implementações funcionam da mesma forma e os clientes .NET conversam com qualquer uma. A decisão pesa para quem vai hospedar o servidor.

docker run -d --name redis -p 6379:6379 redis:8
# ou: docker run -d --name valkey -p 6379:6379 valkey/valkey:8

O cliente .NET de referência é o StackExchange.Redis, criado pela equipe do Stack Overflow. O objeto central, ConnectionMultiplexer, é caro de criar, thread-safe e multiplexa todos os comandos numa única conexão. Deve existir um por aplicação, a mesma lição do HttpClient e do MongoClient:

builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
    ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));

HybridCache: memória e Redis em duas camadas

O HybridCache (pacote Microsoft.Extensions.Caching.Hybrid, estável desde 2025) é a abstração de cache recomendada para código novo. Ele combina:

  • L1: memória local do processo, rapidíssima.
  • L2: qualquer IDistributedCache registrado, como o Redis, compartilhado entre réplicas e sobrevivendo a reinícios.
  • Proteção contra estouro: chamadas simultâneas pela mesma chave ausente esperam uma única execução da fábrica.
  • Tags para invalidar grupos de entradas de uma vez.
  • Serialização automática (System.Text.Json por padrão, configurável).
dotnet add src/Livraria.Api package Microsoft.Extensions.Caching.Hybrid
dotnet add src/Livraria.Api package Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(o =>
{
    o.Configuration = builder.Configuration.GetConnectionString("Redis");
    o.InstanceName = "livraria:";                  // prefixo de todas as chaves
});

builder.Services.AddHybridCache(o =>
{
    o.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(10),      // L2
        LocalCacheExpiration = TimeSpan.FromMinutes(1)   // L1, mais curta
    };
});

O HybridCache detecta o IDistributedCache registrado e passa a usá-lo como L2. Sem Redis registrado, ele funciona só com a memória, o que é útil nos testes e no desenvolvimento.

A L1 expira mais cedo que a L2 de propósito. Quando uma réplica invalida uma chave, ela remove a entrada da própria L1 e da L2, mas as L1 das outras réplicas não ficam sabendo. A expiração curta da L1 limita esse atraso a um minuto no pior caso. Quem precisa de menos que isso reduz o LocalCacheExpiration ou desliga a L1 para aquela entrada.

O cache fica encapsulado num serviço da API, que guarda DTOs, não entidades:

using Microsoft.Extensions.Caching.Hybrid;

public sealed class CatalogoEmCache(HybridCache cache, IRepositorioProdutos repositorio)
{
    private const string TagCatalogo = "catalogo";

    public ValueTask<ProdutoResposta?> ObterAsync(int id, CancellationToken ct) =>
        cache.GetOrCreateAsync<ProdutoResposta?>(
            Chave(id),
            async token => (await repositorio.ObterAsync(id, token))?.ParaResposta(),
            tags: [TagCatalogo],
            cancellationToken: ct);

    public ValueTask InvalidarAsync(int id, CancellationToken ct) =>
        cache.RemoveAsync(Chave(id), ct);

    public ValueTask InvalidarTudoAsync(CancellationToken ct) =>
        cache.RemoveByTagAsync(TagCatalogo, ct);

    private static string Chave(int id) => $"produto:{id}";
}

// registro: scoped, porque o repositório é scoped
builder.Services.AddScoped<CatalogoEmCache>();

Guardar o DTO, e não a entidade, tem três vantagens:

  • O DTO é um record imutável e serializável. A entidade tem invariantes, construtores privados e, no EF, estado de rastreamento que não sobrevive a uma ida e volta pelo Redis.
  • Uma entidade compartilhada na L1 poderia ser modificada por uma requisição e contaminar as outras. O DTO imutável não pode.
  • O cache fica na camada que conhece o formato da resposta, e o domínio continua sem saber que ele existe.

Um detalhe: quando o produto não existe, a fábrica devolve null, e o null também é guardado. Isso é o negative caching, que protege o banco de quem martela ids inexistentes. O efeito colateral é que um produto recém-criado com aquele id pareceria inexistente até expirar, mas como os ids são gerados pelo banco e nunca reutilizados, esse caso não ocorre aqui.

Invalidação: toda escrita avisa o cache

A regra é simples de enunciar e fácil de esquecer: todo caminho que altera um produto invalida a entrada correspondente.

// PUT /produtos/{id}, depois de salvar
await catalogo.InvalidarAsync(id, ct);

// DELETE /produtos/{id}
if (await repositorio.RemoverAsync(id, ct))
{
    await catalogo.InvalidarAsync(id, ct);
    return TypedResults.NoContent();
}

// POST /pedidos, depois de registrar: o estoque de cada item mudou
foreach (var item in pedido.Itens)
    await catalogo.InvalidarAsync(item.ProdutoId, ct);

Esquecer a invalidação no PUT não produz só um dado velho. Produz um laço. O administrador faz GET e recebe o ETag da versão em cache. O PUT compara o If-Match com a versão do banco, que é outra, e responde 412. O cliente, obedecendo ao protocolo, relê, e recebe de novo a versão em cache com o mesmo ETag. A cada tentativa dá 412, até o cache expirar. Por isso o endpoint de PUT continua lendo do repositório, e não do cache. Leituras destinadas a uma escrita vão direto ao banco, e as de exibição podem vir do cache.

Há um limite de fundo nessa abordagem: ela só funciona se todas as escritas passarem pela API. Um script de reajuste de preços que faz UPDATE direto no PostgreSQL deixa o cache servindo preços antigos por até dez minutos. As saídas são aceitar o TTL como teto do atraso, chamar InvalidarTudoAsync ao fim do script (via um endpoint administrativo) ou publicar eventos de alteração. O artigo "Worker Services e mensageria com RabbitMQ" traz esta última.

Output caching: a resposta inteira pronta

Para a listagem paginada, cachear DTO por DTO não ajuda muito: a consulta cara é o Skip/Take com Count. O output caching (nativo desde o .NET 7) guarda a resposta HTTP completa e a devolve sem sequer executar o endpoint:

builder.Services.AddOutputCache(o =>
{
    o.AddPolicy("Catalogo", p => p
        .Expire(TimeSpan.FromSeconds(30))
        .SetVaryByQuery("pagina", "tamanho")
        .Tag("catalogo"));
});

// armazenamento em Redis, compartilhado entre réplicas (pacote
// Microsoft.AspNetCore.OutputCaching.StackExchangeRedis)
builder.Services.AddStackExchangeRedisOutputCache(o =>
{
    o.Configuration = builder.Configuration.GetConnectionString("Redis");
    o.InstanceName = "livraria:saida:";
});
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();          // depois de CORS e roteamento, antes dos endpoints

grupo.MapGet("/", Listar).AllowAnonymous().CacheOutput("Catalogo");

SetVaryByQuery faz cada combinação de pagina e tamanho virar uma entrada distinta. Sem ele, a página 2 receberia a página 1 em cache. Parâmetros que não estão na lista são ignorados, e ?pagina=1&lixo=123 usa a mesma entrada que ?pagina=1, o que protege o cache de ser inundado com chaves inúteis.

A invalidação por tag usa o IOutputCacheStore:

static async Task<...> Atualizar(..., IOutputCacheStore saida, CatalogoEmCache catalogo, CancellationToken ct)
{
    // ... salva ...
    await catalogo.InvalidarAsync(id, ct);
    await saida.EvictByTagAsync("catalogo", ct);
    // ...
}

A política padrão do output cache tem salvaguardas que convém conhecer antes de estranhar o comportamento:

  • Só guarda respostas 200 de GET e HEAD.
  • Não guarda requisições autenticadas (com cabeçalho Authorization) nem respostas que definem cookies. Um cliente logado que lista o catálogo não recebe nem alimenta o cache, porque a resposta poderia depender de quem pergunta. Se a listagem é idêntica para todos, uma política própria pode ignorar essa regra, mas é uma decisão consciente, que deixa de valer quando surgir, por exemplo, "preço especial para assinantes".
  • A proteção contra estouro vem ligada: requisições simultâneas pela mesma entrada esperam uma única execução.

Não confunda com o antigo response caching middleware (UseResponseCaching), que obedece aos cabeçalhos HTTP enviados pelo cliente. Um Cache-Control: no-cache vindo do cliente o faz ignorar o cache. O output cache é controlado pelo servidor, e um cliente não consegue forçar o endpoint a rodar.

Redis além do cache

Redis é também uma caixa de ferramentas para coordenar réplicas. Dois usos resolvem pendências da Livraria.

A chave de idempotência prometida em "A API da Livraria". O comando SET com NX (só se não existir) e expiração é atômico, exatamente o que o protocolo exige:

var db = redis.GetDatabase();

var primeiraVez = await db.StringSetAsync(
    $"livraria:idem:{clienteId}:{chave}",
    "processando",
    TimeSpan.FromHours(24),
    When.NotExists);

if (!primeiraVez)
{
    var guardado = await db.StringGetAsync($"livraria:idem:{clienteId}:{chave}");
    // "processando" → 409; senão, devolver a resposta serializada que foi guardada
}

A chave inclui o clienteId. Sem ele, dois clientes que por coincidência gerassem o mesmo valor colidiriam, e um atacante poderia "reservar" chaves de outros.

O ranking dos mais vendidos. Um sorted set mantém elementos ordenados por pontuação, com incremento atômico:

// a cada pedido registrado
foreach (var item in pedido.Itens)
    await db.SortedSetIncrementAsync("livraria:mais-vendidos", item.ProdutoId, item.Quantidade);

// GET /produtos/mais-vendidos
var top = await db.SortedSetRangeByRankWithScoresAsync(
    "livraria:mais-vendidos", 0, 9, Order.Descending);

Calcular isso no SQL exigiria um GROUP BY sobre todos os itens de pedido a cada consulta. No Redis é uma leitura de dez elementos já ordenados. O preço é que esse dado vive fora do banco principal: se o Redis for perdido sem persistência, o ranking precisa ser reconstruído a partir dos pedidos. Por isso ele é uma projeção derivada, e não a fonte da verdade, a mesma distinção que fizemos em "MongoDB Avançado" entre documento e relacional.

Medindo antes de acreditar

Cache sem medição é superstição. Três números dizem se ele se paga:

  • Taxa de acerto (hit ratio): abaixo de 80% num catálogo, a chave ou o TTL provavelmente estão errados.
  • Latência p95/p99 da rota com e sem cache.
  • Carga no banco: consultas por segundo antes e depois.

O HybridCache e o output cache publicam métricas pelo System.Diagnostics.Metrics, e a coleta e visualização delas é parte de "Produção: CORS, rate limiting, versionamento e OpenTelemetry". Até lá, a suíte do artigo anterior protege o que importa. Vale acrescentar um teste que faz PUT e em seguida GET e afirma que o GET devolve o valor novo: é o teste que pega a invalidação esquecida.

Com isso fechamos o primeiro bloco da Fase 6. A Livraria tem uma API com contrato claro, segura, testada, configurável e rápida nas leituras, com cache em três níveis e uma regra que não se negocia: o caminho da venda nunca passa por uma cópia. Até aqui toda conversa começa com o cliente perguntando. Quando o último exemplar de um livro é vendido, quem está com a página dele aberta só descobre ao recarregar ou ao tentar comprar.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escolha o cabeçalho Cache-Control adequado para cada resposta e justifique: (a) GET /produtos/{id}; (b) GET /pedidos/{id}; (c) o arquivo /imagens/capa-9788535910667-v3.jpg; (d) GET /openapi/v1.json.

Ver resposta

✓ Resposta: (a) no-cache com ETag: pode ser guardado, mas é revalidado a cada uso, o que dá um 304 barato quando nada mudou e nunca exibe dado velho. Se alguns segundos de atraso forem aceitáveis, max-age=30 reduz ainda mais as idas ao servidor. (b) no-store: dado pessoal, que não deve ficar em disco de proxies nem em caches compartilhados. (c) public, max-age=31536000, immutable: o nome contém a versão (v3), e uma capa nova terá outro nome, então o arquivo nunca muda e pode ser guardado por um ano. Essa técnica é o cache busting. (d) no-cache com ETag, ou um max-age curto: muda a cada deploy, e um cliente gerado com o contrato antigo é pior que uma requisição a mais.

Exercício 2

Explique por que o LocalCacheExpiration do HybridCache deve ser menor que o Expiration, e calcule o atraso máximo que um cliente pode ver depois de um PUT com a configuração deste artigo e três réplicas.

Ver resposta

✓ Resposta: A invalidação (RemoveAsync) remove a entrada da L1 da réplica que executou o PUT e da L2 compartilhada. As L1 das outras réplicas continuam com a cópia antiga até expirarem, e por isso a L1 precisa durar pouco. Com LocalCacheExpiration de 1 minuto: no pior caso, uma réplica que acabou de preencher a L1 um instante antes do PUT serve o valor velho por até 1 minuto. A L2 não contribui para o atraso, porque foi invalidada. Os 10 minutos da L2 só valem como teto se a invalidação for esquecida ou se o dado for alterado fora da API. Encurtar a L1 reduz o atraso e aumenta as idas ao Redis.

Exercício 3

Um desenvolvedor aplicou .CacheOutput("Catalogo") também a GET /pedidos/{id} "para aliviar o banco", e nos testes nada quebrou. Por que nada quebrou, e qual seria o desastre se ele também escrevesse uma política que ignora a regra de requisições autenticadas?

Ver resposta

✓ Resposta: Nada quebrou porque a política padrão do output cache não guarda requisições com Authorization, e todo acesso a pedidos é autenticado. O cache simplesmente nunca foi usado. Com uma política que ignore essa salvaguarda, sem variar por usuário, a primeira resposta de GET /pedidos/42 (pedido do Bruno, pedido pelo Bruno) ficaria guardada, e a próxima requisição à mesma URL receberia o pedido do cache sem executar o endpoint, portanto sem a autorização por recurso. Carla veria o pedido do Bruno. Cache compartilhado passa por cima da autorização que roda dentro do endpoint. Por isso dados por usuário ficam fora dele, ou, se for indispensável, variam por identidade do usuário (VaryByValue com o sub), o que raramente compensa.

Exercício 4

Implemente GET /produtos/mais-vendidos que devolve os 10 primeiros do sorted set com título e preço. Como evitar dez idas ao banco para buscar os títulos?

Ver resposta

✓ Resposta: Leia o ranking com SortedSetRangeByRankWithScoresAsync("livraria:mais-vendidos", 0, 9, Order.Descending), que devolve pares (Element = ProdutoId, Score = quantidade vendida). Para os detalhes há duas opções: (a) acrescentar ao repositório ListarPorIdsAsync(IReadOnlyCollection<int> ids, ct), com uma única consulta WHERE Id IN (...) no EF ou Filter.In no Mongo, e reordenar em memória pela ordem do ranking, já que o banco não preserva a ordem do IN; ou (b) usar o CatalogoEmCache para cada id, que em regime acerta quase sempre na L1, e nos acertos não há ida ao banco. A opção (b) chama o cache dez vezes, mas o cache é barato. A (a) faz uma consulta garantida. Em ambos os casos, produtos removidos do catálogo que ainda estão no ranking devem ser filtrados, ou removidos do sorted set com SortedSetRemoveAsync no DELETE.

Exercício 5

O Redis caiu. Descreva o comportamento da API em cada ponto que o usa, e diga qual deles precisa de tratamento explícito para a Livraria continuar vendendo.

Ver resposta

✓ Resposta: HybridCache: falhas na L2 são tratadas como ausência, e o sistema segue com a L1 e o banco. Fica mais lento, com mais carga no banco, mas funciona. Output cache em Redis: sem o armazenamento, as respostas deixam de ser servidas do cache e o endpoint roda normalmente, o que também causa degradação sem queda. Vale confirmar esse comportamento num teste com o Redis parado, porque ele depende da implementação do armazenamento. Ranking: SortedSetIncrementAsync lança RedisConnectionException. Se a chamada estiver no mesmo fluxo do POST /pedidos sem tratamento, a venda falha por causa de uma estatística. Esse é o ponto que precisa de tratamento: capturar a exceção, registrar um warning e seguir, ou melhor, mover o incremento para um processamento assíncrono. Idempotência: aqui não há escolha fácil. Aceitar o pedido sem a verificação arrisca duplicidade, e recusar arrisca vendas. A decisão de negócio mais comum é recusar com 503 e Retry-After, porque o cliente, que tem a chave, pode repetir com segurança. A regra geral: aquilo que é otimização degrada em silêncio, e aquilo que é garantia precisa de uma decisão explícita.

Comentários

Mais em Linguagem C#

C# e IA: Microsoft.Extensions.AI, busca semântica e um assistente com ferramentas
C# e IA: Microsoft.Extensions.AI, busca semântica e um assistente com ferramentas

Modelos de linguagem como infraestrutura na Livraria: Microsoft.Extensions.AI…

Projeto: a Biblioteca em Apuros completa, do documento de design ao export
Projeto: a Biblioteca em Apuros completa, do documento de design ao export

A Biblioteca em Apuros vira um jogo acabado: documento de design de uma…

Tempo real com SignalR: quando o servidor toma a iniciativa
Tempo real com SignalR: quando o servidor toma a iniciativa

A Livraria passa a avisar quem está conectado: polling, SSE e WebSockets, hubs…