Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Лимиты запросов и обратное давление: как защитить сервисы от пиков без 500‑х и потерь заказов

Разработка и технологии24 марта 2026 г.
Пики трафика, ошибки интеграций и шумные клиенты валят даже хорошие системы. Рассказываем, как внедрить лимиты запросов и обратное давление: где ставить, какие алгоритмы выбрать, как ответить клиенту и что мерить. С примерами на Nginx и Redis, плюс практики приоритизации и наблюдаемости. В результате — меньше инцидентов, контролируемая деградация и сохранённая выручка.
Лимиты запросов и обратное давление: как защитить сервисы от пиков без 500‑х и потерь заказов

  • Оглавление
    • Зачем бизнесу лимиты и обратное давление
    • Где ставить ограничения: периметр, приложение, ресурсы
    • Алгоритмы: фиксированное окно, скользящее окно, «ведро с токенами»
    • Практическая реализация на Redis (атомарно и быстро)
    • Лимиты на уровне Nginx: пример конфигурации
    • Корректные ответы клиентам: 429, Retry-After и заголовки квот
    • Обратное давление в приложении: очереди, таймауты, сброс нагрузки
    • Приоритизация: как не обидеть платящих и не задушить фрод-алерты
    • Наблюдаемость и тестирование под пиком
    • Подводные камни и как их обойти
    • Короткий чек‑лист внедрения

Зачем бизнесу лимиты и обратное давление

Лимиты запросов и обратное давление — это «предохранители» для вашего продукта. Они помогают:

  • Сохранить ядро продукта при пиках. Лучше ответить части клиентов «попробуйте позже», чем уронить всех 100% с 500‑ми.
  • Защитить дорогие ресурсы: базу, внешние платёжные и логистические API с тарифами по запросам.
  • Снизить операционные риски: партнёр с петлёй ретраев, бот или регресс в мобильном клиенте не устроят вам ночной инцидент.
  • Управлять экономикой. Приоритизируйте платящих, «тёплые» корзины, платежи — а статистику и тяжёлые отчёты откладывайте.

Хорошо спроектированные лимиты не скрывают проблемы производительности, а дают системе шанс пережить всплеск и корректно деградировать.

Где ставить ограничения: периметр, приложение, ресурсы

  • На периметре: CDN, API‑шлюз, Nginx/Envoy. Плюсы — дёшево, быстро, близко к клиенту. Подходит для глобальных и пер‑клиентных квот.
  • В приложении: тонкая логика пер‑фичи и пер‑операции (например, «платёжные попытки/минуту»). Можно учитывать контекст: тариф, риск‑оценка, SLA партнёра.
  • На ресурсе: пул соединений к БД, ограничение воркеров на очередь, квоты на вызовы внешних интеграций. Это управляет реальной «мощностью» узких мест.

Лучший результат — комбинация уровней: внешние грубые лимиты + точные лимиты в приложении + физические ограничения ресурса.

Алгоритмы: фиксированное окно, скользящее окно, «ведро с токенами»

  • Фиксированное окно. Счётчик в минуту/секунду. Просто, но даёт всплески на границах: можно получить двойной пик на стыке окон.
  • Скользящее окно. Считаем события за последние N секунд. Ровнее и честнее по времени, но дороже по памяти/операциям.
  • Ведро с токенами (token bucket). В «вёдро» равномерно «капают» токены. Запрос «тратит» токен. Можно накапливать небольшой «запас» и сглаживать пики.
  • Капельница/протекающее ведро (leaky bucket). Стабильный «слив» событий с постоянной скоростью. Жёстче сглаживает, но хуже для разовых всплесков.

Для большинства бизнес‑API хорош старт — ведро с токенами: предсказуемо, просто и даёт гибкость.

Практическая реализация на Redis (атомарно и быстро)

Нужно хранить состояние (токены, метка времени) и обновлять его атомарно. В Redis это удобно делать через Lua‑скрипт.

Lua‑скрипт для «ведра с токенами» в Redis

-- filename: token_bucket.lua
-- KEYS[1] = ключ ведра, например "rl:{tenant}:{route}"
-- ARGV[1] = capacity (макс. токенов)
-- ARGV[2] = refill_rate (токенов в секунду)
-- ARGV[3] = now_ms (текущее время, мс)
-- ARGV[4] = cost (стоимость запроса в токенах, обычно 1)

local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])
local now_ms   = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

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 = math.max(0, now_ms - ts) / 1000.0
local new_tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
local remaining = new_tokens

if new_tokens >= cost then
  allowed = 1
  remaining = new_tokens - cost
  ts = now_ms
end

redis.call('HMSET', key, 'tokens', remaining, 'ts', ts)
-- TTL чуть больше времени полного восстановления ведра
local ttl = math.ceil(capacity / rate) + 5
redis.call('EXPIRE', key, ttl)

return { allowed, tostring(remaining) }

Вызов из Go

package main

import (
    "context"
    "fmt"
    "time"

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

func main() {
    ctx := context.Background()
    rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})

    script := redis.NewScript(`
local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate     = tonumber(ARGV[2])
local now_ms   = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])
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 = math.max(0, now_ms - ts) / 1000.0
local new_tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
local remaining = new_tokens
if new_tokens >= cost then
  allowed = 1
  remaining = new_tokens - cost
  ts = now_ms
end
redis.call('HMSET', key, 'tokens', remaining, 'ts', ts)
local ttl = math.ceil(capacity / rate) + 5
redis.call('EXPIRE', key, ttl)
return { allowed, tostring(remaining) }
`)

    key := "rl:tenant123:/orders:create"
    capacity := 50      // максимум токенов
    rate := 10          // 10 токенов/секунда
    cost := 1           // запрос стоит 1 токен

    now := time.Now().UnixMilli()
    res, err := script.Run(ctx, rdb, []string{key}, capacity, rate, now, cost).Result()
    if err != nil {
        panic(err)
    }

    vals := res.([]interface{})
    allowed := vals[0].(int64) == 1
    remaining := vals[1].(string)

    fmt.Printf("allowed=%v remaining=%s\n", allowed, remaining)
}

Рекомендации:

  • Ключ делайте с неймспейсами: rl:{клиент}:{операция}. Так проще приоритизировать и троттлить конкретных потребителей.
  • Для горячих ключей (популярные маршруты) используйте шардирование по нескольким Redis‑нодам или настраивайте локальные лимиты на каждом инстансе + периодическую синхронизацию.

Лимиты на уровне Nginx: пример конфигурации

Для базовой защиты на периметре достаточно пары директив.

# Если есть идентификатор клиента в заголовке — используем его, иначе IP
map $http_x_customer_id $rl_key {
    default $binary_remote_addr;
    ~.+      $http_x_customer_id;
}

# Хранение состояния для лимитов (10 Мб под счётчики)
limit_req_zone $rl_key zone=per_client:10m rate=10r/s;

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        limit_req zone=per_client burst=30 nodelay;
        add_header X-RateLimit-Policy "10r/s; burst=30" always;
        try_files $uri @app;
    }

    location @app {
        proxy_pass http://upstream_app;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    error_page 429 = @rate_limited;

    location @rate_limited {
        add_header Retry-After 2 always;
        add_header Content-Type application/json;
        return 429 '{"error":"rate_limited","retry_after_sec":2}';
    }
}
  • burst — короткий буфер всплеска; nodelay — пропускать «из буфера» без дополнительной задержки.
  • Для критичных операций уменьшайте burst, для неважных — наоборот увеличивайте и допускайте задержку.

Корректные ответы клиентам: 429, Retry-After и заголовки квот

Когда запрос отклонён лимитом, важно дать клиенту шанс восстановиться корректно:

  • Статус 429 Too Many Requests.
  • Заголовок Retry-After: секунда или время в формате HTTP‑даты.
  • Информативные заголовки: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

Пример ответа:

HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712839202
Content-Type: application/json

{"error":"rate_limited","message":"Слишком много запросов, повторите через 2 с"}

С клиентской стороны настроите экспоненциальную паузу между повторами (например, 200 мс, 400 мс, 800 мс с джиттером) и уважайте Retry-After.

Обратное давление в приложении: очереди, таймауты, сброс нагрузки

Помимо «внешних» лимитов, приложению нужна самозащита.

  • Очереди с ограниченной ёмкостью (bounded). Если очередь заполнена — быстро отвечаем 429/503, а не накапливаем долги и не берём в долгую блокировку.
  • Таймауты и бюджет времени на обработку запроса end‑to‑end.
  • Снижение приоритета/сброс второстепенных задач при перегрузке: отчёты, индексация, рассылки.

Ниже — минимальный пример воркер‑пула на Go, который мягко отказывает при заполненной очереди:

package main

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"
)

type Job struct{ ID int }

var (
    queue    = make(chan Job, 100) // ограниченная очередь
    workers  = 8
    ErrBusy  = errors.New("queue is full")
)

func main() {
    // поднимаем воркеров
    for i := 0; i < workers; i++ {
        go worker(i, queue)
    }

    http.HandleFunc("/process", func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
        defer cancel()

        job := Job{ID: time.Now().Nanosecond()}
        select {
        case queue <- job: // принято
            w.WriteHeader(http.StatusAccepted)
            _, _ = w.Write([]byte("{\"status\":\"accepted\"}"))
        case <-ctx.Done(): // нет места/время вышло
            w.Header().Set("Retry-After", "1")
            w.WriteHeader(http.StatusTooManyRequests)
            _, _ = w.Write([]byte("{\"error\":\"rate_limited\"}"))
        }
    })

    fmt.Println("listening on :8080")
    _ = http.ListenAndServe(":8080", nil)
}

func worker(id int, q <-chan Job) {
    for j := range q {
        // имитируем работу
        time.Sleep(100 * time.Millisecond)
        // тут полезная логика
        _ = id
        _ = j
    }
}

Идея проста: не бесконечный бэклог, а быстрый и предсказуемый отказ при перегрузке.

Приоритизация: как не обидеть платящих и не задушить фрод‑алерты

  • Разделяйте очереди: высокоприоритетные (платёж, оформление заказа) и низкоприоритетные (экспорты, отчёты). Выделяйте отдельные воркеры и квоты.
  • Ведро с токенами на каждого клиента/тариф. VIP получают больший «запас» и скорость пополнения.
  • «Справедливая» выборка: если очередь общая — используйте взвешенную политику (например, 80% слотов — критичным задачам, 20% — остальным).

Наблюдаемость и тестирование под пиком

  • Метрики: количество отклонённых запросов (по причинам и маршрутам), среднее/максимальное время ожидания в очереди, заполняемость очереди, глубина бэклога, доля ответов 429/503, ошибки внешних интеграций.
  • Логи: пишите причину отказа и ключ лимита (клиент, операция, алгоритм). Это ускоряет разбор инцидентов.
  • Трейсинг: помечайте спаны, в которых сработал лимит/обратное давление, чтобы видеть путь деградации.
  • Нагрузочные тесты: симулируйте пики и «шумные» профили (один клиент посылает 1000 rps, остальные — норму) и проверяйте, что система остаётся предсказуемой.

Подводные камни и как их обойти

  • Разнесённое состояние. Если лимит локальный на инстанс, клиент может обойти его через балансировщик. Решение: глобальные лимиты (Redis/шлюз) или консистентное закрепление клиента за инстансом.
  • Дрейф часов. Скользящие окна чувствительны ко времени. Лучше присылать now_ms с сервера, а не доверять клиенту. Поддерживайте синхронизацию времени на узлах.
  • Горячие ключи. Популярные маршруты могут перегружать один шард. Решения: шардинг ключей, локальные «пред‑лимиты», батчи.
  • Плохие ретраи. Клиент, который игнорирует 429 и долбит чаще — только усугубляет ситуацию. Защищайте периметр и договаривайтесь о клиентах‑правилах.
  • Скрытые очереди. Балансировщики, пулы соединений, драйверы БД имеют свои очереди и таймауты. Проверьте их настройки, чтобы не накапливать длительный хвост.
  • Миграции лимитов. Плавно меняйте значения: сначала телеметрия «в теневом режиме», затем мягкое включение с логами, потом — жёсткие отказы.

Короткий чек‑лист внедрения

  • Определите бизнес‑критичные маршруты и целевые SLA/квоты per клиент/операция.
  • Выберите уровни защиты: периметр + приложение + ресурс.
  • Запустите ведро с токенами в Redis для точных квот; на периметре — базовые лимиты Nginx.
  • Возвращайте 429 с Retry-After и X-RateLimit‑заголовками.
  • В приложении введите очереди ограниченной ёмкости, таймауты и fallback‑поведение.
  • Разделите очереди по приоритетам и тарифам.
  • Подключите метрики, логи и трейсинг причин отказов. Прогоните нагрузочные тесты с «шумными соседями».
  • Пересматривайте лимиты по данным: где отказы вредят бизнесу, а где экономят бюджет.

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


архитектураrate limitbackpressure