11. Rate limiting в API

«Система, не способная защитить себя от перегрузок и злоупотреблений, не имеет права называться боевой».
— Принцип надежности Bell Labs

Линия обороны: Зачем ограничивать запросы

В предыдущей статье (10. Pagination, filtering, sorting) мы выстроили защиту базы данных от единичных тяжелых запросов, возвращающих миллионы строк. Но что произойдет, если злоумышленник, зациклившийся скрипт интегратора или баг в мобильном приложении начнут запрашивать даже самую легкую страницу 10 000 раз в секунду?

В языке Go создание новой горутины для обработки входящего сетевого сокета обходится рантайму всего в 2 КБ стековой памяти. Сетевой стек Go на базе netpoll (системный вызов epoll в Linux) способен с легкостью удерживать десятки тысяч параллельных TCP-соединений.

Однако нижележащие ресурсы вашей инфраструктуры не обладают такой эластичностью:

  • Пул соединений PostgreSQL (max_connections) мгновенно исчерпается, и новые запросы начнут падать с ошибкой too many connections.
  • Внешние провайдеры (платежные шлюзы, SMS-провайдеры, AI API) выставят гигантские счета или заблокируют ваш аккаунт за нарушение квот.
  • Нагрузка на процессор вызовет деградацию всех остальных сервисов, делящих общий сервер или кластер.

Rate Limiting (Ограничение частоты запросов) — это фундаментальный архитектурный паттерн защиты распределенной системы, который контролирует количество входящих операций от одного клиента (IP-адреса, User ID или API-токена) за единицу времени.


Алгоритмы Rate Limiting: От наивных к индустриальным

Инженер уровня Senior должен знать не только как подключить готовый middleware, но и по какому математическому алгоритму он функционирует, так как выбор алгоритма определяет расход оперативной памяти и устойчивость к пиковым всплескам (Burst).

1. Fixed Window (Фиксированное окно)

Самый простой алгоритм. Мы делим время на фиксированные интервалы (например, с 12:00:00 до 12:01:00) и заводим счетчик в памяти или Redis. Каждое обращение клиента делает инкремент. Если счетчик превысил лимит (например, 100) — отклоняем запросы до наступления следующей минуты.

  • Проблема: «Проблема границы окна» (Window Boundary Spikes). Пользователь может отправить 100 запросов в 12:00:59 и еще 100 запросов в 12:01:01. В результате база данных получит сокрушительный удар в 200 запросов всего за две секунды, хотя формально лимит «100 запросов в минуту» не был нарушен ни в одном из окон.

2. Sliding Window (Скользящее окно)

Улучшенная версия. Мы храним временную метку (Timestamp) каждого запроса в массиве (например, в Redis List или Sorted Set ZSET). При поступлении нового запроса мы удаляем из структуры все метки старше 60 секунд и подсчитываем количество оставшихся элементов.

  • Проблема: Чудовищный расход памяти. Хранить миллионы меток времени для каждого пользователя — это прямой путь к Out Of Memory (OOM) базы данных Redis.

3. Token Bucket (Маркерная корзина) — Индустриальный стандарт

Именно этот алгоритм реализован в стандартной библиотеке golang.org/x/time/rate, а также в сервисах AWS, Stripe и других бигтехах.

У каждого клиента есть виртуальная "корзина", вмещающая максимум Burst токенов. Корзина пополняется со скоростью Rate (например, 10 токенов в секунду). При каждом запросе мы забираем 1 токен. Если корзина пуста — запрос отклоняется.

flowchart TD
    Clock["Таймер ОС"] -->|"Пополняет со скоростью Rate"| Bucket["Корзина токенов<br>Максимум Burst"]
    Request["Входящий HTTP Запрос"] --> Check{"Есть ли токен?"}
    Check -->|"Да"| Consume["Забрать 1 токен"]
    Consume --> Allow["Пропустить в бизнес-логику"]
    Check -->|"Нет токенов"| Block["HTTP 429 Too Many Requests"]

    classDef proc fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
    classDef fast fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
    classDef slow fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#e2e8f0;
    class Clock,Request proc;
    class Bucket,Consume,Allow fast;
    class Check,Block slow;
  

Алгоритм гармонично сглаживает пики: если клиент некоторое время молчал, корзина заполняется до объема Burst, позволяя выполнить кратковременный залп запросов, после чего скорость жестко ограничивается величиной Rate.


Mechanical Sympathy: Как работает Token Bucket в Go

Если попросить начинающего разработчика написать Token Bucket с нуля, он почти наверняка создаст time.Ticker и запустит фоновую горутину:

Go
// АНТИПАТТЕРН: Убийца планировщика Go
func startRefill(bucket *Bucket) {
    ticker := time.NewTicker(time.Second)
    go func() {
        for range ticker.C {
            bucket.Lock()
            bucket.Tokens++
            bucket.Unlock()
        }
    }()
}

Идиоматичное решение (Lazy Evaluation):
Официальный пакет golang.org/x/time/rate не использует ни одной фоновой горутины. Он использует ленивое вычисление.

Структура хранит только текущее количество токенов и last_updated (время последнего запроса).
Когда приходит новый запрос, пакет берет time.Now(), вычисляет delta = now - last_updated, умножает это на скорость пополнения (Rate) и прибавляет результат к текущему количеству токенов.

Это чистая математика (операции с плавающей точкой или целыми числами), выполняющаяся за наносекунды с алгоритмической сложностью $O(1)$ по времени и памяти.


Распределенный Rate Limiting: Боль микросервисов

Локальный лимитер в памяти Go работает отлично... пока вы не деплоите свое приложение в Kubernetes с 5 репликами (Pods).

Если балансировщик Nginx раскидывает запросы клиента по разным подам по принципу Round Robin, каждый под будет считать лимиты отдельно. Лимит в 100 RPS превратится в 500 RPS.

Для распределенного Rate Limiting нам нужен единый источник истины. Обычно это Redis.

Проблема Race Conditions

Представим, что два Go-инстанса одновременно получают запрос от одного IP и обращаются к Redis:

  1. Pod 1 делает GET ip_123 -> получает 10.
  2. Pod 2 делает GET ip_123 -> получает 10.
  3. Pod 1 вычисляет 10 - 1 = 9 и делает SET ip_123 9.
  4. Pod 2 вычисляет 10 - 1 = 9 и делает SET ip_123 9.

Итог: Обработано 2 запроса, но списан только 1 токен. Это классическое состояние гонки (Lost Update).

flowchart LR
    Go1["Go Pod 1"] -->|"EVAL lua-script<br>Атомарный вызов"| Redis["Redis Single Thread"]
    Go2["Go Pod N"] -->|"EVAL lua-script"| Redis
    Redis -->|"Блокирует другие операции<br>пока скрипт работает"| Calc["Lua: Вычисление токенов"]
    Calc --> Redis

    classDef proc fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
    classDef fast fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
    class Go1,Go2 proc;
    class Redis,Calc fast;
  

HTTP Контракт: Как отказывать красиво

Когда лимиты исчерпаны, мы обязаны использовать правильные HTTP-статусы (6. Статусы HTTP).

  1. HTTP Статус: 429 Too Many Requests. Это четкий сигнал клиенту, что проблема не в данных, а в частоте.
  2. Заголовок Retry-After: Спецификация требует возвращать этот заголовок. Он указывает клиенту, через сколько секунд ему разрешено повторить запрос.
    • Retry-After: 60 (повторить через минуту)
  3. Заголовки состояния (X-RateLimit): Индустриальный стандарт де-факто (хоть и не закреплен в RFC жестко) — отдавать клиенту понимание его квоты при КАЖДОМ ответе (даже при 200 OK):
    • X-RateLimit-Limit: 100 (максимум запросов в минуту)
    • X-RateLimit-Remaining: 42 (осталось запросов в текущем окне)
    • X-RateLimit-Reset: 1698765432 (Unix Timestamp, когда лимиты обнулятся)

Ловушка на собеседовании: Утечка памяти в локальном лимитере

Допустим, вы пишете API Gateway на Go и решили использовать локальный golang.org/x/time/rate. Вы создаете мапу:

Go
var (
    limiters = make(map[string]*rate.Limiter) // ключ - IP адрес
    mu       sync.RWMutex
)

func getVisitor(ip string) *rate.Limiter {
    mu.RLock()
    limiter, exists := limiters[ip]
    mu.RUnlock()

    if !exists {
        mu.Lock()
        limiter = rate.NewLimiter(1, 3)
        limiters[ip] = limiter
        mu.Unlock()
    }
    return limiter
}

Итог

  1. Rate Limiting — обязательный паттерн для любого production API. Без него ваш сервис уязвим для DDoS или ошибок на клиенте.
  2. Под капотом правильный лимитер (Token Bucket) использует ленивые вычисления и монотонные часы ОС, избегая создания тысяч горутин.
  3. В распределенных системах полагаться на память Go нельзя — нужен Redis и атомарные Lua-скрипты.
  4. Обязательно соблюдайте HTTP-контракт: возвращайте статус 429 и заголовки Retry-After.

Ограничив агрессивных клиентов, мы можем заняться оптимизацией легитимных. Как сделать так, чтобы тяжелый GET-запрос вообще не доходил до базы данных и даже не парсился внутри Go-приложения? В следующей статье мы разберем тонкости работы с сетями доставки и заголовками для реализации кэширования: 12. Caching HTTP.