REST, gRPC или GraphQL: как выбрать API на system design интервью

REST, gRPC и GraphQL без холивара: модель и формат данных, HTTP/2 и стриминг, контракты OpenAPI и protobuf, версионирование, gRPC-Web в браузере, место WebSocket и ответ на интервью.

16 мин чтениясистемный дизайн · api · grpc · graphql

Коротко

RESTgRPCGraphQL
МодельРесурсы и HTTP-методыВызов удалённых методов (RPC)Запрос нужного графа данных
ФорматОбычно JSONProtobuf (бинарный)JSON
ТранспортHTTP/1.1, HTTP/2, HTTP/3HTTP/2Обычно HTTP POST, подписки — WebSocket
КонтрактOpenAPI, по желанию.proto, обязателенСхема SDL, обязательна
СтримингНет (SSE и WebSocket — отдельно)Четыре режима, включая двунаправленныйПодписки
БраузерНативноТолько через gRPC-Web или ConnectНативно
HTTP-кэшИз коробки (GET, ETag)НетСложно: всё идёт одним POST
Где силёнПубличные API, CRUD, интеграцииВнутренние вызовы между сервисамиМного клиентов с разными нуждами в данных
  • Это не «что быстрее», а «кому и для чего». Публичный API почти всегда REST, вызовы между сервисами часто gRPC, сложные клиентские экраны — повод подумать о GraphQL.
  • gRPC выигрывает за счёт контракта и HTTP/2, а не только бинарного формата: генерация клиентов, дедлайны, стриминг и одно соединение на много запросов.
  • GraphQL переносит сложность на сервер. Клиенту удобно, а серверу нужно бороться с N+1, ограничивать глубину запросов и придумывать кэш.
  • WebSocket — не альтернатива этим трём, а канал для двусторонних событий в реальном времени: чаты, совместное редактирование, котировки.
  • На интервью хватает 2–3 аргументов. Кто клиент, нужен ли стриминг, насколько важен HTTP-кэш и как будет развиваться контракт.

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

На system design интервью после требований и оценки нагрузки обычно идёт API. Интервьюер спрашивает: «Как клиент будет общаться с сервисом?» — и смотрит, понимаешь ли ты компромиссы. Ответ «REST, потому что все так делают» засчитывают, но слабо. Ответ «gRPC, потому что он быстрее» — хуже: скорость сериализации редко бывает узким местом.

Сильный ответ звучит так: «Наружу — REST с OpenAPI: им пользуются сторонние разработчики, нужен HTTP-кэш и CDN. Между сервисами — gRPC: строгий контракт, генерация клиентов, дедлайны и стриминг. GraphQL здесь не нужен: у нас один клиент с предсказуемыми экранами». Дальше разберём, откуда берётся каждый аргумент. Общий порядок ответа на интервью описан в статье о подходах к системному дизайну.

Три модели мышления

REST: ресурсы и глаголы

REST — архитектурный стиль, а не протокол. Ты описываешь ресурсы (/users/42, /orders/7/items) и работаешь с ними стандартными методами HTTP:

GET /users/42 # прочитать POST /users # создать PATCH /users/42 # частично обновить DELETE /users/42 # удалить

Сила REST — в том, что вся инфраструктура интернета уже понимает эту семантику. GET безопасен и кэшируется браузером, CDN и прокси. PUT и DELETE идемпотентны, поэтому их можно повторять при таймауте. Коды ответа (404, 409, 429) понимает любой мониторинг.

Слабости тоже известны:

  • Over-fetching. GET /users/42 возвращает 40 полей, а экрану нужно три.
  • Under-fetching. Чтобы показать профиль, нужно сходить в /users/42, /users/42/orders и /users/42/friends — три запроса вместо одного.
  • Не всё ложится на ресурсы. «Перевести деньги» или «пересчитать рейтинг» — это действия, и в REST их приходится оформлять как POST /transfers или POST /ratings:recalculate.

gRPC: вызов функции на другом сервере

gRPC — фреймворк удалённого вызова процедур, который Google открыл в 2015 году. Ты описываешь сервис и сообщения в .proto-файле, а генератор создаёт клиент и серверную заготовку для нужного языка:

syntax = "proto3"; service UserService { rpc GetUser(GetUserRequest) returns (User); rpc ListOrders(ListOrdersRequest) returns (stream Order); } message GetUserRequest { int64 id = 1; } message User { int64 id = 1; string name = 2; bool active = 3; }

В коде это выглядит как обычный вызов метода: user = stub.GetUser(GetUserRequest(id=42), timeout=0.3). Никаких URL, ручной сериализации и разбора кодов ответа. Ошибки приходят как статусы gRPC: NOT_FOUND, DEADLINE_EXCEEDED, UNAVAILABLE и другие.

GraphQL: клиент сам описывает ответ

GraphQL придумали в Facebook для мобильного приложения и открыли в 2015 году. Сервер публикует схему типов, а клиент присылает запрос ровно на те поля, которые ему нужны:

query Profile { user(id: 42) { name avatarUrl orders(last: 3) { id total } } }

Один запрос — один ответ той же формы. Over-fetching и under-fetching исчезают на стороне клиента. Обычно всё идёт через один эндпоинт POST /graphql, а изменения оформляются как mutation.

Формат данных: JSON против protobuf

JSON текстовый и самоописывающийся: в каждом сообщении повторяются имена полей. Protobuf бинарный: вместо имени поля передаётся его номер из схемы, а числа кодируются varint — маленькие значения занимают меньше байт.

Закодируем вручную сообщение User из примера выше, чтобы увидеть, откуда берётся разница:

import json def varint(n: int) -> bytes: out = bytearray() while True: byte = n & 0x7F n >>= 7 if n: out.append(byte | 0x80) else: out.append(byte) return bytes(out) def field(number: int, wire_type: int, payload: bytes) -> bytes: return varint(number << 3 | wire_type) + payload def encode_user(user_id: int, name: str, active: bool) -> bytes: raw_name = name.encode() return ( field(1, 0, varint(user_id)) # int64 id = 1 + field(2, 2, varint(len(raw_name)) + raw_name) # string name = 2 + field(3, 0, varint(int(active))) # bool active = 3 ) pb = encode_user(150, "Anna", True) js = json.dumps({"id": 150, "name": "Anna", "active": True}, separators=(",", ":")).encode() print(pb.hex(" ")) print(len(pb), "байт protobuf против", len(js), "байт JSON")
08 96 01 12 04 41 6e 6e 61 18 01 11 байт protobuf против 38 байт JSON

08 — это «поле 1, тип varint», 96 01 — число 150. 12 04 — «поле 2, длина 4», дальше байты Anna. Имён полей в сообщении нет вовсе: их знает только схема.

Что из этого следует на практике:

  • Размер. Protobuf обычно в несколько раз компактнее JSON. После gzip разница сокращается, но не исчезает.
  • Скорость разбора. Бинарный разбор по схеме дешевле, чем парсинг текста. Это заметно на сервисах с десятками тысяч запросов в секунду и на мобильных устройствах.
  • Читаемость. JSON можно посмотреть в curl и логах. Для protobuf нужны grpcurl, рефлексия сервера или .proto-файл под рукой.
  • Без схемы сообщение не прочитать. Это плюс для контракта и минус для отладки.

Важная оговорка: REST не обязан быть JSON, а gRPC умеет работать с JSON. Формат — не главное отличие. Главное — модель и контракт.

Транспорт: HTTP/1.1 против HTTP/2

REST работает поверх любой версии HTTP. gRPC требует HTTP/2, и это даёт ему несколько важных свойств.

Мультиплексирование. В HTTP/1.1 на одном TCP-соединении в каждый момент выполняется один запрос. Браузеры открывают по 6 соединений на хост, а сервисы держат пулы соединений. HTTP/2 гоняет много независимых потоков (streams) по одному соединению: медленный ответ не блокирует быстрые на уровне HTTP.

Сжатие заголовков. HPACK не пересылает одинаковые заголовки заново. Для частых мелких вызовов между сервисами это заметная экономия.

Трейлеры. gRPC передаёт статус вызова в HTTP-трейлерах — заголовках после тела ответа. Это позволяет сначала стримить данные, а в конце сообщить об ошибке. Именно трейлеры, а не HTTP/2 как таковой, мешают gRPC работать в браузере.

Подвох для балансировки. Долгоживущее HTTP/2-соединение — проблема для L4-балансировщика. Он распределяет соединения, а не запросы: клиент открыл одно соединение к одному поду, и весь его трафик идёт туда, даже когда рядом поднялись ещё десять. Решения: L7-балансировщик или service mesh, который понимает gRPC (Envoy, Linkerd), либо балансировка на стороне клиента. Подробнее про уровни L4 и L7 — в статье о балансировке нагрузки. Это отличный пункт для интервью: он показывает, что ты гонял gRPC в продакшене, а не только читал о нём.

HTTP/2 по-прежнему работает поверх TCP, поэтому потеря одного пакета задерживает все потоки соединения. Эту блокировку снимает HTTP/3 поверх QUIC. Для REST он уже доступен в браузерах и CDN. Для gRPC поддержка HTTP/3 есть не во всех реализациях, на интервью её лучше не обещать.

Стриминг

У REST стриминга в протоколе нет: запрос — ответ. Для потока событий с сервера используют Server-Sent Events, long polling или WebSocket.

gRPC поддерживает четыре вида вызовов:

ВидПример
Unary: запрос — ответGetUser
Server streamingВыгрузка большого отчёта частями, лента событий
Client streamingЗагрузка телеметрии или файла частями
Bidirectional streamingСинхронизация состояния, голосовой ассистент

В GraphQL для потока есть subscription. Протокол транспорта спецификация не фиксирует, на практике это обычно WebSocket (протокол graphql-ws) или SSE.

Когда стриминг действительно нужен: большие выборки, которые не хочется держать целиком в памяти, долгие операции с прогрессом, двусторонний обмен в реальном времени. Если клиенту хватает ответа раз в 30 секунд, обычный опрос проще и надёжнее.

Где место WebSocket

WebSocket (RFC 6455) — это постоянный двусторонний канал поверх TCP. Соединение начинается как HTTP-запрос с заголовком Upgrade, после чего обе стороны шлют произвольные сообщения в любом направлении.

WebSocket — транспорт, а не стиль API. Формат сообщений, подтверждения, переподключение и авторизацию ты проектируешь сам. Поэтому его берут, когда нужен именно двусторонний поток в браузере:

  • чаты и мессенджеры;
  • совместное редактирование документов;
  • биржевые котировки и онлайн-игры;
  • уведомления, если SSE по какой-то причине не подходит.

Чего WebSocket стоит инфраструктуре: каждое соединение держит память на сервере, балансировщик должен уметь долгие соединения, а при деплое тысячи клиентов переподключаются одновременно. Если данные идут только от сервера к клиенту, SSE проще: это обычный HTTP, переподключение браузер делает сам.

Контракты и кодогенерация

REST + OpenAPI. Спецификация OpenAPI описывает пути, параметры, схемы тел и ошибки в YAML или JSON. Из неё генерируют документацию, клиентов и моки. Проблема в том, что контракт необязателен: бывает, что спецификацию пишут после кода, и она расходится с реальностью. Лечится подходом contract-first и проверкой ответов на соответствие схеме в тестах.

gRPC + protobuf. Контракт обязателен: без .proto нет ни клиента, ни сервера. Типы проверяются на этапе компиляции, клиенты для Go, Java, Python, Kotlin и других языков генерируются из одного файла. Обычно .proto хранят в общем репозитории или реестре схем и проверяют обратную совместимость в CI, например через buf breaking.

GraphQL + SDL. Схема тоже обязательна и доступна через интроспекцию. Клиентские инструменты (Apollo, Relay, graphql-codegen) генерируют типы прямо из запросов.

Версионирование и эволюция

API живёт годами, а клиенты обновляются медленно. Особенно мобильные: старую версию приложения не заставишь обновиться.

REST. Чаще всего версию кладут в путь: /v1/users, /v2/users. Альтернативы — заголовок или параметр медиатипа. Добавить новое поле в ответ безопасно, удалить или переименовать — ломающее изменение, нужен /v2. Параллельная поддержка двух версий — реальная работа, поэтому её стараются избегать.

gRPC. Совместимость обеспечивают номера полей. Правила простые:

  • новое поле получает новый номер — старые клиенты его просто проигнорируют;
  • номер удалённого поля нельзя переиспользовать — объяви его reserved;
  • тип существующего поля не меняют;
  • для действительно ломающих изменений — новый пакет: user.v1 → user.v2.
message User { reserved 3; // было: bool active = 3 reserved "active"; int64 id = 1; string name = 2; UserStatus status = 4; }

Если переиспользовать номер 3 под другое поле, старый клиент прочитает чужие байты как своё active. Ошибки не будет — будут тихо испорченные данные.

GraphQL. Официальная позиция — эволюция без версий. Новые поля добавляются свободно, ведь клиент получает только то, что запросил. Старые помечают @deprecated, смотрят в метриках, кто их ещё запрашивает, и удаляют, когда запросы прекратились.

Браузеры и gRPC-Web

Браузер не даёт JavaScript управлять HTTP/2 на уровне фреймов и не позволяет надёжно читать трейлеры. Поэтому «чистый» gRPC из браузера не работает.

Варианты:

  • gRPC-Web. Отдельный протокол, совместимый с браузерами. Между браузером и сервисом стоит прокси (обычно Envoy), который переводит gRPC-Web в обычный gRPC. Поддерживаются unary-вызовы и server streaming, а client streaming и двунаправленный стриминг — нет.
  • Connect. Протокол и библиотеки от Buf: один сервер отвечает по gRPC, gRPC-Web и простому HTTP с JSON. Ограничения браузера по стримингу те же, зато если сервер написан на Connect, отдельный прокси не нужен.
  • Транскодинг в REST. API Gateway или grpc-gateway публикует REST/JSON поверх gRPC-сервисов по аннотациям в .proto. Внутри строгий контракт, снаружи привычный REST.

Вывод для интервью: если основной клиент — браузер, gRPC добавляет слой инфраструктуры. Это оправдано, когда команда уже живёт на protobuf и хочет единый контракт от фронтенда до базы. Если нет — REST или GraphQL проще.

Подводные камни GraphQL

GraphQL удобен клиенту, но сложность никуда не исчезает — она переезжает на сервер.

  • N+1 запросов. Резолвер orders вызывается для каждого пользователя в списке: 100 пользователей — 100 запросов в базу. Стандартное решение — DataLoader, который собирает ключи в рамках запроса и делает один батч.
  • Дорогие запросы. Клиент может запросить друзей друзей друзей на десять уровней. Нужны ограничение глубины, оценка стоимости запроса и таймауты. Для внешних клиентов — persisted queries: сервер выполняет только заранее зарегистрированные запросы.
  • Кэширование. Всё идёт через POST /graphql, поэтому CDN и браузерный кэш бесполезны «из коробки». Помогают клиентский нормализованный кэш (Apollo, Relay), GET для persisted queries и кэш на уровне резолверов. Общие подходы — в статье о стратегиях кэширования.
  • Rate limiting. Считать запросы бессмысленно: один запрос может стоить в тысячу раз дороже другого. Лимитируют по стоимости. Сами алгоритмы разобраны в статье про rate limiter.
  • Ошибки. Ответ часто приходит со статусом 200 и массивом errors внутри — мониторинг по HTTP-кодам их не увидит.

GraphQL хорошо работает как слой BFF (Backend for Frontend): за ним стоят REST- или gRPC-сервисы, а он собирает из них ответ под конкретный экран. В крупных компаниях схемы нескольких команд объединяют федерацией.

Что выбирать

Внешний (публичный) API

По умолчанию — REST + OpenAPI. Причины:

  • работает из любого языка, curl и браузера без специальных библиотек;
  • HTTP-кэш, CDN, коды ответа и инструменты отладки понятны всем;
  • партнёры ожидают REST, а документацию генерирует OpenAPI;
  • ограничение частоты, авторизация и биллинг легко вешаются на API Gateway.

GraphQL — когда у API много разных потребителей с разными нуждами в данных и ты готов вкладываться в защиту от дорогих запросов. Классический пример — публичный API GitHub, у которого есть и REST, и GraphQL.

gRPC наружу — редкость. Встречается у облачных провайдеров и инфраструктурных продуктов, где клиенты — сервисы, а не люди, и почти всегда рядом есть REST-фасад.

Внутренний API между сервисами

Здесь чаще выигрывает gRPC:

  • контракт в .proto обязателен, и ломающее изменение ловится в CI;
  • клиенты для всех языков генерируются из одного файла;
  • дедлайны передаются по цепочке вызовов, и отменённый запрос не продолжает работу в глубине системы;
  • стриминг и компактный формат пригодятся при больших объёмах.

Но REST внутри — тоже нормальный выбор, особенно для небольшой команды на одном стеке или когда сервисов немного. Не стоит тащить gRPC ради «быстрее» без измеренной проблемы. Если же вызывающему не нужен ответ прямо сейчас, синхронный вызов вообще не нужен: см. статью об очередях сообщений. Как это вписывается в общую архитектуру — в статье о паттернах микросервисов.

Клиентские приложения

  • Веб с несколькими сложными экранами и мобильное приложение — GraphQL или BFF на REST.
  • Мобильное приложение с одной командой бэкенда — REST, а при большом трафике можно и gRPC: мобильные платформы поддерживают его нативно.
  • Реальное время — WebSocket или SSE рядом с основным API, а не вместо него.

Как аргументировать на интервью

Не нужно выбирать один протокол на всю систему. Хороший ответ делит её на зоны и для каждой даёт причину.

Шаблон, который работает почти всегда:

  1. Назови клиента. Браузер, мобильное приложение, партнёр или другой сервис.
  2. Назови паттерн обмена. Запрос-ответ, поток с сервера, двусторонний поток, фоновые события.
  3. Выбери и обоснуй одним-двумя аргументами. «REST, потому что нужен CDN-кэш для GET /videos/{id}». «gRPC, потому что 15 сервисов на трёх языках и нам нужны общий контракт и дедлайны».
  4. Назови цену. «Для gRPC нужна L7-балансировка, иначе нагрузка распределится по подам неравномерно». «В GraphQL придётся ограничивать стоимость запросов».

Пример для ленты новостей:

Клиенты — веб и мобильные приложения, им я отдаю REST: GET /feed?cursor=... с курсорной пагинацией, ответы кэшируются на CDN. Внутри сервис ленты по gRPC ходит в сервисы пользователей, постов и ранжирования: контракты в общем репозитории, дедлайн 200 мс на весь запрос. Новые посты в реальном времени — через SSE, двусторонний канал не нужен. Публикация поста дальше идёт асинхронно через очередь.

Частые дополнительные вопросы:

  • «Почему не GraphQL для клиентов?» — Экранов немного и они стабильны, а GraphQL добавляет N+1, сложное кэширование и лимиты по стоимости. Если появятся десятки разных клиентов — вернусь к вопросу.
  • «Чем gRPC быстрее?» — Компактный бинарный формат, одно HTTP/2-соединение на множество вызовов, сжатие заголовков. Но в типичном сервисе время уходит на базу и сеть, а не на сериализацию.
  • «Как версионировать?» — REST — /v1 и только аддитивные изменения внутри версии. Protobuf — новые номера полей, reserved для удалённых, проверка совместимости в CI.
  • «Как gRPC работает в браузере?» — Через gRPC-Web и прокси или Connect. Двунаправленный стриминг из браузера недоступен — для него WebSocket.

Типичные ошибки

  • «gRPC всегда лучше, потому что быстрее». Скорость сериализации редко бывает узким местом, а отладка и браузерная поддержка становятся дороже.
  • GraphQL ради моды. Один клиент с пятью экранами отлично живёт на REST. GraphQL окупается, когда клиентов и форм данных много.
  • Забыть про балансировку HTTP/2. Без L7 или клиентской балансировки новый под в Kubernetes простаивает, пока старые перегружены.
  • Переиспользовать номер поля в protobuf. Ломает совместимость молча, без ошибок.
  • WebSocket для всего. Если данные идут только с сервера, SSE проще и дешевле.
  • Синхронная цепочка из десяти вызовов. Протокол тут не поможет — нужны асинхронность, таймауты и деградация.

Итог

REST, gRPC и GraphQL решают разные задачи, и в зрелой системе часто живут все три. REST — язык внешнего мира: простой, кэшируемый, понятный любому клиенту. gRPC — язык внутренних вызовов со строгим контрактом, стримингом и дедлайнами. GraphQL — слой для клиентов, которым нужны данные разной формы. WebSocket и SSE — каналы для реального времени рядом с основным API. На интервью важен не выбор сам по себе, а то, что ты связываешь его с клиентом, паттерном обмена и ценой, которую придётся заплатить.

Потренироваться в спокойной обстановке

Все задачи