[CS] CARLOS SEIDL EN
Artigos

Redis Cache em Microsserviços .NET: Menos Consultas ao Banco, Mais Escala

Aprenda cache com Redis em microsserviços .NET 10: padrão cache-aside, TTL, medições de consultas ao banco e as armadilhas que surgem depois do ganho.

Por 11 min de leitura
  • Redis
  • Cache
  • Microsserviços
  • .NET 10
Capa do artigo: Redis Cache em Microsserviços .NET: Menos Consultas ao Banco, Mais Escala

Por que usar cache com Redis em microsserviços .NET

Sem cache, toda leitura vai ao banco; com cache, só a primeira
Sem cache, toda leitura vai ao banco; com cache, só a primeira

Em uma arquitetura de microsserviços, desempenho não depende só de código eficiente. Depende também de quantas vezes cada serviço precisa ir ao banco de dados para responder a mesma pergunta. Um serviço de autenticação, por exemplo, lê os dados do mesmo usuário a cada requisição, e a cada leitura o banco ocupa uma conexão, gasta CPU e acrescenta latência. Com poucos usuários ninguém percebe; com muitos, o banco vira o gargalo de todos os serviços que dependem da identidade.

O Redis é um armazenamento de dados em memória que serve bem como cache para esse tipo de leitura repetida. Neste artigo, que continua o API Gateway + BFF, o Rate Limiting e a ordem dos middlewares, vemos o fluxo de um serviço de autenticação com Redis (padrão cache-aside), um exemplo em .NET 10 com Redis de verdade que conta as consultas ao banco em cada cenário, e os cuidados que o cache exige: dado desatualizado, segurança, concorrência e falha do próprio Redis.

Conceitos de cache com Redis: cache-aside e TTL

Redis guarda pares chave-valor em memória, com prazo de validade opcional por chave. No .NET, a forma mais simples de usá-lo é pela interface IDistributedCache, que expõe GetStringAsync, SetStringAsync e RemoveAsync e não amarra o código ao Redis: a implementação é trocada na configuração.

Quatro termos aparecem em todo o artigo:

  • Hit e miss. Hit é quando o dado está no cache e a resposta sai dele; miss é quando não está e o serviço precisa ir ao banco.
  • TTL (time to live). O prazo de validade da entrada. Passado o prazo, o Redis remove a chave e a próxima leitura é um miss.
  • Invalidação. Remover a chave antes do TTL, porque o dado mudou no banco.
  • Cache-aside. O padrão em que a aplicação controla o cache: lê do cache, e só se faltar lê do banco e guarda o resultado. O cache nunca é a fonte da verdade.

Em código, tudo gira em torno de uma chave por entidade. O exemplo usa usuario:{id}:

C#
static string Chave(int id) => $"usuario:{id}";

Com o InstanceName configurado como demo:, a chave guardada no Redis é demo:usuario:1. O prefixo evita colisão quando vários serviços dividem o mesmo Redis.

Arquitetura do cache com Redis em microsserviços .NET

O fluxo é o do serviço de autenticação: o cliente chama o gateway, que roteia a requisição para o serviço de autenticação, e este consulta o Redis antes do banco. Os demais serviços recebem a identidade e as permissões já validadas.

Redis em um microsserviço de autenticação: o serviço consulta o cache antes do banco
Redis em um microsserviço de autenticação: o serviço consulta o cache antes do banco

Os cinco passos:

  1. O cliente envia a requisição.
  2. O serviço consulta o Redis pela chave do usuário.
  3. Se o dado existe (hit), devolve o resultado do cache.
  4. Se não existe (miss), busca no banco.
  5. Guarda o resultado no Redis com um TTL e devolve a resposta. As próximas requisições reutilizam o dado até ele expirar ou ser invalidado.

O mesmo fluxo, visto como decisão em uma única leitura:

Cache-aside: o fluxo de uma leitura, com os dois caminhos (hit e miss)
Cache-aside: o fluxo de uma leitura, com os dois caminhos (hit e miss)

A configuração do Redis no .NET fica em uma chamada (trecho do Program.cs do exemplo):

C#
servicos.AddStackExchangeRedisCache(o =>
{
    o.Configuration = $"{redis},abortConnect=false";
    o.InstanceName = "demo:";
});

O pacote é o Microsoft.Extensions.Caching.StackExchangeRedis. O abortConnect=false faz a aplicação subir mesmo que o Redis ainda não esteja no ar; o erro só aparece quando alguém tenta usar o cache.

Uma decisão de arquitetura que o diagrama não mostra: cada serviço tem as suas próprias chaves e os seus próprios TTLs. Um Redis compartilhado entre serviços é conveniente, mas acopla o formato dos dados; prefixos por serviço e donos claros para cada chave evitam que um serviço leia ou apague o que é de outro.

Implementando cache-aside com Redis em .NET 10 passo a passo

O exemplo é um único projeto, RedisCache: um console que simula o serviço de autenticação. O banco é simulado (cada consulta custa 50 ms e é contada), o cache é um Redis real em contêiner, e uma demonstração executa cinco cenários e imprime o resultado de cada um.

Os modelos

O cache guarda Usuario; o banco guarda UsuarioRegistro, que tem o hash da senha. Separar os dois tipos garante que a senha nunca chegue ao Redis por descuido de serialização:

C#
namespace RedisCache;

// O que vai para o cache: sem senha, sem token.
public record Usuario(int Id, string Nome, string[] Papeis);

// O que existe no banco: inclui o hash da senha.
public record UsuarioRegistro(
    int Id, string Nome, string SenhaHash, string[] Papeis);

O banco simulado

Ele conta as consultas e demora 50 ms em cada uma. AlterarPapeis simula uma mudança de permissão feita por outro serviço ou por um administrador:

C#
namespace RedisCache;

// Banco simulado: cada consulta custa 50 ms e é contada.
public class BancoDeUsuarios
{
    readonly Dictionary<int, UsuarioRegistro> _dados = new()
    {
        [1] = new(1, "ana", "hash-de-exemplo", ["leitor"]),
    };
    int _consultas;

    public int Consultas => _consultas;

    public async Task<Usuario?> BuscarAsync(int id)
    {
        Interlocked.Increment(ref _consultas);
        await Task.Delay(50);
        return _dados.TryGetValue(id, out var r)
            ? new Usuario(r.Id, r.Nome, r.Papeis)
            : null;
    }

    public void AlterarPapeis(int id, params string[] papeis) =>
        _dados[id] = _dados[id] with { Papeis = papeis };
}

O serviço com cache-aside

ObterAsync é o coração do exemplo. Lê o Redis; se achar, desserializa e devolve; se não, consulta o banco, serializa o resultado em JSON, guarda com AbsoluteExpirationRelativeToNow (o TTL) e devolve. InvalidarAsync remove a chave:

C#
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;

namespace RedisCache;

// Cache-aside: o serviço consulta o Redis antes do banco.
public class ServicoDeUsuarios(
    IDistributedCache cache,
    BancoDeUsuarios banco,
    TimeSpan ttl)
{
    static string Chave(int id) => $"usuario:{id}";

    public Task<Usuario?> ObterSemCacheAsync(int id) =>
        banco.BuscarAsync(id);

    public async Task<Usuario?> ObterAsync(int id)
    {
        var json = await cache.GetStringAsync(Chave(id));
        if (json is not null)
            return JsonSerializer.Deserialize<Usuario>(json);

        var usuario = await banco.BuscarAsync(id);
        if (usuario is null)
            return null;

        await cache.SetStringAsync(
            Chave(id),
            JsonSerializer.Serialize(usuario),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = ttl,
            });
        return usuario;
    }

    // Chamar sempre que o usuário mudar no banco.
    public Task InvalidarAsync(int id) =>
        cache.RemoveAsync(Chave(id));

    public Task<string?> LerBrutoAsync(int id) =>
        cache.GetStringAsync(Chave(id));
}

A demonstração

Cada cenário mede ou mostra uma propriedade do cache: contagem de consultas, expiração, dado velho, invalidação e o conteúdo guardado no Redis:

C#
using System.Diagnostics;

namespace RedisCache;

public static class Demo
{
    const int Leituras = 100;

    public static async Task ExecutarAsync(
        ServicoDeUsuarios servico, BancoDeUsuarios banco)
    {
        await servico.InvalidarAsync(1);

        // 1. sem cache: toda leitura vai ao banco
        var antes = banco.Consultas;
        var tempo = await Medir(
            () => servico.ObterSemCacheAsync(1));
        Console.WriteLine(
            $"sem cache    {Leituras} leituras  " +
            $"{banco.Consultas - antes} consultas  " +
            $"{tempo} ms");

        // 2. com cache: só a primeira vai ao banco
        antes = banco.Consultas;
        tempo = await Medir(() => servico.ObterAsync(1));
        Console.WriteLine(
            $"com cache    {Leituras} leituras  " +
            $"{banco.Consultas - antes} consulta   {tempo} ms");

        // 3. expiração: depois do TTL, volta ao banco
        await Task.Delay(2500);
        antes = banco.Consultas;
        await servico.ObterAsync(1);
        Console.WriteLine(
            "após o TTL   1 leitura     " +
            $"{banco.Consultas - antes} consulta");

        // 4. dado velho: o banco mudou e o cache não soube
        banco.AlterarPapeis(1, "admin");
        var velho = await servico.ObterAsync(1);
        Console.WriteLine("sem invalidar: papéis = " +
            string.Join(",", velho!.Papeis));

        await servico.InvalidarAsync(1);
        var novo = await servico.ObterAsync(1);
        Console.WriteLine("invalidando:   papéis = " +
            string.Join(",", novo!.Papeis));

        // 5. o que fica guardado no Redis
        Console.WriteLine(
            $"no Redis: {await servico.LerBrutoAsync(1)}");
    }

    static async Task<long> Medir(Func<Task> leitura)
    {
        var relogio = Stopwatch.StartNew();
        for (var i = 0; i < Leituras; i++)
            await leitura();
        return relogio.ElapsedMilliseconds;
    }
}

Ponto de entrada

O endereço do Redis vem da variável REDIS (padrão localhost:6379). O TTL do exemplo é de 2 segundos, curto de propósito para a expiração aparecer na demonstração. Se o Redis não estiver no ar, o programa avisa e termina (na máquina de teste, depois de cerca de 18 segundos de tentativas):

C#
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using RedisCache;

// REDIS = endereço do Redis (padrão: localhost:6379)
var redis = Environment.GetEnvironmentVariable("REDIS")
    ?? "localhost:6379";

var servicos = new ServiceCollection();
servicos.AddStackExchangeRedisCache(o =>
{
    o.Configuration = $"{redis},abortConnect=false";
    o.InstanceName = "demo:";
});
await using var provedor = servicos.BuildServiceProvider();

var banco = new BancoDeUsuarios();
var servico = new ServicoDeUsuarios(
    provedor.GetRequiredService<IDistributedCache>(),
    banco,
    ttl: TimeSpan.FromSeconds(2));

try
{
    await Demo.ExecutarAsync(servico, banco);
}
catch (StackExchange.Redis.RedisConnectionException)
{
    Console.WriteLine($"Redis indisponível em {redis}.");
    Environment.ExitCode = 1;
}

Como rodar

O Redis sobe em um contêiner. Um compose.yaml mínimo basta:

yaml
services:
  redis:
    image: redis:7
    ports:
      - "6379:6379"

Com ele no ar, o exemplo roda com o .NET 10:

Bash
docker compose up -d
dotnet run --project src/RedisCache

Saída

Texto
sem cache    100 leituras  100 consultas  6574 ms
com cache    100 leituras  1 consulta   442 ms
após o TTL   1 leitura     1 consulta
sem invalidar: papéis = leitor
invalidando:   papéis = admin
no Redis: {"Id":1,"Nome":"ana","Papeis":["admin"]}
Resultado do exemplo: contagem de consultas ao banco em cada cenário
Resultado do exemplo: contagem de consultas ao banco em cada cenário

Como ler o resultado:

  • Sem cache: 100 leituras do mesmo usuário geraram 100 consultas ao banco.
  • Com cache: as mesmas 100 leituras geraram 1 consulta, a do primeiro miss. Os tempos variam com a máquina (nas execuções de teste, de 6 a 8 segundos sem cache e de 0,4 a 2 segundos com cache); a contagem de consultas é sempre a mesma.
  • Após o TTL: com a entrada expirada, a leitura seguinte voltou ao banco.
  • Sem invalidar: o banco passou o papel para admin, mas o cache continuou devolvendo leitor até o TTL expirar. É o dado desatualizado.
  • Invalidando: com InvalidarAsync logo após a alteração, a leitura seguinte já veio com admin.
  • No Redis: o JSON guardado tem id, nome e papéis, sem hash de senha.

Boas práticas de cache com Redis: armadilhas e medições

O ganho de desempenho vem de graça; os problemas vêm depois. Os quatro abaixo foram reproduzidos em projetos de teste (trechos ilustrativos, fora do projeto de exemplo, com as medições de uma máquina de desenvolvimento).

Dado desatualizado: sem invalidar, o papel antigo vale até o TTL; invalidando, a janela quase some
Dado desatualizado: sem invalidar, o papel antigo vale até o TTL; invalidando, a janela quase some

Dado desatualizado e autorização. Cache e banco só concordam até o primeiro UPDATE. Em dados comuns, alguns segundos de atraso são aceitáveis; em permissões, não. Duas defesas: TTL curto e invalidação explícita sempre que o papel ou a permissão mudar (como no cenário "invalidando"). Mesmo assim existe uma janela entre a gravação no banco e a remoção da chave, então decisões críticas de autorização podem consultar a fonte da verdade.

Segurança dos dados no cache. O que entra no Redis fica em memória em outro processo, fora do banco e das suas regras de acesso.

O que pode e o que não pode ir para o cache em um serviço de autenticação
O que pode e o que não pode ir para o cache em um serviço de autenticação

Regras práticas: não guarde senha nem hash de senha, tokens de acesso ou atualização e dados de sessão com segredo; guarde só o que o serviço precisa para responder (id, nome, papéis); proteja o próprio Redis com rede privada, autenticação e TLS. O exemplo faz a primeira parte pelo tipo: Usuario não tem campo de senha.

Muitas leituras ao mesmo tempo com o cache vazio. Quando a chave expira (ou o Redis é reiniciado), todas as requisições simultâneas veem o miss e vão juntas ao banco, o chamado cache stampede. No teste, 20 leituras simultâneas com o cache vazio geraram 20 consultas. Uma trava por chave resolve dentro do processo: a primeira busca, as outras esperam e leem do cache (trecho ilustrativo, testado à parte):

C#
readonly SemaphoreSlim _trava = new(1, 1);

public async Task<Usuario?> ObterAsync(int id)
{
    var chave = $"usuario:{id}";
    if (await LerCacheAsync(chave) is { } achado)
        return achado;

    await _trava.WaitAsync();
    try
    {
        if (await LerCacheAsync(chave) is { } outro)
            return outro;   // outro chamador já preencheu

        var usuario = await banco.BuscarAsync(id);
        // ... SetStringAsync como no serviço da seção 4.3
        return usuario;
    }
    finally
    {
        _trava.Release();
    }
}

Com a trava, as mesmas 20 leituras geraram 1 consulta. Ela vale por processo: com várias instâncias do serviço, cada uma ainda faz a sua consulta.

O Redis fora do ar. O cache deve acelerar o serviço, não derrubá-lo. O primeiro passo é tratar a falha do cache como um miss (try/catch em volta das chamadas ao Redis, como no LerCacheAsync do trecho acima). Só isso não basta: com o Redis parado e os timeouts padrão, 5 leituras seguidas levaram 92 segundos, porque o cliente tenta reconectar a cada operação. Com a configuração abaixo, o tempo caiu para 15,7 segundos, ainda muito alto:

C#
o.Configuration = "localhost:6379,abortConnect=false," +
    "connectTimeout=500,syncTimeout=500,asyncTimeout=500," +
    "connectRetry=1";
Casos de borda medidos: cache vazio com concorrência e Redis fora do ar
Casos de borda medidos: cache vazio com concorrência e Redis fora do ar

O try/catch evita o erro, não a espera. Para parar de tentar enquanto o Redis está fora, use um circuit breaker, como no artigo de Tolerância a Falhas vs Resiliência, e teste o serviço com o Redis parado antes de ir para produção.

Outras dicas:

  • Defina TTL em toda entrada. Chave sem prazo cresce sem limite e acaba tirando dados úteis do Redis. O TTL certo depende de quanto atraso cada dado tolera.
  • Nomeie as chaves de forma previsível (serviço:entidade:id) e documente quem as cria e quem as invalida.
  • Meça o hit ratio. Cache com poucos hits só acrescenta uma viagem de rede e um ponto de falha.
  • Serialize só o necessário. O JSON do exemplo tem três campos; objetos grandes custam rede e memória a cada hit.

Quando usar cache com Redis e quando evitar

Use cache com Redis quando:

  • Os mesmos dados são lidos muitas vezes e mudam pouco (perfil, papéis, catálogos, configurações).
  • O banco já aparece como gargalo, ou a latência da leitura pesa na experiência.
  • Várias instâncias do serviço precisam ver o mesmo cache. É aí que o Redis supera um cache em memória local (IMemoryCache), que é por processo.

Evite ou adie quando:

  • O dado muda a cada requisição ou precisa estar sempre exato (saldo, estoque no momento da compra): o cache serve atraso, não precisão.
  • A leitura já é rápida e pouco frequente: o cache acrescenta complexidade sem ganho.
  • Você ainda não sabe o que está lento. Meça antes; uma consulta sem índice se resolve no banco, não no cache.
  • A equipe não tem como operar mais um componente (monitoramento, memória, persistência, segurança do Redis).

Conclusão: o que o cache com Redis muda em microsserviços .NET

O Redis ajuda um microsserviço a reduzir consultas repetidas: no exemplo, 100 leituras do mesmo usuário geraram 100 consultas sem cache e 1 com cache, com a mesma resposta. O padrão cache-aside mantém o banco como fonte da verdade e coloca o Redis como cópia com prazo, o que traz uma regra de ouro: toda entrada precisa de um fim, pelo TTL ou pela invalidação.

A reflexão prática: o cache não é só uma otimização, é um segundo lugar onde o dado existe. Isso cria três perguntas que o código precisa responder antes de ir para produção: o que acontece se o dado estiver velho (principalmente permissões), o que acontece se o cache estiver vazio ou fora do ar, e o que não pode ser guardado nele. Quando as três têm resposta, o Redis acelera o serviço sem comprometer a segurança nem a correção dos dados.

Referências

← todos os artigos