Ctrl K
Урок

API и ограничение нагрузки

Интуиция: клуб с одним входом

Представь ночной клуб. Гости не ходят на кухню и в бар через служебные двери: все проходят через один вход, где охранник проверяет билет и не пускает толпу разом. Если внутри 200 человек, новым говорят: «Подожди 10 минут». Это лучше, чем пустить всех и обрушить клуб.

В системе всё устроено так же:

  • API — договор о том, как гости общаются с клубом: меню, правила, формат заказа.
  • API Gateway — вход с охранником.
  • Rate limiting — правило «не больше N человек за минуту».

Разберём по очереди. Все три темы на собесе всплывают почти всегда.

REST vs gRPC vs WebSocket / long polling

Главный вопрос здесь: кто инициирует обмен и как часто.

Подход Как работает Когда брать Минусы
REST (HTTP + JSON) Запрос → ответ Публичное API, CRUD, браузер и мобилки Многословный JSON, нет push от сервера
gRPC (HTTP/2 + Protobuf) Бинарные вызовы, стриминг Общение сервисов внутри компании, высокий RPS, строгие контракты В браузере работает только через прокси, ответы не прочитать глазами
WebSocket Постоянное двустороннее соединение Чаты, онлайн-игры, котировки, коллаборативные редакторы Stateful-соединения сложнее балансировать и масштабировать
Long polling Клиент спрашивает, сервер держит запрос, пока не появятся данные Редкие события, простой fallback, если WebSocket недоступен Лишние переподключения, задержка выше

💡 На собесе звучит так: «Снаружи REST — его понимают все клиенты. Между сервисами gRPC — он быстрее и даёт типизированный контракт. Для чата WebSocket, long polling оставим как fallback».

Пагинация: offset vs курсор

Offset — LIMIT 20 OFFSET 10000. Он простой, и по нему можно прыгнуть сразу на страницу 500. Но базе приходится прочитать 10 020 строк и выбросить 10 000 из них. Есть и вторая проблема: если пока пользователь листает, в начало добавится новый пост, все элементы сдвинутся. Тогда он увидит дубли или пропустит записи.

1050
1041
1032
1023
offset=4 ↓ 1014
1005
996
987
Offset: первые 4 строки база читает и выбрасывает. Чем глубже страница, тем дороже запрос

Курсор — «дай 20 записей после той, что я видел последней»: WHERE id < 101 ORDER BY id DESC LIMIT 20. По индексу база сразу находит нужное место, и стоимость не зависит от глубины. Сдвиги ленте тоже не страшны.

1050
1041
1032
1023
cursor ↓ 1014
1005
996
987
Курсор: прыгаем по индексу сразу за последний виденный id и читаем только нужное
Offset Курсор
Скорость на глубоких страницах Падает линейно Стабильная
Переход на страницу N ✅ Есть ❌ Нет
Стабильность при вставках ❌ Дубли и пропуски ✅ Стабильно
Где уместен Админки, небольшие таблицы Ленты, бесконечный скролл

Курсор обычно отдают клиенту непрозрачной строкой, например base64 от {id, created_at}. Тогда формат можно поменять, не ломая клиентов.

Rate limiting: три алгоритма

Лимит защищает от ботов, от багов в клиенте, который шлёт запросы в цикле, и от одного жадного клиента, съедающего ресурсы остальных.

1. Fixed window. Заводим счётчик на каждую минуту, например ключ user:42:12:05, и делаем INCR. Как только счётчик превысит лимит, отвечаем 429. Подход простой и дешёвый, но у него есть проблема границы окна. При лимите 100/мин можно отправить 100 запросов в 12:05:59 и ещё 100 в 12:06:00 — получится 200 за секунду.

2. Sliding window. Окно «скользит» вместе со временем. Есть два варианта:

  • log — храним timestamp каждого запроса (sorted set в Redis). Считает точно, но занимает много памяти;
  • counter — взвешиваем текущее и прошлое окно: prev × (доля прошлого окна, попавшая в скользящее) + current. Считает почти точно и дёшево.

3. Token bucket. Представь ведро на 10 жетонов, в которое каждую секунду падает 1 новый. Каждый запрос забирает один жетон. Ведро пустое — запрос получает 429.

Время Жетонов до Запросов Итог
0 с 10 10 (всплеск) Все прошли, осталось 0
1 с 1 3 1 прошёл, 2 → 429
5 с 4 2 Оба прошли, осталось 2

Token bucket разрешает короткие всплески (ёмкость ведра), но держит среднюю скорость (скорость пополнения). Поэтому его используют AWS, Stripe и большинство gateway.

Алгоритм Точность Память Всплески
Fixed window Низкая на границах 1 счётчик До 2× на стыке окон
Sliding window Высокая Log — много, counter — мало Сглажены
Token bucket Хорошая 2 числа: жетоны и время Контролируемые, до ёмкости

Где хранить счётчики

Если держать счётчики в памяти каждого инстанса, при 5 инстансах клиент фактически получит лимит 5×. Поэтому счётчики хранят централизованно в Redis: он быстрый, умеет TTL и атомарный INCR. Для token bucket проверку и списание жетона упаковывают в Lua-скрипт, чтобы они выполнились атомарно, без гонок.

HTTPSHTTPSINCR / LuagRPCgRPCМобильное приложениеклиентВебклиентAPI Gateway: auth, rate limitшлюзRedis: счётчики лимитовкэшUser ServiceсервисFeed ServiceсервисPostgreSQLБД
Лимит проверяется на входе: Gateway спрашивает Redis и только потом пускает запрос к сервисам

Компромиссы, которые стоит назвать вслух:

  • Redis добавляет ~1 мс к каждому запросу и сам становится зависимостью.
  • Если Redis упал, нужно выбрать политику: fail-open (пускать всех — сервис доступен, но без защиты) или fail-closed (отклонять всех — надёжно, но бьёт по пользователям). Чаще выбирают fail-open.

API Gateway

Это единая точка входа, которая берёт на себя сквозные задачи, чтобы сервисы ими не занимались:

  • аутентификация (проверка JWT);
  • rate limiting;
  • маршрутизация /users/* → User Service;
  • TLS-терминация, логи, метрики, трассировка;
  • перевод REST снаружи в gRPC внутри.

Минусы: это ещё один сетевой хоп, потенциальная единая точка отказа (поэтому держим несколько инстансов за балансировщиком) и соблазн положить туда бизнес-логику. Делать этого не стоит.

Как выглядит ответ 429

GET /api/v1/feed?cursor=eyJpZCI6MTAxfQ&limit=20 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718000012

{"error": "rate_limited", "message": "Слишком много запросов", "retry_after": 12}

Retry-After подсказывает клиенту, через сколько секунд повторить запрос. Хороший клиент ждёт и использует exponential backoff с jitter, а не долбит сервер снова и снова.

Как это звучит на собесе

«Публичное API — REST с курсорной пагинацией, внутри — gRPC. На входе API Gateway: проверяет токен и применяет token bucket по user_id, 100 запросов в минуту с всплеском до 20. Счётчики в Redis, атомарность через Lua. Если Redis недоступен — fail-open, чтобы не уронить сервис. При превышении лимита — 429 с Retry-After».

Частые ошибки

  • Хранить счётчики локально на каждом инстансе: лимит «размножается» по числу серверов.
  • Offset-пагинация для бесконечной ленты: на глубоких страницах запросы тормозят, а пользователь видит дубли.
  • WebSocket там, где хватит REST: сложность ради редких обновлений.
  • Лимит только по IP: за NAT сидит целый офис. Лучше лимитировать по user_id или API-ключу, а IP оставить для анонимных запросов.
  • Не назвать поведение при отказе Redis. Интервьюер почти наверняка спросит об этом.

Ты только что собрал «вход в клуб» целиком: формат общения, охранника и правила очереди. Это готовый блок для любого дизайна на собесе 💪