Синхронное взаимодействие: REST и gRPC

Один сервис зовёт другой и ждёт ответа. Самый привычный способ общения — и самый опасный, если не понимать его цену.

Синхронное взаимодействие — обмен, при котором вызывающий сервис блокируется и ждёт ответа собеседника, а его собственный ответ клиенту напрямую зависит от того, ответит ли тот и как быстро.

Когда монолит разрезают на сервисы, привычный вызов метода getUser(id) превращается в сетевой запрос. Снаружи код почти не меняется: там была функция, здесь — HTTP-клиент. Именно эта обманчивая похожесть и создаёт большинство аварий в распределённых системах. Локальный вызов не может «подвиснуть на две секунды», не может вернуть ответ наполовину и не может исчезнуть в сети. Сетевой — может всё это одновременно.

Зачем вообще синхронный вызов

Не надо демонизировать: синхронный запрос-ответ нужен и уместен там, где данные требуются прямо сейчас, чтобы продолжить работу. Пользователь открыл страницу заказа — сервису заказов нужно имя клиента и статус доставки, чтобы отрисовать ответ. Ждать «когда-нибудь потом» тут нечего: страница либо отрисуется, либо нет.

Практическое правило: если результат нужен для формирования ответа пользователю — вызов синхронный. Если результат нужен «где-то потом» (письмо, аналитика, бонусы, индексация) — вызов не должен быть синхронным. Половина проблем начинается с того, что сервис оформления заказа синхронно дёргает сервис уведомлений и падает вместе с ним, хотя письмо клиенту вообще не влияет на успех заказа.

Запрос-ответ на практике: REST

REST (Representational State Transfer) — стиль построения HTTP-API, где всё крутится вокруг ресурсов и стандартных глаголов: GET читает, POST создаёт, PUT/PATCH меняет, DELETE удаляет. Формат обмена — обычно JSON, читаемый глазами.

curl -s -X GET http://users-service:8080/api/v1/users/42 \
  -H "Accept: application/json" \
  -H "X-Request-Id: 7c4a-9f11" \
  --max-time 2

Результат:

{
  "id": 42,
  "email": "anna@example.com",
  "status": "active",
  "tier": "gold"
}

Обратите внимание на две вещи, которые новички почти всегда забывают. Первая — --max-time 2: у любого сетевого вызова обязан быть таймаут. Клиент без таймаута ждёт вечно, и «вечно» рано или поздно наступает. Вторая — X-Request-Id: сквозной идентификатор запроса, по которому вы потом соберёте всю цепочку в логах. Без него отладка распределённого вызова превращается в гадание.

Цепочка вызовов: как копится задержка

Настоящая беда начинается не с одного вызова, а с цепочки. Клиент зовёт API Gateway, тот — сервис заказов, тот — сервис клиентов, тот — сервис лояльности, тот — базу.

Клиент → Gateway → Orders → Customers → Loyalty → БД
          15 мс     40 мс      35 мс      60 мс   20 мс

Итого пользователь ждёт: 15 + 40 + 35 + 60 + 20 = 170 мс (в лучшем случае)

Задержки складываются, а не усредняются. И считать надо не по среднему, а по хвостам: p99 (99-й перцентиль — «столько ждут самые невезучие 1% запросов») у одного сервиса может быть 300 мс при среднем в 40. Если в цепочке пять сервисов, шанс поймать чей-нибудь хвост на каждом запросе резко растёт: медленный «один процент» четырёх сервисов складывается в заметно более частые тормоза наверху.

Ещё хуже с доступностью. Если каждый сервис в цепочке жив 99,9% времени и любой из них обязателен для ответа, доступности перемножаются. Посчитаем:

uptime = 0.999          # каждый сервис доступен 99.9% времени
minutes_in_month = 30 * 24 * 60

for n in (1, 2, 3, 5, 10):
    chain = uptime ** n
    downtime = (1 - chain) * minutes_in_month
    print(f"Длина цепочки {n}: доступность {chain * 100:.3f}%, простой ~{downtime:.0f} мин/мес")

Результат:

Длина цепочки 1: доступность 99.900%, простой ~43 мин/мес
Длина цепочки 2: доступность 99.800%, простой ~86 мин/мес
Длина цепочки 3: доступность 99.700%, простой ~129 мин/мес
Длина цепочки 5: доступность 99.501%, простой ~216 мин/мес
Длина цепочки 10: доступность 99.004%, простой ~430 мин/мес

Десять «очень надёжных» сервисов в синхронной цепочке дают доступность примерно 99%, то есть семь часов простоя в месяц. Это и есть распределённый монолит — система, которую разрезали на сервисы, но связали синхронными вызовами так, что падает она по-прежнему целиком, только теперь ещё и по сети.

gRPC: контракт и скорость

gRPC — фреймворк удалённого вызова процедур от Google. Вместо «дёрни URL и распарси JSON» вы описываете сервис в файле .proto, а компилятор генерирует клиент и сервер на нужных языках.

syntax = "proto3";
package users.v1;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
}

message GetUserRequest {
  int64 id = 1;
}

message User {
  int64 id = 1;
  string email = 2;
  string status = 3;
  string tier = 4;
}

Ключевая ценность здесь не в скорости, а в том, что контракт стал артефактом: файл лежит в репозитории, его нельзя «случайно» нарушить, потому что клиент сгенерирован из него же. Если сервер убрал поле — клиент не соберётся ещё в CI, а не упадёт ночью в проде.

Скорость — приятный бонус: protobuf кодирует данные в бинарный формат (в 3–10 раз компактнее JSON), а поверх работает HTTP/2 с мультиплексированием — несколько запросов едут по одному TCP-соединению, не выстраиваясь в очередь.

КритерийREST + JSONgRPC + protobuf
КонтрактOpenAPI-файл (часто пишется отдельно и врёт).proto — источник кода клиента и сервера
ФорматТекстовый JSON, читается глазамиБинарный, глазами не читается
ТранспортHTTP/1.1 или HTTP/2Только HTTP/2
Отладкаcurl, браузер, любой проксиНужен grpcurl и .proto
СтримингКостыли (SSE, long polling)Встроенный, в обе стороны
Где уместенПубличные API, фронтенд, интеграцииВнутренние вызовы между сервисами

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

Как это работает

Под капотом синхронный вызов — это TCP-соединение, установленное клиентской библиотекой, и ожидание байтов в сокете. Чтобы такая связь не убивала систему, вокруг неё выстраивают четыре защитных механизма.

  • Таймаут. Всегда конечный и всегда меньше, чем таймаут вызывающего. Если Gateway ждёт ответ 3 секунды, а Orders ждёт Customers 5 секунд — Orders продолжит работать над запросом, который уже никому не нужен.
  • Retry (повтор). Полезен при сетевом сбое, но опасен: повторяя запрос, вы удваиваете нагрузку на уже страдающий сервис. Повторять можно только идемпотентные операции — те, которые от повторения не меняют результат (GET, DELETE, POST с ключом идемпотентности). И только с экспоненциальной задержкой и джиттером.
  • Circuit breaker («предохранитель»). Считает ошибки; когда их доля переходит порог, «размыкает цепь» и на время перестаёт вообще ходить в упавший сервис — сразу отдаёт ошибку или заглушку. Это даёт больному сервису шанс подняться, а не добивает его ретраями.
  • Bulkhead («переборка»). Отдельный пул соединений/потоков на каждого соседа. Если сервис лояльности завис, он выест только свой пул, а не все потоки приложения, и вызовы к остальным сервисам продолжат работать.

Все четыре сегодня чаще всего живут не в коде, а в service mesh (Istio, Linkerd) или в клиентской библиотеке — но знать их надо, потому что настраивать их всё равно вам.

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

  • Вызов без таймаута. Дефолтный таймаут многих HTTP-клиентов — бесконечность. Один зависший сосед выедает весь пул потоков, и сервис перестаёт отвечать даже на /health.
  • Ретраи без ограничений. Три сервиса в цепочке, каждый повторяет по 3 раза — база получает 27-кратный шторм запросов ровно в тот момент, когда ей и так плохо.
  • Синхронный вызов там, где нужен был асинхронный. Оформление заказа не должно падать из-за недоступного сервиса рассылки. Спросите себя: «влияет ли ответ этого сервиса на результат операции?» Если нет — событие, а не вызов.
  • Цепочка вместо композиции. A зовёт B, B зовёт C, C зовёт D. Лучше, когда один координатор зовёт B, C и D параллельно: задержка станет максимумом из трёх, а не суммой.
  • Игнор частичных отказов. Ответ 500 от соседа — не повод отдавать 500 пользователю. Часто можно отдать данные без блока рекомендаций (graceful degradation), и пользователь ничего не заметит.
  • Раздача внутреннего gRPC наружу. Браузер не умеет в gRPC напрямую, партнёры не хотят генерировать клиентов. Для внешнего мира нужен REST-фасад.

Итоги

  • Синхронный вызов уместен, когда ответ соседа нужен, чтобы сформировать ответ пользователю. Во всех остальных случаях он создаёт лишнюю связанность.
  • В цепочке синхронных вызовов задержки складываются, а доступности перемножаются: 10 сервисов по 99,9% дают около 99% — часы простоя в месяц.
  • REST — читаемый, универсальный, хорош наружу. gRPC — типизированный и быстрый, хорош внутрь; главная его ценность — контракт в виде .proto, из которого генерируется код.
  • Любой сетевой вызов обязан иметь таймаут; повторять можно только идемпотентные операции; circuit breaker и bulkhead не дают одному упавшему соседу утащить за собой весь сервис.
  • Система из сервисов, связанных синхронными цепочками, — это распределённый монолит: сложность распределённой системы вы получили, а устойчивость — нет.
Проверьте себя
1. Пять сервисов, каждый доступен 99,9% времени, выстроены в синхронную цепочку, и ответ каждого обязателен. Какой будет итоговая доступность?
AПримерно 99,9% — доступность определяется самым надёжным звеном
BПримерно 99,5% — доступности звеньев перемножаются
CПримерно 99,98% — резервирование повышает надёжность
DРовно 100%, если у всех вызовов настроены ретраи
2. Сервис оформления заказа синхронно вызывает сервис email-рассылки, чтобы отправить письмо о заказе. Что здесь не так?
AНичего, письмо действительно часть заказа
BНужно вызывать по gRPC, а не по REST — так быстрее
CЗаказ теперь падает, если недоступна рассылка, хотя письмо не влияет на успех операции
DНужно увеличить таймаут вызова, чтобы письмо точно ушло