Learning Platform
Глоссарий Troubleshooting
Урок 06.02 · 30 мин
Средний
ProtobufSchema EvolutionBackwards CompatibilityForwards CompatibilityProto2Proto3Edition 2024

Protobuf: Schema Evolution

Field Numbers как контракт

В Protobuf field number — это единственный идентификатор поля на wire. Имя поля используется только в сгенерированном коде, на wire его нет. Это означает:

  • Переименование безопасно: поменять user_nameusername — ОК, field number не меняется
  • Переназначение запрещено: присвоить field number 3 другому полю после удаления оригинального — нарушение контракта
  • Изменение типа ограничено: wire type должен совпадать, иначе десериализация ломается
Field Number = Wire Identity
v1: message User { int32 id = 1; string name = 2; string email = 3; }Исходная схема v1: три поля с фиксированными номерами. Номера 1, 2, 3 — это контракт на wire.
Да БезопасноПереименование поля. Wire identity (field number 2) не меняется. Старые данные декодируются правильно.
Нет ОпасноПереназначение field number 3 другому полю. Старые данные с email (string) будут интерпретированы как phone (string) — silent data corruption.

Backwards и Forwards Compatibility

Два направления совместимости:

Backwards vs Forwards Compatibility
Backwards CompatibilityНовый код читает старые данные. Гарантирует: новые поля имеют defaults, удалённые поля игнорируются. Критично для: consumer upgrades before producer.
Forwards CompatibilityСтарый код читает новые данные. Гарантирует: неизвестные field numbers пропускаются по wire type и сохраняются как unknown fields. Критично для: producer upgrades before consumer.

Protobuf обеспечивает оба направления — если соблюдать правила эволюции. Ключевой механизм: wire type позволяет пропускать неизвестные поля без знания их типа.

Правила безопасной эволюции

Безопасные и опасные изменения
Да Безопасные измененияЭти изменения сохраняют backwards и forwards compatibility. Могут быть выполнены без координации producer и consumer.
Нет Опасные измененияЭти изменения нарушают wire compatibility. Требуют координированного обновления всех producers и consumers.

Добавление полей

Самая частая операция. Правила:

  1. Выбрать новый field number (никогда ранее не использованный)
  2. Выбрать appropriate type и wire type
  3. Добавить default value (proto3: implicit zero/empty; proto2: explicit default)
Добавление поля: wire-level
v1: { id=1:int32, name=2:string }Схема v1: два поля. Данные записаны по v1.
v2: { id=1:int32, name=2:string, age=3:int32 }Схема v2: добавлено поле age с field number 3. Тип int32, wire type VARINT.
v2 reader → v1 datav1 данные не содержат field 3. v2 reader не находит tag с field number 3 → использует default value (0 для int32). Backwards compatible.
v1 reader → v2 datav2 данные содержат field 3 (age). v1 reader не знает field 3 → читает wire type (VARINT), пропускает varint, сохраняет в unknown fields. Forwards compatible.

Удаление полей и reserved

При удалении поля critical: зарезервировать его номер и имя. Иначе будущий разработчик может переиспользовать номер для нового поля — и старые данные на wire будут интерпретированы неправильно.

message User {
 reserved 3, 7; // зарезервированные номера
 reserved "email", "phone"; // зарезервированные имена
 
 int32 id = 1;
 string name = 2;
 // email (3) и phone (7) удалены
 int32 age = 4;
}
Почему reserved обязателен
v1: { email = 3: string }Исходная схема: email = 3 (string). Данные записаны с email.
Нет Без reservedРазработчик добавляет phone = 3 (string). Wire type совпадает (LEN), но семантика другая. Старые данные: tag 3 содержит email, но v3 reader интерпретирует как phone. Silent data corruption.
Да С reservedreserved 3 блокирует переиспользование номера 3. Компилятор protoc выдаст ошибку, если кто-то попытается использовать номер 3 для нового поля.
WARNING

reserved проверяется только компилятором protoc. На wire никакой защиты нет — если кто-то обойдёт проверку и использует зарезервированный номер, данные будут corrupted. reserved — это safety net на уровне tooling, не wire-level enforcement.

Type Compatibility

Не все типы совместимы друг с другом. Ключевое ограничение — wire type должен совпадать:

Type Compatibility Matrix
ИзменениеКонкретная пара типов.
Wire TypeWire type обоих типов.
СтатусБезопасно или нет.
ПримечаниеДетали и ограничения.
int32 ↔ int64int32 и int64 оба используют wire type 0 (VARINT). Совместимо.
Оба — VARINT (wire type 0).
Безопасно: varint декодирует одинаково. Возможно truncation при int64→int32.
int64→int32: truncation если значение > 2^31-1.
sint32 ↔ sint64sint32 и sint64 оба используют zigzag + VARINT. Совместимо.
Оба — VARINT (wire type 0), zigzag decoded.
Безопасно: zigzag varint совместим.
Аналогичное truncation.
string ↔ bytesstring и bytes оба используют wire type 2 (LEN). На wire идентичны.
Оба — LEN (wire type 2). На wire формат одинаков.
Безопасно на wire. Но string ожидает valid UTF-8.
bytes→string: возможна ошибка если не valid UTF-8.
int32 → stringint32 (VARINT) и string (LEN) — разные wire types. НЕСОВМЕСТИМО.
VARINT (0) vs LEN (2) — разные wire types.
Нельзя: ридер попытается прочитать VARINT как LEN или наоборот — garbage.
Wire type mismatch = data corruption.
fixed32 → int32fixed32 (I32, 4 байта) и int32 (VARINT, variable) — разные wire types.
I32 (5) vs VARINT (0) — разные wire types.
Нельзя: 4 фиксированных байта vs variable-length varint.
Несмотря на то, что оба хранят int32.

Proto2 vs Proto3 vs Editions

Эволюция Protobuf: proto2 → proto3 → Editions
proto2 (2008)Первая публичная версия. Поддерживает required, optional с explicit defaults, groups. Has-bits отслеживают установленные поля.
proto3 (2016)Упрощённая версия. Убраны required и explicit defaults. Все поля implicit, default = zero value. Добавлен JSON mapping. optional keyword вернулся в 3.15.
Edition 2024Editions заменяют syntax = 'proto2/proto3'. Каждая feature (field_presence, enum_type, etc.) настраивается отдельно через edition defaults и overrides. Edition 2024 — текущая.

Proto2: required — антипаттерн

required в proto2 казалось хорошей идеей: гарантировать наличие поля. На практике — бомба замедленного действия:

Почему required опасен
v1: message User { required string email = 3; }Proto2 schema v1: email помечен как required. Все клиенты используют v1.

Бизнес-требование: email стал необязательным

Через год: email больше не обязателен в бизнес-логике. Нужно сделать optional.
Проблема 1Если producer v2 перестаёт отправлять email — старый consumer v1 падает с 'missing required field'. Нужно обновить ВСЕ consumers до v2 прежде чем producer перестанет отправлять email.
Проблема 2required проверяется при десериализации. Даже если consumer не использует email — отсутствие поля = hard error. Required — это wire-level constraint, не business-level.
DANGER

Google’s official recommendation: никогда не используйте required. Именно поэтому proto3 полностью убрал required. Все поля в proto3 — implicit optional. Валидация обязательности — ответственность application layer, не wire format.

Field Presence в proto3

Proto3 убрал required, но создал новую проблему: нельзя отличить «поле явно = 0» от «поле не установлено». Решение — optional keyword (вернулся в proto3 с версии 3.15):

Field Presence: implicit vs explicit
Implicit (default proto3)Поле без optional keyword. Default = zero value. has_field() не доступен. На wire: field с zero value не кодируется. Ридер не знает: поле = 0 или поле не установлено.
Explicit (optional keyword)Поле с optional keyword. has_field() доступен. На wire: field с zero value кодируется если explicitly set. Ридер может отличить set(0) от unset.

Edition 2024

Editions — новый подход к версионированию Protobuf. Вместо syntax = "proto2" или syntax = "proto3"edition = "2024" с настраиваемыми features:

edition = "2024";

message User {
 int32 id = 1; // IMPLICIT presence (edition default)
 string name = 2;
 string email = 3 [features.field_presence = EXPLICIT]; // override
}
Edition Features
FeatureИмя feature в системе editions.
Proto3 эквивалентКак эта feature работала в proto3.
Можно override?Можно ли менять поведение на уровне файла, message или field.
field_presencefield_presence: контролирует has_field(). IMPLICIT = proto3 default, EXPLICIT = proto2 optional.
Proto3 default: IMPLICIT (нет has_field, zero = unset).
Можно переключить на EXPLICIT для конкретного поля.
enum_typeenum_type: OPEN (unknown values сохраняются) vs CLOSED (unknown = error).
Proto3 default: OPEN (unknown enum values → numeric value сохраняется).
Можно переключить на CLOSED.
repeated_encodingrepeated_field_encoding: PACKED (proto3 default) vs EXPANDED (proto2 default).
Proto3 default: PACKED (один tag + length prefix).
Можно переключить на EXPANDED.
NOTE

Миграция proto2/proto3 → Edition: инструмент protodelegate автоматически конвертирует .proto файлы, добавляя explicit overrides для сохранения текущего поведения. Wire format не меняется — editions влияют только на кодогенерацию.

Сценарии эволюции

Wire Decode с отсутствующими полями
Writer v2Writer использует v2 схему с 4 полями: id, name, email, age. Все закодированы на wire.

Wire: [tag1=0x08, 42] [tag2=0x12, “Alice”] [tag3=0x1A, “[email protected]”] [tag4=0x20, 30]

Байтовый поток на wire: 4 пары (tag, value). Порядок не гарантирован, но обычно следует порядку field numbers.
Reader v1 (нет age)Reader v1 не знает field 4. Встречает tag 0x20 → field=4, wire=VARINT. Field 4 не в schema → skip varint. Сохраняет raw bytes в unknown fields. Остальные 3 поля декодируются нормально.
Reader v3 (нет email)Reader v3: email удалён, добавлен phone = 5. Встречает tag 0x1A → field=3, wire=LEN. Field 3 reserved → skip LEN (read length, skip bytes). phone (field 5) не найден → default.

Сравнение с Avro Schema Resolution

АспектProtobufAvro
Идентификация поляField number на wireИмя + позиция в схеме
Unknown fieldsSkip по wire typeТребует writer’s schema для skip
Добавление поляНовый field number → defaultНовое поле + default → resolution
Удаление поляreserved + skipWriter-only field → skip по writer’s schema
Type promotionОграничен одним wire typeint→long→float→double
Schema transport.proto файлы распространяются отдельноWriter’s schema в header / registry
RequiredDeprecated (proto2 only) default = обязателен
Protobuf vs Avro: подход к эволюции
Protobuf: self-describing tagsКаждое поле на wire содержит field tag с номером и wire type. Ридер может пропустить неизвестные поля без внешней информации. Overhead: 1-2 байта per field.
Avro: schema-driven resolutionПоля на wire — только значения в порядке writer's schema. Ридер строит resolution plan из writer's + reader's schema. Нет overhead, но нужны обе схемы.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Proto3 schema v1: message User { int32 id = 1; string name = 2; string email = 3; }. Команда решает удалить email. Какой правильный подход?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5