Rate Limiting com Redis no Spring Boot

1. Visão geral

Rate limiting ajuda você a bloquear clientes que chamam a API muitas vezes em um período de tempo, evitando sobrecarga do sistema ou abuso. Este artigo apresenta como usar Redis como contador centralizado, combinado com Lua script para garantir atomicidade quando múltiplas instâncias da aplicação acessam simultaneamente. Após ler, você será capaz de escrever seu próprio RateLimiter usando os algoritmos fixed window e sliding window, integrar ao Spring Boot via interceptor, e escrever testes para validar o comportamento.

2. Por que escolher Redis para rate limiting

Quando a aplicação executa múltiplas instâncias (horizontal scaling), os contadores armazenados na memória de cada instância (por exemplo, AtomicInteger) não ficam sincronizados. Redis resolve esse problema porque:

  • É um repositório de dados centralizado, todas as instâncias leem/escrevem no mesmo lugar.
  • Suporta TTL (EXPIRE) para limpeza automática de chaves antigas, sem necessidade de cron job.
  • Suporta script Lua executados atomicamente no servidor, evitando race conditions ao incrementar o contador e verificar o limite.

Nota: Se o sistema tem apenas uma única instância e não precisa compartilhar estado, usar um limiter em memória (por exemplo, Guava RateLimiter) será mais rápido e simples. Use Redis apenas quando precisar de rate limiting distribuído.

3. Configurar dependência

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot configura automaticamente RedisConnectionFactory e StringRedisTemplate com base nessas propriedades.

4. Algoritmo Fixed Window

Fixed window divide o tempo em intervalos fixos (por exemplo, a cada 60 segundos), contando o número de requisições em cada intervalo. Se exceder o limite, recusa.

4.1. Script Lua atômico

Use um script Lua para combinar INCR e EXPIRE em um único comando atômico, evitando que outra requisição se interponha entre os dois passos.

public class FixedWindowRateLimiter {

    private static final String SCRIPT = """
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return current
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public FixedWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowSeconds) {
        String key = "rate:fixed:" + clientId;
        Long current = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(windowSeconds));
        return current != null && current <= limit;
    }
}

Explicação:

  • INCR KEYS[1] incrementa o contador da chave, retorna o novo valor.
  • Se current == 1 (primeira vez criando a chave), define o TTL para windowSeconds segundos.
  • O script inteiro executa atomicamente no servidor Redis, sem ser intercalado por outras requisições, mesmo em execução multi-thread ou multi-instância.
  • isAllowed retorna true se o número atual de requisições ainda não excedeu o limit.

4.2. Usar em um interceptor do Spring Boot

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final FixedWindowRateLimiter rateLimiter;

    public RateLimitInterceptor(FixedWindowRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws IOException {
        String clientId = request.getRemoteAddr();
        boolean allowed = rateLimiter.isAllowed(clientId, 100, 60);
        if (!allowed) {
            response.setStatus(429);
            response.getWriter().write("Too many requests");
            return false;
        }
        return true;
    }
}

O interceptor pega o IP do cliente como clientId, limitando 100 requisições a cada 60 segundos. Se exceder o limite, retorna HTTP 429 e bloqueia a requisição de prosseguir para o controller.

Nota: Fixed window tem a desvantagem de “burst na borda”: se o cliente enviar 100 requisições no final do segundo 59 e 100 requisições mais no início do segundo 60, na verdade há 200 requisições em 1 segundo, embora cada janela individualmente seja válida. Se precisar de mais precisão, use sliding window.

5. Algoritmo Sliding Window Log

Sliding window log armazena o timestamp de cada requisição em um Sorted Set, removendo timestamps antigos a cada verificação. Mais preciso que fixed window, mas consome mais memória por armazenar cada requisição.

public class SlidingWindowRateLimiter {

    private static final String SCRIPT = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local windowMs = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])

        redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
        local count = redis.call('ZCARD', key)
        if count < limit then
            redis.call('ZADD', key, now, now)
            redis.call('PEXPIRE', key, windowMs)
            return 1
        end
        return 0
        """;

    private final StringRedisTemplate redisTemplate;
    private final RedisScript<Long> redisScript;

    public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.redisScript = RedisScript.of(SCRIPT, Long.class);
    }

    public boolean isAllowed(String clientId, int limit, int windowMs) {
        String key = "rate:sliding:" + clientId;
        long now = System.currentTimeMillis();
        Long result = redisTemplate.execute(
                redisScript,
                List.of(key),
                String.valueOf(now),
                String.valueOf(windowMs),
                String.valueOf(limit));
        return result != null && result == 1;
    }
}

Explicação de cada passo no Lua:

  • ZREMRANGEBYSCORE remove as entradas com score (timestamp) menor que now - windowMs, ou seja, remove requisições que saíram da janela de tempo.
  • ZCARD conta o número de entradas restantes no Sorted Set — este é exatamente o número de requisições na janela atual.
  • Se count < limit, adiciona uma nova entrada com score now, define o TTL da chave para windowMs (para limpeza automática quando não há mais tráfego), retorna 1 (permitir).
  • Se está cheio, retorna 0 (recusar), sem adicionar nova entrada.

Resultado: a janela desliza continuamente em tempo real, sem o fenômeno de burst na borda como em fixed window.

6. Comparação dos dois algoritmos

Critério Fixed Window Sliding Window Log
Precisão Baixa, burst na borda Alta
Memória O(1) por chave O(limit) por chave
Complexidade Simples Mais complexo
Adequado para Limite aproximado, alto tráfego API sensível, requer precisão

Se não tiver certeza qual algoritmo escolher, comece com fixed window — simples, bom desempenho, suficiente para a maioria das APIs internas.

7. Escrever testes com Testcontainers

Para que os testes não dependam de Redis pré-instalado na máquina, use Testcontainers para iniciar Redis em Docker.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class FixedWindowRateLimiterTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    FixedWindowRateLimiter rateLimiter;

    @BeforeEach
    void setUp() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
                redis.getHost(), redis.getMappedPort(6379));
        LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
        factory.afterPropertiesSet();

        StringRedisTemplate template = new StringRedisTemplate(factory);
        template.afterPropertiesSet();

        rateLimiter = new FixedWindowRateLimiter(template);
    }

    @Test
    void shouldBlockRequestsExceedingLimit() {
        String clientId = "client-A";
        int limit = 3;

        boolean r1 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r2 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r3 = rateLimiter.isAllowed(clientId, limit, 60);
        boolean r4 = rateLimiter.isAllowed(clientId, limit, 60);

        assertThat(r1).isTrue();
        assertThat(r2).isTrue();
        assertThat(r3).isTrue();
        assertThat(r4).isFalse();
    }
}

Este teste envia 4 requisições consecutivas com limit = 3. Os três primeiros requests são aceitos (isTrue), o quarto é recusado (isFalse) porque excedeu o limite na mesma janela. Testcontainers inicia e destrói automaticamente o container Redis, garantindo que o teste seja independente do ambiente da máquina CI.

8. Erros comuns

  • Não definir TTL para a chave: se esquecer EXPIRE, a chave permanece indefinidamente no Redis, causando vazamento de memória e o contador nunca é resetado.
  • Incrementar counter e verificar limite com dois comandos separados (GET seguido de INCR): cria race condition quando múltiplas requisições chegam simultaneamente, levando a exceder o limite real. Sempre combine em um único script Lua ou use INCR atômico.
  • Usar chave sem distinguir cliente: se esquecer de embeddar clientId na chave, todos os clientes compartilham o mesmo contador, causando bloqueio incorreto de outros usuários.
  • Sliding window log com tráfego extremamente alto: o Sorted Set cresce se o limit é grande e muitos clientes acessam simultaneamente, considere usar sliding window counter (combinando duas janelas fixas) para economizar mais memória.

9. Conclusão

Redis combinado com script Lua é a escolha comum para construir um rate limiter distribuído: simples, atômico e fácil de escalar conforme o número de instâncias da aplicação. Fixed window é adequado para a maioria dos casos devido à sua simplicidade, enquanto sliding window log é adequado quando você precisa controlar precisamente na borda do tempo. Sempre teste cuidadosamente o comportamento nos limites (boundary) antes de colocar em produção, pois é onde erros lógicos são mais propensos a ocorrer.

redisrate-limitingspring-bootlua-scriptjava