Три стиля и когда какой
Выбор способа взаимодействия — первое архитектурное решение на границе сервисов, и от него зависит связность всей системы.
REST поверх HTTP/JSON: универсален, отлаживается curl'ом, кэшируется промежуточными узлами, понятен всем клиентам, включая браузер. Плата — многословность, отсутствие строгой схемы по умолчанию и синхронная связность вызывающего с вызываемым. Разумный выбор для публичных API и для внешних границ системы.
gRPC поверх HTTP/2 с protobuf: строгий контракт и кодогенерация, компактный бинарный формат, мультиплексирование в одном соединении, потоковая передача в обе стороны, заметно меньшие задержки. Плата — плохая отлаживаемость глазами, ограниченная поддержка в браузере (нужен grpc-web), необходимость общей схемы и её версионирования. Хороший выбор для внутреннего взаимодействия сервисов, особенно чувствительного к задержкам.
События через брокер: издатель не знает потребителей и не ждёт ответа. Это единственный из трёх способов, который действительно снижает связность: получатель может лежать, а издатель продолжит работать. Плата — согласованность только в конечном счёте, отсутствие немедленного ответа и необходимость идемпотентной обработки, потому что доставка at-least-once.
Практическое правило выбора: нужен ли вызывающему ответ прямо сейчас, чтобы продолжить? Если да — синхронный вызов (REST наружу, gRPC внутрь). Если нет — событие. Самая частая ошибка проектирования — синхронный вызов там, где хватило бы события: он превращает независимые сервисы в цепочку, где доступность перемножается.
Семантика HTTP и идемпотентность
Значительная часть вопросов по REST сводится к тому, понимаете ли вы семантику методов. Безопасные (safe) методы не меняют состояние: GET, HEAD, OPTIONS. Идемпотентные дают тот же результат при повторе: GET, PUT, DELETE, HEAD, OPTIONS. POST не является ни тем, ни другим — и именно поэтому браузер предупреждает о повторной отправке формы.
Важно понимать, что идемпотентность — про состояние сервера, а не про идентичность ответа. Повторный DELETE идемпотентен, хотя первый вернёт 204, а второй 404: состояние после обоих одинаково — ресурса нет.
Отсюда практическая проблема: создание ресурса — это POST, он не идемпотентен, а сеть при этом ненадёжна. Клиент не получил ответ и повторил запрос — создался второй заказ. Стандартное решение: клиент генерирует ключ идемпотентности (UUID) и передаёт его в заголовке Idempotency-Key; сервер сохраняет ключ вместе с результатом и при повторе возвращает сохранённый ответ, не выполняя операцию заново. Ключ и бизнес-эффект обязаны записываться в одной транзакции, иначе между ними можно упасть.
По кодам ответов ждут понимания групп, а не заучивания списка: 2xx — успех (201 с заголовком Location при создании, 202 если приняли в обработку асинхронно, 204 если тела нет), 4xx — виноват клиент (400 невалидный запрос, 401 не аутентифицирован, 403 не авторизован, 404 нет ресурса, 409 конфликт состояния, 422 семантически неверные данные, 429 превышен лимит), 5xx — виноват сервер. Классическая ошибка — отдавать 200 с полем error внутри тела: это ломает ретраи, кэширование и мониторинг, которые смотрят на статус.
Для формата ошибок есть стандарт — RFC 7807 problem details: тип, заголовок, статус, деталь, идентификатор запроса. Единый формат ошибок по всем сервисам сильно упрощает жизнь клиентам. И правило безопасности: наружу не отдают стектрейсы, SQL и внутренние идентификаторы — только код ошибки и correlation id, по которому в логах найдётся всё остальное.
Пагинация: offset против курсора
Пагинация — любимый практический вопрос, потому что почти все делают её через offset и почти все на этом обжигаются.
Offset-пагинация (LIMIT 20 OFFSET 10000) имеет два дефекта. Производительность: база обязана отсчитать и отбросить все пропускаемые строки, поэтому стоимость растёт линейно с номером страницы, и глубокие страницы становятся всё дороже. Корректность: если между запросами страниц кто-то вставил или удалил строку, элементы сдвигаются — пользователь увидит дубли или пропустит записи.
Курсорная (keyset) пагинация обходит оба: вместо номера страницы клиент передаёт позицию последнего увиденного элемента, а запрос превращается в WHERE (created_at, id) < (:lastCreatedAt, :lastId) ORDER BY created_at DESC, id DESC LIMIT 20. По индексу это переход к позиции без отбрасывания строк, поэтому стоимость страницы не зависит от её глубины, и вставки не сдвигают выдачу.
Цена курсора: нельзя прыгнуть на произвольную страницу и трудно показать общее число страниц (впрочем, точный COUNT на большой таблице и так дорог). Ключ сортировки обязан быть уникальным — поэтому в него добавляют id как тайбрейкер, иначе строки с одинаковым временем будут теряться или дублироваться. Сам курсор отдают клиенту непрозрачной строкой (base64), чтобы иметь возможность поменять его устройство позже.
Практический вывод для ответа: для бесконечной ленты и для любых больших выборок — курсор; offset допустим только для маленьких админских таблиц, где глубина заведомо ограничена.
-- Offset: с ростом смещения база отбрасывает всё больше строк
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
-- Keyset: переход по индексу, стоимость не зависит от глубины
SELECT * FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Версионирование и обратная совместимость
Главная мысль: лучшая стратегия версионирования — не создавать новых версий. Каждая живая версия API — это код, который надо поддерживать, тестировать и когда-то выключать, а выключить его вы сможете, только когда мигрирует последний клиент.
Поэтому сначала исчерпывают совместимые изменения. Совместимо: добавить необязательное поле в ответ, добавить необязательный параметр запроса, добавить новый эндпоинт, добавить значение в перечисление (если клиенты умеют игнорировать незнакомые). Несовместимо: удалить или переименовать поле, изменить тип, сделать необязательное поле обязательным, изменить смысл существующего значения.
Со стороны клиента этому помогает принцип толерантного читателя (tolerant reader): игнорируйте незнакомые поля, не завязывайтесь на порядок и на строгую валидацию всего документа. Если все клиенты написаны так, добавление полей перестаёт быть событием.
Когда ломающее изменение неизбежно, применяют схему expand–contract (она же parallel change) в три шага: сначала добавить новое рядом со старым и поддерживать оба, затем перевести клиентов, затем удалить старое. Тот же приём работает и для схемы базы данных при выкатке без простоя.
Способ обозначить версию — вопрос вкуса, и на интервью достаточно назвать варианты с их минусами: в пути (/v1/orders) — самый явный и удобный для отладки, но версионирует весь API целиком; в заголовке или через content negotiation — гибче и чище с точки зрения URI, но менее заметно и хуже кэшируется. Главное — иметь явную политику поддержки: сколько версий живёт одновременно и как долго.
Контракт как проверяемый артефакт
На senior и lead уровне ждут не рассуждений про красоту URI, а ответа на вопрос: как вы гарантируете, что изменение API не сломает потребителей.
Первый уровень — машиночитаемая схема: OpenAPI для REST, .proto для gRPC, Avro или Protobuf для событий. Схема должна быть источником истины, а не документацией, написанной постфактум: из неё генерируют клиентов и по ней проверяют ответы в тестах.
Второй уровень — автоматическая проверка совместимости в сборке. Для событий это Schema Registry с правилами BACKWARD/FORWARD/FULL: попытка опубликовать несовместимую схему просто отклоняется. Для REST существуют аналогичные линтеры диффа OpenAPI. Это превращает договорённость в проверяемое правило.
Третий уровень — контрактные тесты, управляемые потребителем (consumer-driven contracts): каждый потребитель декларирует, что именно он использует, и сборка поставщика проверяет все эти ожидания. Это единственный масштабируемый способ менять контракты без сквозных end-to-end тестов и релизного поезда.
Отдельно про GraphQL, раз о нём часто спрашивают: он решает реальную проблему over-fetching и множества запросов от клиента, и хорош на границе с разнородными фронтендами. Но он переносит сложность на сервер (проблема N+1 в резолверах, ограничение глубины и сложности запроса, кэширование по HTTP фактически исчезает) и не заменяет межсервисное взаимодействие.
Как отвечать на собеседовании
На «REST или gRPC» не выбирайте абстрактно — задайте контекст: наружу и в браузер REST, между сервисами внутри периметра gRPC при чувствительности к задержкам, а если ответ не нужен немедленно — вообще не синхронный вызов, а событие.
Про идемпотентность отвечайте полностью: какие методы идемпотентны, почему POST нет, и как это чинится ключом идемпотентности с сохранением результата в одной транзакции. Уточнение, что идемпотентность — про состояние сервера, а не про одинаковый ответ, почти всегда производит впечатление.
Пагинацию объясняйте через две причины: деградация offset на глубине и сдвиг выдачи при конкурентных вставках. Покажите keyset-запрос с составным ключом и упомяните id как тайбрейкер — эта деталь отличает того, кто делал, от того, кто читал.
По версионированию главная мысль: сначала совместимые изменения и толерантный читатель, ломающие — через expand–contract в три шага, и обязательно явная политика поддержки версий. А на вопрос «как не сломать потребителей» — схема как источник истины, проверка совместимости в CI и контрактные тесты.