Backend-разработка

Rate Limiting и защита REST API: стратегии на основе Redis с примерами для Go и Laravel

Ruslan Ismailov Опубликовано 14 мин чтения
R

1. Зачем нужен rate limiting в 2026 году

Любой публичный REST API без ограничений на частоту запросов — открытая мишень. В 2026 году атаки на уровне приложения (L7) составляют большую часть инцидентов: credential stuffing, scraping, абьюз бесплатных тарифов, DDoS через легитимные эндпоинты. Сетевые фильтры здесь бессильны — трафик выглядит как обычные HTTP-запросы.

Rate limiting решает сразу несколько задач: защищает бэкенд от перегрузки, снижает затраты на вычисления и исходящий трафик, обеспечивает честное распределение ресурсов между пользователями и делает монетизацию API предсказуемой. Для микросервисных архитектур (microservices) это особенно критично: один перегруженный сервис каскадно роняет всю систему.

2. Алгоритмы rate limiting: сравнение подходов

Прежде чем писать код, важно выбрать правильный алгоритм. Каждый из них предлагает свои компромиссы между точностью, потреблением памяти и простотой реализации.

Fixed Window (фиксированное окно)

Счётчик сбрасывается каждые N секунд. Например, 100 запросов в минуту. Реализация тривиальна, но есть серьёзный изъян: на границе окна возможен двойной burst — 100 запросов в последние секунды одного окна и 100 запросов в первые секунды следующего.

  • Плюсы: минимальное потребление памяти, простая реализация.
  • Минусы: граничный эффект удвоения трафика.

Sliding Window Log (журнал скользящего окна)

Для каждого клиента хранится список временных меток всех запросов. При новом запросе устаревшие метки удаляются, проверяется длина списка. Абсолютно точный метод.

  • Плюсы: нет граничного эффекта, точность 100%.
  • Минусы: потребление памяти пропорционально количеству запросов в окне.

Sliding Window Counter (счётчик скользящего окна)

Гибрид: берётся счётчик текущего окна и часть счётчика предыдущего, пропорциональная прошедшему времени. Формула: count = current_count + prev_count * (window - elapsed) / window. Хорошее соотношение точность/память.

  • Плюсы: O(1) память, хорошая точность, нет граничного эффекта.
  • Минусы: приближение, а не точное значение.

Token Bucket (ведро с токенами)

Ведро наполняется токенами с фиксированной скоростью до максимума. Каждый запрос потребляет токен. Если токенов нет — отказ. Поддерживает burst: клиент может накопить токены и потратить их разом.

  • Плюсы: естественная поддержка burst, интуитивно понятен.
  • Минусы: нужно хранить два значения (количество токенов + время последнего пополнения).

Leaky Bucket (дырявое ведро)

Запросы ставятся в очередь и обрабатываются с постоянной скоростью. Идеален для выравнивания трафика, но не подходит для интерактивных API — запросы могут зависнуть в очереди.

  • Плюсы: равномерный исходящий трафик.
  • Минусы: задержки, очередь требует памяти.

Практический вывод: для большинства публичных REST API оптимальный выбор — Sliding Window Counter (баланс точности и ресурсов) или Token Bucket (когда нужен burst). Fixed Window допустим для внутренних сервисов с низкими требованиями к точности.

3. Redis как основа распределённого rate limiting

In-memory счётчики в процессе приложения не работают при горизонтальном масштабировании: каждый инстанс считает независимо, реальный лимит умножается на количество подов. Redis решает это централизованным хранилищем с атомарными операциями.

Ключевые возможности Redis для rate limiting:

  • INCR + EXPIRE: атомарное увеличение счётчика и установка TTL за одну операцию.
  • Lua-скрипты: выполняются атомарно на стороне Redis, позволяют реализовать сложную логику без гонок данных.
  • ZADD / ZRANGEBYSCORE: sorted sets для Sliding Window Log.
  • Pipelines: пакетная отправка команд для снижения latency.

Критически важен Lua: без него операция «прочитать счётчик → проверить → увеличить» — три отдельных запроса, между которыми возможна гонка данных. Lua-скрипт атомарен по определению.

4. Реализация Sliding Window Counter на Redis + Go

Рассмотрим полную реализацию rate limit middleware для Go с использованием Redis. Алгоритм: два ключа на каждое окно (текущее и предыдущее), счётчик рассчитывается как взвешенная сумма.

// ratelimit/sliding_window.go
package ratelimit

import (
    "context"
    "fmt"
    "math"
    "net/http"
    "strconv"
    "time"

    "github.com/redis/go-redis/v9"
)

const slidingWindowLua = `
local current_key = KEYS[1]
local previous_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local elapsed = now % window
local weight = (window - elapsed) / window

local prev_count = tonumber(redis.call('GET', previous_key) or 0)
local curr_count = tonumber(redis.call('GET', current_key) or 0)

local estimated = math.floor(prev_count * weight + curr_count)

if estimated >= limit then
    return {0, estimated, limit}
end

local new_count = redis.call('INCR', current_key)
if new_count == 1 then
    redis.call('EXPIRE', current_key, window * 2)
end

return {1, estimated + 1, limit}
`

type SlidingWindowLimiter struct {
    client     *redis.Client
    limit      int
    windowSecs int
    script     *redis.Script
}

func NewSlidingWindowLimiter(client *redis.Client, limit, windowSecs int) *SlidingWindowLimiter {
    return &SlidingWindowLimiter{
        client:     client,
        limit:      limit,
        windowSecs: windowSecs,
        script:     redis.NewScript(slidingWindowLua),
    }
}

func (l *SlidingWindowLimiter) Allow(ctx context.Context, key string) (allowed bool, remaining int, err error) {
    now := time.Now().Unix()
    windowStart := now / int64(l.windowSecs)

    currentKey := fmt.Sprintf("rl:%s:%d", key, windowStart)
    previousKey := fmt.Sprintf("rl:%s:%d", key, windowStart-1)

    res, err := l.script.Run(ctx, l.client,
        []string{currentKey, previousKey},
        l.limit, l.windowSecs, now,
    ).Slice()
    if err != nil {
        return false, 0, err
    }

    allowed = res[0].(int64) == 1
    current := int(res[1].(int64))
    remaining = int(math.Max(0, float64(l.limit-current)))
    return allowed, remaining, nil
}

// Middleware для net/http
func (l *SlidingWindowLimiter) Middleware(keyFn func(r *http.Request) string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            key := keyFn(r)
            allowed, remaining, err := l.Allow(r.Context(), key)
            if err != nil {
                // При ошибке Redis — пропускаем (fail-open), логируем
                next.ServeHTTP(w, r)
                return
            }

            w.Header().Set("X-RateLimit-Limit", strconv.Itoa(l.limit))
            w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
            w.Header().Set("X-RateLimit-Window", strconv.Itoa(l.windowSecs))

            if !allowed {
                retryAfter := l.windowSecs - int(time.Now().Unix())%l.windowSecs
                w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
                http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// Пример использования
func main() {
    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    limiter := NewSlidingWindowLimiter(rdb, 100, 60) // 100 req/min

    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"status":"ok"}`))
    })

    keyFn := func(r *http.Request) string {
        // Ограничение по IP
        return "ip:" + r.RemoteAddr
    }

    http.ListenAndServe(":8080", limiter.Middleware(keyFn)(mux))
}

Lua-скрипт вычисляет взвешенную сумму и инкрементирует счётчик атомарно. Go-код оборачивает логику в HTTP middleware, добавляет заголовки X-RateLimit-* и возвращает 429 Too Many Requests с заголовком Retry-After.

5. Реализация Token Bucket на Redis + Laravel

Laravel имеет встроенный RateLimiter фасад, но для production-нагрузок с точным контролем удобнее реализовать Token Bucket через Redis напрямую. Создадим middleware с поддержкой burst.

<?php
// app/Http/Middleware/TokenBucketRateLimit.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;

class TokenBucketRateLimit
{
    /**
     * Lua-скрипт Token Bucket для Redis.
     * KEYS[1] = bucket key
     * ARGV[1] = capacity (макс. токенов)
     * ARGV[2] = refill_rate (токенов/сек)
     * ARGV[3] = now (unix timestamp float)
     * ARGV[4] = cost (стоимость запроса, обычно 1)
     */
    private string $luaScript = <<<'LUA'
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    local cost = tonumber(ARGV[4])

    local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
    local tokens = tonumber(bucket[1]) or capacity
    local last_refill = tonumber(bucket[2]) or now

    -- Пополняем токены пропорционально прошедшему времени
    local elapsed = math.max(0, now - last_refill)
    tokens = math.min(capacity, tokens + elapsed * refill_rate)

    if tokens < cost then
        -- Сохраняем состояние без изменений
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 10)
        local wait = (cost - tokens) / refill_rate
        return {0, math.floor(tokens), math.ceil(wait)}
    end

    tokens = tokens - cost
    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 10)
    return {1, math.floor(tokens), 0}
    LUA;

    public function handle(Request $request, Closure $next, int $capacity = 60, int $refillRate = 1): Response
    {
        $key = $this->resolveKey($request);
        $now = microtime(true);

        $result = Redis::eval(
            $this->luaScript,
            1,
            "tb_rl:{$key}",
            $capacity,
            $refillRate,
            $now,
            1
        );

        [$allowed, $remaining, $retryAfter] = $result;

        $response = $allowed
            ? $next($request)
            : response()->json(['error' => 'Too Many Requests'], 429);

        $response->headers->set('X-RateLimit-Limit', $capacity);
        $response->headers->set('X-RateLimit-Remaining', max(0, $remaining));

        if (!$allowed) {
            $response->headers->set('Retry-After', $retryAfter);
        }

        return $response;
    }

    private function resolveKey(Request $request): string
    {
        // Приоритет: API-ключ > аутентифицированный пользователь > IP
        if ($apiKey = $request->header('X-API-Key')) {
            return 'apikey:' . hash('sha256', $apiKey);
        }

        if ($user = $request->user()) {
            return 'user:' . $user->id;
        }

        return 'ip:' . $request->ip();
    }
}

Регистрируем middleware в app/Http/Kernel.php или через Route::middleware:

// routes/api.php
use App\Http\Middleware\TokenBucketRateLimit;

// 60 токенов, пополнение 1 токен/сек (burst до 60)
Route::middleware([TokenBucketRateLimit::class . ':60,1'])
    ->group(function () {
        Route::get('/data', [DataController::class, 'index']);
    });

// Премиум эндпоинт: 300 токенов, 5 токенов/сек
Route::middleware([TokenBucketRateLimit::class . ':300,5'])
    ->group(function () {
        Route::get('/premium/data', [PremiumController::class, 'index']);
    });

Конфигурация Redis в Laravel (config/database.php) должна использовать phpredis или predis. Рекомендуется phpredis для production из-за меньших накладных расходов на сериализацию.

6. Гранулярность ограничений

Эффективная защита REST API требует многоуровневых ограничений. Практическая иерархия:

  • По IP: базовая защита от анонимных атак. Ненадёжна при NAT (офисные сети), но необходима как первый уровень. Ключ: rl:ip:1.2.3.4.
  • По API-ключу: для B2B-интеграций. Позволяет назначать индивидуальные квоты. Ключ: rl:apikey:sha256(key). Никогда не используйте сырой ключ в имени Redis-ключа.
  • По пользователю: для аутентифицированных запросов. Не зависит от IP, работает при смене сети. Ключ: rl:user:42.
  • По эндпоинту: разные лимиты для POST /login (5/мин) и GET /catalog (1000/мин). Ключ: rl:user:42:POST:/login.
  • Глобальный лимит сервиса: защита от перегрузки независимо от источника. Ключ: rl:global:service-name.

На практике применяют комбинацию: сначала проверяется глобальный лимит, затем лимит по IP, затем по пользователю/ключу. Если хотя бы один превышен — возвращается 429.

7. Распределённый rate limiting в Kubernetes

В Kubernetes каждый под имеет собственный процесс. Локальные счётчики бесполезны: при 10 репликах реальный лимит становится в 10 раз выше заявленного. Redis Cluster решает проблему централизацией состояния.

Ключевые моменты развёртывания:

  • Redis Sentinel или Redis Cluster: для HA. Sentinel подходит для большинства задач, Cluster — при объёмах > 100K операций/сек.
  • Хеш-теги в ключах: в Redis Cluster ключи одного клиента должны попадать в один слот. Используйте rl:{user:42}:endpoint — фигурные скобки гарантируют хеширование по user:42.
  • Connection pooling: в Go используйте go-redis с настроенным пулом соединений; в Laravel — phpredis с persistent connections.
  • Fail-open vs fail-closed: при недоступности Redis решите заранее: пропускать запросы (fail-open, риск перегрузки) или блокировать (fail-closed, риск отказа сервиса). Для публичного API обычно выбирают fail-open с алертом.
# kubernetes/redis-rate-limiter.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: rate-limiter-config
data:
  REDIS_ADDR: "redis-cluster.default.svc.cluster.local:6379"
  RATE_LIMIT_DEFAULT: "100"
  RATE_LIMIT_WINDOW_SEC: "60"
  RATE_LIMIT_BURST: "20"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-service
spec:
  replicas: 5  # Все 5 подов используют один Redis
  template:
    spec:
      containers:
      - name: api
        envFrom:
        - configMapRef:
            name: rate-limiter-config

8. Правильные HTTP-заголовки и защита от обхода

Стандартные заголовки rate limiting важны для клиентов и совместимости:

  • X-RateLimit-Limit: максимальное количество запросов в окне.
  • X-RateLimit-Remaining: оставшееся количество запросов.
  • X-RateLimit-Reset: Unix timestamp сброса счётчика (для Fixed/Sliding Window).
  • Retry-After: секунды до следующей попытки (обязателен при 429, регламентирован RFC 6585).

Дополнительные механизмы защиты:

  • Whitelist: внутренние сервисы, мониторинг, CI/CD — исключаются из rate limiting по IP или специальному заголовку. Реализуйте через проверку до основной логики.
  • Burst allowance: Token Bucket естественно поддерживает burst. Для Sliding Window можно добавить отдельный burst-счётчик с коротким TTL.
  • Защита от IP-спуфинга: не доверяйте X-Forwarded-For без проверки. Настройте trusted proxies явно (в Laravel — TrustProxies middleware; в Go — X-Real-IP только от известных балансировщиков).
  • Jitter при retry: рекомендуйте клиентам использовать exponential backoff с jitter, чтобы избежать синхронного шторма повторных запросов после снятия блокировки.

9. Мониторинг и алерты

Rate limiting без мониторинга — слепая защита. Необходимо отслеживать:

  • Частота срабатывания лимитов (429 responses): резкий рост — сигнал атаки или бага в клиенте. Экспортируйте метрику rate_limit_exceeded_total{key_type, endpoint} в Prometheus.
  • Топ нарушителей: ключи с наибольшим количеством блокировок за последний час.
  • Latency добавленная rate limiter: вызов Redis должен занимать < 1ms. Если больше — проблема с сетью или Redis.
  • Redis memory usage: при высоком трафике ключи rate limiting могут занимать значительный объём. Мониторьте redis_memory_used_bytes.
// Go: prometheus metrics для rate limiter
var (
    rateLimitHits = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "rate_limit_exceeded_total",
            Help: "Total number of rate limit exceeded events",
        },
        []string{"key_type", "endpoint"},
    )
    rateLimitLatency = prometheus.NewHistogram(
        prometheus.HistogramOpts{
            Name:    "rate_limit_check_duration_seconds",
            Help:    "Duration of rate limit check",
            Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01},
        },
    )
)

func init() {
    prometheus.MustRegister(rateLimitHits, rateLimitLatency)
}

Настройте алерты: если доля 429 ответов превышает 5% от общего трафика в течение 5 минут — немедленное уведомление команды. Если Redis недоступен более 30 секунд — критический алерт.

10. Как выбрать алгоритм под свою задачу

Подведём итог выбора алгоритма для реальных сценариев:

  • Публичный REST API с разными тарифами: Token Bucket — гибкость burst, легко настраивать индивидуальные лимиты per API-key.
  • Защита эндпоинта авторизации (brute force): Sliding Window Counter — точность без граничного эффекта, низкое потребление памяти.
  • Внутренние микросервисы (microservices): Fixed Window — простота, минимальные накладные расходы; граничный эффект некритичен.
  • Стриминг или webhooks: Leaky Bucket — равномерная нагрузка на downstream-сервисы.
  • Многоуровневая защита (рекомендуется): Sliding Window Counter по IP (грубая защита) + Token Bucket по пользователю (точная квота).

Независимо от выбора алгоритма, три правила остаются неизменными: централизованное хранилище состояния (Redis), атомарные операции (Lua), и правильные HTTP-заголовки для клиентов. Rate limiting в 2026 году — не опция, а baseline требование для любого production REST API, особенно в среде Kubernetes с горизонтальным масштабированием.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →