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

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}:
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.

Os cinco passos:
- O cliente envia a requisição.
- O serviço consulta o Redis pela chave do usuário.
- Se o dado existe (hit), devolve o resultado do cache.
- Se não existe (miss), busca no banco.
- 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:

A configuração do Redis no .NET fica em uma chamada (trecho do Program.cs do exemplo):
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:
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:
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:
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:
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):
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:
services:
redis:
image: redis:7
ports:
- "6379:6379"Com ele no ar, o exemplo roda com o .NET 10:
docker compose up -d
dotnet run --project src/RedisCacheSaída
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"]}
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 devolvendoleitoraté o TTL expirar. É o dado desatualizado. - Invalidando: com
InvalidarAsynclogo após a alteração, a leitura seguinte já veio comadmin. - 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 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.

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):
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:
o.Configuration = "localhost:6379,abortConnect=false," +
"connectTimeout=500,syncTimeout=500,asyncTimeout=500," +
"connectRetry=1";
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
- Microsoft Learn: Cache distribuído no ASP.NET Core
- Microsoft Learn: Visão geral do cache no ASP.NET Core
- Redis: Documentação do Redis
- StackExchange.Redis: Opções de configuração
