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
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:
- Pod 1 делает
GET ip_123-> получает10. - Pod 2 делает
GET ip_123-> получает10. - Pod 1 вычисляет 10 - 1 = 9 и делает
SET ip_123 9. - 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).
- HTTP Статус:
429 Too Many Requests. Это четкий сигнал клиенту, что проблема не в данных, а в частоте. - Заголовок Retry-After: Спецификация требует возвращать этот заголовок. Он указывает клиенту, через сколько секунд ему разрешено повторить запрос.
Retry-After: 60(повторить через минуту)
- Заголовки состояния (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. Вы создаете мапу:
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
}
Итог
- Rate Limiting — обязательный паттерн для любого production API. Без него ваш сервис уязвим для DDoS или ошибок на клиенте.
- Под капотом правильный лимитер (Token Bucket) использует ленивые вычисления и монотонные часы ОС, избегая создания тысяч горутин.
- В распределенных системах полагаться на память Go нельзя — нужен Redis и атомарные Lua-скрипты.
- Обязательно соблюдайте HTTP-контракт: возвращайте статус
429и заголовкиRetry-After.
Ограничив агрессивных клиентов, мы можем заняться оптимизацией легитимных. Как сделать так, чтобы тяжелый GET-запрос вообще не доходил до базы данных и даже не парсился внутри Go-приложения? В следующей статье мы разберем тонкости работы с сетями доставки и заголовками для реализации кэширования: 12. Caching HTTP.