Executando verificação de segurança...
1

Redis: Big Keys Destroem o Desempenho Compartilhado

Introdução#

Se você usa Redis em um ambiente compartilhado — seja uma instância usada por múltiplos microserviços, múltiplos times ou múltiplos tenants — uma única chave grande pode degradar o desempenho de todos os outros consumidores ao mesmo tempo. Não é exagero: é como o Redis funciona internamente, e ignorar esse comportamento em produção custa caro.

O problema das chaves grandes (big keys) no Redis existe porque o servidor é single-threaded para operações de dados. Quando um comando acessa ou manipula uma chave que contém centenas de megabytes — seja uma lista com 500 mil elementos, um hash com 200 mil campos ou um string binário de 50 MB — o event loop inteiro fica bloqueado processando essa operação. Todos os outros clientes, de outros serviços completamente diferentes, ficam esperando na fila. Em ambientes compartilhados isso é fatal.

Neste artigo vou mostrar o que caracteriza uma chave grande no Redis, por que o impacto é amplificado em ambientes multi-tenant, como detectar big keys em produção, e as estratégias concretas para eliminar ou mitigar o problema — com exemplos em C# usando StackExchange.Redis.

O que é uma “Big Key” no Redis#

O Redis não tem um limite fixo que define quando uma chave é “grande”. A comunidade e a documentação oficial usam o termo big key para descrever chaves cujo custo de processamento ou memória é desproporcional em relação ao workload geral da instância.

Na prática, os limites que costumam surgir como referência são:

Tipo de DadoConsiderado “grande” a partir de
String / Binary> 5 MB
List> 10.000 elementos ou > 10 MB
Hash> 10.000 campos
Set> 10.000 membros
Sorted Set> 10.000 membros
Stream> 10.000 mensagens pendentes

Esses números não são absolutos — dependem do hardware, da versão do Redis e do padrão de acesso. Mas são referências sólidas para alertas de monitoramento.

ℹ️ Informação: O Redis 7.0+ introduziu melhorias nas estruturas de dados internas (como Listpack substituindo ZipList), o que mudou ligeiramente os limiares de quando estruturas “promovem” para representações maiores. O conceito de big key, no entanto, permanece o mesmo.

Diferença entre chave grande e chave quente#

É comum confundir big key com hot key. São problemas diferentes:

  • Big key: chave com muito volume de dados — o problema é o custo de CPU/memória para processar o dado.
  • Hot key: chave acessada com altíssima frequência — o problema é a contenção no slot de hash (relevante em Redis Cluster).
    Um artigo pode ter ambos os problemas ao mesmo tempo, mas as soluções são distintas. Este artigo foca em big keys.

Como o Redis Processa Comandos (e por que isso importa)#

Para entender o impacto de big keys, é preciso entender como o Redis executa comandos. O Redis usa um event loop single-threaded para processar operações de dados. Isso significa:

  • O servidor recebe um comando de um cliente.
  • O event loop executa o comando até o fim antes de processar qualquer outro.
  • Somente depois o próximo comando da fila é processado.
    Essa arquitetura é uma das razões pelo qual o Redis é tão rápido para operações simples: sem locks, sem context-switching entre threads. O custo é que uma operação lenta bloqueia tudo.
Cliente A: GET user:123          → resposta em 0,1ms ✅
Cliente B: LRANGE big-list 0 -1  → 2.3s processando 500k elementos ⚠️
Cliente C: SET config:timeout 30  → aguardando na fila... ⌛
Cliente D: GET session:abc123     → aguardando na fila... ⌛

⚠️ Atenção: Comandos como LRANGE key 0 -1, SMEMBERS key, HGETALL key e KEYS * são O(N) — o custo cresce linearmente com o tamanho da estrutura. Em uma chave com 500 mil elementos, isso pode levar segundos.

O modelo de I/O do Redis não é bloqueante — mas o processamento é#

É importante separar dois conceitos:

  • I/O: o Redis usa multiplexação de I/O (epoll/kqueue), então aceitar conexões e ler dados de rede é não bloqueante.
  • Processamento de comandos: a execução do comando em si é single-threaded e bloqueante para os outros clientes.
    O Redis 6.0 introduziu threads de I/O para leitura e escrita de rede, mas a execução dos comandos ainda é single-threaded no thread principal. O problema das big keys não foi resolvido por essa mudança.

Impacto em Ambientes Compartilhados#

Em um ambiente onde uma única instância Redis serve múltiplos microserviços ou múltiplos tenants de um SaaS, o impacto de uma big key é amplificado porque:

1. A latência afeta todos, não só o culpado

Se o serviço de relatórios lê uma chave com 300 mil registros toda hora, isso introduz picos de latência para o serviço de autenticação, o serviço de notificações e qualquer outro que compartilhe a mesma instância — mesmo que esses serviços estejam funcionando perfeitamente.

2. O consumo de memória é disputado

Redis mantém todos os dados em memória. Uma big key que ocupa 500 MB comprime o espaço disponível para todos os outros tenants. Quando o Redis atinge maxmemory, inicia a política de eviction (expulsão de chaves), que pode descartar dados críticos de outros serviços.

3. Operações de snapshot e replicação ficam mais lentas

Quando o Redis gera um RDB snapshot (persistência) ou replica dados para um replica, big keys aumentam o tempo do fork() e o tamanho do arquivo. Em instâncias cloud (AWS ElastiCache, Azure Cache for Redis), isso pode causar falhas de replicação ou penalidades de performance durante a sincronização.

4. Dificuldade de debug e rastreabilidade

Em um ambiente compartilhado, identificar quem criou a big key e qual serviço é o responsável exige instrumentação cuidadosa. Sem namespacing adequado nas chaves, rastrear a origem do problema pode levar horas.

Como Detectar Big Keys em Produção#

O Redis oferece ferramentas nativas para detectar big keys. Veja as principais abordagens:

redis-cli –bigkeys#

O comando mais simples para uma varredura inicial:

# Varredura completa — use com cuidado em produção (pode ser lenta)
redis-cli --bigkeys

# Com host e porta específicos
redis-cli -h redis.example.com -p 6379 --bigkeys

A saída mostra as maiores chaves por tipo de dado. O problema é que esse comando usa SCAN internamente e pode demorar em instâncias grandes. Em produção, prefira executá-lo em horários de menor carga.

MEMORY USAGE — verificação pontual#

Para checar o tamanho de uma chave específica:

# Retorna o tamanho em bytes, incluindo overhead de metadados
MEMORY USAGE minha-chave

# Com nested sampling (para estruturas complexas)
MEMORY USAGE minha-chave SAMPLES 10

SCAN + MEMORY USAGE — varredura automatizada em C##

Para monitoramento programático com StackExchange.Redis:

public class RedisBigKeyScanner
{
    private const long LimiarBigKeyBytes = 10 * 1024 * 1024; // 10 MB
    private readonly IDatabase _db;
    private readonly IServer _server;
    private readonly ILogger<RedisBigKeyScanner> _logger;

    public RedisBigKeyScanner(IConnectionMultiplexer redis, ILogger<RedisBigKeyScanner> logger)
    {
        _db = redis.GetDatabase();
        _server = redis.GetServer(redis.GetEndPoints().First());
        _logger = logger;
    }

    public async Task VarrerBigKeysAsync(string padrao = "*", int tamanhoPagina = 100,
        CancellationToken ct = default)
    {
        // SCAN é não bloqueante — itera em lotes (tamanhoPagina por vez)
        await foreach (var key in _server.KeysAsync(pattern: padrao, pageSize: tamanhoPagina)
                                         .WithCancellation(ct))
        {
            var sizeBytes = await _db.ExecuteAsync("MEMORY", "USAGE", key, "SAMPLES", "5");
            if (sizeBytes.IsNull) continue;

            var size = (long)sizeBytes;
            if (size >= LimiarBigKeyBytes)
                _logger.LogWarning("Big key: {Key} | {SizeMB:F2} MB", key, size / (1024.0 * 1024.0));
        }
    }
}

📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog:
BlogSamples/Cache/Redis/

📝 Exemplo: Em um ambiente com 2 milhões de chaves, uma varredura com pageSize = 500 e pattern = "relatorio:*" limita o escopo apenas ao namespace problemático, reduzindo o tempo de varredura de horas para minutos.

Redis Latency Monitoring#

Para detectar o impacto em tempo real, ative o monitoramento de latência:

# Ativar no redis.conf ou via CONFIG SET
CONFIG SET latency-monitor-threshold 100

# Ver eventos de latência
LATENCY LATEST
LATENCY HISTORY command

Estratégias para Evitar Big Keys#

Detectar o problema é o primeiro passo. Resolver é mais interessante. Existem quatro abordagens principais:

1. Fragmentação de dados (Sharding)#

Em vez de uma única chave grande, distribuir os dados em múltiplas chaves menores usando um campo de particionamento:

public class RedisHashSharded
{
    private readonly IDatabase _db;
    private const int QuantidadeShards = 16; // potência de 2 para distribuição uniforme

    public RedisHashSharded(IConnectionMultiplexer redis) => _db = redis.GetDatabase();

    private string ObterChaveShard(string chaveBase, string campo)
    {
        var indiceShard = Math.Abs(campo.GetHashCode()) % QuantidadeShards;
        return $"{chaveBase}:shard:{indiceShard}";
    }

    public async Task GravarAsync(string chaveBase, string campo, string valor)
    {
        var chaveShard = ObterChaveShard(chaveBase, campo);
        await _db.HashSetAsync(chaveShard, campo, valor);
    }

    public async Task<string?> LerAsync(string chaveBase, string campo)
    {
        var chaveShard = ObterChaveShard(chaveBase, campo);
        var resultado = await _db.HashGetAsync(chaveShard, campo);
        return resultado.HasValue ? resultado.ToString() : null;
    }

    // LerTodosAsync paralleliza a leitura dos QuantidadeShards shards — código completo no repositório
}

2. Serialização eficiente com compressão#

Um dos maiores culpados por strings grandes é a serialização ingênua de objetos complexos. Trocar JSON por um formato binário + compressão reduz dramaticamente o tamanho:

public static class RedisSerializerExtensions
{
    public static async Task GravarComprimidoAsync<T>(
        this IDatabase db, string chave, T valor, TimeSpan? expiracao = null)
    {
        using var stream = new MemoryStream();
        await using (var gzip = new GZipStream(stream, CompressionLevel.Optimal))
            await JsonSerializer.SerializeAsync(gzip, valor);

        await db.StringSetAsync(chave, stream.ToArray(), expiracao);
    }

    public static async Task<T?> LerComprimidoAsync<T>(this IDatabase db, string chave)
    {
        var valor = await db.StringGetAsync(chave);
        if (!valor.HasValue) return default;

        using var stream = new MemoryStream((byte[])valor!);
        await using var gzip = new GZipStream(stream, CompressionMode.Decompress);
        return await JsonSerializer.DeserializeAsync<T>(gzip);
    }
}

💡 Dica: Compressão GZip típica reduz payloads JSON em 60–80%. Um objeto de 5 MB pode cair para 800 KB — o que muda a classificação de “big key” para um tamanho gerenciável. Mas atenção: compressão tem custo de CPU, avalie o trade-off para dados acessados com alta frequência.

3. TTL obrigatório para chaves de cache#

Chaves sem TTL em ambientes compartilhados tendem a acumular dados indefinidamente. Implemente uma política de TTL obrigatória:

public class RedisCacheComPoliticaTtl
{
    private readonly IDatabase _db;
    private static readonly TimeSpan TtlPadrao = TimeSpan.FromHours(1);
    private static readonly TimeSpan TtlMaximo = TimeSpan.FromHours(24);

    public RedisCacheComPoliticaTtl(IConnectionMultiplexer redis)
        => _db = redis.GetDatabase();

    public async Task GravarAsync(string chave, string valor, TimeSpan? ttl = null)
    {
        // Nunca permite TTL nulo — usa o padrão se não especificado
        var ttlEfetivo = ttl.HasValue
            ? TimeSpan.FromTicks(Math.Min(ttl.Value.Ticks, TtlMaximo.Ticks))
            : TtlPadrao;

        await _db.StringSetAsync(chave, valor, ttlEfetivo);
    }
}

4. Namespacing e segregação por tenant#

Em ambientes multi-tenant, use prefixos obrigatórios para identificar a origem das chaves. Isso facilita o diagnóstico e permite políticas de limpeza seletivas:

// Padrão de chave: {ambiente}:{tenant}:{dominio}:{entidade}:{id}
// Exemplo: prod:acme-corp:relatorio:mensal:2026-05

public static class RedisKeyBuilder
{
    public static string Construir(
        string tenant,
        string dominio,
        string entidade,
        string id,
        string ambiente = "prod")
    {
        return $"{ambiente}:{Sanitizar(tenant)}:{Sanitizar(dominio)}:{Sanitizar(entidade)}:{id}";
    }

    private static string Sanitizar(string segmento) =>
        segmento.ToLowerInvariant().Replace(" ", "-").Replace(":", "");
}

Exemplo Prático#

Vou mostrar um cenário real: um serviço de relatórios que carregava todos os registros de transações do mês em uma única chave Redis para “cache” — e estava derrubando o desempenho dos outros serviços no mesmo cluster.

O problema original#

// ❌ CÓDIGO PROBLEMÁTICO — NÃO FAÇA ISSO
public async Task<List<Transacao>> ObterTransacoesMensaisAsync(
    int clienteId,
    int mes,
    int ano)
{
    var cacheKey = $"transacoes:{clienteId}:{mes}/{ano}";

    var cached = await _cache.StringGetAsync(cacheKey);
    if (cached.HasValue)
    {
        // Desserializa 300.000 transações em memória de uma vez
        return JsonSerializer.Deserialize<List<Transacao>>(cached!)!;
    }

    // Busca 300.000 registros do banco
    var transacoes = await _db.Transacoes
        .Where(t => t.ClienteId == clienteId
                 && t.Data.Month == mes
                 && t.Data.Year == ano)
        .ToListAsync();

    // Serializa 300.000 objetos para um JSON de ~80 MB e salva no Redis
    // Sem TTL → fica para sempre
    await _cache.StringSetAsync(
        cacheKey,
        JsonSerializer.Serialize(transacoes));

    return transacoes;
}

Esse código cria uma chave que pode ter 80 MB no Redis, sem TTL, e é lida inteira a cada acesso. Cada leitura bloqueia o event loop por centenas de milissegundos.

A solução refatorada#

// ✅ SOLUÇÃO CORRETA — Cache paginado com TTL obrigatório

public class TransacaoCache
{
    private readonly IDatabase _redis;
    private const int TamanhoPagina = 100;
    private const string PrefixoChave = "tx";

    public TransacaoCache(IConnectionMultiplexer redis) => _redis = redis.GetDatabase();

    public async Task GravarPaginaAsync(int clienteId, int mes, int ano, int pagina,
        IEnumerable<Transacao> transacoes)
    {
        var chave = ConstruirChavePagina(clienteId, mes, ano, pagina);
        var json = JsonSerializer.Serialize(transacoes.Take(TamanhoPagina));
        // TTL de 30 minutos — dado de relatório, não precisa ser eterno
        await _redis.StringSetAsync(chave, json, TimeSpan.FromMinutes(30));
    }

    public async Task<List<Transacao>?> ObterPaginaAsync(int clienteId, int mes, int ano, int pagina)
    {
        var chave = ConstruirChavePagina(clienteId, mes, ano, pagina);
        var cache = await _redis.StringGetAsync(chave);
        return cache.HasValue ? JsonSerializer.Deserialize<List<Transacao>>((string)cache!) : null;
    }

    // Cada chave contém apenas TamanhoPagina transações (~30 KB)
    private static string ConstruirChavePagina(int clienteId, int mes, int ano, int pagina) =>
        $"{PrefixoChave}:{clienteId}:{mes}-{ano}:p{pagina}";
}

Com essa abordagem, cada chave passa de 80 MB para ~30 KB. O event loop é liberado em microssegundos, e os outros serviços compartilhados deixam de sofrer latência por conta do serviço de relatórios.


📖 Artigo completo com exemplos de código: Redis: Big Keys Destroem o Desempenho Compartilhado

Carregando publicação patrocinada...