Rate limiting distribuído em Go: token bucket local com fallback em Redis
Por John Pires · 16 de março de 2026

Motivacao e trade-offs
Em serviços distribuídos precisamos controlar tráfego por instância e globalmente. Um token bucket local (em memória) entrega latência muito baixa; Redis fornece coordenacão entre instâncias. Combinar os dois oferece performance e consistência eventual — com trade-offs claros: precisão vs. latência, complexidade operacional, e necessidade de lidar com inconsistências temporárias.
Por que usar token bucket local?
Token bucket local reduz chamadas remotas por requisicao, evitando latência e picos de IOPS no Redis. Ideal quando a maioria das requisições pode ser satisfeita com tokens locais já preenchidos. Em Go, um bucket local é simples de implementar com mutexes ou atomicidade e funciona bem em alta concorrência.
Quando delegar para Redis?
Usar Redis como fallback ou para enforce global limits é necessário quando você precisa garantir limites firmes entre varias instâncias (ex.: quota por cliente). Redis suporta scripts atômicos (Lua) que implementam token bucket global com precisão e alta taxa de operações atômicas.
Trade-offs principais:
- Precisão: Redis é preciso; local é eventual.
- Performance: Local vence para latência; Redis adiciona overhead de rede.
- Complexidade: Hibrido exige mecanismos de sincronização e fallback.
Regra prática: use bucket local para aceitar a maioria das requisições; só consulte Redis quando local estiver esgotado ou para decisões globais.
Fundamentos tecnicos
Modelo do token bucket
Token bucket é composto por uma capacidade (cap) e uma taxa de refill (r tokens/seg). Tokens são adicionados periodicamente até o teto. Cada requisição consome N tokens; se não houver tokens suficientes, a requisição é negada ou enfileirada.
Implementação local em Go
Em Go, mantenha estado leve: últimos timestamp de refill, tokens atuais e mutex/atomic para concorrência. Evite timers por requisição — calcule tokens com base na diferença de tempo desde o último refill.
Exemplo de operações necessárias:
- TryConsume(n int) bool
- Refill() (implícito ao calcular com timestamp)
- GetAvailable() int
Token bucket distribuído via Redis
Redis pode hospedar o contador global e servir scripts Lua atômicos. A vantagem é que a checagem e decremento são atomicamente garantidos. Também dá para expirar chaves e manter propriedades por usuário/tenant.
Lua script típico:
- ler tokens e timestamp
- computar tokens recém-adicionados
- se tokens suficientes, decrementar e retornar sucesso
- senão, retornar falha
Exemplo 1: caso simples
Visão geral do caso
Implemente um rate limiter por IP que first tenta consumir de um bucket local por instância e, se falhar, consulta um bucket global em Redis. Objetivo: baixa latência para a maioria dos requests e coordenação quando o tráfego é alto.
Estrutura do código
Componentes:
- LocalBucket: token bucket em memória por chave (IP).
- RedisBucket: fallback global com script Lua.
- Middleware HTTP: combina os dois.
Segue implementação do bucket local. Contexto: o código abaixo define um bucket simples com refill baseado em timestamps. Use-o para evitar timers e facilitar testes.
package limiter
import (
"sync"
"time"
)
// LocalBucket é um token bucket básico por chave.
// Uso: criar com capacidade e taxa; chamar TryConsume para consumir tokens.
type LocalBucket struct {
mu sync.Mutex
tokens float64
capacity float64
rate float64 // tokens por segundo
last time.Time
}
func NewLocalBucket(capacity, rate float64) *LocalBucket {
return &LocalBucket{
tokens: capacity,
capacity: capacity,
rate: rate,
last: time.Now(),
}
}
// TryConsume tenta consumir n tokens. Retorna true se sucesso.
func (b *LocalBucket) TryConsume(n float64) bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
elapsed := now.Sub(b.last).Seconds()
if elapsed > 0 {
b.tokens += elapsed * b.rate
if b.tokens > b.capacity {
b.tokens = b.capacity
}
b.last = now
}
if b.tokens >= n {
b.tokens -= n
return true
}
return false
}
O que acontece: o bucket atualiza tokens com base no tempo decorrido e tenta consumir. Sem timers, eficiente em cenários de alta concorrência.
Agora um fragmento simples que mostra uso desse bucket dentro de um middleware HTTP. Contexto: integrável com net/http ou Gin.
package middleware
import (
"context"
"net/http"
"time"
"your/module/limiter"
)
// ExampleHandler mostra a integração: tenta local; se falhar, chama o RedisFallback (implementado depois).
func RateLimitHandler(local *limiter.LocalBucket, redisFallback func(ctx context.Context) bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if local.TryConsume(1) {
next.ServeHTTP(w, r)
return
}
// local esgotado: tenta Redis
ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
if redisFallback(ctx) {
next.ServeHTTP(w, r)
return
}
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
})
}
O que acontece: middleware continua rápida para casos locais; quando local está zerado, um fallback remoto com timeout limita impacto de latência.
Exemplo 2: caso avancado do mundo real
Requisitos do cenário
API multi-tenant com quotas diferentes por cliente (burst e taxa), alta taxa de requisições, e múltiplas réplicas. Requisitos:
- Latência baixa para a maioria das requisições.
- Precisão forte para quotas globais por cliente.
- Tolerância a falhas no Redis (degradação graciosa).
Design do sistema
Combinação de estratégias:
- Cache local de buckets por tenant com refill otimista.
- Redis armazena fonte da verdade; script Lua faz decremento atômico.
- Sync background: preencher local com tokens do Redis periodicamente (prefetch).
- Fallback/Grace: se Redis indisponível, permitir operação limitada usando tokens locais e métricas para alertar.
Segue um exemplo de script Lua que implementa token bucket no Redis. Contexto: esse script é carregado e executado por EVALSHA para performance. Ele usa chaves para tokens e timestamp.
-- Lua script for Redis token bucket
-- ARGV: rate, capacity, now, requested
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local key = KEYS[1]
local data = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(data[1]) or capacity
local last = tonumber(data[2]) or now
local elapsed = math.max(0, now - last)
local added = elapsed * rate
tokens = math.min(capacity, tokens + added)
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, 3600)
return {1, tokens}
else
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, 3600)
return {0, tokens}
end
O que acontece: o script atualiza os tokens com base no tempo e decide se a requisição pode ser atendida. Também define TTL para limpeza automática.
Agora o trecho Go que chama esse script com go-redis/redis/v8. Contexto: a função RedisTryConsume executa o script e interpreta o resultado.
package redislimiter
import (
"context"
"strconv"
"time"
"github.com/go-redis/redis/v8"
)
var luaScript = redis.NewScript(`
-- (cole o script Lua do bloco anterior aqui)
`)
func RedisTryConsume(ctx context.Context, client *redis.Client, key string, rate, capacity, requested float64) (bool, error) {
now := float64(time.Now().Unix())
res, err := luaScript.Run(ctx, client, []string{key}, strconv.FormatFloat(rate, 'f', -1, 64), strconv.FormatFloat(capacity, 'f', -1, 64), strconv.FormatFloat(now, 'f', -1, 64), strconv.FormatFloat(requested, 'f', -1, 64)).Result()
if err != nil {
return false, err
}
arr, ok := res.([]interface{})
if !ok || len(arr) < 1 {
return false, nil
}
success := arr[0].(int64)
return success == 1, nil
}
O que acontece: a função executa o script atomico e retorna sucesso/falha. Em produção, carregue o script em init e use EVALSHA para eficiência.
Sync background e prefetch
Implementar goroutine que periodicamente chama Redis para preencher buckets locais reduz chamadas online. Atenção ao jitter na periodicidade para evitar picos de sincronização simultânea entre réplicas.
Dica operacional: use diferentes horários baseados em hash(tenant) + random para escalonar a carga de sincronização.
Debugging e troubleshooting
Erros comuns e armadilhas
1) Over-allowing quando há muitos servidores com buckets locais: se cada instância tem capacidade de burst elevada, o total permitido pode exceder a quota global. Mitigação: reduzir burst local ou aumentar frequência de sincronização com Redis.
2) Latência de Redis sem timeouts: chamadas bloqueando goroutines e aumentando latência. Mitigação: use Context com timeout e circuit breaker simples que desvia para modo degradado.
3) Clock drift: implementações que dependem do relógio local para Redis/clients podem ter comportamentos inconsistentes. Use timestamps do servidor Redis (ex: TIME) para garantir consistência, ou aceite pequenas inconsistências se TTLs forem maiores.
Ferramentas práticas para debugging
Use métricas e tracing:
- Contadores de hits locais vs. hits Redis.
- Histogramas de latência de fallback Redis.
- Contadores de erros Redis e de fallback para modo degradado.
Logs estruturados devem incluir tenant/IP, decisão (local accept / redis accept / reject), tokens remanescentes estimados. Isso facilita análise posterior e tuning.
Se não estiver medindo, você não consegue ajustar corretamente. Métricas são tão importantes quanto a implementação.
Erros comuns, armadilhas e dicas práticas
Silenciar Redis vs. garantir quota
Silenciar Redis (permitir mais tráfego quando Redis cai) pode manter disponibilidade, mas quebra quotas globais. Escolha com base na prioridade entre disponibilidade e controle de recursos. Em pagamentos, por exemplo, falha segura (degrade) é preferível.
Escolhas de parâmetros
Parâmetros típicos:
- rate: tokens por segundo — defina com base em RPS desejado por tenant.
- capacity: burst permitido — mantenha baixo para evitar overcommit
- local capacity: uma fração do global (ex.: global/num-instances * safety-factor)
Dica: começar conservador (baixo burst local) e aumentar conforme observações das métricas.
Conclusão prática
Combinar token bucket local com fallback em Redis oferece um bom balanço entre latência e controle global. Implementação em Go é direta: buckets locais com mutex/atomic, scripts Lua em Redis e middleware que prefere local, com timeout e circuit-breaker para as chamadas remotas.
Recomendações finais:
- Priorize métricas: monitore hits locais vs Redis, latências e erros.
- Defina parâmetros conservadores: burst local pequeno; prefira sincronização inteligente (prefetch + jitter).
- Implemente fallback claro: timeout curto para Redis e modo degradado controlado.
Com esses padrões você terá um rate limiter distribuído que atende requisitos de performance e segurança operacional, mantendo a complexidade manejável. Ajuste os parâmetros com base nas métricas reais e automatize testes de carga para validar comportamento sob falhas.
Assuntos: Go, NoSQL, rate-limiting, concurrency, api
Leia também
Retries exponenciais com jitter em Go: backoff, Context e cancelamento
Aprenda a implementar retries exponenciais com jitter em Go, integrando backoff, context.Context e cancelamento para evitar vazamentos e reduzir carga em…
API REST em Go: worker pool com backpressure, Context e desligamento gracioso
Implementar um worker pool em Go para uma API REST exige backpressure, Context para cancelamento e shutdown gracioso. Veja anti-patterns, versões corrigidas e…
Cache LRU concorrente em Go: singleflight, sharding e benchmarks práticos
Implementação prática de cache LRU concorrente em Go: comparo mutex global, sharding e singleflight, mostro código funcional, benchmarks e armadilhas para…
Diagnóstico e redução de tail-latency em Go: pprof, contenção e GOMAXPROCS
Como identificar e reduzir tail-latency em Go: pprof, contenção (locks, sync.Pool) e GOMAXPROCS. Mostro o erro comum (mutex global) e correções práticas com…
Downloader concorrente em Go: goroutines, channels, cancelamento e progresso
Aprenda a construir um downloader concorrente em Go usando goroutines, channels e context para cancelamento e progresso. Exemplo funcional, CLI simples e…
Mutex vs RWMutex em Go: quando usar, analogias e exemplos passo a passo
Entenda de forma prática quando usar sync.Mutex vs sync.RWMutex em Go: analogias, exemplos funcionais passo a passo, armadilhas comuns e um mini-projeto HTTP…