DEV Community

Cristiano Rodrigues for Unhacked

Posted on

Redis INCR: O comando atômico que você deveria conhecer

Em sistemas distribuídos, contar algo corretamente é mais difícil do que parece. Dois processos lendo e atualizando o mesmo valor podem facilmente gerar race conditions e inconsistências.

É exatamente aqui que o comando INCR do Redis brilha.

O INCR resolve 3 problemas clássicos:

  • contadores distribuídos
  • rate limiting
  • geração de IDs

Vamos entender por que esse comando aparentemente simples é tão poderoso.

O que é o comando INCR?

O INCR incrementa o valor numérico armazenado em uma chave por 1. Se a chave não existir, ela é criada com valor 0 antes do incremento, resultando em 1.

> SET contador 10
OK
> INCR contador
(integer) 11
> INCR contador
(integer) 12
Enter fullscreen mode Exit fullscreen mode

Se a chave não existe:

> INCR novo_contador
(integer) 1
Enter fullscreen mode Exit fullscreen mode

A família completa de comandos de incremento inclui:

Comando Descrição
INCR Incrementa por 1
INCRBY Incrementa por um valor inteiro específico
INCRBYFLOAT Incrementa por um valor decimal
DECR Decrementa por 1
DECRBY Decrementa por um valor inteiro específico

Por que o INCR é especial?

A característica mais importante do INCR é sua atomicidade. Em sistemas distribuídos, onde múltiplas instâncias da aplicação podem tentar modificar o mesmo valor simultaneamente, a atomicidade garante que cada operação seja executada de forma isolada.

A atomicidade do INCR é possível porque o Redis executa comandos de forma sequencial em um único thread. Isso significa que não existe concorrência interna entre comandos: cada operação é processada por completo antes da próxima iniciar. Essa característica de single-thread para execução de comandos é o que torna operações como INCR naturalmente seguras em ambientes concorrentes.

Considere o cenário clássico de race condition:

Tempo    Processo A          Processo B          Valor Real
─────────────────────────────────────────────────────────────
T1       Lê valor: 10                            10
T2                           Lê valor: 10        10
T3       Calcula: 10 + 1                         10
T4                           Calcula: 10 + 1     10
T5       Escreve: 11                             11
T6                           Escreve: 11         11  ← Perdemos um incremento!
Enter fullscreen mode Exit fullscreen mode

Com INCR, o Redis garante que isso não acontece. A operação de leitura-incremento-escrita é executada como uma única unidade atômica.

Casos de Uso Práticos

1. Contadores de visualização

Um dos casos mais comuns é contar visualizações de páginas ou recursos:

public class ViewCounterService
{
    private readonly IDatabase _redis;
    private readonly TimeSpan _cacheExpiry = TimeSpan.FromHours(24);

    public ViewCounterService(IConnectionMultiplexer connection)
    {
        _redis = connection.GetDatabase();
    }

    public async Task<long> IncrementViewCountAsync(string resourceId)
    {
        var key = $"views:{resourceId}";
        var count = await _redis.StringIncrementAsync(key);

        // Define TTL apenas na primeira visualização
        if (count == 1)
        {
            await _redis.KeyExpireAsync(key, _cacheExpiry);
        }

        return count;
    }

    public async Task<long> GetViewCountAsync(string resourceId)
    {
        var key = $"views:{resourceId}";
        var value = await _redis.StringGetAsync(key);
        return value.HasValue ? (long)value : 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Rate Limiting com Fixed Window

O rate limiting é essencial para proteger APIs contra abusos. O INCR combinado com TTL cria um rate limiter baseado em fixed window, simples e eficiente:

public class RateLimiter
{
    private readonly IDatabase _redis;

    public RateLimiter(IConnectionMultiplexer connection)
    {
        _redis = connection.GetDatabase();
    }

    public async Task<RateLimitResult> CheckRateLimitAsync(
        string clientId, 
        int maxRequests, 
        TimeSpan window)
    {
        var key = $"ratelimit:{clientId}:{DateTime.UtcNow.Ticks / window.Ticks}";

        var count = await _redis.StringIncrementAsync(key);

        if (count == 1)
        {
            await _redis.KeyExpireAsync(key, window);
        }

        return new RateLimitResult
        {
            IsAllowed = count <= maxRequests,
            CurrentCount = count,
            Limit = maxRequests,
            RetryAfter = count > maxRequests 
                ? await _redis.KeyTimeToLiveAsync(key) 
                : null
        };
    }
}

public record RateLimitResult
{
    public bool IsAllowed { get; init; }
    public long CurrentCount { get; init; }
    public int Limit { get; init; }
    public TimeSpan? RetryAfter { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Nota: Essa abordagem usa fixed window counter, onde cada janela de tempo tem seu próprio contador. Para cenários que exigem sliding window real, considere usar Sorted Sets com ZADD/ZRANGEBYSCORE ou um algoritmo de token bucket. Além disso, vale observar que o padrão INCR seguido de EXPIRE pode sofrer uma race condition sutil: outro processo pode incrementar a chave entre o INCR e o EXPIRE. Para maior robustez, você pode encapsular ambos em um script Lua, garantindo a execução atômica de INCR + EXPIRE.

Uso em um middleware ASP.NET Core:

public class RateLimitingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly RateLimiter _rateLimiter;

    public RateLimitingMiddleware(RequestDelegate next, RateLimiter rateLimiter)
    {
        _next = next;
        _rateLimiter = rateLimiter;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var clientId = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        var result = await _rateLimiter.CheckRateLimitAsync(
            clientId, 
            maxRequests: 100, 
            window: TimeSpan.FromMinutes(1));

        context.Response.Headers["X-RateLimit-Limit"] = result.Limit.ToString();
        context.Response.Headers["X-RateLimit-Remaining"] = 
            Math.Max(0, result.Limit - result.CurrentCount).ToString();

        if (!result.IsAllowed)
        {
            context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            if (result.RetryAfter.HasValue)
            {
                context.Response.Headers["Retry-After"] = 
                    ((int)result.RetryAfter.Value.TotalSeconds).ToString();
            }
            return;
        }

        await _next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Geração de IDs sequenciais

Quando você precisa de IDs sequenciais em um sistema distribuído:

public class SequentialIdGenerator
{
    private readonly IDatabase _redis;
    private readonly string _prefix;

    public SequentialIdGenerator(IConnectionMultiplexer connection, string prefix = "id")
    {
        _redis = connection.GetDatabase();
        _prefix = prefix;
    }

    public async Task<long> GenerateIdAsync(string entity)
    {
        var key = $"{_prefix}:{entity}:sequence";
        return await _redis.StringIncrementAsync(key);
    }

    public async Task<string> GenerateFormattedIdAsync(string entity, string format = "{0}-{1:D8}")
    {
        var id = await GenerateIdAsync(entity);
        return string.Format(format, entity.ToUpper(), id);
    }
}

// Uso
var generator = new SequentialIdGenerator(connection);
var orderId = await generator.GenerateFormattedIdAsync("order"); 
// Resultado: "ORDER-00000001"
Enter fullscreen mode Exit fullscreen mode

4. Contadores de sessões ativas

Monitorar quantos usuários estão ativos em tempo real:

public class ActiveSessionTracker
{
    private readonly IDatabase _redis;
    private const string ActiveSessionsKey = "sessions:active:count";
    private const string SessionPrefix = "session:";

    public ActiveSessionTracker(IConnectionMultiplexer connection)
    {
        _redis = connection.GetDatabase();
    }

    public async Task<string> StartSessionAsync(string userId)
    {
        var sessionId = Guid.NewGuid().ToString("N");
        var sessionKey = $"{SessionPrefix}{sessionId}";

        var transaction = _redis.CreateTransaction();

        _ = transaction.StringSetAsync(sessionKey, userId, TimeSpan.FromMinutes(30));
        _ = transaction.StringIncrementAsync(ActiveSessionsKey);

        await transaction.ExecuteAsync();

        return sessionId;
    }

    public async Task EndSessionAsync(string sessionId)
    {
        var sessionKey = $"{SessionPrefix}{sessionId}";

        var existed = await _redis.KeyDeleteAsync(sessionKey);

        if (existed)
        {
            await _redis.StringDecrementAsync(ActiveSessionsKey);
        }
    }

    public async Task<long> GetActiveSessionCountAsync()
    {
        var value = await _redis.StringGetAsync(ActiveSessionsKey);
        return value.HasValue ? (long)value : 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

Nota: Nessa abordagem, se uma sessão expirar automaticamente pelo TTL, o contador ActiveSessionsKey não será decrementado, o que pode gerar drift ao longo do tempo. Para maior robustez em produção, considere usar um Sorted Set (ZSET) com timestamps para rastrear sessões ativas, permitindo que você calcule o total real a qualquer momento com ZRANGEBYSCORE, ou implemente um processo periódico de reconciliação do contador.

5. Métricas por intervalo de tempo

Coletar métricas agregadas por minuto/hora/dia:

public class MetricsCollector
{
    private readonly IDatabase _redis;

    public MetricsCollector(IConnectionMultiplexer connection)
    {
        _redis = connection.GetDatabase();
    }

    public async Task RecordMetricAsync(string metricName, long value = 1)
    {
        var now = DateTime.UtcNow;

        var keys = new[]
        {
            $"metrics:{metricName}:minute:{now:yyyyMMddHHmm}",
            $"metrics:{metricName}:hour:{now:yyyyMMddHH}",
            $"metrics:{metricName}:day:{now:yyyyMMdd}"
        };

        var expiries = new[]
        {
            TimeSpan.FromHours(2),
            TimeSpan.FromDays(2),
            TimeSpan.FromDays(60)
        };

        var batch = _redis.CreateBatch();

        for (int i = 0; i < keys.Length; i++)
        {
            var key = keys[i];
            var expiry = expiries[i];

            _ = batch.StringIncrementAsync(key, value);
            _ = batch.KeyExpireAsync(key, expiry, ExpireWhen.HasNoExpiry);
        }

        batch.Execute();
    }

    public async Task<Dictionary<string, long>> GetHourlyMetricsAsync(
        string metricName, 
        DateTime date)
    {
        var results = new Dictionary<string, long>();

        for (int hour = 0; hour < 24; hour++)
        {
            var key = $"metrics:{metricName}:hour:{date:yyyyMMdd}{hour:D2}";
            var value = await _redis.StringGetAsync(key);
            results[$"{hour:D2}:00"] = value.HasValue ? (long)value : 0;
        }

        return results;
    }
}
Enter fullscreen mode Exit fullscreen mode

Sistemas Distribuídos com múltiplos pods

Em ambientes Kubernetes ou qualquer arquitetura com múltiplas instâncias, o Redis INCR se torna ainda mais valioso. Cada pod opera de forma independente, sem conhecimento dos outros, mas todos precisam coordenar acesso a recursos compartilhados.

O Problema do estado compartilhado

Imagine 5 pods processando requisições simultaneamente. Sem coordenação central, cada pod manteria seu próprio contador local:

Pod 1: contador local = 47
Pod 2: contador local = 52
Pod 3: contador local = 38
Pod 4: contador local = 61
Pod 5: contador local = 44
                         ───────
Total real de requisições: 242
Mas cada pod "acha" que processou apenas seu número local
Enter fullscreen mode Exit fullscreen mode

Com Redis como ponto central de coordenação, todos os pods compartilham a mesma visão do estado.

E em Redis Cluster?

Em arquiteturas maiores que utilizam Redis Cluster, o INCR funciona normalmente desde que as chaves relacionadas estejam no mesmo hash slot. O Redis Cluster distribui chaves entre nós usando CRC16, então se você precisa que múltiplas chaves sejam processadas no mesmo nó, utilize hash tags. Por exemplo, {rate}:user:123 e {rate}:user:456 serão direcionadas para o mesmo slot por compartilharem a tag {rate}. Isso é especialmente relevante quando você usa transactions ou scripts Lua que operam sobre múltiplas chaves.

Considerações de Performance

O INCR é extremamente rápido, com complexidade O(1). No entanto, existem algumas práticas para otimizar ainda mais:

Batch de operações: Quando você precisa incrementar múltiplos contadores, use batching:

public async Task IncrementMultipleAsync(Dictionary<string, long> counters)
{
    var batch = _redis.CreateBatch();
    var tasks = new List<Task<long>>();

    foreach (var (key, increment) in counters)
    {
        tasks.Add(batch.StringIncrementAsync(key, increment));
    }

    batch.Execute();
    await Task.WhenAll(tasks);
}
Enter fullscreen mode Exit fullscreen mode

Pipeline: Para operações que não dependem do resultado imediato:

public void RecordEventsFireAndForget(IEnumerable<string> eventKeys)
{
    foreach (var key in eventKeys)
    {
        _redis.StringIncrement(key, flags: CommandFlags.FireAndForget);
    }
}
Enter fullscreen mode Exit fullscreen mode

Armadilhas Comuns

1. Não definir TTL: Contadores sem expiração podem acumular indefinidamente. Sempre considere se o contador precisa de um TTL.

2. Usar GET seguido de SET: Nunca faça isso para incrementar valores. Use sempre INCR:

// ERRADO: Race condition!
var value = await _redis.StringGetAsync(key);
await _redis.StringSetAsync(key, (long)value + 1);

// CORRETO: Atômico
await _redis.StringIncrementAsync(key);
Enter fullscreen mode Exit fullscreen mode

3. Ignorar overflow: O Redis armazena inteiros como strings de até 64 bits signed. Em cenários extremos, considere verificar limites.

4. Não tratar valores não numéricos: Se uma chave contiver um valor não numérico, INCR retornará erro. Valide ou use chaves dedicadas.

Conclusão

O INCR do Redis é uma ferramenta fundamental para construir sistemas distribuídos robustos. Sua atomicidade elimina race conditions, sua velocidade permite uso em cenários de alta performance, e sua simplicidade torna a implementação direta.

Os casos de uso vão desde contadores simples até rate limiters sofisticados e sistemas de métricas em tempo real. O segredo está em entender que operações atômicas simples, quando combinadas corretamente, resolvem problemas complexos de concorrência.

O poder do Redis muitas vezes não está em comandos complexos, mas em operações simples que são atômicas, rápidas e distribuídas.

Na próxima vez que você precisar de um contador distribuído, pense no INCR antes de partir para soluções mais complexas. Frequentemente, a resposta mais simples é também a mais elegante.

Top comments (0)