- Оглавление
- Зачем бизнесу лимиты и квоты
- Где ставить лимиты: периметр, шлюз, приложение, база
- Что именно ограничиваем: субъект, ресурс, «вес» запроса
- Алгоритмы: фиксированное окно, скользящее окно, ведро с жетонами, «протекающее ведро»
- Примеры: NGINX на периметре; Redis + Lua + Go внутри сервиса
- Корректные ответы клиенту и UX
- Наблюдаемость: метрики, логи, алерты
- Квоты и монетизация
- Тестирование и проверка на бою
- Частые ошибки и чек-лист внедрения
Зачем бизнесу лимиты и квоты
Лимиты запросов (rate limiting) и квоты — это не про «мешать пользователям», а про здоровье сервиса и деньги. Они:
- защищают от внезапных пиков, не давая одному клиенту «съесть» весь ресурс;
- стабилизируют время ответа и сокращают инциденты при всплесках;
- делают расходы на инфраструктуру предсказуемыми и управляемыми;
- упрощают тарифы: можно честно обещать SLA и масштабировать выручку без бесконечного оверинжиниринга.
Признаки, что пора внедрять лимиты:
- редкие, но разрушительные пики нагрузки (акции, интеграции партнёров, массовые ретраи);
- «шум» от одной группы клиентов бьёт по остальным;
- непредсказуемые счета за облако из‑за автоскейлинга;
- служба поддержки ловит жалобы «вчера работало быстро, сегодня всё тормозит».
Где ставить лимиты: периметр, шлюз, приложение, база
Правильный ответ обычно «в нескольких местах», но с разными задачами и деталями.
- На периметре (CDN/WAF/балансировщик). Быстрый и дешёвый отсев по IP, ASN, стране, бот‑признакам. Полезно против перегрева от внешних источников. Подходит для простых правил: «не более 10 запросов/с с одного IP».
- В API‑шлюзе/прокси (Kong, Envoy, NGINX). Тонкие лимиты по ключу клиента, маршруту, методу, «весу» запроса. Хорошее место, если у вас много бэкендов и нужна единая политика.
- В приложении. Здесь видно бизнес‑контекст: клиент, организация, тариф, стоимость операции. Можно учитывать нагрузку на зависимые системы и выносить «дорогие» запросы в отдельные корзины.
- У хранилищ и внешних интеграций. Лимиты на пул подключений к базе, на частоту запросов к внешним API, чтобы чужие лимиты не выбивали ваш SLA.
Золотое правило: дешёвые фильтры — как можно раньше; самые умные и «дорогие» — там, где есть бизнес‑контекст.
Что именно ограничиваем: субъект, ресурс, «вес» запроса
- Субъект лимита: ключ API, пользователь, организация (тенант), IP, токен скоупа. Часто применяют каскад: «сначала по ключу, затем по организации».
- Ресурс: путь или группа маршрутов (например, /search отдельно от /billing), метод (GET/POST), отдельные «дорогие» операции.
- «Вес» запроса: не все запросы равны. Поиск по большим индексам, сложные отчёты или генерация файлов могут обходиться в 5–50 раз дороже, чем «получить профиль». Весовой лимит позволяет считать «жетоны» пропорционально стоимости.
- Тип лимита:
- скорость: X запросов в секунду/минуту (с «всплеском»);
- квота за период: Y операций в сутки/месяц;
- конкурентность: не более Z одновременных запросов/задач;
- бюджет ретраев: ограничить шквал повторов от неудачных интеграций.
Алгоритмы: фиксированное окно, скользящее окно, ведро с жетонами, «протекающее ведро»
Фиксированное окно (fixed window)
Просто и быстро: считаем запросы в минуте и обнуляем счётчик в начале новой минуты. Плюсы — легко реализовать в Redis (INCR + EXPIRE). Минус — пограничные «качели»: клиент может сделать почти двойной объём на стыке окон.
Скользящее окно (sliding window)
Гладкая нагрузка: учитываем последние N секунд. Реализации:
- «два окна»: текущая и предыдущая минуты с линейной интерполяцией — почти бесплатно, достаточно двух счётчиков;
- «окно с журналом» на отсортированном множестве (Redis ZSET), где удаляем старые метки времени — точнее, но дороже по памяти и времени.
Ведро с жетонами (token bucket)
Подходит, когда нужен «всплеск» (burst), но средняя скорость ограничена. Ведро пополняется жетонами с постоянной скоростью; запрос тратит жетоны по своему «весу». Если жетонов нет — отклоняем или ждём. Можно хранить состояние в Redis и обновлять атомарным Lua‑скриптом.
Протекающее ведро (leaky bucket)
Стабильный «слив» с фиксированной скоростью. Удобно, если главное — равномерный поток. Минус — хуже поддержка «всплеска».
Практика: для API чаще берут token bucket или «два окна». Для экспортов/отчётов — квоты за период и/или очереди задач.
Примеры: NGINX на периметре; Redis + Lua + Go внутри сервиса
NGINX: простой лимит по IP и по ключу клиента
Этот вариант дешёв и эффективен у границы. Он не знает о тарифах, но срежет грубые пики.
# Лимит по IP: 5 запросов/с, допускаем всплеск до 10 без задержки
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=5r/s;
# Лимит по ключу API из заголовка X-API-Key: 100 запросов/мин
map $http_x_api_key $api_key {
default $http_x_api_key;
}
limit_req_zone $api_key zone=per_key:20m rate=100r/m;
server {
listen 443 ssl;
location /api/ {
# Сначала отсев по IP
limit_req zone=per_ip burst=10 nodelay;
# Затем — по ключу клиента
limit_req zone=per_key burst=50 nodelay;
# Полезные заголовки клиенту
add_header X-RateLimit-Policy "per_ip=5r/s; per_key=100r/m" always;
proxy_pass http://backend;
}
}
Плюс: быстро и просто. Минус: нет знания о «весе» запроса и тарифах. Часто комбинируют с более умным лимитом в приложении.
Redis + Lua + Go: ведро с жетонами с учётом «веса» запросов
Атомарный Lua‑скрипт хранит состояние ведра (сколько жетонов, когда последний раз пополняли). Приложение передаёт ключ (клиент/тенант), вместимость ведра, скорость пополнения и «вес» текущего запроса.
Lua‑скрипт для Redis:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_per_sec = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl_sec = tonumber(ARGV[5])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now_ms
end
local delta_ms = now_ms - ts
if delta_ms < 0 then delta_ms = 0 end
local add = (delta_ms / 1000.0) * refill_per_sec
tokens = math.min(capacity, tokens + add)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now_ms)
redis.call('EXPIRE', key, ttl_sec)
return {allowed, math.floor(tokens)}
Пример на Go с github.com/redis/go-redis/v9:
package main
import (
"context"
"fmt"
"time"
redis "github.com/redis/go-redis/v9"
)
var script = redis.NewScript(`
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_per_sec = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl_sec = tonumber(ARGV[5])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now_ms
end
local delta_ms = now_ms - ts
if delta_ms < 0 then delta_ms = 0 end
local add = (delta_ms / 1000.0) * refill_per_sec
tokens = math.min(capacity, tokens + add)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now_ms)
redis.call('EXPIRE', key, ttl_sec)
return {allowed, math.floor(tokens)}
`)
func allow(ctx context.Context, rdb *redis.Client, key string, capacity, refillPerSec, cost, ttlSec int) (bool, int, error) {
res, err := script.Run(ctx, rdb, []string{key}, capacity, refillPerSec, time.Now().UnixMilli(), cost, ttlSec).Result()
if err != nil {
return false, 0, err
}
vals := res.([]interface{})
allowed := vals[0].(int64) == 1
remaining := int(vals[1].(int64))
return allowed, remaining, nil
}
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
defer rdb.Close()
key := "rate:org:42"
capacity := 200
refillPerSec := 50
cost := 5
ttlSec := 3600
ok, rem, err := allow(ctx, rdb, key, capacity, refillPerSec, cost, ttlSec)
if err != nil {
panic(err)
}
fmt.Printf("allowed=%v, remaining=%d\n", ok, rem)
}
Как связать с тарифом: храните параметры ведра (capacity, refill) в профиле тенанта, а «вес» операции — в справочнике маршрутов. Например, «чтение профиля» = 1, «генерация отчёта» = 20.
Корректные ответы клиенту и UX
- Статус 429 Too Many Requests. Текст — человеческий: что произошло и через сколько повторить.
- Заголовки:
- Retry-After — подскажите секунды ожидания (или время в формате даты);
- X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset — де‑факто стандарт, понятный многим клиентам;
- или поля семейства RateLimit-*, если ваша платформа их поддерживает.
- Не душите «тихие» запросы: давайте минимальный бесплатный лимит, чтобы интеграции не падали сразу и могли деградировать грациозно.
- Для квот за период — заранее предупреждайте (почта/веб‑хуки), когда израсходовано 80/90/100%.
Мини‑пример ответа:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 15
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712678400
{"error":"rate_limited","message":"Превышен лимит: попробуйте через 15 секунд или обратитесь в поддержку для повышения квоты."}
Наблюдаемость: метрики, логи, алерты
- Метрики:
- rate_limited_total{key, route, reason}
- remaining_tokens_gauge{tenant}
- обработанные/отклонённые запросы по субъектам и маршрутам
- Логи: ключ клиента, маршрут, рассчитанный «вес», оставшиеся жетоны, причина отклонения.
- Дашборды: распределение отказов по тарифам, карта «горячих» маршрутов.
- Алерты: резкий рост 429, исчерпание квот у ключевых клиентов, всплеск ретраев.
Квоты и монетизация
- Прозрачные тарифы: «10 тыс. дешёвых операций + 1 тыс. дорогих в месяц» проще объяснить, чем «безлимит». Клиент понимает, за что платит.
- «Мягкие» и «жёсткие» лимиты: сначала замедляйте (увеличивайте задержки или снижайте приоритет), затем — отказывайте. Это уменьшает негатив и сохраняет SLA для остальных.
- Сверхлимиты: дайте возможность докупать пакет операций. Автоматический апгрейд при согласии клиента снижает отток.
- Самообслуживание: страница «Мои лимиты» с графиками расхода и кнопкой «Увеличить» экономит часы поддержки.
Тестирование и проверка на бою
- Нагрузочное тестирование: сценарии «пила», «ступенька», «шквал ретраев». Проверяйте, что средняя скорость выдерживается, а всплески ограничиваются.
- Трафик‑тени: применяйте лимиты в «сухом режиме» (только считаем, не блокируем) и сравнивайте метрики.
- Интеграции с партнёрами: предупредите о новых лимитах, дайте тестовый стенд и образцы ответов 429.
- Каос‑тесты: что будет, если Redis недоступен? Предусмотрите режим «fail-open» или «fail-closed» по типам маршрутов. К критичным (платёж) — одно, к вспомогательным (поиск) — другое.
Частые ошибки и как их избежать
- «Один глобальный лимит на всё». В итоге шумный маршрут душит критичные операции. Лечится разделением по маршрутам и «весу».
- Только в памяти на инстансе. При горизонтальном масштабировании лимиты перестают быть глобальными. Если нужна общая политика — используйте Redis/шлюз.
- Срыв на границах окон. Фиксированное окно провоцирует двойные пики. Используйте «два окна» или token bucket.
- Отсутствие обратной связи. Без заголовков и Retry-After клиенты слепы и бомбят ретраями. Дайте подсказки и примеры экспоненциальной паузы.
- Бесконечные EXPIRE без контроля. Держите состояние не дольше разумного TTL, чтобы не разрастался ключспейс Redis.
- Игнор времени сервера. Используйте время Redis (команда TIME) или передавайте время с бэкенда, где вы контролируете синхронизацию.
Чек-лист внедрения
- Определите субъекты и ресурсы лимита: кто и что ограничивается.
- Разбейте операции по «весу». Согласуйте со службой поддержки и продуктовой командой.
- Выберите места применения: периметр для грубого отсечения, шлюз/приложение — для умных правил.
- Подберите алгоритм: token bucket для «всплесков», «два окна» для простоты.
- Продумайте отказоустойчивость: что делать, если хранилище лимитов недоступно.
- Возвращайте корректные ответы: 429, Retry-After, X-RateLimit-*.
- Включите наблюдаемость: метрики, логи, алерты, дашборды.
- Сверьте с тарифами и биллингом. Настройте уведомления об исчерпании.
- Запустите в «сухом режиме», затем постепенно включайте блокировку.
- Проведите учения с поддержкой: ответы на типовые вопросы клиентов.
Итоги
Лимитирование запросов и квотирование — это управляемая производительность и предсказуемые деньги. Начните с простого: грубые лимиты на периметре и осознанные правила в приложении с учётом «веса» операций. Добавьте корректные ответы, наблюдаемость и связь с тарифами — и у вас будет система, которая защищает сервис, помогает продажам и снижает риски инцидентов без боли.