Projetando um rate limiter para API gateway com Redis
1. Visão geral
Quando múltiplos clientes chamam a API simultaneamente, um cliente com falha ou que está deliberadamente fazendo spam pode derrubar o backend de todos. Um rate limiter na camada de API gateway bloqueia as requisições que excedem o limite antes que elas alcancem os serviços de backend.
Este artigo passa por um estudo de caso completo. Os passos incluem especificar requisitos, escolher um algoritmo, projetar o modelo de dados e a API, e depois implementar usando Lua script no Redis com Spring Cloud Gateway. Após ler, você será capaz de construir um rate limiter distribuído e saberá como lidar com condições de corrida, dessincronização de relógio, hot keys e falhas do Redis.
2. Requisitos do problema
2.1. Requisitos funcionais
- Limitar por API key do tenant e por route. Por exemplo: o tenant
shop-apode chamarPOST /ordersno máximo 100 requisições/segundo, com um burst permitido de até 200. - Requisições que excedem o limite recebem
429 Too Many Requestscom o headerRetry-After. - Toda resposta tem um header
X-RateLimit-Remainingpara que o cliente possa se auto-regular. - As regras podem ser alteradas em tempo de execução sem necessidade de redeploy do gateway.
2.2. Escala e restrições
- Aproximadamente 50.000 RPS em horário de pico, 20 instâncias de gateway, 100.000 tenants.
- A latência adicionada por requisição deve ser menor que 2ms no p99.
- O limite deve ser preciso em todo o cluster, não calculado individualmente por instância. Se o limite é 100/s, o cluster inteiro deve permitir apenas cerca de 100/s no total.
- O rate limiter não deve se tornar um único ponto de falha. Quando o Redis falhar, o gateway ainda deve ser capaz de servir requisições.
- Aceita-se um pequeno erro (alguns %) em troca de melhor desempenho.
A terceira restrição descarta a opção de contar na memória de cada instância. Precisamos de um armazenamento de estado compartilhado. Redis é a escolha natural devido à sua baixa latência e operações atômicas via Lua.
3. Escolha do algoritmo
| Algoritmo | Vantagens | Desvantagens |
|---|---|---|
| Fixed window | Simples, um INCR |
Burst duplicado na fronteira da janela |
| Sliding log | Precisão absoluta | Caro em memória, salva cada timestamp |
| Sliding window counter | Aproximado, leve | Difícil de representar burst |
| Token bucket | Suporta burst naturalmente, apenas 2 campos | Requer cálculo de reposição de tokens |
Escolhemos token bucket. Cada bucket tem capacity (tamanho do burst) e uma taxa de reposição refillPerSecond. Cada requisição tira um token. Quando os tokens se esgotam, a requisição é rejeitada. O estado consiste apenas em dois números: tokens e ts (última atualização). Com 100.000 tenants vezes algumas rotas, o tamanho total é apenas algumas dezenas de MB.
4. Modelo de dados
4.1. Regras de configuração no SQL
As regras mudam raramente e precisam ser gerenciadas, então as armazenamos no PostgreSQL:
CREATE TABLE rate_limit_rule (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
route_id VARCHAR(64) NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
refill_per_second INT NOT NULL CHECK (refill_per_second > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, route_id)
);
INSERT INTO rate_limit_rule (tenant_id, route_id, capacity, refill_per_second)
VALUES ('shop-a', 'create-order', 200, 100);
O gateway não lê esta tabela em cada requisição. Cada instância carrega todas as regras na memória e as atualiza periodicamente, por exemplo a cada 30 segundos. Outra abordagem é ouvir eventos rule-changed via Kafka. O caminho quente (hot path) nunca toca no banco de dados.
4.2. Estado do bucket no Redis
Cada par tenant-route é um hash Redis:
Key: rl:{shop-a}:create-order
Fields: tokens = 153.4
ts = 1760256000123 (ms, segundo o relógio do Redis)
TTL: expira automaticamente quando o bucket fica cheio e não é usado por muito tempo
A parte {shop-a} é uma hash tag. No Redis Cluster, toda chave de um mesmo tenant cai no mesmo slot. Se mais tarde você precisar verificar vários buckets em um script, eles estarão no mesmo nó.
5. API e fluxo de processamento
O cliente vê dois tipos de resposta:
HTTP/1.1 200 OK
X-RateLimit-Remaining: 152
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0
O fluxo de processamento no gateway:
- Obter
X-Api-Keypara identificar o tenant e obter o ID da rota da rota correspondida. - Consultar a regra no cache em memória. Se não houver regra, usar a regra padrão.
- Chamar o script Lua no Redis para repor tokens e obter um token em um round-trip atômico.
- Se permitido, anexar headers e encaminhar. Se rejeitado, retornar 429.
- Se Redis falhar ou fizer timeout, mudar para um limitador local (seção 7.3).
6. Implementação
6.1. Dependências
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies>
6.2. Script Lua para token bucket
Coloque o arquivo em src/main/resources/scripts/token_bucket.lua:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0 -- tokens por ms
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * refillMs)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refillMs) * 2)
return { allowed, math.floor(tokens) }
Pontos principais:
redis.call('TIME')obtém o tempo do Redis em vez do tempo do gateway. 20 instâncias podem ter relógios desincronizados por dezenas de ms, então se cada instância enviassenowseparadamente, os tokens seriam repostos incorretamente. A partir do Redis 7, scripts replicam por padrão usando efeitos (effects replication), então é seguro chamarTIMEantes de escrever.- Toda a sequência de leitura, cálculo e escrita é executada atomicamente, pois Redis executa scripts em uma única thread. Esta é a solução para condições de corrida entre instâncias.
PEXPIREé definido para duas vezes o tempo para preencher o bucket. Um bucket não utilizado desaparecerá automaticamente. Quando reaparecer, ele começará cheio, exatamente como esperado semanticamente.- O script retorna
[allowed, remaining]. Lua converte números reais em inteiros ao retornar, então fazemosfloorantecipadamente para ser claro.
6.3. Chamar o script a partir de Java
public record RateLimitRule(int capacity, int refillPerSecond) {}
public record Decision(boolean allowed, long remaining) {}
@Component
public class RedisTokenBucket {
private final ReactiveStringRedisTemplate redis;
private final RedisScript<List> script =
RedisScript.of(new ClassPathResource("scripts/token_bucket.lua"), List.class);
public RedisTokenBucket(ReactiveStringRedisTemplate redis) {
this.redis = redis;
}
@SuppressWarnings("unchecked")
public Mono<Decision> tryAcquire(String tenant, String route, RateLimitRule rule) {
String key = "rl:{" + tenant + "}:" + route;
return redis.execute(script, List.of(key),
List.of(String.valueOf(rule.capacity()),
String.valueOf(rule.refillPerSecond())))
.next()
.map(r -> {
List<Long> res = (List<Long>) r;
return new Decision(res.get(0) == 1L, res.get(1));
});
}
}
Spring calcula automaticamente o SHA1 do script e usa EVALSHA. Se o Redis ainda não tem o script em cache, Spring faz fallback para EVAL. Portanto, apenas a primeira chamada envia o conteúdo completo do script pela rede.
6.4. Global filter no gateway
@Component
public class RateLimitFilter implements GlobalFilter, Ordered {
private static final RateLimitRule DEFAULT_RULE = new RateLimitRule(20, 10);
private final RedisTokenBucket redisBucket;
private final LocalFallbackLimiter fallback;
private final RuleCache rules; // Map<String, RateLimitRule> carregado do DB periodicamente
public RateLimitFilter(RedisTokenBucket redisBucket,
LocalFallbackLimiter fallback, RuleCache rules) {
this.redisBucket = redisBucket;
this.fallback = fallback;
this.rules = rules;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String tenant = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
if (tenant == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
String routeId = route != null ? route.getId() : "default";
RateLimitRule rule = rules.find(tenant, routeId).orElse(DEFAULT_RULE);
return redisBucket.tryAcquire(tenant, routeId, rule)
.timeout(Duration.ofMillis(5))
.onErrorResume(e -> Mono.just(fallback.tryAcquire(tenant, routeId, rule)))
.flatMap(decision -> {
var response = exchange.getResponse();
response.getHeaders().set("X-RateLimit-Remaining",
String.valueOf(decision.remaining()));
if (decision.allowed()) {
return chain.filter(exchange);
}
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
response.getHeaders().set("Retry-After",
String.valueOf(Math.max(1, 1 / rule.refillPerSecond())));
return response.setComplete();
});
}
@Override
public int getOrder() {
return -100; // executar após a rota ter sido correspondida, antes do filtro de roteamento
}
}
O filtro é executado para toda rota. Ele define timeout(5ms) para que um nó Redis lento não arraste a latência de todo o gateway. Quando o Redis falha, onErrorResume muda para um limitador local. Teste rapidamente com curl usando a regra capacity=2:
for i in 1 2 3; do
curl -s -o /dev/null -w "%{http_code}\n" -H "X-Api-Key: shop-a" -X POST localhost:8080/orders
done
200
200
429
7. Desafios e soluções
7.1. Condições de corrida entre instâncias
A abordagem ingênua é chamar HGET, fazer o cálculo em Java e depois chamar HSET. Duas instâncias podem ler tokens=1 simultaneamente e deixar ambas as requisições passarem. O script Lua elimina completamente essa condição de corrida. A abordagem WATCH/MULTI também funciona, mas requer retry em caso de conflito e usa mais round-trips.
7.2. Hot keys e picos de carga
Um grande tenant pode enviar 20.000 RPS para uma chave. Um nó Redis pode suportar cerca de 100.000 ops/s, mas essa chave se torna um ponto quente. Existem duas abordagens para resolver:
- Alocação de tokens em lotes (pre-fetch local): cada instância solicita antecipadamente um lote de tokens, por exemplo 50 tokens por vez, e depois os consome gradualmente na memória. O número de chamadas ao Redis diminui cerca de 50 vezes. Em contrapartida, o limite total pode exceder
número de instâncias × tamanho do loteem um curto período. Com o requisito de “erro de alguns %” na seção 2.2, isso é aceitável. - Divisão do bucket (sharding): dividir o limite de 20.000/s em 10 sub-buckets
rl:{shop-a}:create-order:0..9, cada um com 2.000/s. Requisições são direcionadas para um sub-bucket aleatoriamente. Remova a hash tag dessas chaves para que elas se espalhem entre vários nós.
Um burst válido já é absorvido pelo capacity. Essa é a razão pela qual escolhemos token bucket em vez de fixed window.
7.3. Redis indisponível: fail-open controlado
Fail-closed, ou seja, rejeitar todas as requisições quando o Redis falha, transforma uma falha secundária em uma falha de todo o sistema. Fail-open completo perde a camada de proteção. A solução intermediária é um limitador local com o limite dividido igualmente entre instâncias:
@Component
public class LocalFallbackLimiter {
private final int instanceCount;
private final LongSupplier clock;
private final ConcurrentHashMap<String, LocalBucket> buckets = new ConcurrentHashMap<>();
public LocalFallbackLimiter(@Value("${gateway.instances:20}") int instanceCount) {
this(instanceCount, System::currentTimeMillis);
}
LocalFallbackLimiter(int instanceCount, LongSupplier clock) {
this.instanceCount = instanceCount;
this.clock = clock;
}
public Decision tryAcquire(String tenant, String route, RateLimitRule rule) {
double cap = Math.max(1, rule.capacity() / (double) instanceCount);
double perMs = rule.refillPerSecond() / (double) instanceCount / 1000.0;
LocalBucket b = buckets.computeIfAbsent(tenant + ":" + route,
k -> new LocalBucket(cap, clock.getAsLong()));
return b.take(cap, perMs, clock.getAsLong());
}
static final class LocalBucket {
private double tokens;
private long ts;
LocalBucket(double tokens, long ts) {
this.tokens = tokens;
this.ts = ts;
}
synchronized Decision take(double cap, double perMs, long now) {
tokens = Math.min(cap, tokens + Math.max(0, now - ts) * perMs);
ts = now;
if (tokens >= 1) {
tokens -= 1;
return new Decision(true, (long) tokens);
}
return new Decision(false, 0);
}
}
}
Cada instância mantém 1/instanceCount do limite. O total do cluster ainda é aproximadamente correto sem precisar do Redis. Em ambiente de produção, você deve envolver a chamada ao Redis com um circuit breaker (por exemplo, Resilience4j). Quando o Redis está falhando, o circuit breaker evita gastar 5ms de timeout em cada requisição.
7.4. Consistência multi-região
Se o gateway é executado em duas regiões, sincronizar Redis entre regiões a cada requisição violaria o requisito de 2ms. A abordagem pragmática é dividir o limite por região conforme a proporção de tráfego, por exemplo 70/30, e aceitar que um tenant pode ser limitado em uma região enquanto outra ainda tem disponibilidade. Se for necessário otimizar mais, um job em background pode ajustar a proporção a cada alguns minutos com base em métricas reais.
8. Testes
A lógica do token bucket deve ser testada independentemente do relógio real. Como LocalFallbackLimiter recebe LongSupplier, podemos avançar o tempo usando AtomicLong:
class LocalFallbackLimiterTest {
@Test
void shouldRejectWhenBucketEmptyAndRefillOverTime() {
AtomicLong now = new AtomicLong(0);
var limiter = new LocalFallbackLimiter(1, now::get);
var rule = new RateLimitRule(2, 1); // burst 2, repõe 1 token/segundo
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isFalse();
now.addAndGet(1_000); // após 1 segundo há 1 token adicional
Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
assertThat(d.allowed()).isTrue();
assertThat(d.remaining()).isZero();
}
}
Este teste confirma três comportamentos: burst é permitido até capacity, requisições que excedem o limite são rejeitadas, e tokens são repostos corretamente com base no tempo. Para o script Lua, você deve escrever testes de integração usando Testcontainers com a imagem redis:7. Dispare 1.000 requisições em paralelo e verifique que o número de requisições permitidas não excede capacity.
Nota: Alguns erros comuns:
- Usar
System.currentTimeMillis()do gateway comonowno script. Dessincronização entre instâncias causará reposição incorreta de tokens.- Esquecer de definir TTL para a chave. O Redis crescerá gradualmente com o número de tenants que aparecem.
- Identificar cliente por IP atrás de NAT ou load balancer. Uma empresa inteira compartilhará um bucket.
- Não use o rate limiter no gateway como substituto para mecanismos de proteção contra sobrecarga dentro do próprio serviço (bulkhead, load shedding). O gateway só conhece a quantidade de requisições, não qual delas é pesada.
9. Conclusão
Neste artigo, projetamos um rate limiter para API gateway desde os requisitos até a implementação. Token bucket lida com burst de forma elegante. O script Lua no Redis resolve condições de corrida com um round-trip atômico. Obter o tempo do Redis elimina problemas de dessincronização entre instâncias.
Os desafios reais estão além do algoritmo. Hot keys precisam de alocação em lotes ou divisão de buckets. Falhas do Redis precisam de fail-open controlado com um limitador local. Sistemas multi-região precisam de divisão de limite em vez de sincronização rígida. Em cada ponto, trocamos um pouco de precisão por baixa latência e disponibilidade, em linha com os requisitos estabelecidos desde o início.