---
title: "Сериализация: оптимизация proto"
description: "Оптимизация protobuf: бинарный формат proto, varint и zigzag, теги полей, reserved, backward compatibility, optional в proto3 и сжатие gzip в gRPC."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/19-proto-opt/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 20
---

# Сериализация: оптимизация proto

*Курс «gRPC-Go: высокопроизводительный RPC», урок 19 из 25. ~20 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/19-proto-opt/*

Восемнадцатый урок научил сервер рассказывать о себе, а grpcurl — превращать ваш JSON в то, что реально едет по сети. Но что именно едет по сети, мы так и не видели: gRPC передаёт protobuf в бинарном виде, и весь ключ «оптимизация protobuf» живёт именно там, в этих байтах. Здесь вы разберёте устройство проводного формата: сообщение — это последовательность пар ключ-значение, где ключом служит номер поля, а не имя; откуда берётся формула тега (field_number << 3) | wire_type и почему номера 1–15 дешевле остальных; как работает varint и почему отрицательные int32 стоят десять байт, а sint32 со схемой ZigZag — один; как устроены length-delimited записи, вложенные сообщения и packed repeated. Затем — правила эволюции: reserved и катастрофа переиспользования номера, backward compatibility при выкатке новых версий proto-схем, что произойдёт, когда старый клиент прочитает новое сообщение, чем optional в proto3 (с протокола 3.15) отличается от неявных полей и почему это лучший дефолт. Финал — по-инженерному: сжатие gzip в gRPC через encoding.RegisterCompressor и grpc.UseCompressor, пятибайтовая рамка сообщения с флагом сжатия и потолок в четыре мегабайта, который gRPC ставит каждому входящему сообщению по умолчанию.

### оптимизация protobuf: сообщение как серия ключ-значение

Прежде чем что-то оптимизировать, посмотрим, что мы оптимизируем. Документация protobuf.dev по кодированию начинается с определения: «A protocol buffer message is a series of key-value pairs» — сообщение на проводе это серия пар ключ-значение, и дальше — самое важное предложение темы: «The binary version of a message just uses the field's number as the key – the name and declared type for each field can only be determined on the decoding end by referencing the message type's definition». В байтах нет ни имён полей, ни типов: есть номер поля, номер wire type и payload. Имя, тип и всё остальное живёт только в схеме, которую обе стороны обязаны держать согласованной — этим, кстати, занимается кодогенерация из третьего урока, раскладывая дескрипторы в сгенерированные пакеты. Каждая пара кодируется как запись из номера поля, wire type и полезной нагрузки, и «The wire type tells the parser how big the payload after it is. This allows old parsers to skip over new fields they don't understand» — вот фундамент всей совместимости: парсер, не знающий поля, всё равно знает, сколько байт пропустить. Схему называют Tag-Length-Value: «This type of scheme is sometimes called Tag-Length-Value, or TLV». Отсюда два практических вывода, ради которых и существует оптимизация protobuf. Первый: на проводе правит бал номер поля — менять его нельзя, а переименовывать можно почти безнаказанно, что перевернёт ваш взгляд на эволюцию схем ниже. Второй: размер сообщения зависит не от объявления, а от значений и номеров — «разжиревший» int64 в поле номер 30 дороже аккуратного значения в поле номер 3. Подглядывать за байтами удобно тем же grpcurl из восемнадцатого урока: он, правда, показывает вам JSON и дескрипторы, а не сырые байты, но именно сырые байты мы сейчас разберём руками.

### Шесть wire types: что чем кодируется

| Номер и имя | Типы полей | Payload |
| --- | --- | --- |
| 0 — VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum | varint: от одного до десяти байт, длина зависит от значения |
| 1 — I64 | fixed64, sfixed64, double | восемь байт фиксированно, little-endian |
| 2 — LEN | string, bytes, вложенные сообщения, packed repeated | varint-префикс длины, затем столько байт полезной нагрузки |
| 3 — SGROUP | группы (устарело) | пустая запись-открывашка, конец — парной записью EGROUP |
| 4 — EGROUP | группы (устарело) | пустая запись-закрывашка |
| 5 — I32 | fixed32, sfixed32, float | четыре байта фиксированно, little-endian |

### Тег: формула (field_number << 3) | wire_type

Ключ каждой записи — это varint, собранный из номера поля и wire type: «encoded as a varint formed from the field number and the wire type via the formula (field_number << 3) | wire_type». Разбор обратный: «the low 3 bits tell us the wire type, and the rest of the integer tells us the field number» — младшие три бита это wire type, остальное номер. Проверьте на двух знакомых байтах. Первый байт сообщения из схемы message Test1 { int32 a = 1; } — 0x08: двоичное 00001000, младшие три бита 000 — VARINT, сдвиг вправо даёт номер 1. Для message Test2 { string b = 2; } тег — 0x12: двоичное 00010010, младшие биты 010 — это LEN, номер 2. Отсюда следствие, с которого начинается любая оптимизация proto: чем меньше номер, тем короче тег. «You should use the field numbers 1 through 15 for the most-frequently-set fields» — документ прямо советует отдавать диапазон 1–15 самым часто заполняемым полям, потому что «Lower field number values take less space in the wire format»: номера 1–15 кодируются тегом в один байт, 16–2047 — в два. Диапазон номеров ограничен 536 870 911, потому что «The field number is limited to 29 bits rather than 32 bits because three bits are used to specify the field's wire format». И есть запрещённая зона посередине: «Field numbers 19,000 to 19,999 are reserved for the Protocol Buffers implementation» — «The protocol buffer compiler will complain if you use one of these reserved field numbers in your message», protoc отвергнет такую схему на компиляции. Каноничным для нас остаётся Order из второго урока: id = 1, items = 2 — как раз в дешёвом диапазоне.

### Проверка: разберите тег

**Вопрос:** Первый байт записи в бинарном сообщении — 0x12 (десятичное 18). Что это значит?

- ✅ Поле номер 2, wire type LEN: дальше varint-длина и столько байт payload — строка, байты, вложенное сообщение или packed repeated
- Поле номер 12, wire type LEN: двоичную запись нужно читать как 12 в старших битах
- Поле номер 2, wire type 8: 0x12 = 8 + 10, поэтому тип восьмой
- Поле номер 1, wire type VARINT: это тот же 0x08, только с продолжением

18 в двоичном виде — 00010010. Младшие три бита 010 дают wire type 2 (LEN), сдвиг вправо на три бита — номер поля 2. Это ровно пример из документации: для message Test2 { string b = 2; } и значения testing на проводе лежит 12 07 74 65 73 74 69 6e 67 — тег 0x12, varint-длина 7, семь байт строки. Остальные варианты путают байтовую запись с десятичной и смешивают payload с тегом.

### varint: база-128 и экономия на малых значениях

VARINT — самый частый тип на проводе, и «Variable-width integers, or varints, are at the core of the wire format. They allow encoding unsigned 64-bit integers using anywhere between one and ten bytes, with small values using fewer bytes». Устройство простое: «Each byte in the varint has a continuation bit that indicates if the byte that follows it is part of the varint. This is the most significant bit (MSB)» — старший бит байта это продолжение, остальные семь бит — полезная нагрузка, и «These 7-bit payloads are in little-endian order» — семибитные группы склеиваются младшими вперёд. Классический пример из доки: значение 150 для поля a = 1 едет тремя байтами 08 96 01. Тег 0x08 мы разобрали; varint 96 01 раскладывается так: 10010110 00000001, у первого байта MSB установлен — есть продолжение; сбрасываем биты продолжения, получаем 0010110 и 0000001, разворачиваем в big-endian: 00000010010110 — это 128 + 16 + 4 + 2 = 150. Обратите внимание на цену: 150 стоит одного байта varint, но 1 000 000 — уже трёх, 2^31 и больше — пяти, а почти любое большое 64-битное — всех десяти. Отсюда главное правило варинт-экономии: размер зависит от значений, а не от объявления. Есть и обратная сторона sparseness: «Missing fields are easy to encode: we just leave out the record if it's not present» — незаполненное поле не едет вовсе, поэтому «huge»-прото с горой объявленных полей и парой заполненных на проводе компактнее, чем выглядит в схеме. Bool живёт в VARINT и всегда кодируется ровно одним байтом 00 или 01 — промежуточных значений у него нет.

### Что лежит на проводе?

```go
// proto
message Test1 {
  int32 a = 1;
}
// в коде: a = 150, сериализация
```

**Что выведет программа?**

- ✅ 08 96 01
- 96 01
- 08 96
- 01 96 08

Нужны обе части: тег и payload. 0x08 — тег поля 1 с wire type VARINT; 96 01 — varint со значением 150 (MSB первого байта сигнализирует о продолжении, семь бит первой группы 0010110 плюс семь бит второй 0000001 в big-endian дают 150). Вариант 96 01 — забыт тег: декодер даже не узнает, какому полю принадлежит значение. 08 96 — потерян байт продолжения, декодер прочтёт 22 и продолжит парсить посередине числа. 01 96 08 — перепутан порядок: payload всегда после тега.

### Отрицательные числа: десять байт за -2

Здесь новички платят самую дорогую цену. Для обычных int32 и int64 документ говорит жёстко: «The intN types encode negative numbers as two's complement, which means that, as unsigned, 64-bit integers, they have their highest bit set. As a result, this means that all ten bytes must be used» — отрицательное значение превращается в 64-битное дополнительное с выставленным старшим битом, и varint вынужден тащить все десять байт. Двести пятьдесят байт на сотню отрицательных температур — это не шутка, а реальный профиль трафика. Лечится специальной парой типов: «sintN uses the ZigZag encoding instead of two's complement to encode negative integers» — положительные значения переходят в чётные, отрицательные в нечётные: «Positive integers p are encoded as 2 * p (the even numbers), while negative integers n are encoded as 2 * |n| - 1 (the odd numbers)». Таблица отражения: 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2147483647 → 4294967294, -2147483648 → 4294967295; формулы — (n << 1) ^ (n >> 31) для sint32 и (n << 1) ^ (n >> 63) для 64-битной версии. В protoscope из доки -500 помечается суффиксом z и равняется varint 999. Итог: -1 в sint32 стоит один байт, в int32 — десять. Когда ширина важнее знака, есть второй путь — fixed: fixed64, sfixed64 и double всегда занимают восемь байт (wire type I64), fixed32, sfixed32 и float — четыре (wire type I32); для координат, хэшей и денежных сумм с плавающей точкой фиксированная ширина честнее varint-roulette.

### Проверка: тип для отрицательных значений

**Вопрос:** Поле будет часто хранить отрицательные и часто небольшие положительные значения — дельты мониторинга, разности координат. Что выбрать, чтобы не платить за каждое отрицательное значение десятью байтами?

- int32: varint и так оптимальная кодировка
- ✅ sint32: ZigZag отражает -1 в 1 и укладывает отрицательные значения в обычную varint-длину
- string с ручным форматированием числа
- fixed64: восемь байт всё равно меньше десяти

int32 кодирует отрицательные числа как 64-битное дополнение до двух — все десять байт, даже для -1. sint32 применяет ZigZag: (n << 1) ^ (n >> 31), маленькие по модулю значения — любого знака — стоят один-два байта. fixed64 прилично, но четыре байта (fixed32/sfixed32) или varint у sint32 обычно короче для малых дельт. Строки — самое расточительное: плюс десятичный текст, плюс длина, плюс парсинг. Помните и про совместимость: sint32 и sint64 взаимно совместимы, но не совместимы на проводе с int32/int64/uint — менять int на sint в живой схеме нельзя без переезда.

### LEN: строки, байты, вложенные сообщения и packed

Всё остальное на проводе — length-delimited. «The LEN wire type has a dynamic length, specified by a varint immediately after the tag, which is followed by the payload as usual» — тег, varint-длина, payload. Пример из доки: строка testing для поля b = 2 — это 12 07 74 65 73 74 69 6e 67: тег 0x12, длина 7, семь байт UTF-8; «The int32 varint means that the max length of a string is 2GB» — отсюда же потолок в 2 ГиБ на сериализованное сообщение. bytes кодируется так же. Вложенные сообщения — тоже LEN: для message Test3 { Test1 c = 3; } со значением c.a = 150 на проводе лежит 1a 03 08 96 01 — тег поля 3, длина 3, внутри ровно те три байта из первого примера. Заметьте цену вложенности: каждый уровень добавляет свой тег и свою длину — глубокая иерархия мелких сообщений раздувает трафик сильнее плоской схемы. repeated-поля чисел в proto3 кодируются packed по умолчанию: вместо записи на элемент — «encoded as a single LEN record that contains each element concatenated» — один LEN-рекорд с конкатенацией значений. При этом порядок записей не важен: записи одного repeated-поля могут перемежаться другими полями, и «only the order of records for the same field with respect to each other is preserved». Парсеры обязаны понимать обе формы — packed и по-элементную — «This permits adding [packed=true] to existing fields in a forward- and backward-compatible way», ещё один пример того, как формат прощает эволюцию.

### reserved: почему переиспользование номера — катастрофа

Теперь правила эволюции — то, ради чего бинарный формат вскрывали. Поле больше не нужно: удаляйте спокойно, но с одним обязательным жестом. Документ proto3 не оставляет выбора: «you must reserve the deleted field number» — номер удалённого поля обязан попасть в reserved. Почему так строго? Потому что формат не даёт второго шанса: «Reusing a field number makes decoding wire-format messages ambiguous» — декодирование сообщений, записанных одной версией схемы и прочитанных другой, становится неоднозначным, и «The protobuf wire format is lean and doesn't provide a way to detect fields encoded using one definition and decoded using another» — на проводе нет контрольной суммы или сигнатуры, которая поймает подмену. Побегут старые данные мимо нового поля — документ перечисляет последствия по возрастанию боли: потерянные часы отладки, «A parse/merge error (best case scenario)», а дальше — «Leaked PII/SPII» и «Data corruption»: например, старое поле хранило email в LEN-записи, новое поле с тем же номером объявили int32 — парсер прочитает длину как значение, а байты письма молча выбросит или уронит разбор. Синтаксис защиты: reserved 2, 15, 9 to 11; — «Reserved field number ranges are inclusive (9 to 11 is the same as 9, 10, 11)». Номер — не единственное, что стоит заблокировать: «You should also reserve the field name to allow JSON and TextFormat encodings of your message to continue to parse» — имя тоже резервируют — reserved "foo", "bar"; — потому что JSON-кодирование ходит по именам. И жёсткое ограничение языка: «you can't mix field names and field numbers in the same reserved statement» — имена и номера резервируются отдельными строками. protoc проверит оба списка и не даст новому полю занять чужой номер или имя.

> **Номер навсегда.** Документ формулирует запрет без исключений: «Field numbers should never be reused». Переезд поля с номера 5 на номер 7 — это не перенос, а удаление и добавление двух полей одновременно: все данные, записанные под старым номером, для новых парсеров пропадут или прочитаются как мусор. Типичная ловушка — эстетика: кому-то захочется отсортировать номера покрасивее, и он делает renumber живой схемы. Не делайте этого никогда. Удаление — только через reserved для номера и имени, и лучше один раз выучить конвенцию доки: переименовывать, а не переносить, а удалённому имени дать префикс OBSOLETE_, если поле решено оставить видимым в схеме.

### Эволюция Order: reserved в бою

```go
// shop/v1/order.proto — схема выросла после второго урока
syntax = "proto3";

package shop.v1;

option go_package = "github.com/example/shop/gen/shopv1";

import "google/protobuf/timestamp.proto";

message Order {
  int64 id = 1;                    // канон второго урока: номера 1–6 не переезжают
  repeated OrderItem items = 2;
  map<string, string> metadata = 3;
  optional string promo_code = 4;  // явное присутствие — ниже объясним, что это меняет
  OrderStatus status = 5;
  google.protobuf.Timestamp created_at = 6;

  // Поле coupon_code = 7 жило в промежуточной версии и было удалено.
  // Номер и имя выведены из оборота навсегда — protoc не даст их занять:
  reserved 7;
  reserved "coupon_code";

  optional int64 total_cents = 8;  // новое поле: старые клиенты молча его пропустят
}
```

Два reserved-оператора — номер и имя — обязаны стоять раздельно, как требует proto3. Обратите внимание, что grpcurl из восемнадцатого урока покажет вам только живые поля схемы: ranges reserved в describe не печатаются, они видны в .proto-исходнике и в дескрипторе. Поэтому проверка эволюции двухэтапная: describe после перегенерации должен показать total_cents и НЕ показать coupon_code, а сам факт reserved гардится компилятором — новое поле с номером 7 или именем coupon_code сломает protoc с понятной ошибкой.

### Соберите защитную схему

Восстановите proto3-объявление: синтаксис, оба reserved-списка и поле с явным присутствием.

```go
syntax = "___";

message Legacy {
  int64 id = 1;
  string name = 2;

  // номера 5 и 9–11 освобождены в прошлых версиях схемы
  ___ 5, 9 to 11;

  // имена, которые тоже нельзя переиспользовать — JSON ходит по именам
  ___ "email", "fax";

  // заданное false тоже едет по проводу
  ___ bool active = 3;
}
```

Подсказка: Имена и номера резервируются отдельными операторами — смешивать их в одной строке нельзя; поле с явным присутствием в proto3 помечается словом, которое в proto2 было режимом по умолчанию.

Правильные ответы в пропусках: `proto3`, `reserved`, `reserved`, `optional`

### Backward compatibility: старый клиент читает новое сообщение

Настало время развернуть два термина, которые постоянно путают. Forward compatibility — старый код читает данные новой схемы; backward compatibility — новый код читает данные старой схемы. Протокол даёт оба направления сразу, и механизм уже знаком: «The wire type tells the parser how big the payload after it is. This allows old parsers to skip over new fields they don't understand». Старый клиент встречает незнакомый тег, смотрит на три младших бита и ровно столько байт пропускает, сколько требует тип: VARINT — до байта без MSB, LEN — читает varint длины и отматывает её, I32/I64 — отматывает 4/8 байта. Документ proto3 подтверждает: «Adding new fields is safe ... old binaries simply ignore the new field when parsing» — добавление полей безопасно в обе стороны, значения незнакомых полей примут дефолты. Зеркально про номера: «Changing the field number is equivalent to deleting the field and adding a new field with the same type» — смена номера это не перенос, а удаление плюс новое поле, поэтому смена номеров живых полей — «Changing field numbers for any existing field is not safe». Причём unknown fields не выбрасываются: парсер сохраняет их в сообщении и возвращает на провод при ре-сериализации — цепочка прокси из старых версий не теряет новые данные. Есть и обратная сторона честности: дефолты неотличимы от пустых значений, поэтому старый код, читающий новое сообщение, видит нули там, где новый код видит данные. Наконец, правило Last One Wins: «if the same field appears multiple times, the parser accepts the last value it sees» — дубликат скаляра перезаписывается последним значением, дубликаты вложенных сообщений мержатся, а разбор конкатенации двух сообщений эквивалентен разбору по очереди с merge — удобное свойство для стриминга и логов.

### Матрица изменений схемы

| Изменение | Вердикт формата | Что помнить |
| --- | --- | --- |
| Добавить поле с новым номером | Безопасно | Старые клиенты пропускают по wire type; новые видят дефолты вместо отсутствия |
| Удалить поле с reserved | Безопасно | Номер и имя — в reserved, иначе кто-то займёт их заново |
| Переименовать поле | Безопасно на проводе | Номер не меняется; имя важно для JSON и TextFormat — поэтому резервируют и его |
| Сменить номер живого поля | Wire-unsafe | Документ: «Changing field numbers for any existing field is not safe» — эквивалент удаления плюс нового поля |
| int32 → int64 | Условно совместимо | Старый int32-читатель усечёт значения больше INT32_MAX — следите за диапазоном |
| int32 → sint32 | Несовместимо | ZigZag кодирует иначе; sint-типы совместимы только друг с другом |

### Проверка: сервер выкатил новые поля

**Вопрос:** Сервер переехал на схему v2 с тремя новыми полями, а мобильный клиент ещё жив на v1. Клиент разбирает ответ v2-сервера. Что произойдёт?

- Разбор упадёт: у клиента нет объявлений новых полей
- ✅ Клиент пропустит незнакомые записи по wire type, поля получат дефолты, а байты неизвестных полей сохранятся и вернутся при ре-сериализации
- Клиент получит InvalidArgument от gRPC: версии схем не совпали
- Клиент прочитает только первое незнакомое поле и оборвёт сообщение

Это forward compatibility в чистом виде: wire type в теге говорит парсеру, сколько байт пропустить, поэтому незнакомые поля безопасны. Значения их клиент видит как дефолты — нули и пустые строки, — а сырые байты сохраняются в unknown fields и воспроизводятся при повторной сериализации, поэтому цепочка прокси не теряет данные. Никакого статуса gRPC здесь не возникает: разбор успешен, вопрос совместимости версий proto решается внутри protobuf, а не кодами ошибок. Ошибка возможна только при wire-unsafe смене — например, если под тем же номером объявили несовместимый тип.

### Дубликат на проводе

```go
// proto: message Counter { int32 hits = 1; }
//
// с провода приходит конкатенация двух сериализаций:
// 08 05        (hits = 5)
// 08 07        (hits = 7)
//
// Counter parsed; fmt.Println(parsed.GetHits())
```

**Что выведет программа?**

- 5 — первое значение сильнее
- ✅ 7 — последнее значение побеждает
- 12 — значения складываются
- ошибка парсинга: поле встречается дважды

Формат допускает дубликаты скалярных полей и разрешает их правилом Last One Wins: «if the same field appears multiple times, the parser accepts the last value it sees». Поэтому gRPC-парсер прочитает 7. Это свойство важнее, чем кажется: разбор конкатенации двух закодированных сообщений эквивалентен merge двух объектов — им пользуются логи, склейка стримов и ретраи из пятнадцатого урока. Ошибки парсинга при дубликате не будет: строгий запрет — только на несовместимые типы под одним номером.

### optional в proto3: явное присутствие с версии 3.15

Во втором уроке в схеме Order уже мелькнуло optional string promo_code = 4; — теперь разберём, что это значит на проводе. У обычного (implicit) скалярного поля proto3 присутствия нет: нулевое значение не кодируется вовсе, и set-ли вы его или нет, на проводе не отличишь. Поле optional включает явное присутствие: «An optional field is in one of two possible states: the field is set, and contains a value that was explicitly set or parsed from the wire. It will be serialized to the wire» — либо unset, тогда возвращается дефолт и на провод ничего не едет; главное — «You can check to see if the value was explicitly set». В Go это делает поле указателем — PromoCode *string — и хаззеры GetPromoCode/HasPromoCode в сгенерированном коде. История версии: «Presence tracking for proto3 messages is enabled by default since v3.15.0 release», а до 3.12.0 требовался флаг --experimental_allow_proto3_optional; свежий protoc просто принимает optional. Зачем платить указателем? Разница проявляется в круглых поездках: «The serialized value following implicit presence discipline does not contain the default value, even though it was explicitly set» — клиент с implicit-полем, получив explicit-значение 0, потеряет факт «было ноль», и документ показывает, как assert на has после такого round trip падает. Поэтому рекомендация синтаксиса прямая: «optional is recommended over implicit fields for maximum compatibility with protobuf editions and proto2» — тот же механизм presence даёт и oneof из второго урока, поэтому различать «не задано» и «задано пусто» можно без своих флагов-костылей.

### Сжатие gzip: кодеки, заголовки и пятибайтовая рамка

Каждое protobuf-сообщение внутри gRPC едет не голым, а в пятибайтовой рамке: один байт — флаг сжатия, четыре байта — длина big-endian. В исходниках rpc_util.go это буквально: «The format of the payload: compressed or not?», тип payloadFormat, где compressionNone = 0 и compressionMade = 1 — единица в первом байте значит, что содержимое сжато кодеком, названным в заголовке grpc-encoding. Сжатие gzip в gRPC собирается из трёх деталей. Первая — регистрация кодека: предпочтительный способ — encoding.RegisterCompressor, и для gzip он уже написан за вас: пакет gzip «implements and registers the gzip compressor» — достаточно импортировать google.golang.org/grpc/encoding/gzip, init() зарегистрирует кодек сам; имя фиксировано константой Name со значением «gzip», и пакет честно помечен: «This package is EXPERIMENTAL and may be changed or removed in a later release». Вторая — выбор кодека клиентом: CallOption grpc.UseCompressor(gzip.Name) — док говорит, что он «sets the compressor used when sending the request», а через WithDefaultCallOptions становится дефолтом всех вызовов канала. Если кодек не установлен, клиент не пойдёт в сеть: «If UseCompressor is used and the corresponding compressor has not been installed, an Internal error will be returned to the application before the RPC is sent»; имя, которого нет в реестре, даёт InvalidArgument с текстом «grpc: compressor %q is not registered». Третья — сервер: «registered compressors will be used automatically to decode request messages and encode the responses», и правило симметрии: «Servers currently always respond using the same compression method specified by the client» — сжатый запрос получает сжатый ответ; кодека нет у сервера — «an Unimplemented status will be returned to the client». Форсировать кодек ответа можно grpc.SetSendCompressor(ctx, gzip.Name) внутри хендлера — «SetSendCompressor sets a compressor for outbound messages from the server», — но только если клиент заявлял кодек в grpc-accept-encoding — «compressor name must exist in the client advertised compressor names sent in grpc-accept-encoding header»; базовое правило остаётся прежним: «By default, server messages will be sent using the same compressor with which request messages were sent». Уровень сжатия настраивает gzip.SetLevel — но он «must only be called during initialization time (i.e. in an init() function), and is not thread-safe», и «gzip.HuffmanOnly is not supported».

### Клиент: UseCompressor и blank-импорт кодека

```go
// cmd/orders-client/main.go — gzip для всех вызовов канала
import (
    "context"
    "log"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    gzip "google.golang.org/grpc/encoding/gzip" // init() регистрирует кодек
    pb "shop/gen/shop/v1"
)

func main() {
    // grpc.NewClient из пятого урока + кодек по умолчанию для всех вызовов
    conn, err := grpc.NewClient("orders:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        // каждый вызов уйдёт с заголовком grpc-encoding: gzip
        grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)),
    )
    if err != nil {
        log.Fatalf("NewClient: %v", err)
    }
    defer conn.Close()

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    client := pb.NewOrderServiceClient(conn)
    order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
    if err != nil {
        log.Fatalf("GetOrder: %v", err)
    }
    log.Printf("order %d: %v байт истории", order.GetId(), len(order.GetItems()))
}
```

Именованный импорт gzip здесь делает двойную работу: он и запускает init() с encoding.RegisterCompressor, и даёт доступ к gzip.Name — blank-импорта для клиента с UseCompressor не хватит, константу надо откуда-то взять. Флаг сжатия в первом байте рамки встанет в 1, а сервер, у которого пакет импортирован, ответит тем же кодеком. Если сервер gzip не знает, ответ придёт статусом Unimplemented — это диагностичный признак забытого импорта, а не сетевой сбой.

### Сервер: распаковка автоматом, ответ — тем же кодеком

```go
// cmd/orders/main.go — серверу достаточно blank-импорта
import (
    "log"
    "net"

    "google.golang.org/grpc"
    _ "google.golang.org/grpc/encoding/gzip" // регистрация кодека в init()
    pb "shop/gen/shop/v1"
)

func main() {
    s := grpc.NewServer()
    pb.RegisterOrderServiceServer(s, &server{})

    // Внутри хендлера можно сменить кодек ответа принудительно:
    //   grpc.SetSendCompressor(ctx, gzip.Name)
    // но клиент обязан заявить gzip в grpc-accept-encoding,
    // иначе SetSendCompressor вернёт ошибку валидации.

    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatal(err)
    }
    log.Print("listening :50051")
    s.Serve(lis) // сжатые запросы распакуются, ответы уйдут сжатыми
}
```

Серверу имя кодека в коде не нужно — blank-импорт срабатывает в init(), дальше транспорт читает grpc-encoding запроса и сам ставит флаг сжатия в ответах: по умолчанию используется тот же кодек, каким пришёл запрос. Дедлайн, метаданные и перехватчики из прежних уроков на сжатие не влияют: кодек стоит ниже перехватчиков, на границе транспорта, и работает для стриминга так же, как для unary — каждый элемент стрима едет в своей рамке со своим флагом.

### Размер: 4 МБ по умолчанию, 2 ГиБ потолок, порядок полей

Сжатие экономит байты, но у gRPC есть жёсткий потолок на каждое входящее сообщение. В server.go константа defaultServerMaxReceiveMessageSize = 1024 * 1024 * 4 — четыре мегабайта; столько же стоит и у клиента, defaultClientMaxReceiveMessageSize. Превышение ловится в парсере рамки: «grpc: received message larger than max (%d vs. %d)» со статусом ResourceExhausted — цифры в тексте это фактическая длина рамки и текущий потолок. Сжатие не спасает от лимита: длина в рамке — это размер сжатого payload, но grpc-go ограничивает и результат декомпрессии — декомпрессор режет распакованный поток на границе maxMessageSize+1, чтобы поймать превышение (doWithMaxSize в rpc_util.go). Свой потолок и у protobuf: «Protos must be smaller than 2 GiB when serialized» — varint-префикс длины LEN-записей шире не даёт. Теперь про порядок: «Field numbers may be declared in any order in a .proto file. The order chosen has no effect on how the messages are serialized» — перестановка полей в объявлении не меняет размер, писать «горячие поля первыми» для скорости сериализации бессмысленно; выгоден только номер — 1–15 дешевле тегом. И важное предостережение: «Serialization order is an implementation detail», «Do not assume the byte output of a serialized message is stable», по умолчанию «the default serialization is not deterministic» — хэшировать байты сериализации, сравнивать их или класть в БД как канонические нельзя; детерминированная сериализация гарантируется только внутри конкретной сборки. Всё это — ручки, которые крутим дальше: «двадцать второй урок — производительность и тюнинг: keepalive, message size, компрессия» — разберёт MaxReceiveMessageSize и буферные пулы по-настоящему.

### Чек-лист: оптимизация proto без ломки совместимости

- Горячие поля получайте номера 1–15 — тег в один байт; 16–2047 стоят два байта.
- Часто отрицательные значения — sint32/sint64; int32 с минусами платит десятью байтами за запись.
- Стабильная ширина (координаты, хэши, float) — fixed32/fixed64/float/double, а не varint.
- Удаляете поле — reserved для номера и отдельный reserved для имени, без исключений.
- Никогда не меняйте номер живого поля: это удаление плюс новое поле для всех парсеров.
- int32 → int64 совместимо с оговоркой об усечении; int32 → sint32 на проводе несовместимо вовсе.
- Не хэшируйте и не сравнивайте байты сериализации: сериализация недетерминирована по умолчанию.
- Огромные repeated-числа не размаркируйте вручную: packed включён, парсеры понимают обе формы.
- «не задано» и «задано пусто» — это optional или oneof, а не sentinel-строки в implicit-полях.
- После каждого изменения схемы: protoc, describe через grpcurl, прогон клиентов обеих версий.

### Workflow: убираем поле, не сломав никого

Соберите безопасный порядок удаления поля coupon_code = 7 из живой схемы Order.

Правильный порядок строк:

1. `поле перестаёт заполняться: сервер пишет пустоту, в схеме появляется комментарий-объявление о деприкации`
2. `телеметрия подтверждает: клиенты всех версий перестали присылать coupon_code`
3. `в схему вносятся два оператора: reserved 7; и отдельной строкой reserved "coupon_code";`
4. `новая схема выкатывается: старые клиенты продолжают шлать номер 7 — новые парсеры пропускают запись по wire type LEN`
5. `новые клиенты перевыпускаются без поля: читать его они больше не должны`
6. `grpcurl describe shop.v1.Order после перегенерации показывает схему без coupon_code`
7. `квартал наблюдений: ни одного репорта о несовместимости, unknown fields не потеряли данные`
8. `номер 7 и имя coupon_code остаются в reserved навсегда — повторное использование сломает архивы`

### Карточки: бинарный формат proto

- **varint** — Базовая кодировка wire format: «Variable-width integers, or varints, are at the core of the wire format» — от одного до десяти байт. В каждом байте MSB — бит продолжения, остальные семь бит — payload, группы склеиваются little-endian. 150 = 96 01. Размер зависит от значения, поэтому малые числа дёшевы, а большие и отрицательные int — дороги.
- **ZigZag (sint32/sint64)** — Кодировка sint-типов для отрицательных чисел: «Positive integers p are encoded as 2 * p (the even numbers), while negative integers n are encoded as 2 * |n| - 1 (the odd numbers)». Формула (n << 1) ^ (n >> 31): 0→0, -1→1, 1→2, -2→3. Обычные int32/int64 кодируют минус как 64-битное дополнение — «all ten bytes must be used».
- **Тег записи** — varint по формуле (field_number << 3) | wire_type: младшие три бита — wire type (0 VARINT, 1 I64, 2 LEN, 5 I32), остальное — номер поля. Номера 1–15 кодируются байтом, 16–2047 — двумя; 19000–19999 зарезервированы за реализацией protobuf. На проводе имён полей нет — только номера.
- **reserved** — Защита от переиспользования после удаления поля: reserved 2, 15, 9 to 11; для номеров (диапазоны включительные) и отдельной строкой reserved "foo", "bar"; для имён — смешивать нельзя. «you must reserve the deleted field number»; причина: «Reusing a field number makes decoding wire-format messages ambiguous» — итог бывает даже «Leaked PII/SPII».
- **Неизвестные поля и Last One Wins** — Старый парсер пропускает незнакомые записи по wire type — так работает forward compatibility; байты unknown fields сохраняются и возвращаются при ре-сериализации. Дубликат скалярного поля: «the parser accepts the last value it sees»; вложенные дубликаты мержатся; разбор конкатенации = merge двух сообщений.
- **Сжатие gzip в gRPC** — Каждое сообщение едет в 5-байтовой рамке: 1 байт флага (compressionNone=0 / compressionMade=1) + 4 байта длины. Кодек задаёт заголовок grpc-encoding, клиент заявляет его в grpc-accept-encoding. encoding.RegisterCompressor + gzip.Name, клиент — grpc.UseCompressor; сервер отвечает тем же кодеком, без регистрации — Unimplemented. Потолок входящего сообщения по умолчанию — 4 МБ, ResourceExhausted.

### Что дальше по курсу

Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — protoc, gRPC-сервер и grpcurl в браузере не запустить, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет; каждое упражнение делается локально за несколько минут. Три эксперимента. Первый: соберите сервер и клиент из четвёртого и пятого уроков, сериализуйте Order с id = 150 и hexdump-ните тело ответа — найдите те самые 08 96 01, затем поменяйте значение на 200000 и пересчитайте varint руками. Второй: добавьте gzip-кодек в клиент из этого урока и не импортируйте его на сервере — GetOrder вернёт Unimplemented; добавьте blank-импорт на сервер и убедитесь, что ответ снова приходит, а сниффер показывает grpc-encoding: gzip в обоих направлениях. Третий: удалите поле из схемы, поставьте reserved, перегенерируйте код и убедитесь, что старый бинарник клиента читает ответы нового сервера без ошибок, а protoc отвергает попытку занять зарезервированный номер. Дальше по маршруту: двадцатый урок — версионирование API: там «эволюция схем» поднимается с уровня формата на уровень организации кода — пакеты и версии proto-схем в одном сервисе, несколько версий одного API без вилок в коде; сегодняшняя матрица изменений станет его словарём. Перед этим освежите [синтаксис proto3 из второго урока](/courses/grpc-go/lessons/02-proto3/) — номера полей, enum и oneof — и [рефлексию из восемнадцатого](/courses/grpc-go/lessons/18-reflection/) — describe покажет живую схему после каждой эволюции. Полный маршрут курса — на [странице gRPC-Go](/courses/grpc-go/).

## Коротко: что запомнить

- Сообщение на проводе — серия пар ключ-значение: ключ это номер поля и wire type по формуле (field_number << 3) | wire_type; имён и типов в байтах нет, они живут в схеме.
- Wire types: 0 VARINT, 1 I64, 2 LEN, 5 I32 (3/4 — устаревшие группы); тип в теге говорит парсеру, сколько байт payload прочитать или пропустить.
- varint — база-128 с битом продолжения: 150 = 96 01; размер зависит от значения; незаполненные поля на провод не едут — proto выходит sparse и компактным.
- Отрицательные int32/int64 стоят десять байт; sint-типы применяют ZigZag ((n << 1) ^ (n >> 31)) и укладывают -1 в один байт; стабильная ширина — fixed/float/double.
- Удаляйте поля только с reserved: номер и имя резервируются отдельными операторами; переиспользование номера делает декодирование неоднозначным — до утечки PII и порчи данных.
- Backward и forward compatibility даются форматом: старые парсеры пропускают незнакомые поля, дубликаты скаляров разрешает Last One Wins, вложенные мержатся; смены номера поля не допускаются вовсе.
- optional в proto3 — явное присутствие с протокола 3.15 (флаг --experimental_allow_proto3_optional раньше): в Go это указатель, различает «не задано» и «задано пусто», и рекомендуется вместо implicit-полей.
- Сжатие gzip: encoding.RegisterCompressor, grpc.UseCompressor у клиента, сервер отвечает тем же кодеком; флаг сжатия — первый байт пятибайтовой рамки; потолок входящего сообщения — 4 МБ (ResourceExhausted), сериализованный прото — 2 ГиБ.

## Домашнее задание

Соберите лабораторию проводного формата на схеме Order. Первое: сериализуйте сообщение с a = 150, a = 200000 и a = -1 в int32-поле, hexdump-ните байты и пересчитайте каждый varint руками — убейтесь, что -1 занял десять байт; повторите тот же -1 в sint32-поле и объясните разницу формулами ZigZag. Второе: возьмите схему из урока, удалите поле с reserved для номера и имени, попробуйте объявить новое поле с этим номером и сохраните текст ошибки protoc; затем уберите reserved и повторите — почувствуйте, что защищает компилятор, а что остаётся на вашей совести. Третье: соберите три бинарника — сервер v1, сервер v2 с новым полем и клиент v1 — и проверьте матрицу из урока: клиент v1 читает ответы обеих версий серверов, клиент v2 читает ответ v1; опишите, где видны дефолты, а где unknown fields. Четвёртое: включите gzip только в клиенте и добейтесь Unimplemented от сервера без кодека; добавьте кодек серверу, потом форсируйте ответ grpc.SetSendCompressor внутри хендлера и разберите, при каком grpc-accept-encoding это легально. Пятое: напишите бенчмарк, сравнивающий размер сериализации Order с десятью элементами items против плоского repeated-поля чисел, и объясните, сколько байт на запись съедает вложенность LEN. Шестое: отправьте сообщение размером выше 4 МБ на дефолтный сервер и разберите статус ResourceExhausted по цифрам в тексте ошибки; поднимите потолок MaxReceiveMessageSize на сервере и клиенте и повторите, затем объясните, почему 2 ГиБ не преодолеть никак.

## Вопросы и ответы по уроку

### Почему нельзя переиспользовать номер удалённого поля, если тип тот же?

Даже тот же тип не спасает от неоднозначности в архивах: сообщения, записанные старой версией схемы, на проводе неотличимы от новых, а «The protobuf wire format is lean and doesn't provide a way to detect fields encoded using one definition and decoded using another». Между удалением и повторным использованием поле обычно живёт под другим смыслом: старые данные прочитаются новыми семантиками, валидации пропустят мусор, а дальше — документ прямо перечисляет «Leaked PII/SPII» и «Data corruption». Если же под номером объявят несовместимый тип (LEN вместо VARINT), парсер вообще не сможет найти границы записи. reserved для номера и имени стоит ноль байт на проводе и снимает весь класс проблем — protoc сам отвергнет коллизию.

### Что будет, если у одной из сторон нет gzip-кодека?

Сценарии разные по сторонам. Клиент вызвал grpc.UseCompressor, но пакет кодека не импортирован — клиент вернёт Internal до отправки запроса: «If UseCompressor is used and the corresponding compressor has not been installed, an Internal error will be returned to the application before the RPC is sent». Клиент послал grpc-encoding: gzip, но сервер кодек не зарегистрировал — клиент получит Unimplemented. Сервер вызвал grpc.SetSendCompressor, а клиент не заявлял кодек в grpc-accept-encoding — ошибка валидации внутри сервера. По умолчанию симметрия работает сама: сжатый запрос получает сжатый ответ тем же кодеком. Диагностика простая: проверьте импорты encoding/gzip с обеих сторон и заголовки grpc-encoding/grpc-accept-encoding на проводе.

### Чем optional в proto3 отличается от просто нулевого значения поля?

Присутствием на проводе. Implicit-поле proto3 нулевое значение не кодирует — «не задано» и «задано пусто» неотличимы. optional включает явное присутствие: заданное поле едет по проводу даже с нулевым значением, незаданное — нет, и код может спросить наличие явно (в Go — указатель и Has-хаззер). Флаг стал доступен с protoc 3.15: «Presence tracking for proto3 messages is enabled by default since v3.15.0 release», ранее требовался --experimental_allow_proto3_optional. Документация рекомендует optional вместо implicit-полей ради совместимости с editions и proto2; тот же эффект присутствия даёт oneof. Помните про round trip: клиент на implicit-поле потеряет факт «было задано нулём» при пересылке через промежуточную версию схемы.
