Destino Dev

Blog

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:

  1. TryConsume(n int) bool
  2. Refill() (implícito ao calcular com timestamp)
  3. 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:

  1. Cache local de buckets por tenant com refill otimista.
  2. Redis armazena fonte da verdade; script Lua faz decremento atômico.
  3. Sync background: preencher local com tokens do Redis periodicamente (prefetch).
  4. 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