Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Ограничение частоты запросов (rate limit) в API: защита от всплесков и предсказуемые расходы

Разработка и технологии16 февраля 2026 г.
Когда клиенты или интеграции присылают слишком много запросов, страдают и SLA, и счёт за инфраструктуру. Лимиты запросов позволяют честно распределять ресурсы, защищать систему от всплесков и контролировать затраты — без наращивания серверов. Разбираем алгоритмы, архитектуру и готовую реализацию на Redis и NGINX.
Ограничение частоты запросов (rate limit) в API: защита от всплесков и предсказуемые расходы

Оглавление

  • Зачем бизнесу лимиты запросов
  • Где ставить лимиты: уровни защиты
  • Алгоритмы лимитирования: что выбрать и почему
  • Проектирование квот: по кому, на что и как сообщать
  • Где хранить счетчики и как масштабировать
  • Реализация на Redis + Lua: токенный бак с точными ответами
    • Почему Lua в Redis
  • Пример для NGINX: быстрое ограничение на периметре
  • Мониторинг, алерты и нагрузочное тестирование
  • Продуктовые и юридические нюансы
  • Подводные камни и как их обойти
  • Чек-лист внедрения
  • Итоги

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

API живёт в непредсказуемой среде: интеграции, боты, периодические задачи клиентов, новые релизы партнёров. Один неудачный цикл повтора у клиента — и ваш бекенд получает шквал идентичных запросов. Без лимитов вы платите за пик в инфраструктуре, теряете SLA, а поддержка разбирает инциденты.

Ограничение частоты запросов (rate limit) решает три задачи:

  • Защита от всплесков и ошибок интеграций — система остаётся в строю.
  • Честное распределение ресурсов между клиентами — никто не «съедает» всё.
  • Предсказуемые расходы — инфраструктура рассчитывается под целевые уровни нагрузки.

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

Где ставить лимиты: уровни защиты

Один уровень редко достаточно надёжен. Лучше лестница защиты, где каждый уровень дёшев и быстр:

  1. На периметре: CDN/WAF/NGINX
  • Плюсы: дешёво, близко к источнику, разгружает приложение.
  • Минусы: ограниченные ключи лимитирования (обычно IP/регион/заголовок), сложнее учесть продуктовые правила.
  1. Шлюз API или сервис авторизации
  • Плюсы: знаете клиента (ключ/токен), продукты и тарифы, можно по-умному делить квоты.
  • Минусы: держите состояние и хранилище счётчиков, нужна высокая доступность.
  1. В приложении (middleware)
  • Плюсы: гибкие правила на уровне маршрута/операции, точность метрик.
  • Минусы: нельзя полноценно защитить сам сервис без внешнего rate limit (пока запрос дошёл до приложения, он уже занял соединение/CPU).

Комбинация: грубые лимиты на периметре (защита от «залётов» по IP) + точные квоты в API (по ключу клиента, тарифу и типу операции).

Алгоритмы лимитирования: что выбрать и почему

Основные подходы:

  • Фиксированное окно (fixed window)
    • Считаем запросы за N секунд. Просто, но «рвётся» на границах окна: можно отправить двойной пик.
  • Скользящее окно (sliding window)
    • Точнее, но требует больше данных (например, временные метки) и дороже операций.
  • Протекающее ведро (leaky bucket)
    • Гарантирует стабильный отток с постоянной скоростью. Хорошо сглаживает пики, но хуже передаёт «кредиты» за простой.
  • Токенное ведро (token bucket)
    • Позволяет накапливать «жетоны» в простое и тратить их на короткий всплеск. Хороший баланс между гибкостью и простотой.

Для продуктовых квот чаще подходит токенный бак: «5 запросов в секунду, с пиками до 20» звучит понятно и работает предсказуемо.

Проектирование квот: по кому, на что и как сообщать

  • Ключ лимитирования

    • По API‑ключу/токену клиента: честное распределение.
    • Дополнительно по методу/операции: например, читать можно чаще, чем писать.
    • Порог по IP на периметре: базовая защита от аномалий.
  • Параметры квоты

    • Базовая скорость (RPS) и ёмкость «ведра» (burst).
    • Отдельные лимиты для «дорогих» операций (загрузка, генерация отчётов).
    • Почасовые/суточные квоты для тарифов: ограничение общего потребления.
  • Контракт с клиентом в заголовках

    • Возвращайте:
      • X-RateLimit-Limit: базовый лимит (например, 5)
      • X-RateLimit-Remaining: сколько осталось
      • X-RateLimit-Reset: когда лимит восстановится (UNIX‑время в секундах)
      • При блокировке — 429 Too Many Requests и Retry-After (в секундах)

Документация должна описывать лимиты, политику повтора (экспоненциальный бэкофф) и возможные «окна» по тарифам.

Где хранить счетчики и как масштабировать

  • Локальная память инстанса

    • Дёшево и быстро, но лимиты действуют только «внутри» одного инстанса. Подходит для периметра/NGINX, но не для глобальных квот.
  • Redis/Memcached

    • Быстро, распределённо и просто. Redis даёт атомарные операции и Lua, что удобно для сложной логики.
  • База данных

    • Надёжно, но медленно и дорого под высокую частоту обновлений. Лучше не использовать для горячих счётчиков.
  • Масштабирование Redis

    • Отдельный пул для лимитов (не смешивать с кешом бизнес‑данных).
    • Кластер/шардинг по ключу клиента.
    • TTL на ключи, чтобы не копить мусор от «молчаливых» клиентов.

Реализация на Redis + Lua: токенный бак с точными ответами

Ниже — атомарный Lua‑скрипт для Redis. Он реализует токенный бак: накапливает «жетоны» с заданной скоростью, позволяет кратковременные всплески до ёмкости «ведра», возвращает, прошёл ли запрос, сколько жетонов осталось и через сколько миллисекунд можно повторить.

-- file: token_bucket.lua
-- KEYS[1]  - ключ ведра (например, rate:{client_id}:{route})
-- ARGV[1]  - now (мс)
-- ARGV[2]  - refill_rate (жетонов в секунду)
-- ARGV[3]  - capacity (максимум жетонов)
-- ARGV[4]  - cost (стоимость запроса в жетонах)
-- ARGV[5]  - ttl_sec (TTL ключа в секундах)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl_sec = tonumber(ARGV[5])

local tokens = tonumber(redis.call('HGET', key, 'tokens'))
local ts = tonumber(redis.call('HGET', key, 'ts'))

if tokens == nil then tokens = capacity end
if ts == nil then ts = now end

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

-- Сколько жетонов добавилось со времени последнего запроса
local refill = (delta_ms / 1000.0) * refill_rate
if refill > 0 then
  tokens = math.min(capacity, tokens + refill)
end

local allowed = 0
local retry_after_ms = 0

if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  allowed = 0
  -- Сколько времени нужно, чтобы накопить недостающее
  local need = cost - tokens
  retry_after_ms = math.floor((need / refill_rate) * 1000.0)
end

redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl_sec)

-- Возвращаем: allowed, оставшиеся жетоны (float), retry_after_ms
return { allowed, string.format('%.3f', tokens), retry_after_ms }

Пример middleware на Node.js (Express) с ioredis. Он загружает скрипт, вычисляет ключ по клиентскому токену или IP, проставляет заголовки и отдаёт 429 при превышении.

// file: rateLimit.js
// Node.js 18+, Express 4+, ioredis 5+
import fs from 'node:fs/promises';
import path from 'node:path';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');

let scriptSha = null;

async function loadScript() {
  const scriptPath = path.join(process.cwd(), 'token_bucket.lua');
  const lua = await fs.readFile(scriptPath, 'utf8');
  scriptSha = await redis.script('load', lua);
}

export async function initRateLimit() {
  await loadScript();
  // На случай перезапуска Redis перезагружаем скрипт по ошибке NOSCRIPT
  redis.on('error', (e) => console.error('Redis error', e));
}

export function rateLimit({
  capacity = 20,           // ёмкость ведра (burst)
  refillPerSec = 5,        // скорость пополнения (RPS)
  cost = 1,                // стоимость запроса
  ttlSec = 3600,           // TTL ключа
  keyBuilder,              // (req) => string — ключ лимитирования
  getNowMs = () => Date.now()
} = {}) {
  if (!keyBuilder) {
    keyBuilder = (req) => {
      const clientKey = req.header('X-API-Key') || req.header('Authorization') || '';
      const ip = req.ip || req.connection?.remoteAddress || 'unknown';
      // Ключ учитывает маршрут, чтобы можно было задавать разные квоты по операциям
      return `rate:${clientKey || ip}:${req.method}:${req.baseUrl || ''}${req.path}`;
    };
  }

  return async function rateLimitMiddleware(req, res, next) {
    try {
      const key = keyBuilder(req);
      const now = getNowMs();

      const args = [ now, refillPerSec, capacity, cost, ttlSec ];
      let result;
      try {
        result = await redis.evalsha(scriptSha, 1, key, ...args);
      } catch (e) {
        if (String(e.message || '').includes('NOSCRIPT')) {
          await loadScript();
          result = await redis.evalsha(scriptSha, 1, key, ...args);
        } else {
          throw e;
        }
      }

      const allowed = Number(result[0]) === 1;
      const tokensLeft = Math.max(0, Math.floor(Number(result[1])));
      const retryAfterMs = Number(result[2]);

      // Заголовки контракта
      res.setHeader('X-RateLimit-Limit', String(capacity));
      res.setHeader('X-RateLimit-Remaining', String(tokensLeft));
      // Примерно когда ведро полностью восстановится
      const resetSec = Math.ceil(now / 1000 + (capacity - Number(result[1])) / refillPerSec);
      res.setHeader('X-RateLimit-Reset', String(resetSec));

      if (!allowed) {
        const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
        res.setHeader('Retry-After', String(retryAfterSec));
        return res.status(429).json({
          error: 'too_many_requests',
          message: `Превышен лимит. Повторите через ${retryAfterSec} сек.`,
        });
      }

      return next();
    } catch (err) {
      // В случае недоступности Redis лучше «пропустить», чем сломать все запросы,
      // но залогировать и поднять алерт.
      console.error('RateLimit failure', err);
      return next();
    }
  };
}

Использование в Express:

// file: app.js
import express from 'express';
import { initRateLimit, rateLimit } from './rateLimit.js';

const app = express();
await initRateLimit();

// Глобальный лимит по ключу клиента
app.use(rateLimit({ capacity: 20, refillPerSec: 5 }));

// Более строгий лимит на «дорогой» маршрут
app.post('/v1/reports/generate', rateLimit({ capacity: 5, refillPerSec: 1 }));

app.get('/v1/ping', (req, res) => res.json({ ok: true }));

app.listen(3000, () => console.log('API on :3000'));

Почему Lua в Redis

  • Атомарность: всё выполняется одной командой, без гонок.
  • Производительность: минимизируем сетевые RTT.
  • Простота поддержки: логика в одном месте, легко раскатить всем сервисам.

Пример для NGINX: быстрое ограничение на периметре

На периметре хорошо работает встроенный модуль limit_req. Он прост и почти ничего не стоит.

# 10Мб под зону — достаточно примерно для десятков тысяч уникальных ключей
# Ключ — по API‑ключу, если есть, иначе по IP
map $http_x_api_key $limit_key {
  default $http_x_api_key;
  ''       $binary_remote_addr;
}

limit_req_zone $limit_key zone=per_client:10m rate=5r/s;

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

  location /v1/ {
    # Разрешаем кратковременный всплеск до 20 без задержки
    limit_req zone=per_client burst=20 nodelay;
    proxy_pass http://backend;

    # Опционально: на 429 отдаём заголовок Retry-After
    error_page 429 = @rate_limited;
  }

  location @rate_limited {
    add_header Retry-After 1 always;
    return 429;
  }
}

Важно: NGINX лимитирует по отдельному инстансу. Для точных глобальных квот нужен внешний стор (Redis) на уровне шлюза или приложения.

Мониторинг, алерты и нагрузочное тестирование

Что снимать в метриках:

  • Доля 429 по маршрутам и по клиентам.
  • Средние и p95/p99 значений X-RateLimit-Remaining.
  • Сколько ключей активно (кардинальность), hit‑ratio периметра.
  • Ошибки Redis, время ответа скрипта Lua.

Алерты:

  • Всплеск 429 > X% по ключу/маршруту.
  • Ошибки NOSCRIPT/таймауты Redis.
  • Резкий рост активных ключей (возможная атака).

Нагрузочное тестирование:

  • Эмулируйте 1) стабильную нагрузку 2) кратковременный пик 3) «лифт» (быстрый рост).
  • Проверяйте, что лимиты срабатывают до того, как начинается деградация базы/пула соединений.

Продуктовые и юридические нюансы

  • Прозрачность: опишите лимиты в документации, приведите примеры заголовков и обработки 429.
  • Тарифы: квоты — часть ценности платного плана. Чётко отделяйте мягкие и жёсткие лимиты.
  • Служебные ключи: для внутренних сервисов настройте отдельные квоты и белые списки.
  • Поддержка: в логах фиксируйте ключ клиента и причину отклонения, чтобы помогать быстрее.

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

  • «Горячие» ключи: один клиент с огромным трафиком создаёт нагрузку на один шард Redis. Решение: выделяйте отдельный пул/шард для крупных клиентов.
  • Плавающее время: фиксированные окна чувствительны к границам. Используйте токенный бак или истинное скользящее окно.
  • Задержка сети: выносите лимиты как можно ближе к источнику, чтобы не тратить дорогие ресурсы на отклонённые запросы.
  • Непредсказуемые ретраи клиентов: документируйте экспоненциальный бэкофф и указывайте Retry-After.
  • Память Redis: ставьте TTL, храните только нужные поля, не плодите ключи по лишним измерениям.
  • Мульти‑ЦОД: счётчики должны жить «рядом» с трафиком. Для глобальных квот используйте геопривязку ключа или кросс‑региональную репликацию (дорого и сложнее).

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

  • Определили ключи лимитирования: по клиенту, маршруту, операции.
  • Выбрали алгоритм: токенный бак с burst.
  • Настроили грубые лимиты на периметре (NGINX/CDN) по IP/ключу.
  • Подняли Redis‑пул под лимиты, отделили от бизнес‑кеша, включили мониторинг.
  • Реализовали middleware/шлюз с Lua‑скриптом, добавили заголовки X-RateLimit-* и Retry-After.
  • Задокументировали политику лимитов и повторы.
  • Прогнали нагрузочные тесты на профиле «пик + ретраи».
  • Настроили алерты по 429, ошибкам Redis и аномалиям кардинальности ключей.

Итоги

Лимитирование запросов — недорогой и надёжный способ защитить API и бюджет. Комбинируя быстрые лимиты на периметре и точные квоты в приложении/шлюзе, вы получаете предсказуемые SLA и честное распределение ресурсов между клиентами. Токенный бак на Redis с Lua даёт атомарность, прозрачные заголовки и гибкость под тарифы — без лишних серверов и сложных очередей.


Redisлимиты APINGINX