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 parawindowSecondssegundos. - 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.
isAllowedretornatruese o número atual de requisições ainda não excedeu olimit.
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:
ZREMRANGEBYSCOREremove as entradas com score (timestamp) menor quenow - windowMs, ou seja, remove requisições que saíram da janela de tempo.ZCARDconta 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 scorenow, define o TTL da chave parawindowMs(para limpeza automática quando não há mais tráfego), retorna1(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 (
GETseguido deINCR): cria race condition quando múltiplas requisições chegam simultaneamente, levando a exceder o limite real. Sempre combine em um único script Lua ou useINCRatômico. - Usar chave sem distinguir cliente: se esquecer de embeddar
clientIdna 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.