DEV Community

Cover image for Redis além do tutorial. Com os problemas que ninguém te conta
Milton Camara
Milton Camara

Posted on

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.


Como o Redis Funciona Por Dentro

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.

Por que RAM Não é o Único Motivo

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.

O Event Loop Single-Threaded

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.

Persistência: Você Escolhe o Risco

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.


Onde o Redis Realmente Brilha (Com Código)

1. Caching - O Padrão Cache-Aside

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<Product?> GetByIdAsync(int id)
    {
        var cacheKey = $"product:{id}";

        var cached = await _cache.GetStringAsync(cacheKey);
        if (cached is not null)
            return JsonSerializer.Deserialize<Product>(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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Configuração no Program.cs:

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "myapp:";
});
Enter fullscreen mode Exit fullscreen mode

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.


2. Rate Limiting — Atomicidade é Tudo

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<bool> 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 <= maxRequests;
    }
}
Enter fullscreen mode Exit fullscreen mode

Este padrão usa uma janela fixa (fixed window). É simples, mas tem um edge case: um usuário pode fazer o dobro de requisições na virada da janela. Para uso mais preciso, o padrão sliding window usa Sorted Sets, cada requisição é inserida com timestamp como score, e você remove as entradas fora da janela antes de contar.


3. Session Storage

Sessions precisam de leituras e escritas rápidas em cada requisição. Redis se encaixa porque:

  • Latência baixa sem delay por request
  • TTL nativo, sessões expiram automaticamente sem cleanup jobs
  • Alta throughput para bases de usuários grandes

No ASP.NET Core, a configuração é direta:

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});

builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(30);
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});
Enter fullscreen mode Exit fullscreen mode

4. Pub/Sub vs. Streams — Saiba a Diferença

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<Order>(message!);
    // processar pedido
});
Enter fullscreen mode Exit fullscreen mode

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ícios vs. Trade-offs

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

As Armadilhas Que Aparecem em Produção

Esta é a parte que a maioria dos artigos ignora. Redis em produção tem problemas específicos que se manifestam sob carga real.

Cache Stampede (Thundering Herd)

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<T?> GetWithEarlyRenewalAsync<T>(
    string key,
    Func<Task<T>> 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<T>(raw.Value!);
    }

    var result = await factory();
    await db.StringSetAsync(key, JsonSerializer.Serialize(result), ttl);
    return result;
}
Enter fullscreen mode Exit fullscreen mode

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<Product>(retry) : null;
}
Enter fullscreen mode Exit fullscreen mode

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.


Key Eviction: O Redis Descarta Dados Silenciosamente

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.


Hot Keys em Cluster Mode

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.

Memory Fragmentation

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
Enter fullscreen mode Exit fullscreen mode

O Problema do KEYS * em Produção

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);
Enter fullscreen mode Exit fullscreen mode

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.


O que Monitorar em Produção

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.


Quando Não Usar Redis

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.

Redis no Stack Real

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.


Conclusão

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.

Top comments (0)