Между двумя сервисами внутри системы вызовы идут тысячами в секунду, и каждый байт, каждая миллисекунда парсинга и каждая строка повторяющегося кода воспроизводятся миллионы раз. JSON поверх HTTP/1.1 прост, читается глазами и не требует генерации кода, но на больших внутренних потоках его цена становится заметной. gRPC обещает бинарный формат, мультиплексирование HTTP/2 и готовые клиенты из одной схемы. Разберём, где обещание выполняется с запасом, а где границы технологии становятся неприятным сюрпризом для команды.

Устройство вызова поверх HTTP/2 и бинарный формат Protobuf

gRPC представлен в 2015 году как открытый каркас вызова удалённых процедур. Сервис описывается в proto файле на языке Protobuf, из которого генерируется код клиента и сервера на десятках языков. Схема платежа занимает полстраницы:

syntax = "proto3";

package billing.v1;

message ChargeRequest {
  string user_id = 1;
  int64 amount_kopecks = 2;
  string currency = 3;
}

message ChargeResponse {
  string operation_id = 1;
  bool accepted = 2;
}

service Billing {
  rpc Charge(ChargeRequest) returns (ChargeResponse);
}

Генерация кода для Python делается одной командой:

python -m grpc_tools.protoc -I proto --python_out=. --grpc_python_out=. proto/billing.proto

Транспорт работает поверх HTTP/2: одно TCP-соединение мультиплексирует десятки одновременных вызовов, HPACK сжимает повторяющиеся заголовки, каждое сообщение несёт пятибайтовый префикс с длиной. Долгие соединения поддерживаются пингами активности, поэтому сетевые шлюзы не рвут канал по собственным таймаутам. Мультиплексирование убирает очередь запросов на соединение: вызовы не ждут друг друга, как в HTTP/1.1, где один медленный ответ блокирует весь канал. Честная физика остаётся в силе: канал один, потеря пакета задержит все потоки разом.

Сервер на Python умещается в несколько строк:

from concurrent import futures
import grpc
import billing_pb2
import billing_pb2_grpc

class Billing(billing_pb2_grpc.BillingServicer):
    def Charge(self, request, context):
        return billing_pb2.ChargeResponse(
            operation_id="op-771",
            accepted=True,
        )

server = grpc.server(futures.ThreadPoolExecutor(max_workers=32))
billing_pb2_grpc.add_BillingServicer_to_server(Billing(), server)
server.add_insecure_port("[::]:50051")
server.start()

Актуальные версии из реестра пакетов: grpcio 1.84.0 и protobuf 7.36.2. Контракт метода, типы полей и коды ошибок описаны в одном файле, и у всех команд они совпадают до байта, потому что код генерируется из одного источника.

Реальные числа экономии трафика и нагрузки на процессор

Типовое сообщение о платеже в JSON со строковыми ключами занимает около 420 байт, та же запись в Protobuf укладывается в 140: числа хранятся компактно, имена полей заменены короткими номерами, повторяющиеся ключи исчезают. Почти половину веса JSON-сообщения занимают сами ключи и кавычки:

{"user_id": "u42", "amount_kopecks": 9900, "currency": "RUB", "meta": {"session": "01HA"}}

На фоне одного вызова разница копеечная, на потоке она превращается в деньги: при 5000 вызовах в секунду JSON гоняет 2.1 мегабайта в секунду, Protobuf 0.7, за месяц набегает 5.5 терабайта против 1.8.

Экономия живёт и в заголовках: HPACK держит словарь повторяющихся метаданных, и строка авторизации, имя метода и служебные поля не летают целиком в каждом вызове, сотни байт сжимаются до десятков. На 5000 вызовах в секунду экономия заголовков даёт ещё полмегабайта в секунду, и за месяц счётчик насчитывает лишний терабайт.

Процессор экономит заметнее сети. Парсер JSON строит строки, словари и промежуточные объекты, Protobuf читает поля по смещениям без лишних копий памяти. Замер решает споры лучше любых мнений, скрипт умещается в десять строк:

import json
import time
import billing_pb2

data = {"user_id": "u42", "amount_kopecks": 9900, "currency": "RUB"}
msg = billing_pb2.ChargeRequest(user_id="u42", amount_kopecks=9900, currency="RUB")

start = time.perf_counter()
for _ in range(1_000_000):
    json.dumps(data)
print("json:", round(time.perf_counter() - start, 2))

start = time.perf_counter()
for _ in range(1_000_000):
    msg.SerializeToString()
print("protobuf:", round(time.perf_counter() - start, 2))

На одном ядре миллион сериализаций JSON занимает секунды, Protobuf на том же объёме укладывается в доли и отдаёт более короткие байты. Точный коэффициент зависит от профиля сообщения, поэтому споры о цифрах закрываются личным замером на живой форме, а не цитатами из чужих презентаций. Честная оговорка: при 50 вызовах в секунду выигрыш незаметен, сетевая задержка съедает разницу между 3 и 1 миллисекундой парсинга. gRPC платит там, где вызовы короткие, частые и внутренние.

Четыре вида вызовов от одиночного до двунаправленного потока

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

Серверный поток в коде выглядит как обычный генератор:

import warehouse_pb2
import warehouse_pb2_grpc

class Feed(warehouse_pb2_grpc.FeedServicer):
    def Subscribe(self, request, context):
        for event in warehouse_events(request.warehouse_id):
            yield warehouse_pb2.Event(
                id=event.id,
                kind=event.kind,
            )

Клиентский поток экономит на обратной связи: приложение собирает показания датчиков за минуту и отправляет пачкой, сервер подтверждает один раз. Двунаправленный режим держит открытым канал для торговых котировок и живых чатов, где обе стороны говорят без очередей запросов. Каждый вид потока описывается одной строкой в схеме, клиентский код генерируется автоматически, ручной разбор кадров не нужен.

Потоки подчиняются контролю хода HTTP/2: медленный клиент задерживает отправку, сервер не заливает память получателя. Ошибка в середине потока приходит в завершающих заголовках после последнего сообщения, клиент отличает обрыв сети от штатного конца по статусу в них. В REST потоки эмулируются долгим опросом или событиями сервера, и обе техники остаются костылями поверх пары запроса и ответа. Практический эффект виден на телеметрии: 10 000 точек метрик в секунду уходят одним потоком, а не 10 000 одиночными вызовами с заголовками и кругами по сети.

Дедлайны и коды статусов как встроенная дисциплина надёжности

У вызова есть крайний срок, и он едет по цепочке сервисов: первый выделил 500 миллисекунд, израсходовал 100, второму передал остаток в заголовке grpc-timeout. Когда время кончается, вся цепочка отменяет работу за микросекунды, а не ждёт системных таймаутов на каждом звене по отдельности.

import grpc
import billing_pb2
import billing_pb2_grpc

channel = grpc.insecure_channel("billing:50051")
stub = billing_pb2_grpc.BillingStub(channel)

try:
    resp = stub.Charge(
        billing_pb2.ChargeRequest(
            user_id="u42",
            amount_kopecks=9900,
            currency="RUB",
        ),
        timeout=0.5,
    )
except grpc.RpcError as e:
    if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
        ...

Ошибки типизированы кодами: UNAVAILABLE, RESOURCE_EXHAUSTED, INVALID_ARGUMENT, NOT_FOUND. Клиентская библиотека повторяет вызов для повторимых кодов с экспоненциальной паузой, прикладной код различает недоступность сервиса и отказ валидации без разбора текста сообщения. Повторимая операция не равна безопасной: повторный Charge без идемпотентного ключа списывает деньги дважды, и протокол тут бессилен, дисциплина остаётся на стороне приложения. Перехватчики вешают метрики, журналирование и трассировку одним блоком на клиента и сервера, гистограмма задержек по методам собирается без копипасты. В REST всё то же обычно пишется вручную, и каждая команда изобретает свой велосипед с разной степенью круглости.

Предохранитель собирается на тех же кодах: клиент замечает серию UNAVAILABLE, отсекает аварийный сервис на фиксированный интервал и быстро отказывает вышележащим вызовам, вместо того чтобы копить очередь и ронять всю цепочку. Для gRPC это перехватчик в двадцать строк, для самодельного REST-клиента обычно ещё один модуль в копилку велосипедов.

Ограничения gRPC на стыке с браузером и внешними партнёрами

Браузерный JavaScript не отправляет сырые кадры HTTP/2, поэтому прямого gRPC из веба не существует: работает вариант grpc-web, и ему нужен дополнительный шлюз вроде Envoy между браузером и бэкендом. Каждый вызов живёт по пути вида /billing.v1.Billing/Charge, это единственное читаемое место в дампе, остальное тело бинарное. Отладка требует инструментов: curl тело не покажет, вместо него grpcurl, а в логах без декодеров видны байты, а не поля. Лимит сообщения по умолчанию равен 4 мегабайтам, и передача больших файлов через протокол остаётся занятием на любителя.

Файлы и большие бинарные тела протоколу в тягость: лимит настраивается в большую сторону, но каждое сообщение живёт в памяти целиком, и поток гигабайтских архивов разумнее отдать объектному хранилищу, передав по gRPC только ссылку и метаданные. Такой шаблон снимает вопрос размеров и оставляет бинарному протоколу короткие управляющие вызовы.

Внешние потребители ждут понятный REST с документацией и простым первым запросом. Наконец, длинные HTTP/2 соединения путают балансировщики, распределяющие нагрузку по TCP-соединениям: миллионы вызовов остаются внутри одного соединения, и нагрузка ложится неровно. Классические дашборды с HTTP-кодами слепнут: gRPC отвечает кодом 200, а причины спрятаны в кодах статусов вызова, мониторинг переезжает на доли UNAVAILABLE и DEADLINE_EXCEEDED по методам. На стыке миров нужен переводчик, и его цена входит в общую смету проекта.

Генерация кода из proto файла и дисциплина совместимости

Protobuf держит совместимость номерами полей, правила короткие. Номер поля вечен, занятый номер после удаления поля уходит в блок reserved, новое поле безопасно для старых клиентов, изменение типа ломает всех. Ломающее изменение выглядит невинно: разработчик меняет поле суммы с числового типа на строковое, старые клиенты читают двоичный мусор вместо денег, и инцидент находят по крикам бухгалтерии.

message ChargeRequest {
  reserved 3, 4;
  reserved "legacy_currency";
  string user_id = 1;
  int64 amount_kopecks = 2;
}

Перечисления расширяются добавлением значений, но строгий клиент при неизвестном значении вправе отказаться от разбора, поэтому новые значения обкатывают на своих потребителях первыми. Машинная проверка спасает от человеческой забывчивости:

buf lint proto/
buf breaking proto/ --against ".git#branch=main"

Линтер ловит нарушение правил, проверка ломающих изменений сравнивает схему с основной веткой и не пускает релиз. Схема становится строгим контрактом с дисциплиной версий: новые поля, новые методы, никаких тихих переименований. Сравните с JSON, где контракт живёт в документации и молчаливо устаревает с каждым спринтом.

Когда REST с JSON остаётся разумным выбором

REST с JSON остаётся разумным в четырёх случаях:

  1. API потребляют внешние разработчики, им нужен читаемый контракт с документацией и отладкой.;
  2. Нагрузка измеряется десятками вызовов в секунду, экономия байтов не окупает генерацию кода.;
  3. Команда читает логи глазами и проверяет ручки curl без декодеров.;
  4. Сервис живёт на границе с браузером и мобильным вебом без дополнительного шлюза.

Нативные мобильные приложения говорят на gRPC без шлюзов, поэтому выбор для клиентов на Kotlin или Swift выглядит иначе, чем для веба. Миграция между протоколами идёт по одной паре сервисов: шлюз держит оба интерфейса на переходный период, старых потребителей не бросают с переписыванием за выходные. Крупные системы чаще всего выбирают гибрид: наружу REST, понятный внешнему миру, внутри gRPC для плотной связи между своими сервисами. Экономия gRPC проявляется на коротких частых внутренних вызовах: платежные транзакции, каталоги, телеметрия, раздача кеша. Универсальности ждать не стоит, стоит считать: байты, ядра, круги задержек, стоимость поддержки. Цифры подскажут границу точнее любых вкусовых споров.