Синхронное взаимодействие: 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 + JSON | gRPC + 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 не дают одному упавшему соседу утащить за собой весь сервис.
- Система из сервисов, связанных синхронными цепочками, — это распределённый монолит: сложность распределённой системы вы получили, а устойчивость — нет.