6. Rate limiting

Фейсконтроль для вашего API: Защита от абьюза и DDoS

В предыдущей статье 5. Traffic shaping мы примерили на себя роль вежливого клиента: учились «причёсывать» исходящие вызовы, чтобы не создавать помех чужим серверам и беречь буферы сетевых коммутаторов. Теперь мы разворачиваемся на 180 градусов: мы — сервер, выставленный открытым лицом в агрессивный публичный интернет, и наша прямая обязанность — защитить внутренние ресурсы от захлёбывания.

Ранее мы познакомились с механизмом 5. Load shedding. Он выступает в роли аварийного рубильника при глобальной перегрузке ноды. Но у механизма Load Shedder есть фундаментальный изъян: он слеп к источнику трафика. Если один недобросовестный клиент (или сломавшийся сторонний парсер) начнёт бомбардировать шлюз миллионами запросов, Load Shedder начнёт без разбора сбрасывать запросы всех подряд, и добропорядочные пользователи увидят холодный ответ 503 Service Unavailable.

Паттерн Rate Limiting (Ограничение скорости / Квотирование) восстанавливает справедливость. Он выясняет, кто именно стучится в дверь, сопоставляет интенсивность его запросов с выделенной квотой, и при превышении лимита хладнокровно возвращает код ответа 429 Too Many Requests.

В этой лекции мы препарируем математику классических алгоритмов лимитирования, спроектируем потокобезопасное middleware на Go с защитой от утечек памяти и разберём, почему Redis и язык Lua стали стандартом де-факто для распределённого квотирования.


Алгоритмы Rate Limiting

Проектирование надёжного лимитера начинается с выбора математической модели. На архитектурных собеседованиях недостаточно просто назвать алгоритм — важно ясно понимать его краевые эффекты (Edge Cases) и цену по памяти.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Models["Фундаментальные алгоритмы Rate Limiting"]
        direction TB
        FW["Fixed Window Counter<br>Простые счетчики по интервалам<br>(Опасность Thundering Herd)"]
        SWL["Sliding Window Log<br>Точный лог таймстампов в памяти<br>(Высокий расход RAM)"]
        SWC["Sliding Window Counter<br>Взвешенная интерполяция двух окон<br>(Индустриальный стандарт Cloudflare)"]
        TB["Token Bucket<br>Корзина токенов с допуском Burst<br>(x/time/rate Allow)"]
    end
  

1. Fixed Window Counter (Счётчик фиксированного окна)

Самый примитивный подход: ось времени разбивается на равные дискретные интервалы (например, ровно с 12:00 до 12:01). Для каждого уникального идентификатора клиента в памяти или хранилище поддерживается целочисленный счётчик. При поступлении запроса счётчик инкрементируется. Если он превысил квоту (например, 100 запросов), входящие вызовы блокируются вплоть до наступления следующей календарной минуты.

  • Преимущества: Предельная простота реализации. В Redis сводится к паре команд INCR и EXPIRE.
  • Недостатки (Проблема Thundering Herd на стыке окон): Всплеск нагрузки на границе интервалов. Если хитрый клиент отправит 100 запросов в 12:00:59, а следующие 100 запросов — в 12:01:01, ваш сервер за две секунды получит двойной удар в 200 запросов, хотя формально лимит «100 запросов в минуту» формально не был нарушен ни в одном из окон.

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

Чтобы избавиться от проблемы стыка окон, мы отказываемся от единого счётчика и начинаем протоколировать точный timestamp (метку времени) каждого поступившего запроса (например, с помощью структуры Sorted Set в Redis). При поступлении очередного запроса система удаляет все метки старше расчётного окна (например, старше 60 секунд) и проверяет размер оставшегося множества.

  • Преимущества: Абсолютная математическая точность. Никаких артефактов на границах окон.
  • Недостатки: Колоссальный аппетит к оперативной памяти. Если лимит клиента составляет 10 000 RPS, вам придётся непрерывно аллоцировать и хранить тысячи 64-битных чисел для каждого активного пользователя.

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

Элегантный компромисс, снискавший статус промышленного стандарта (в частности, лежит в основе алгоритмов Cloudflare). Вместо хранения лога запросов мы храним всего два целочисленных счётчика: для текущего и предыдущего фиксированного окна.

При проверке запроса интенсивность трафика интерполируется по времени:
$$\text{Оценка запросов} = (\text{Счётчик предыдущего окна} \times \% \text{оставшегося времени}) + \text{Счётчик текущего окна}$$

Алгоритм требует мизерного объёма памяти (всего два числа на пользователя), полностью сглаживает скачки на стыках окон и даёт погрешность не более 0.05% на реальном трафике.

4. Token Bucket (Маркерная корзина)

Алгоритм, с которым мы работали в пакете x/time/rate. Корзина непрерывно пополняется токенами с заданной скоростью до предела ёмкости. Приходящий запрос списывает токен. Если токенов нет — запрос отсекается.

Для фильтрации входящего трафика мы используем неблокирующий метод Allow() (вместо усыпляющего Wait()). Если Allow() вернул false — сервер мгновенно рапортует об отказе.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Req["Входящий HTTP-запрос"]:::entry --> ID{"Идентификация<br>(IP, JWT, API Key)"}
    ID --> Fetch["Получение состояния<br>Token Bucket из памяти"]
    Fetch --> Check{"Токенов > 0?"}

    Check -->|"Да"| Allow["Уменьшение токенов на 1<br>(Allow() == true)<br>Пропуск к бизнес-логике"]
    Check -->|"Нет"| Deny["Fast Fail<br>(Allow() == false)<br>HTTP 429 Too Many Requests"]
  

Реализация In-Memory (Локальный Rate Limiter)

Классическая практическая задача на проектирование Go-сервисов — создание HTTP-middleware для защиты эндпоинтов по IP-адресу без привлечения внешних баз данных.

Ниже приведена образцовая реализация локального in-memory лимитера с контролем устаревания записей:

Go
package ratelimit

import (
    "net/http"
    "sync"
    "time"

    "golang.org/x/time/rate"
)

// client содержит лимитер и время последней активности (для очистки)
type client struct {
    limiter  *rate.Limiter
    lastSeen time.Time
}

type IPMiddleware struct {
    mu      sync.RWMutex
    clients map[string]*client
    r       rate.Limit
    b       int
}

// NewIPMiddleware создает лимитер: r - кол-во запросов в секунду, b - всплеск
func NewIPMiddleware(r rate.Limit, b int) *IPMiddleware {
    m := &IPMiddleware{
        clients: make(map[string]*client),
        r:       r,
        b:       b,
    }

    // Запускаем фоновый сборщик мусора для старых IP
    go m.cleanupStaleClients()
    return m
}

// Limit - стандартный HTTP middleware
func (m *IPMiddleware) Limit(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr // Упрощенно. В реальности нужно парсить X-Forwarded-For

        m.mu.Lock() // Глобальный лок - узкое место! (См. Mechanical Sympathy)
        c, exists := m.clients[ip]
        if !exists {
            c = &client{limiter: rate.NewLimiter(m.r, m.b)}
            m.clients[ip] = c
        }
        c.lastSeen = time.Now()
        m.mu.Unlock()

        if !c.limiter.Allow() { // Allow() работает атомарно под капотом, без сна
            http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
            return
        }

        next.ServeHTTP(w, r)
    })
}

// cleanupStaleClients удаляет IP, которые не делали запросов более 3 минут
func (m *IPMiddleware) cleanupStaleClients() {
    for {
        time.Sleep(time.Minute)
        m.mu.Lock()
        for ip, client := range m.clients {
            if time.Since(client.lastSeen) > 3*time.Minute {
                delete(m.clients, ip)
            }
        }
        m.mu.Unlock()
    }
}

Mechanical Sympathy: Проблема глобального мьютекса

Взгляните на наш middleware глазами аппаратуры многоядерного процессора. В коде используется единая блокировка sync.RWMutex (а точнее, m.mu.Lock()), которая захватывается в эксклюзивном режиме на каждый входящий HTTP-запрос для обновления метки lastSeen.

Если через сервис проходит скромный трафик в 100 RPS, накладные расходы незаметны. Но когда нагрузка достигает 10 000–50 000 RPS на 32-ядерном сервере, этот единственный мьютекс превращается в чудовищное узкое место — точку жесточайшей конкуренции за блокировку (Lock Contention):

  1. Десятки ядер процессора одновременно пытаются выполнить атомарную инструкцию LOCK CMPXCHG над одной строкой кэша L3 (Cache Line 64 байта).
  2. Происходит бесконечный шторм инвалидации кэшей межъядерной шины (Cache Bouncing).
  3. Планировщик Go вынужден вытеснять горутины в очередь ожидания семафора рантайма (semacquire), сжигая драгоценные миллисекунды в системных паузах.
flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Single["Глобальный Mutex (Высокая конкуренция)"]
        Reqs1["10 000 RPS"]:::data --> Mutex["Единый sync.RWMutex<br>(Lock Contention, Cache Bouncing)"]
        Mutex --> Map1["Единая map[string]*client"]
    end

    subgraph Sharded["Шардированная структура (Sharded Map)"]
        Reqs2["10 000 RPS"]:::process --> Hash["Хэш от IP (FNV-1a / Murmur3)"]
        Hash --> B0["Корзина 0 (Mutex 0)"]
        Hash --> B1["Корзина 1 (Mutex 1)"]
        Hash --> B255["Корзина 255 (Mutex 255)"]
    end
  

Как устранить бутылочное горлышко?
Вместо единой гигантской мапы используется паттерн Sharded Map (принцип, популяризированный пакетами concurrent-map и внутренним дизайном sync.Map). Мы создаём массив из 256 независимых корзин (шардов). Каждая корзина защищена своим персональным мьютексом. Быстрый хэш от IP-адреса направляет запрос в соответствующий шард:
$$\text{Шард} = \text{hash}(IP) \pmod{256}$$
Это распределяет нагрузку на межъядерную шину и снижает уровень конкуренции за блокировку ровно в 256 раз!


Распределенный Rate Limiter (Redis)

Локальный лимитер в оперативной памяти превосходно справляется с защитой от грубых атак типа DoS/DDoS. Однако он принципиально непригоден для управления бизнес-квотами (например, «Пользователь тарифного плана Free имеет право на 1000 запросов к API в час»).

В продакшене ваше Go-приложение масштабировано на 10 независимых подов в Kubernetes за балансировщиком нагрузки. Если пользователь будет отправлять запросы в цикле, они будут равномерно распределяться между подами. В результате клиент сможет безнаказанно совершить $10 \times 1000 = 10\,000$ обращений в час!

Для поддержания глобальной согласованности необходимо централизованное хранилище состояния с минимальной задержкой. Общепризнанным стандартом индустрии здесь выступает Redis.

Но наивная реализация в связке с Redis опасна. Если Go-сервер сначала делает GET key, затем инкрементирует число в коде и делает SET key, в условиях высокой конкуренции возникает классическое состояние гонки (Race Condition). Чтобы гарантировать строгость подсчёта, проверки оформляют в виде атомарных Lua-скриптов:

Канонический Lua-скрипт для алгоритма фиксированного окна с автоматической установкой времени жизни (TTL):

LUA
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = redis.call("GET", key)

if current and tonumber(current) >= limit then
    return 0 -- Лимит исчерпан: возвращаем отказ (HTTP 429)
end

redis.call("INCR", key)
if not current then
    -- Если счетчик создан впервые, устанавливаем TTL 60 секунд
    redis.call("EXPIRE", key, 60)
end
return 1 -- Запрос одобрен (HTTP 200)

На стороне Go для взаимодействия с Redis используется библиотека go-redis. Для максимальной производительности скрипт загружается в память Redis единожды при старте приложения, после чего вызывается через команду EvalSha, передающую лишь SHA-1 хэш скрипта, массив ключей KEYS (IP-адрес или идентификатор клиента) и аргументы ARGV (максимально допустимый лимит):

Go
// Вызов предкомпилированного Lua-скрипта через EvalSha в go-redis
res, err := redisClient.EvalSha(ctx, scriptSHA, []string{clientKey}, limit).Int()

Архитектурные ловушки (Gotchas)

1. Подмена X-Forwarded-For (IP Spoofing)

В нашем примере локального лимитера мы обратились к полю r.RemoteAddr. Однако в реальной инфраструктуре контейнеры Go всегда изолированы за сетевыми балансировщиками (AWS ALB, Cloudflare, Ingress-контроллеры Nginx или Envoy). Значение r.RemoteAddr будет неизменно содержать внутренний IP-адрес самого балансировщика! Если вы начнёте блокировать по этому адресу, то при первом же нарушении положите весь входящий трафик кластера.

Инженеры вынуждены извлекать реальный IP клиента из HTTP-заголовков X-Forwarded-For или X-Real-IP.

2. Цена распределенного лимитирования и стратегия Fail Open

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

Как реагировать Go-приложению на сбой центрального хранилища?

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Start["Запрос к Redis EvalSha"]:::entry --> State{"Доступен ли Redis?"}
    State -->|"OK"| Check{"Лимит превышен?"}
    Check -->|"Нет"| Allow["HTTP 200 OK"]
    Check -->|"Да"| Deny["HTTP 429 Too Many Requests"]

    State -->|"TIMEOUT / ERROR"| Strategy{"Выбор стратегии"}
    Strategy -->|"Fail Closed"| Kill["HTTP 500 Error<br>(Падение бизнеса)"]
    Strategy -->|"Fail Open (Рекомендовано)"| Pass["Логирование инцидента<br>Пропуск запроса (HTTP 200)"]
  

Итог раздела "Сеть"

  1. Эшелонированное квотирование: Мы защищаем бэкенд многослойно: сначала быстрый L4/L7 Rate Limiting отсекает недобросовестных потребителей, а при экстремальном системном стрессе аварийный Load Shedding спасает процесс от падения.
  2. Опасность наивных структур: Локальный лимитер в Go обязан иметь цикл сборки устаревших ключей (TTL), иначе сервис неминуемо погибнет от утечки памяти при атаке ботов.
  3. Механическое сочувствие к памяти: На высоких нагрузках глобальный sync.RWMutex парализует производительность из-за Lock Contention. Шардирование таблицы клиентов на 256 корзин кратно увеличивает пропускную способность.
  4. Атомарность распределённых лимитов: Для согласованного контроля квот между множеством реплик в Kubernetes используется связка Redis + Lua-скрипты, вызываемые через EvalSha.
  5. Отказоустойчивость: При деградации распределённого хранилища лимитов используйте стратегию Fail Open: стабильность клиентского сервиса всегда в приоритете.

Мы завершили фундаментальный и насыщенный раздел, охватывающий сетевой стек, сервис-меши, mTLS, сетевые политики, шейпинг и квотирование трафика в распределённых системах. Теперь наш Go-код надёжен, защищён и готов к любым штормам. Но как упаковать этот сложный ансамбль сервисов так, чтобы он развёртывался предсказуемо и одинаково безупречно на ноутбуке инженера и на кластерах из тысяч физических серверов?

Мы открываем новую большую главу, посвящённую контейнеризации и оркестрации. Вперёд, к следующему разделу: 1. Контейнеризация и Docker!