Redis além do tutorial. Com os problemas que ninguém te conta
Seu banco de dados é rápido. As queries estão otimizadas. Os índices estão no lugar. E mesmo assim, sob carga real, o sistema continua lento. O problema muitas vezes não é o banco em si. É que você está pedindo para ele responder as mesmas perguntas, repetidamente, milhares de vezes por segundo. É aqui que o Redis começa a importar. Mas ao contrário do que muitos tutoriais sugerem, o Redis não é mágica. É uma ferramenta com trade-offs muito específicos — e usá-la errado pode criar problemas piores do que os que você tentou resolver. Redis armazena tudo em RAM como pares chave–valor, mas os valores não se limitam a strings simples. Ele suporta um conjunto rico de estruturas de dados: Estrutura Casos de uso típicos Strings Contadores, flags, resultados em cache Hashes Perfis de usuário, dados de sessão Lists Filas, feeds de atividade recente Sets Tags, relacionamentos únicos Sorted Sets Leaderboards, ranking com score Streams Log de eventos com consumer groups Cada estrutura vem com comandos otimizados que operam diretamente nos dados. Isso é a chave: em vez de buscar tudo e processar na sua aplicação, você delega a operação ao Redis. A diferença entre ZRANGE leaderboard 0 9 REV WITHSCORES e buscar 10.000 linhas e ordenar no código é drástica em escala. A maioria das pessoas assume que Redis é rápido "porque usa RAM". Isso é verdade, mas incompleto. O ganho real está em evitar round-trips desnecessários ao banco. Mesmo em redes locais de baixa latência, cada chamada TCP ao banco custa entre 0.5ms e 5ms. Com 500 usuários simultâneos fazendo 10 requests por segundo cada, você está fazendo 5.000 chamadas/segundo ao banco, sendo que boa parte delas responde a mesma pergunta. Redis elimina essa camada de comunicação para os dados mais acessados. O hit em cache retorna em microssegundos não só porque está em RAM, mas porque está na mesma máquina, sem handshake TCP, sem parser de query, sem plano de execução. Redis usa um event loop de thread única, um comando executa de cada vez. Isso parece limitante. Na prática, é uma escolha deliberada e que funciona bem para workloads típicos: sem locks, sem contenção, operações atomicamente previsíveis. Em benchmarks, o Redis processa centenas de milhares de operações por segundo em hardware comum porque cada operação é pequena, previsível e não bloqueia outras. O modelo escala bem enquanto os comandos são simples e rápidos, que é o caso da esmagadora maioria dos usos reais de Redis. Onde isso vira problema: A partir do Redis 6.0, o I/O de rede passou a ser multi-threaded, mas a execução dos comandos continua single-threaded. Comandos lentos como KEYS *, SORT sem LIMIT, ou LRANGE em listas muito grandes bloqueiam todo o servidor enquanto executam. Um único comando mal escrito pode degradar o sistema inteiro. É por isso que KEYS * em produção é considerado um erro grave. Redis é in-memory, mas pode persistir dados de duas formas: RDB (Redis Database): Snapshots periódicos. Mais rápido, mas você pode perder as escritas desde o último snapshot. AOF (Append-Only File): Registra cada operação de escrita. Mais durável, mas gera arquivos grandes e pode impactar latência dependendo do fsync configurado (always, everysec, no). Você pode usar ambos simultaneamente. A configuração padrão (appendfsync everysec) aceita perder até 1 segundo de dados em caso de crash, aceitável para cache, problemático para dados financeiros. O caso de uso mais comum. A aplicação verifica o Redis primeiro; em um hit, os dados retornam em microssegundos; em um miss, a aplicação consulta o banco e popula o Redis. Em .NET, a abstração mais portável é IDistributedCache: public class ProductService { private readonly IDistributedCache _cache; private readonly IProductRepository _repository; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); public ProductService(IDistributedCache cache, IProductRepository repository) { _cache = cache; _repository = repository; } public async Task GetByIdAsync(int id) { var cacheKey = $"product:{id}"; var cached = await _cache.GetStringAsync(cacheKey); if (cached is not null) return JsonSerializer.Deserialize(cached); var product = await _repository.GetByIdAsync(id); if (product is null) return null; await _cache.SetStringAsync( cacheKey, JsonSerializer.Serialize(product), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheDuration }); return product; } } Configuração no Program.cs: builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("Redis"); options.InstanceName = "myapp:"; }); Quando isso funciona bem: Para dados lidos muito mais frequentemente do que mudam. A chave é ter um TTL razoável e aceitar que usuários diferentes podem ver versões ligeiramente diferentes do dado durante a janela de cache. Redis torna rate limiting simples através de operações atômicas. Com StackExchange.Redis: public class RedisRateLimiter { private readonly IDatabase _db; public RedisRateLimiter(IConnectionMultiplexer redis) { _db = redis.GetDatabase(); } public async Task IsAllowedAsync(string userId, int maxRequests, TimeSpan window) { var key = $"rate_limit:{userId}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds() / (long)window.TotalSeconds}"; var current = await _db.StringIncrementAsync(key); if (current == 1) await _db.KeyExpireAsync(key, window); return current { options.Configuration = "localhost:6379"; }); builder.Services.AddSession(options => { options.IdleTimeout = TimeSpan.FromMinutes(30); options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; }); Redis tem Pub/Sub built-in: // Publisher var sub = multiplexer.GetSubscriber(); await sub.PublishAsync("order:created", JsonSerializer.Serialize(order)); // Subscriber await sub.SubscribeAsync("order:created", (channel, message) => { var order = JsonSerializer.Deserialize(message!); // processar pedido }); Pub/Sub é simples e eficiente, mas tem uma limitação fundamental: mensagens são fire-and-forget. Se nenhum subscriber estiver ouvindo no momento do publish, a mensagem é perdida para sempre. Não há histórico, não há replay, não há garantia de entrega. Para workloads onde isso importa, Redis Streams é a alternativa correta. A diferença conceitual é significativa: Pub/Sub Streams Persistência Nenhuma — mensagem some após entrega Persistida no log até você deletar Consumer groups Não — todos recebem tudo Sim — múltiplos consumers dividem as mensagens Replay Impossível Sim — leitura por offset ou timestamp Pending entries Não existe Sim — mensagens entregues mas não confirmadas ficam no PEL Caso de uso Notificações em tempo real, broadcast Pipelines de eventos, filas duráveis Com Streams, um consumer group garante que cada mensagem seja processada por exatamente um consumer do grupo. Se o consumer falha antes de confirmar (XACK), a mensagem fica no Pending Entries List (PEL) e pode ser reclamada e reprocessada. Isso não existe no Pub/Sub. Resumindo: use Pub/Sub para broadcast em tempo real onde perder mensagens é tolerável. Use Streams quando você precisa de garantias de entrega, múltiplos consumers independentes ou auditoria de eventos. Benefício Trade-off Velocidade Microssegundos de latência, sem round-trips ao banco RAM é cara por GB; inviável para datasets grandes e frios Simplicidade API rica e intuitiva Operações relacionais e agregações complexas não existem Flexibilidade Múltiplos data structures otimizados Modelagem exige pensar diferente de um banco relacional Durabilidade Configurável (RDB + AOF) Por padrão, risco de perda de dados em crash Operação Cloud-managed disponível Cluster mode tem complexidade; resharding não é trivial Esta é a parte que a maioria dos artigos ignora. Redis em produção tem problemas específicos que se manifestam sob carga real. Imagine 10.000 usuários acessando simultaneamente um dado cujo cache acabou de expirar. Todos consultam o banco ao mesmo tempo. O banco sofre um spike de carga. Paradoxalmente, o cache que deveria proteger o banco o derruba no exato momento em que expira. Como mitigar: 1. Probabilistic Early Expiration (PER): Antes do TTL expirar, alguns processos já começam a renovar o cache de forma probabilística: public async Task GetWithEarlyRenewalAsync( string key, Func> factory, TimeSpan ttl, double beta = 1.0) { var db = _redis.GetDatabase(); var raw = await db.StringGetWithExpiryAsync(key); if (raw.Value.HasValue) { var remaining = raw.Expiry ?? TimeSpan.Zero; var recomputeTime = EstimatedRecomputeTime; // medir em produção // Decide probabilisticamente se renova antes de expirar var shouldRenew = -recomputeTime.TotalSeconds * beta * Math.Log(Random.Shared.NextDouble()) >= remaining.TotalSeconds; if (!shouldRenew) return JsonSerializer.Deserialize(raw.Value!); } var result = await factory(); await db.StringSetAsync(key, JsonSerializer.Serialize(result), ttl); return result; } 2. Mutex/Lock no miss com ownership: Apenas um processo refaz a query; os outros aguardam ou retornam o dado stale temporariamente. Mas atenção ao bug clássico desta abordagem: se o processo que adquiriu o lock demorar mais do que o TTL do lock para terminar, o lock expira, outro processo o adquire, e o processo original — ao finalizar — deleta o lock do processo errado, criando uma race condition silenciosa. A solução é usar um token de ownership: só quem criou o lock pode deletá-lo. var lockKey = $"lock:{cacheKey}"; var lockToken = Guid.NewGuid().ToString(); // token único por processo var lockAcquired = await _db.StringSetAsync( lockKey, lockToken, TimeSpan.FromSeconds(5), When.NotExists); if (lockAcquired) { try { var fresh = await _repository.GetByIdAsync(id); await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(fresh), options); return fresh; } finally { // Deleta APENAS se o token ainda é o nosso — operação atômica via Lua const string releaseLua = @" if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; await _db.ScriptEvaluateAsync(releaseLua, [(RedisKey)lockKey], [lockToken]); } } else { await Task.Delay(50); var retry = await _cache.GetStringAsync(cacheKey); return retry is not null ? JsonSerializer.Deserialize(retry) : null; } Para distributed locking em ambientes com múltiplas instâncias Redis (cluster ou sentinel), o algoritmo RedLock oferece garantias mais fortes, adquirindo o lock em N instâncias independentes. A biblioteca RedLock.net implementa isso para .NET. Para a maioria dos casos com uma instância Redis, o padrão acima com token é suficiente. Quando a memória do Redis está cheia e maxmemory está configurado, o Redis precisa remover chaves. A política padrão (noeviction) retorna erros de escrita — o que derruba a aplicação silenciosamente se não for tratado. As políticas mais comuns: Política Comportamento noeviction Erros nas escritas quando memória cheia. Seguro para filas. allkeys-lru Remove as chaves menos recentemente usadas. Boa para cache puro. volatile-lru LRU apenas em chaves com TTL definido. allkeys-random Remove aleatoriamente. Raramente é o que você quer. volatile-ttl Remove as chaves com TTL mais curto primeiro. Erro comum: usar Redis para cache e para dados persistentes (sessões, filas) na mesma instância sem separar políticas de eviction. Quando a memória enche, o Redis pode descartar uma sessão ativa ou uma mensagem de fila. Use instâncias separadas ou, no mínimo, chaves com TTL apenas no que pode ser descartado. Em uma instância Redis única, toda a carga vai para o mesmo processo — e ele aguenta bem. Em cluster mode, as chaves são distribuídas entre shards por hash slot. O problema: se uma chave específica é acessada muito mais do que as outras (uma página inicial, um produto em promoção, uma configuração global), ela concentra carga em um único shard enquanto os outros ficam ociosos. Isso anula o benefício de escala horizontal do cluster. Como detectar: o comando redis-cli --hotkeys (disponível com maxmemory-policy configurada) ou monitoramento via MONITOR em ambiente de staging. Como mitigar: Key sharding local: criar múltiplas chaves com sufixos (config:app:1, config:app:2, ...) e distribuir as leituras entre elas. Client-side caching: Redis 6.0 introduziu client-side caching via o protocolo CLIENT TRACKING, permitindo que o cliente mantenha uma cópia local e invalide apenas quando o servidor notifica mudança — eliminando o round-trip para hot keys. Cache em memória local: para dados imutáveis ou de baixíssima variação, um IMemoryCache na aplicação serve como primeira camada antes do Redis. Redis aloca e libera memória constantemente. Com o tempo, a fragmentação cresce: o processo ocupa mais RAM do que os dados realmente precisam. O indicador é o mem_fragmentation_ratio no INFO memory. Valores acima de 1.5 indicam fragmentação significativa. Acima de 2.0, você provavelmente precisa de um restart controlado ou de habilitar activedefrag (disponível desde Redis 4.0): activedefrag yes active-defrag-ignore-bytes 100mb active-defrag-threshold-lower 10 KEYS * bloqueia o event loop single-threaded enquanto escaneia todo o keyspace. Em instâncias com milhões de chaves, isso pode causar segundos de indisponibilidade. Nunca use KEYS * em produção. Use SCAN com cursor: var cursor = 0L; var pattern = "product:*"; do { var result = await _db.ExecuteAsync("SCAN", cursor.ToString(), "MATCH", pattern, "COUNT", "100"); var array = (RedisResult[])result!; cursor = (long)array[0]; var keys = (RedisResult[])array[1]; foreach (var key in keys) Console.WriteLine(key); } while (cursor != 0); SCAN não garante ausência de duplicatas, mas não bloqueia o servidor — e é iterativo, o que permite processar keyspaces enormes sem travar nada. Redis falha de formas silenciosas: evicta dados sem avisar, fragmenta memória gradualmente, acumula latência em comandos específicos. Sem observabilidade, você descobre o problema quando o usuário reclama. As métricas essenciais do INFO para monitorar: Métrica O que indica evicted_keys Chaves removidas por pressão de memória. Zero é o ideal; qualquer valor crescente é alerta. used_memory_rss vs used_memory A diferença é a fragmentação. Se rss for muito maior que used_memory, você tem fragmentação. instantaneous_ops_per_sec Throughput atual. Útil para correlacionar com picos de latência. latency (via LATENCY HISTORY) Latência por evento. Detecta comandos lentos específicos. connected_clients Pico de conexões pode indicar connection pool mal configurado. keyspace_hits vs keyspace_misses A taxa de hit é o indicador mais direto de eficiência do cache. Em ambientes cloud (ElastiCache, Azure Cache for Redis), essas métricas são exportadas para CloudWatch/Azure Monitor nativamente. Em ambientes self-hosted, ferramentas como Redis Exporter + Prometheus + Grafana são o padrão de mercado. Redis não é a ferramenta certa quando: Dataset grande e frio: RAM custa muito por GB. Use um banco em disco e faça cache apenas do subconjunto quente. Queries relacionais e agregações complexas: "Todos os usuários da Região A que compraram o Produto B nos últimos 30 dias" exige um banco relacional com suporte a joins e índices compostos. Redis não é otimizado para esse tipo de acesso, ele recupera por chave, não por predicado sobre os dados. Durabilidade forte: Se perder segundos de escrita é inaceitável (transações financeiras, registros médicos), o modelo de persistência do Redis exige configuração cuidadosa, e um banco ACID-compliant é a opção mais segura. Workload stateless e simples: Para sistemas sem cache, sem sessões e sem mensageria, adicionar Redis introduz complexidade operacional sem benefício proporcional. Em arquiteturas de microsserviços, o Redis assume um segundo papel como datastore compartilhado rápido: um session store central que todos os frontends consultam, um rate limiter que todos os API gateways respeitam, ou um message broker leve que conecta serviços sem o overhead de Kafka ou RabbitMQ. Provedores cloud oferecem Redis gerenciado (AWS ElastiCache, Azure Cache for Redis, Google Cloud Memorystore) que cuidam de provisionamento, scaling e failover. Isso facilita inserir Redis numa stack existente sem gerenciar infraestrutura diretamente. Uma alternativa que ganhou tração recentemente é o Garnet, lançado pela Microsoft em 2024 como projeto open source. É compatível com o protocolo RESP do Redis, escrito em C#, e apresenta throughput superior em alguns benchmarks específicos de .NET. Ainda é jovem e não tem o ecossistema do Redis, mas vale acompanhar se você roda .NET. Redis é um multiplicador de performance, não um substituto de banco de dados. Ele não tenta resolver tudo. Resolve um conjunto estreito de problemas com precisão: caching, sessões, rate limiting, mensageria leve. E é exatamente por isso que está em todo lugar. Mas há uma armadilha real: Redis é fácil de adicionar e difícil de operar bem. Um cache sem política de eviction bem definida vira um ponto cego. Um Pub/Sub sem tratamento de mensagens perdidas vira um bug silencioso. Um distributed lock sem token de ownership vira uma race condition esperando para acontecer. Um keyspace mal modelado vira um problema de memória que aparece às 2h da manhã. Usado corretamente, Redis transforma caminhos lentos em rápidos e sistemas frágeis em responsivos. Usado sem cuidado, vira uma abstração cara e com vazamentos. A diferença está em entender não só o que ele faz bem, mas onde ele mente para você. Gostou? Deixa um comentário com o padrão que você usa Redis no seu stack. E se você já teve um cache stampede em produção, conta como resolveu, as histórias de guerra são sempre as mais úteis.
