Em aplicações web modernas, a escalabilidade e a proteção contra abusos são fundamentais. Dois mecanismos essenciais para alcançar esses objetivos são o rate limiting (limitação de taxa) e o cache. O rate limiting controla a quantidade de requisições que um cliente pode fazer em um determinado intervalo de tempo, prevenindo sobrecarga e ataques de força bruta. O cache, por sua vez, armazena respostas ou resultados de operações custosas para reutilizá-los, reduzindo o tempo de resposta e o consumo de recursos. Nesta aula, exploraremos os conceitos, estratégias e implementações práticas em PHP, além de ferramentas que facilitam a adoção dessas técnicas.

Combinar rate limiting e cache é uma prática comum: o cache alivia a carga no servidor, enquanto o rate limiting protege o cache de ser sobrecarregado por requisições excessivas. Vamos mergulhar em cada aspecto com exemplos claros e aplicáveis.

Por que limitar

O rate limiting é necessário para proteger a infraestrutura e garantir a disponibilidade do serviço. Sem ele, um único usuário (ou bot) poderia fazer milhares de requisições por segundo, esgotando recursos como CPU, memória e banda de rede, causando lentidão ou indisponibilidade para todos. Além disso, muitos endpoints de API são projetados para permitir um certo número de chamadas por minuto ou hora; o rate limiting ajuda a garantir que os clientes respeitem esses limites.

Outro motivo importante é a prevenção de ataques de força bruta, como tentativas de login ou exploração de vulnerabilidades. Ao limitar a taxa de tentativas, reduzimos drasticamente a chance de sucesso de um atacante. O rate limiting também protege o cache: se não houver limite, um cliente pode inundar o cache com requisições, forçando a evicção de itens legítimos e reduzindo a eficiência do cache.

Implementar rate limiting não é apenas uma questão de segurança, mas também de qualidade de serviço. Clientes bem-comportados são beneficiados com respostas previsíveis, enquanto servidores mantêm sua estabilidade.

Estratégias

Existem várias estratégias para implementar rate limiting. A mais simples é a contagem por janela fixa: define-se um período (por exemplo, 60 segundos) e um número máximo de requisições permitidas nesse período. Ao final de cada janela, o contador é zerado. A implementação é fácil, mas pode permitir rajadas de requisições no início de cada janela.

Outra abordagem é a janela deslizante, que considera um histórico contínuo de requisições. Em vez de janelas fixas, usa-se uma lista de timestamps para verificar se o número de requisições dentro do último intervalo (ex.: 1 minuto) excede o limite. Isso suaviza o tráfego, mas é mais complexo de implementar.

O token bucket é uma estratégia popular: um balde contém um número máximo de tokens; cada requisição consome um token. Tokens são reabastecidos a uma taxa fixa (ex.: 10 tokens por segundo). Assim, permite rajadas até o tamanho do balde, mas mantém uma média sustentável. É eficiente e amplamente usado.

Em PHP, podemos implementar rate limiting usando armazenamento em memória, como Redis ou APCu, ou até mesmo em banco de dados. Vamos ver um exemplo com Redis e token bucket:

redis = $redis;
        $this->key = $key;
        $this->capacity = $capacity;
        $this->refillRate = $refillRate;
    }

    public function allow(): bool {
        $script = <<<'LUA'
            local key = KEYS[1]
            local capacity = tonumber(ARGV[1])
            local refillRate = tonumber(ARGV[2])
            local now = tonumber(ARGV[3])
            local requested = tonumber(ARGV[4])
            
            local data = redis.call('HMGET', key, 'tokens', 'last_refill')
            local tokens = tonumber(data[1]) or capacity
            local lastRefill = tonumber(data[2]) or now
            
            local elapsed = math.max(0, now - lastRefill)
            tokens = math.min(capacity, tokens + elapsed * refillRate)
            
            if tokens >= requested then
                tokens = tokens - requested
                redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
                return 1
            else
                return 0
            end
LUA;

        $now = microtime(true);
        $result = $this->redis->eval($script, [$this->key, $this->capacity, $this->refillRate, $now, 1], 1);
        return (bool)$result;
    }
}

// Uso
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$bucket = new TokenBucket($redis, 'api:user:123', 10, 1); // 10 tokens, 1 token/s
if ($bucket->allow()) {
    // processa a requisição
    echo "Requisição permitida\n";
} else {
    http_response_code(429);
    echo "Too Many Requests\n";
}
?>

Esse script Lua é atômico no Redis, o que garante consistência entre requisições concorrentes. É uma implementação robusta e eficiente.

Cache de respostas

O cache de respostas consiste em armazenar a resposta de uma requisição e reutilizá-la para requisições idênticas, evitando reprocessamento. Isso é especialmente útil para endpoints que retornam dados que mudam com pouca frequência, como listas de produtos, configurações ou páginas estáticas.

Em PHP, podemos usar sistemas como Redis, Memcached ou até mesmo o sistema de arquivos para armazenar respostas em cache. Uma técnica comum é armazenar a resposta serializada com uma chave baseada na URL e parâmetros, e definir um TTL (time to live). Também podemos usar cabeçalhos HTTP como Cache-Control para permitir que o navegador ou proxies intermediários façam cache.

Exemplo de cache simples com Redis:

connect('127.0.0.1', 6379);
    $key = 'response:' . md5($uri . serialize($params));
    
    $cached = $redis->get($key);
    if ($cached !== false) {
        return unserialize($cached);
    }
    
    // Simula a geração da resposta (substitua pela lógica real)
    $response = ['data' => 'conteúdo da resposta', 'timestamp' => time()];
    $redis->setex($key, $ttl, serialize($response));
    return $response;
}

$response = getCachedResponse('/api/produtos', ['page' => 1]);
echo json_encode($response);
?>

É importante considerar a invalidação do cache: quando os dados subjacentes mudam, precisamos remover ou atualizar as entradas de cache. Isso pode ser feito manualmente ou usando estratégias como cache tagging. Em sistemas complexos, bibliotecas como Symfony Cache facilitam essas operações.

Outra abordagem é o cache de página inteira, onde o HTML gerado é armazenado e servido diretamente, ignorando a execução do PHP. Isso pode ser feito com ferramentas como Varnish ou NGINX FastCGI Cache, mas também é possível implementar com PHP puro.

Visão geral de ferramentas

Existem muitas ferramentas e bibliotecas que facilitam a implementação de rate limiting e cache em PHP. Para rate limiting, destacam-se bibliotecas como Symfony RateLimiter (componente do Symfony), Laravel RateLimiter (integrado ao Laravel) e RateLimit (biblioteca independente). Essas bibliotecas oferecem implementações testadas e configuráveis.

Para cache, as opções são variadas: Symfony Cache é um componente robusto que suporta múltiplos adaptadores (Redis, Memcached, APCu, arquivos). Laravel Cache fornece uma API unificada para vários drivers. APCu é uma extensão PHP que oferece cache em memória compartilhada, ideal para aplicações single-server. Redis e Memcached são servidores de cache distribuídos, escaláveis e amplamente utilizados.

Além disso, no nível de servidor, ferramentas como NGINX e Varnish podem fazer rate limiting e cache de forma eficiente, sem intervenção do PHP. Isso é especialmente útil para alta performance.

A escolha da ferramenta depende da arquitetura da aplicação, da escala e dos requisitos. Para aplicações pequenas, APCu e arquivos podem ser suficientes; para grandes sistemas distribuídos, Redis e Varnish são mais adequados.

Exemplo de uso do componente RateLimiter do Symfony:

use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;

$factory = new RateLimiterFactory([
    'id' => 'api',
    'policy' => 'token_bucket',
    'limit' => 10,
    'rate' => ['interval' => '1 minute']
], new InMemoryStorage());

$limiter = $factory->create('user:123');
if ($limiter->consume()->isAccepted()) {
    // processar
} else {
    http_response_code(429);
}

Esse código mostra como a abstração facilita a implementação.

Boas práticas e observações finais

Ao implementar rate limiting e cache, considere as seguintes práticas:

  • Defina limites adequados com base no comportamento esperado dos clientes e na capacidade do servidor.
  • Retorne cabeçalhos HTTP como X-RateLimit-Limit, X-RateLimit-Remaining e Retry-After para informar os clientes sobre o estado do limite.
  • Use cache com cautela: defina TTLs adequados e invalide corretamente quando os dados mudarem.
  • Monitore o uso de cache e rate limiting para ajustar parâmetros conforme o tráfego.
  • Combine as técnicas: cache para reduzir carga e rate limiting para proteger o cache e o servidor.

Lembre-se de que cada aplicação tem necessidades específicas; teste e meça o desempenho para encontrar a configuração ideal.

Referências

Exercícios

  1. Exercício 1: Implemente um rate limiter simples de janela fixa usando sessão PHP (sem Redis). O limite é de 5 requisições por minuto por usuário. Se exceder, retorne HTTP 429.

    ✓ Resposta:
    session_start();
    $limit = 5;
    $window = 60; // segundos
    $key = 'rate_limit';
    
    if (!isset($_SESSION[$key])) {
        $_SESSION[$key] = ['count' => 0, 'start' => time()];
    }
    
    $data = $_SESSION[$key];
    if (time() - $data['start'] >= $window) {
        $data = ['count' => 0, 'start' => time()];
    }
    
    if ($data['count'] >= $limit) {
        http_response_code(429);
        echo "Too Many Requests";
        exit;
    }
    
    $data['count']++;
    $_SESSION[$key] = $data;
    // Restante da aplicação
    ?>
    
  2. Exercício 2: Crie uma função de cache usando APCu que armazena o resultado de uma consulta a banco de dados por 10 minutos. A chave deve ser baseada na consulta SQL.

    ✓ Resposta:
    function getCachedQuery(string $sql) {
        $key = 'query:' . md5($sql);
        $cached = apcu_fetch($key, $success);
        if ($success) {
            return $cached;
        }
        // Simula consulta ao banco
        $result = ['rows' => [/* dados do banco */]];
        apcu_store($key, $result, 600); // 10 minutos
        return $result;
    }
    ?>
    
  3. Exercício 3: Explique a diferença entre janela fixa e janela deslizante e dê um cenário onde cada uma é mais adequada.

    ✓ Resposta: A janela fixa divide o tempo em intervalos fixos (ex.: de minuto em minuto) e conta as requisições em cada intervalo. É simples, mas pode permitir rajadas no início de cada janela. A janela deslizante considera um período contínuo, por exemplo, as últimas 60 segundos a partir de cada requisição, evitando rajadas. A janela fixa é adequada para casos onde a precisão não é crítica e a implementação é simples. A janela deslizante é melhor para APIs onde se deseja um controle mais suave, como limitar a 100 requisições por minuto sem picos.
  4. Exercício 4: Implemente um cache de respostas HTTP usando Redis e cabeçalhos Cache-Control. A resposta deve ser armazenada com TTL de 60 segundos e o cabeçalho deve ser enviado.

    ✓ Resposta:
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    $key = 'response:' . md5($_SERVER['REQUEST_URI']);
    $cached = $redis->get($key);
    if ($cached !== false) {
        header('Cache-Control: max-age=60');
        echo $cached;
        exit;
    }
    // Gera resposta
    $response = "

    Olá

    "; $redis->setex($key, 60, $response); header('Cache-Control: max-age=60'); echo $response; ?>
  5. Exercício 5: Pesquise sobre o algoritmo leaky bucket e implemente uma versão simples em PHP usando um arquivo para armazenar o estado. O bucket tem capacidade de 10 e vaza a uma taxa de 1 requisição por segundo.

    ✓ Resposta:
    function leakyBucket(string $key, int $capacity, float $leakRate): bool {
        $file = sys_get_temp_dir() . '/' . md5($key) . '.txt';
        $data = ['tokens' => 0, 'last' => microtime(true)];
        if (file_exists($file)) {
            $data = json_decode(file_get_contents($file), true);
        }
        $now = microtime(true);
        $elapsed = $now - $data['last'];
        // Vaza tokens com base no tempo
        $data['tokens'] = max(0, $data['tokens'] - $elapsed * $leakRate);
        if ($data['tokens'] < $capacity) {
            $data['tokens']++;
            $data['last'] = $now;
            file_put_contents($file, json_encode($data));
            return true;
        }
        $data['last'] = $now;
        file_put_contents($file, json_encode($data));
        return false;
    }
    
    if (leakyBucket('api', 10, 1)) {
        echo "OK";
    } else {
        http_response_code(429);
    }
    ?>