API
gRPC и Protobuf: бинарный контракт и его цена
Выкатили новый микросервис. В течение часа orders-сервис начинает читать неверные значения: priority приходит как discount_pct, и возвраты срабатывают на здоровых заказах. Данные никто не менял — кто-то перенумеровал поле protobuf с 4 на 3, чтобы «прибраться в схеме». На проводе нет имён полей, только номера, поэтому каждый потребитель, всё ещё работающий на старой схеме, декодировал поле 3 не в тот слот. Proto скомпилировался чисто. Тесты прошли. Прод сорок минут молча портил состояние.
Формат провода: номера, а не имена
JSON шлёт схему с каждым сообщением. {"userId": 42, "active": true} повторяет ключи userId и active в каждом payload, как UTF-8 текст, на каждом запросе. Protobuf делает наоборот: сериализует только номер поля, тип на проводе и значение. То же сообщение становится примерно 08 2A 10 01 — горстка байт, где 08 кодирует «поле 1, varint», 2A — это 42, и так далее. Имена полей живут только в файле .proto на этапе компиляции; они никогда не путешествуют.
Этот один выбор даёт те самые числа, о которых думают сеньоры. Для данных с большим числом чисел protobuf примерно в 4-5 раз компактнее JSON благодаря varint-кодированию (маленькие целые стоят один байт, а не длину их десятичной строки). Сериализация примерно в 3-6 раз быстрее, а парсинг в 5-10 раз быстрее, потому что нет токенизации текста, нет аллокации строк-ключей, нет рефлексии — декодер идёт по известной раскладке. В Go-бенчмарках это выливается в ~5-7x быстрее round-trip при ~3x меньше аллокаций. Расплата — зеркало суперсилы JSON: нельзя декодировать protobuf-сообщение без его схемы. Байты сами по себе бессмысленны.
Почему это работает
Компактность зависит от формы данных, и сеньоры обжигаются, считая её универсальной. Для payload с большим числом строк protobuf лишь на ~4% меньше JSON — строки хранятся как сырой UTF-8 в обоих, поэтому единственная экономия это отбрасывание повторяющихся имён ключей. Большие выигрыши (4-5x) идут от чисел, булевых, enum и repeated-полей. Если твой payload это в основном свободный текст, аргумент про размер почти исчезает; ты берёшь protobuf ради схемы и скорости, а не ради байт.
Номера полей это контракт, навсегда
Поскольку на проводе несётся поле 3, а никогда "discount_pct", идентичность данных задаёт номер поля, а не имя. Это полностью переворачивает привычную интуицию:
- Переименовать поле бесплатно.
discount_pct→discountPercentничего не меняет на проводе; старые и новые бинарники взаимодействуют идеально. Имя это локальная метка в исходнике. - Перенумеровать поле катастрофа. Поменяй
3на4, и каждый пир всё ещё на старой схеме читает твоё новое поле4в то, что он зовёт полем4, а данные твоего старого поля3падают туда, где он ждёт3. Это и есть баг из Hook. Компилятор его не ловит, потому что каждая сторона компилирует самосогласованную схему. - Переиспользовать удалённый номер та же катастрофа, отложенная. Удали поле
3, потом позже добавь новое несвязанное поле и присвой ему3— теперь старые клиенты, шлющие исходное поле3, отравляют новое.
Дисциплина, которая всё это предотвращает, — add-only эволюция плюс reserved. Ты никогда не меняешь и не переиспользуешь номер; только дописываешь новые поля с новыми номерами, а удаляя поле, резервируешь его номер (и имя), чтобы никто не смог его вернуть:
message Order {
reserved 3; // discount_pct, удалено 2026-Q1 — не переиспользовать
reserved "discount_pct";
string id = 1;
int64 amount_cents = 2;
Priority priority = 4; // безопасно: дописано со свежим номером
}Сделанная так схема одновременно обратно совместима (новый код читает сообщения, записанные старым: отсутствующие новые поля просто берут дефолты proto3 — 0, "", пусто) и прямо совместима (старый код читает сообщения нового: неизвестные номера полей пропускаются и сохраняются, а не вызывают ошибку). Эта двусторонняя совместимость и есть вся причина, почему большие полиглот-системы могут деплоить продюсеров и консьюмеров независимо.
| Изменение схемы | Безопасно? | Почему (причина на уровне провода) |
|---|---|---|
| Добавить новое поле с новым номером | Да | Старые пиры пропускают неизвестный номер; новые поля дефолтятся у старых писателей |
| Переименовать поле (тот же номер) | Да | Имена не путешествуют; на проводе только номер |
Удалить поле + reserved его номер | Да | Reserve блокирует будущее переиспользование, номер нельзя отравить |
| Сменить номер поля | Нет | Пиры декодируют значение не в то поле — тихая порча |
| Переиспользовать удалённый номер под новое поле | Нет | Отстающие на старой схеме пишут туда старый смысл |
Одно соединение, четыре формы вызова
gRPC работает поверх HTTP/2, и это даёт мультиплексирование: множество одновременных RPC переплетаются как независимые стримы по одному TCP-соединению, без head-of-line blocking на уровне HTTP — 50 одновременных вызовов нужны одно соединение, а не 50. Отменённый или истёкший по дедлайну вызов шлёт RST_STREAM, который убивает только этот стрим и оставляет остальные нетронутыми. Поэтому пропускная способность gRPC в service mesh держится под конкурентностью там, где модель «соединение на запрос» у HTTP/1.1 рушится.
Поверх этого одного соединения блок service в схеме определяет четыре формы вызова, и выбор не той — частый запах дизайна:
- Unary — один запрос, один ответ. REST-образный случай на 95%.
- Server streaming — один запрос, стрим ответов (поток цен, хвост лога, прогресс с пушем от сервера).
- Client streaming — стрим запросов, один ответ (загрузка чанков, батчинг телеметрии).
- Bidirectional streaming — обе стороны стримят независимо в одном вызове (чат, живая коллаборация, долгоживущий канал управления).
service PriceService {
rpc GetQuote(QuoteRequest) returns (Quote); // unary
rpc WatchPrices(WatchRequest) returns (stream Quote); // server streaming
rpc UploadTrades(stream Trade) returns (UploadSummary); // client streaming
rpc Trade(stream Order) returns (stream Fill); // bidirectional
}Вместе с этим едут две операционные фичи, которые легко пропустить, пока они не укусят. Дедлайны: клиент задаёт таймаут, который распространяется вниз по цепочке вызовов; когда он истекает, RPC авто-отменяется на обоих концах, и медленный downstream не может вечно держать серверные потоки. Классический сбивающий с толку отказ — вызов с deadline-exceeded, который успел на сервере («я отправил все ответы»), но упал на клиенте («они пришли слишком поздно») — работа сделана, а вызывающий увидел ошибку. Отмена: любая сторона может прервать вызов на лету, и проброшенная отмена останавливает downstream-работу вместо того, чтобы дать осиротевшим запросам молоть впустую. Пропуск дедлайнов — самая частая причина, почему gRPC-mesh каскадно уходит в исчерпание ресурсов при частичном отказе.
Где gRPC выигрывает, а где больно
gRPC — верный дефолт для внутреннего трафика между сервисами: типизированный контракт, разделяемый полиглот-сервисами, выигрыши в 4-10x по скорости и размеру на горячих путях, нативный стриминг и проброс дедлайнов по графу вызовов. В нагруженном mesh эта экономия не академическая — она режет хвостовую latency и CPU на пути сериализации, который REST тратит на JSON.
Боль реальна и приходит в двух местах. Во-первых, браузер не умеет говорить на gRPC. Браузеры не могут управлять HTTP/2-фреймингом или трейлерами, поэтому нужен grpc-web плюс прокси (Envoy или собственный прокси фреймворка), чтобы транслировать между браузером и gRPC-бэкендом — лишняя инфраструктура, и grpc-web всё ещё не умеет client- или bidirectional-стриминг. (Connect-RPC обходит часть этого, отдавая unary-вызовы как обычный HTTP.) Во-вторых, отлаживаемость рушится. Бинарный payload нечитаем во вкладке Network в Chrome — ты видишь «передано байт», а не поля. curl бесполезен без схемы и декодера; трейлеры прячутся внутри байт тела ответа, где у DevTools нет хука. Логи, реплей запросов и ad-hoc осмотр — всё требует дополнительного тулинга. Эта непрозрачность и есть налог за компактность — то самое свойство, что делает провод маленьким, делает его непостижимым для людей.
Публичный REST-эндпоинт, в который React-приложение бьёт напрямую, тормозит под нагрузкой. Коллега предлагает перевести его на gRPC. Выбери ход.
Поле discount_pct = 3 удаляют из proto. Какой ход сеньора, чтобы схема осталась безопасной?
Почему нельзя вставить захваченный gRPC-запрос в инструмент и прочитать его как тело JSON?
Расставь безопасные шаги эволюции proto-сообщения в проде:
- 1 Никогда не меняй и не переиспользуй существующий номер поля — номер это идентичность на проводе
- 2 Чтобы удалить поле, удали его и добавь reserved для его номера и имени
- 3 Чтобы добавить данные, допиши новое поле со свежим, ранее не использованным номером
- 4 Полагайся на дефолты proto3, чтобы старые читатели терпели отсутствие новых полей (обратная совм.)
- 5 Полагайся на пропуск неизвестных полей, чтобы старые читатели терпели новые поля (прямая совм.)
- 01Объясни, почему переименовать поле protobuf безопасно, а перенумеровать его — катастрофа продакшен-уровня.
- 02Когда выбрать gRPC вместо REST/JSON, и какие конкретные издержки тянет это решение?
Определяющий выбор gRPC — формат провода protobuf: он сериализует номер поля, тип на проводе и значение, но никогда имена полей — те живут только в .proto. Это делает payload в 4-5 раз компактнее и в 4-10 раз быстрее в (де)сериализации, чем JSON, для числовых данных, но это же значит, что идентичность данных задаёт номер поля, а не имя. Поэтому переименование бесплатно, перенумерация или переиспользование удалённого номера молча портит каждого пира на другой схеме, и единственная безопасная эволюция — add-only с reserved-метками для удалений — что и даёт protobuf его двустороннюю обратную/прямую совместимость. Поверх HTTP/2 gRPC мультиплексирует множество RPC на одном соединении и предлагает четыре формы вызова (unary, server, client, bidirectional streaming) плюс дедлайны и отмену, которые пробрасываются вниз по графу вызовов. Цена в том, что браузерам нужны grpc-web и прокси, а бинарный payload непрозрачен для людей и стандартного тулинга. Паттерн сеньора: gRPC для внутренних хопов между сервисами, REST/JSON или Connect-RPC на браузерном крае.