Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Rate limiting в API: защита от всплесков и предсказуемые расходы без лишних серверов

Разработка и технологии28 января 2026 г.
Лимиты запросов помогают удерживать нагрузку в рамках, сдерживать злоупотребления и делать расходы прогнозируемыми. Разбираем, где их ставить, какие алгоритмы выбрать, как реализовать на Redis и Nginx, какие заголовки возвращать и как избежать типичных ошибок.
Rate limiting в API: защита от всплесков и предсказуемые расходы без лишних серверов

Оглавление

  • Зачем бизнесу лимиты запросов
  • Где и какие лимиты ставить
  • Алгоритмы: фиксированное окно, скользящее окно, токен‑бакет, GCRA
  • Архитектура: локально vs. распределённо, Redis и консистентное распределение ключей
  • Реализация на практике
    • Nginx: быстрый входной фильтр
    • Redis + Lua: атомарный токен‑бакет
    • Go‑middleware: прагматичная вставка в API
  • Протокол и UX: 429, Retry‑After и RateLimit‑заголовки
  • Тестирование и метрики: как убедиться, что лимиты помогают, а не мешают
  • Типичные ошибки и как их избежать
  • Приоритизация, тарифы и «бурсты» для B2B
  • Чек‑лист внедрения

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

  • Предсказуемые расходы. Пиковые всплески трафика перестают прожигать бюджет на CDN, серверы и внешние интеграции. Лимиты не дают «забить» систему дороже, чем вы планировали.
  • Защита от злоупотреблений и ошибок. Ботам и неверно настроенным интеграциям сложнее ронять сервис; единичная ошибка клиента не превращается в массовую атаку.
  • Стабильнее SLA для всех. Один «шумный сосед» больше не портит опыт остальным, а вы можете раздать приоритеты платным тарифам и ключевым клиентам.

Где и какие лимиты ставить

Лимиты полезны на трёх уровнях:

  1. Внешний периметр (шлюз, балансировщик, CDN)
  • Дёшево отсекает лишнее, защищает от ковровых запросов. Отлично для лимитов по IP и грубых потолков по ключам.
  1. На уровне приложения (middleware)
  • Тонкая логика: по пользователю, тарифу, эндпоинту, клиентскому ключу. Здесь проще добавить бизнес‑правила и корректные заголовки для клиента.
  1. Выходящие интеграции (egress)
  • Ограничение вызовов во внешние API, чтобы не ловить баны, капчу и штрафы. Часто нужен свой лимит на каждый целевой хост или ключ.

Алгоритмы: фиксированное окно, скользящее окно, токен‑бакет, GCRA

  • Фиксированное окно (fixed window)
    • Простой счётчик в минуту/секунду: быстро, но несправедливо на границах окна (в конце и начале минуты можно «протолкнуть» двойной объём).
  • Скользящее окно (sliding window)
    • Счёт на интервале «последние N секунд»; точнее, но дороже по памяти/операциям, если делать наивно.
  • Токен‑бакет (token bucket)
    • Ведро с токенами пополняется со скоростью r и вмещает burst до capacity. Запрос «берёт» токены; нет токенов — отклоняем или ждём. Хорош для «бурстов» и сглаживает нагрузку.
  • GCRA (Generic Cell Rate Algorithm)
    • Детерминированное «виртуальное время прихода» запросов. Экономичен по памяти, точен, но сложнее в объяснении.

Практический выбор:

  • Для входного API с «бурстами» и честным сглаживанием — токен‑бакет.
  • Для строгих тарифов с предсказуемой скоростью — GCRA или sliding window.
  • Для чернового ограничения на периметре — fixed window/Leaky bucket в прокси.

Архитектура: локально vs. распределённо, Redis и консистентное распределение ключей

  • Локальные лимиты (в каждом экземпляре сервиса)
    • Просто и быстро. Но суммарный лимит «x на клиента» расползается по инстансам. Подходит для защитных локальных потолков (пер‑IP), не для строгих глобальных квот.
  • Распределённые лимиты (общий стор в Redis/Memcached)
    • Единый взгляд на мир. Берём Redis: атомарные операции, Lua‑скрипты, низкая задержка. Ставим репликацию и мониторинг.
  • Консистентное распределение ключей
    • Если несколько узлов Redis, используйте sharding по ключу (consistent hashing), чтобы ключ клиента всегда попадал на один шард. Так меньше «скачков» и лучше кэш‑локальность.

Реализация на практике

Nginx: быстрый входной фильтр

Грубый, но эффективный щит на периметре. Пример: ограничим по API‑ключу в заголовке X-Api-Key до 50 r/s с бурстом 100.

# В http {} секции
map $http_x_api_key $ratelimit_key {
    default "$http_x_api_key";
}

limit_req_zone $ratelimit_key zone=api_per_key:20m rate=50r/s;

server {
    listen 80;
    server_name api.example.com;

    location / {
        # Разрешим короткие всплески без задержки до 100 запросов
        limit_req zone=api_per_key burst=100 nodelay;
        limit_req_status 429;

        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://backend;
    }
}

Плюсы: 0 доп.кода в сервисе, минимальная задержка. Минусы: не знает ваши тарифы и роли; на один ключ из разных дата‑центров лимит может применяться неравномерно.

Redis + Lua: атомарный токен‑бакет

Храним по ключу состояние ведра: сколько токенов осталось и время последнего пополнения. Всё обновление делаем в Lua атомарно.

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

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = 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 end
if ts == nil then ts = now end

local delta = now - ts
if delta < 0 then delta = 0 end

local new_tokens = tokens + (delta * rate / 1000.0)
if new_tokens > capacity then new_tokens = capacity end

local allowed = 0
local remaining = new_tokens
local wait_ms = 0

if new_tokens >= need then
  allowed = 1
  remaining = new_tokens - need
else
  local deficit = need - new_tokens
  if rate > 0 then
    wait_ms = math.ceil(deficit * 1000.0 / rate)
  else
    wait_ms = -1 -- бесконечное ожидание, если rate == 0
  end
end

redis.call('HMSET', key, 'tokens', remaining, 'ts', now)

-- TTL: примерно два времени полного восстановления ведра
if rate > 0 then
  local ttl = math.ceil((capacity * 2) / rate)
  if ttl < 1 then ttl = 1 end
  redis.call('EXPIRE', key, ttl)
end

return { allowed, remaining, wait_ms }

Скрипт возвращает кортеж: [allowed(0/1), remaining_tokens, wait_ms]. Если allowed=0, клиенту стоит вернуть 429 и Retry-After.

Go‑middleware: прагматичная вставка в API

Пример минимального middleware, вызывающего скрипт в Redis. Лимит: 100 r/s, ведро 200 токенов, 1 токен на запрос, ключ — из X-Api-Key.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"strconv"
	"time"

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

var (
	capacity   = 200.0 // burst
	ratePerSec = 100.0 // средняя скорость

demand     = 1.0
)

const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = 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 end
if ts == nil then ts = now end
local delta = now - ts
if delta < 0 then delta = 0 end
local new_tokens = tokens + (delta * rate / 1000.0)
if new_tokens > capacity then new_tokens = capacity end
local allowed = 0
local remaining = new_tokens
local wait_ms = 0
if new_tokens >= need then
  allowed = 1
  remaining = new_tokens - need
else
  local deficit = need - new_tokens
  if rate > 0 then
    wait_ms = math.ceil(deficit * 1000.0 / rate)
  else
    wait_ms = -1
  end
end
redis.call('HMSET', key, 'tokens', remaining, 'ts', now)
if rate > 0 then
  local ttl = math.ceil((capacity * 2) / rate)
  if ttl < 1 then ttl = 1 end
  redis.call('EXPIRE', key, ttl)
end
return { allowed, remaining, wait_ms }
`

func main() {
	ctx := context.Background()
	addr := getenv("REDIS_ADDR", "127.0.0.1:6379")
	client := redis.NewClient(&redis.Options{Addr: addr})
	defer client.Close()

	// Проверим соединение
	if err := client.Ping(ctx).Err(); err != nil {
		log.Fatalf("redis ping: %v", err)
	}

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		apiKey := r.Header.Get("X-Api-Key")
		if apiKey == "" {
			w.WriteHeader(http.StatusUnauthorized)
			w.Write([]byte("missing X-Api-Key"))
			return
		}
		key := fmt.Sprintf("rl:{%s}", apiKey) // {} — чтоб хешировалось консистентно в кластере Redis

		nowMs := time.Now().UnixMilli()
		res, err := client.Eval(ctx, luaScript, []string{key},
			fmt.Sprintf("%g", capacity),
			fmt.Sprintf("%g", ratePerSec),
			strconv.FormatInt(nowMs, 10),
			fmt.Sprintf("%g", demand),
		).Result()
		if err != nil {
			log.Printf("redis eval error: %v", err)
			w.WriteHeader(http.StatusServiceUnavailable)
			w.Write([]byte("temporary unavailable"))
			return
		}

		vals := res.([]interface{})
		allowed := vals[0].(int64) == 1
		remaining := toFloat(vals[1])
		waitMs := toInt(vals[2])

		// RFC 9239: информируем клиента о лимитах
		w.Header().Set("RateLimit-Limit", fmt.Sprintf("%d;w=1", int(ratePerSec)))
		w.Header().Set("RateLimit-Remaining", fmt.Sprintf("%d", int(remaining)))
		if waitMs > 0 {
			w.Header().Set("RateLimit-Reset", fmt.Sprintf("%d", int(waitMs/1000)))
		}

		if !allowed {
			if waitMs > 0 {
				w.Header().Set("Retry-After", fmt.Sprintf("%d", int(waitMs/1000)))
			}
			w.WriteHeader(http.StatusTooManyRequests)
			w.Write([]byte("rate limit exceeded"))
			return
		}

		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func getenv(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

func toFloat(v interface{}) float64 {
	switch x := v.(type) {
	case int64:
		return float64(x)
	case string:
		f, _ := strconv.ParseFloat(x, 64)
		return f
	default:
		return 0
	}
}

func toInt(v interface{}) int64 {
	switch x := v.(type) {
	case int64:
		return x
	case string:
		i, _ := strconv.ParseInt(x, 10, 64)
		return i
	default:
		return 0
	}
}

Запускаем: REDIS_ADDR=127.0.0.1:6379 go run . и отправляем запросы с нужным X-Api-Key.

Советы по продакшену:

  • Вставьте префикс проекта в ключ (rl:{proj}:{api_key}) — пригодится при миграциях.
  • Ключ в фигурных скобках закрепляет шард в кластере Redis.
  • Следите за задержкой до Redis; ставьте рядом с приложением.

Протокол и UX: 429, Retry‑After и RateLimit‑заголовки

  • Код ответа: 429 Too Many Requests (RFC 6585).
  • Retry-After: через сколько секунд клиенту попробовать снова.
  • RateLimit‑заголовки (RFC 9239):
    • RateLimit-Limit: предел и окно, например 100;w=1.
    • RateLimit-Remaining: сколько осталось до конца окна/ведра.
    • RateLimit-Reset: сек до восстановления.

Хороший UX: документируйте лимиты, возвращайте понятные сообщения и, по возможности, адекватные «бурсты». Для платежных клиентов — отдельные лимиты и ранний контакт с менеджером, если клиент часто упирается в потолок.

Тестирование и метрики: как убедиться, что лимиты помогают, а не мешают

  • Нагрузочное тестирование: воспроизведите сценарии «бурст 10× от среднего». Проверьте, что время ответа остаётся стабильным.
  • Дашборды:
    • Количество 429 по ключам/эндпоинтам.
    • Среднее и p95 времени до reset.
    • Ошибки Redis, таймауты к стору лимитов.
  • А/В‑включение: сначала 10% трафика, затем 50%, затем 100%.
  • SLO: доля корректно обслуженных запросов без 429 для «хороших» клиентов (в рамках тарифов) должна стремиться к 100%.

Типичные ошибки и как их избежать

  • Эффект границы окна: фиксированное окно даёт «двойной выстрел» на стыке минут. Используйте скользящее окно или токен‑бакет.
  • Несогласованное время: доверяйте времени сервера (в Redis скрипт запрашивает своё TIME или получайте now_ms из приложения с синхронизированными часами). Следите за NTP.
  • Узкое место Redis: лимиты не должны жить в одном узле без реплики и мониторинга. Настройте оповещения по задержке и ошибкам.
  • Высокая стоимость сетевых прыжков: кладите Redis ближе к приложению, используйте keep‑alive, пулы соединений.
  • Неправильный ключ: не лимитируйте по IP, если клиенты за общим NAT. Используйте ключ API/токен пользователя, а IP — как дополнительный защитный слой.
  • Отсутствие заголовков: без RateLimit‑* клиенты не понимают, что делать. Публикуйте лимиты явно.

Приоритизация, тарифы и «бурсты» для B2B

  • Планы и роли: храните параметры лимитов в конфиге/БД и подтягивайте по ключу. Пример: бесплатный 10 r/s burst 20, платный 100 r/s burst 300.
  • Приоритетные очереди: при перегрузе можно задерживать низкоприоритетные запросы, а не сразу отдавать 429. Это лучше для UX, если ваша бизнес‑логика допускает задержку.
  • Платные «bursts»: разрешайте кратковременное превышение лимита за доплату или автоматический апгрейд тарифа при систематических упорах.
  • Разделение по эндпоинтам: тяжёлые операции (экспорт, отчёты) — отдельный куб лимитов, чтобы не выедали бюджет лёгких запросов.

Чек‑лист внедрения

  • Определены цели: защита, прогноз расходов, справедливость между клиентами.
  • Выбран алгоритм: для внешнего API — токен‑бакет со справедливым «бурстом».
  • Решена архитектура: Nginx на периметре + Redis‑лимиты в приложении.
  • Реализация: атомарный Lua‑скрипт, ключи с фигурными скобками для кластера.
  • Протокол: 429, Retry‑After, RateLimit‑заголовки, документация для клиентов.
  • Метрики и мониторинг: 429 по ключам, задержка Redis, успех/отказ, p95 reset.
  • Категоризация трафика: по тарифам/ролям/эндпоинтам.
  • Тесты под нагрузкой и по отказам (падение Redis, деградация сети).
  • План деградации: при недоступности Redis — локальный «аварийный» лимит и «мягкие» ответы.

Итог: корректно внедрённые лимиты запросов дают экономию на инфраструктуре, защищают от злоупотреблений и делают поведение системы предсказуемым. При этом клиенты получают прозрачные правила и понятные сигналы, когда они близки к потолку. Это всё — без покупки «лишних серверов» и с быстрой окупаемостью.


APIRedisrate limiting