Schema Resolution и эволюция
Writer schema vs reader schema
В Protobuf и Thrift каждое поле имеет числовой tag — ридер сопоставляет поля по tag. В Avro тегов нет: поля записи — просто байты в порядке объявления. Как тогда ридер интерпретирует данные, если его схема отличается от схемы записи?
Ответ: schema resolution. При десериализации Avro работает с двумя схемами одновременно:
- Writer schema — схема, с которой данные были записаны. В OCF хранится в header (
avro.schema), в Kafka — в Schema Registry - Reader schema — схема, которую использует читающее приложение. Определяет, какие поля нужны и какого типа
ResolvingDecoder принимает обе схемы и строит план преобразования: какие поля прочитать из потока, какие пропустить, какие заполнить значениями по умолчанию.
ResolvingDecoder
Сопоставляет поля writer и reader по имени (с учётом aliases). Строит план: read, skip, default, promote. Ошибка — если нет совместимого плана.Schema resolution — это compile-time операция (выполняется один раз при создании ридера), а не runtime. ResolvingDecoder строит план чтения из пары схем, затем применяет его к каждой записи без повторного анализа. Стоимость resolution не зависит от количества записей.
Алгоритм ResolvingDecoder
ResolvingDecoder сопоставляет поля writer schema и reader schema по имени, а не по позиции или тегу. Алгоритм для record:
- Для каждого поля writer schema: найти поле в reader schema с тем же именем или alias
- Если найдено — проверить совместимость типов. Если совместимы — включить в план чтения (с type promotion при необходимости)
- Если не найдено — включить в план пропуска (skip). Байты будут прочитаны из потока и отброшены
- Для каждого поля reader schema, отсутствующего в writer: подставить значение по умолчанию (default). Если default нет — ошибка
Вход: writer schema + reader schema
На вход: writer schema (определяет бинарный поток) и reader schema (определяет результат). Оба — record с полями.Для каждого writer field → поиск в reader по name/alias
Для каждого поля writer schema ищем совпадение по name в reader schema. Если у reader-поля есть aliases — проверяем и их.Пропуск поля (skip) — не бесплатная операция. Avro binary encoding использует variable-length encoding, поэтому нельзя просто «перепрыгнуть» N байт — нужно полностью декодировать значение, чтобы узнать его размер. Skip для вложенного record или массива с 10000 элементов — это полное декодирование без сохранения результата.
Добавление и удаление полей
Добавление поля
Writer записал данные без поля email. Reader ожидает email. Resolution:
- Reader field
emailне найден в writer schema - Если
emailимеет"default": ""— подставляется пустая строка для каждой записи - Если default отсутствует — ошибка: данные нечитаемы этим reader
Правило: новое поле в reader обязано иметь default. Без default ридер не сможет прочитать старые данные.
Удаление поля
Writer записал данные с полем age. Reader не ожидает age. Resolution:
- Writer field
ageне найден в reader schema - ResolvingDecoder добавляет skip: декодирует int из потока и отбрасывает
- Данные успешно читаются —
ageпросто игнорируется
Правило: удалённое поле в writer должно было иметь default (для обратной совместимости — чтобы старые ридеры могли читать новые данные без этого поля).
// Добавление: writer v1, reader v2
Writer: {id, name} → бинарный поток: [id_bytes][name_bytes]
Reader: {id, name, email} → id: read, name: read, email: default("")
// Удаление: writer v2, reader v1
Writer: {id, name, email} → бинарный поток: [id_bytes][name_bytes][email_bytes]
Reader: {id, name} → id: read, name: read, email: skip
В Protobuf добавление поля с новым tag безусловно безопасно — ридер пропускает неизвестные теги. В Avro безопасность зависит от default: без default новое поле ломает чтение старых данных. Это более строгий контракт, но он явный — ошибка обнаруживается при resolution, а не при доступе к полю.
Type Promotions
Avro spec определяет ограниченный набор допустимых преобразований типов. Если writer написал int, а reader ожидает long — ResolvingDecoder автоматически выполнит promotion:
int
32-bit signed integer, zigzag + variable-length encoding. Диапазон: -2^31 до 2^31-1.long
64-bit signed integer, zigzag + variable-length encoding. Расширение без потерь: каждый int — валидный long.float
32-bit IEEE 754 float. Conversion из int/long может терять точность для больших значений (float имеет только 24 бита мантиссы).double
64-bit IEEE 754 double. Conversion из float без потерь. Из int — без потерь (52 бита мантиссы > 32 бита int). Из long — возможна потеря точности.bytes
Произвольные байты (length-prefixed). Бинарные данные без интерпретации.string
UTF-8 строка (length-prefixed). Promotion из bytes: интерпретировать байты как UTF-8. Обратное тоже допустимо.Правила promotion:
| Writer type | Допустимые Reader types |
|---|---|
int | long, float, double |
long | float, double |
float | double |
string | bytes |
bytes | string |
Promotion int → float и long → float/double технически с потерей точности. Значение 2147483647 (max int) в float станет 2147483648.0. Avro spec допускает это, но в приложении нужно быть осторожным при promotion числовых типов через float. Цепочка int → long → double безопаснее, чем int → float → double.
Union Resolution
Union — особый случай. Writer schema ["null", "string"] и reader schema ["null", "string", "int"] — как работает resolution?
Правило: для каждого типа в writer union Avro ищет первый совместимый тип в reader union. Если writer записал union index = 1 (string), ResolvingDecoder ищет string (или promotable) тип в reader union.
Writer union: ["null", "string"]
Reader union: ["null", "string", "int"]
Writer index 0 (null) → Reader index 0 (null) + match
Writer index 1 (string) → Reader index 1 (string) + match
Порядок типов в union меняться может — resolution по типам, не по индексам. Но добавлять тип в union — это не backward-compatible: старый ридер с ["null", "string"] не сможет декодировать запись, если writer записал index = 2 (int).
Aliases и совместимость имён
Record fields и named types (record, enum, fixed) могут иметь aliases — альтернативные имена для resolution:
{
"type": "record",
"name": "UserProfile",
"aliases": ["User", "Account"],
"fields": [
{"name": "userId", "type": "long", "aliases": ["id", "user_id"]}
]
}
ResolvingDecoder сначала ищет поле по name, затем по aliases. Это позволяет переименовывать поля без потери backward compatibility: старое имя остаётся как alias.
Aliases — односторонний механизм: они используются только при resolution (ридер ищет writer-поле по reader aliases). Alias не меняет имя поля в результате — reader видит поле под своим именем. Это не rename в SQL смысле, а mapping при десериализации.
Backward, Forward, Full Compatibility
Совместимость определяется направлением schema resolution:
| Операция | BACKWARD | FORWARD | FULL |
|---|---|---|---|
| Добавить поле с default | (только с default) | ||
| Добавить поле без default | |||
| Удалить поле с default | |||
| Удалить поле без default | |||
| Rename поля (с alias) | |||
| Type promotion (int→long) |
BACKWARD — default в Confluent Schema Registry. Это значит: consumer (reader) обновляется первым, producer (writer) — вторым. Новый consumer уже умеет читать старые данные, когда producer начинает писать по новой схеме. Подробнее о TRANSITIVE вариантах и Schema Registry — в модуле 08.
Почему Avro не нужны field tags
В Protobuf и Thrift каждое поле имеет числовой tag, который включается в wire format:
// Protobuf: tag + type + value
field 1: [tag=1, type=varint] [value: 42]
field 2: [tag=2, type=LEN] [length: 5] [value: "Alice"]
// Avro: только values, порядок из схемы
[value: 42] [value: "Alice"]
Protobuf тратит 1-2 байта на tag каждого поля. Avro — 0 байт. На записи с 20 полями и миллиардом строк это экономит гигабайты. Цена — зависимость от schema resolution: без writer schema бинарный поток нечитаем.
Avro вместо тегов использует:
- Writer schema — определяет порядок байт в потоке
- ResolvingDecoder — маппит writer fields на reader fields по имени
- Aliases — поддерживает переименование полей
- Defaults — поддерживает добавление полей
Это архитектурный выбор: компактность wire format за счёт обязательного наличия схемы. Для Kafka (схема в Registry) и Hadoop (схема в OCF header) — идеальный trade-off. Для RPC между микросервисами (где schema versioning сложнее) — Protobuf с тегами удобнее.
Ключевые выводы
- Schema resolution — сопоставление writer и reader schema по именам полей. Выполняется однократно, результат — план чтения
- ResolvingDecoder обрабатывает три случая: read (поле есть в обоих), skip (только в writer), default (только в reader)
- Добавление поля требует default в reader schema. Удаление — writer записывает байты, reader пропускает их (skip)
- Type promotions ограничены: int→long→float→double и bytes↔string. Остальные преобразования запрещены
- BACKWARD compatibility (default): новый reader читает старые данные. FORWARD — наоборот. FULL — оба направления
- Отсутствие field tags — главное архитектурное отличие от Protobuf/Thrift. Экономит место, но требует schema для десериализации