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 из них. Есть и вторая проблема: если пока пользователь листает, в начало добавится новый пост, все элементы сдвинутся. Тогда он увидит дубли или пропустит записи.
Курсор — «дай 20 записей после той, что я видел последней»: WHERE id < 101 ORDER BY id DESC LIMIT 20. По индексу база сразу находит нужное место, и стоимость не зависит от глубины. Сдвиги ленте тоже не страшны.
| 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-скрипт, чтобы они выполнились атомарно, без гонок.
Компромиссы, которые стоит назвать вслух:
- 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. Интервьюер почти наверняка спросит об этом.
Ты только что собрал «вход в клуб» целиком: формат общения, охранника и правила очереди. Это готовый блок для любого дизайна на собесе 💪