Эволюция схемы: что ломается молча в Avro, Protobuf и JSON Schema

Девять изменений схемы через Avro, Protobuf и JSON Schema в двух направлениях, и три исхода вместо двух: прочиталось, отказ и — самый дорогой — прочиталось неверно. Одно изменение дало три разных операционных ответа: два декодера и реестр схем

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

Стенд гоняет девять изменений схемы через три формата в двух направлениях и различает не два исхода, а три. Ниже — что получилось, чего я не ожидал и где мне пришлось признать, что таблица меряет не совсем то, что я думал.

digital-cookbook/architecture/serialization-formats

Ретрофутуристичная схема-блюпринт: приёмный зал с тремя воротами под общей вывеской «схема изменилась». Левые ворота распахнуты, сквозь них проезжает тележка с целым закрытым ящиком, подпись «прочиталось». Средние перекрыты опущенным шлагбаумом и створкой, перед ними стоит такая же тележка с целым ящиком, горит сигнальный фонарь, подпись «отказ». Правые ворота распахнуты так же широко, как левые, и сквозь них проезжает тележка с ящиком распахнутым и пустым, содержимое рассыпано по полу позади, при этом ни шлагбаума, ни фонаря, ни любого другого знака тревоги нет; подпись «прочиталось неверно», а над воротами табличка «то же самое снаружи». Слева вертикальная врезка «один формат, три библиотеки»: один и тот же документ на трёх контролёрских стойках, и рядом три разных штампа — «неверно», «отказ» и «422». Справа внизу врезка «исход зависит от данных»: пять одинаковых тележек, четыре перечёркнуты, пятая проезжает. Внизу плита «отказ заметят сегодня, подмену — через квартал»

В статье

Три исхода вместо двух

Читатель со схемой версии 2 получает байты, записанные схемой версии 1 (или наоборот). Возможны три исхода:

  • прочиталось — читатель получил ровно то, что писатель имел в виду;
  • отказ — формат сказал «не могу» и остановился;
  • прочиталось неверно — ошибки не было, а значения другие.

Третий и есть предмет статьи. Отказ вы заметите в тот же день: сервис падает, алерт срабатывает. Молчаливая подмена уезжает в хранилище и всплывает через квартал, когда исходных байтов уже нет.

Чтобы отличить второе от третьего, стенд считает ожидаемую запись до чтения — из записи писателя и двух схем, не заглядывая в результат. Совпало — прочиталось; ошибка — отказ; не совпало без ошибки — неверно.

Матрица

Девять изменений, три формата, два направления. NR — читатель новее писателя, NW — писатель новее читателя.

изменение схемы Avro NR Avro NW Protobuf NR Protobuf NW JSON Schema NR JSON Schema NW
добавлено поле с умолчанием прочиталось прочиталось прочиталось прочиталось прочиталось отказ
добавлено поле без умолчания отказ прочиталось прочиталось прочиталось отказ отказ
поле удалено прочиталось отказ прочиталось прочиталось отказ отказ
поле переименовано прочиталось отказ прочиталось прочиталось отказ отказ
сменился тип поля отказ отказ неверно неверно отказ отказ
переиспользован номер поля неприменимо неприменимо неверно неверно неприменимо неприменимо
появилось незнакомое поле отказ прочиталось прочиталось прочиталось прочиталось отказ
конфликт псевдонимов неверно отказ неприменимо неприменимо неприменимо неприменимо
поле стало вложенным сообщением отказ отказ отказ / неверно неверно отказ отказ

Любую клетку можно повторить у себя — стенд принимает координаты, а не пути к файлам:

probe --format=avro --change=alias_conflict --direction=newer_reader --op=compat

Имена изменений в порядке строк таблицы: add_default, add_nodefault, remove, rename, retype, reuse_tag, unknown_field, alias_conflict, retype_message.

«Неприменимо» — там, где изменение в этой нотации вырождено: у Avro и JSON Schema нет номеров полей, поэтому переиспользовать номер негде, а у Protobuf имена полей по проводу не едут вовсе, и конфликтовать псевдонимам не с чем.

Сложим по плечам:

формат прочиталось неверно отказ
Protobuf 10 6 1
Avro 6 1 9
JSON Schema 2 0 12

Применимых клеток у каждого плеча шестнадцать, и у Avro с JSON Schema числа так и складываются. У Protobuf их семнадцать: одна клетка расщепилась — часть записей дала отказ, часть неверное чтение, — и попала в обе колонки сразу. Про неё отдельный раздел ниже.

Две склонности, между которыми выбирают

Из таблицы видно то, чего не видно из документации.

Сразу оговорю область: всё, что ниже, — про эти девять изменений, корректные сообщения и названные ниже версии библиотек. Ни «всегда», ни «никогда» здесь не будет: оба крайних утверждения мы проверяли и оба опровергли собственными контрпримерами, о них следующий раздел.

Protobuf отказался читать один раз из шестнадцати. Остальное он прочитал — и шесть раз из шестнадцати прочитал не то, что имел в виду писатель.

Avro прочитал неверно один раз из шестнадцати, и то лишь у одной из двух проверенных реализаций. Взамен он отказал девять раз — там, где Protobuf молча продолжил бы.

JSON Schema отказала двенадцать раз. Строгие схемы с запретом посторонних полей делают ломающим почти любое изменение.

Это не «лучше и хуже». Это разные склонности, и выбирать приходится между ними:

Protobuf в этой матрице чаще читал хоть что-нибудь. Avro в этой матрице чаще отказывал, чем искажал.

Выбирая формат, вы выбираете, какой отказ вам дешевле — громкий в день выката или тихий через квартал.

Контрпримеры, которые пришлось искать

Первая версия таблицы была красивее: ноль отказов у Protobuf и ноль неверных чтений у Avro. Ровные нули — и именно поэтому им нельзя было верить.

Оба оказались следствием того, какие изменения я выбрал, а не свойств форматов. Контрпримеры нашлись, но их пришлось искать целенаправленно.

Protobuf отказывает, если поле меняет тип со строки на вложенное сообщение. Тонкость в том, что тип провода при этом не меняется — обе величины едут как «длина плюс данные», — поэтому привычное объяснение «несовпадение типа провода просто уводит поле в неизвестные» здесь не работает. Разбор доходит до содержимого и ломается на нём.

Проверенная Go-библиотека Avro читает неверно, если читатель объявляет поле с псевдонимом, который у писателя занят отдельным полем. Ситуация законная: поле переименовали, а старое имя ещё живёт. Сразу оговорюсь, что это утверждение не о формате: Java-реализация на тех же байтах отказалась читать, и следующий раздел про это. Разрешение схем берёт первое подходящее поле писателя — и в записи оказывается почта вместо имени.

Мораль не про форматы, а про метод: ровный ноль в колонке — повод искать контрпример, а не повод радоваться. Оба нуля рассыпались с первой же попытки их сломать.

Одно изменение, три ответа

Конфликт псевдонимов дал самый неожиданный результат всей работы.

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

реализация ответ
библиотека Avro для Go прочиталось неверно — почта вместо имени, ошибки нет
библиотека Avro для Java отказ — двусмысленность распознана
реестр схем 422 — схема отвергнута как необрабатываемая, ещё до данных

Перекрёстная проба уточнила картину: на этом изменении исход определяется библиотекой читателя, а не тем, кто записал байты. Записал Go, читает Java — отказ. Записал Java, читает Go — неверное чтение.

Утверждение опирается на отдельно проверенное условие: байты обеих реализаций на этих записях совпали побайтово, что подтверждено сверкой хешей с контролем. Не совпади они — расхождение исходов можно было бы списать на разницу в записи, и вывод про читателя не следовал бы. За пределы этой пробы — этого изменения, этих записей и этих версий библиотек — утверждение не распространяется.

То есть в этом углу поведение формата не определено. Не «Avro читает неверно» и не «Avro отказывает» — а «как повезёт с библиотекой».

Клетка, у которой исход не один

Обычно клетка таблицы — одно значение. Одна оказалась не такой.

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

Отсюда неприятный вывод для эксплуатации: устойчивость такого изменения зависит от данных. Вы выкатываете новую схему, прогоняете на выборке, всё падает предсказуемо — а в проде часть записей проходит молча и портится.

Неизвестные поля: кто их бережёт

Отдельная проба: прочитать старым читателем, записать обратно и посмотреть, пережило ли поле, о котором читатель не знал.

Protobuf бережёт — при двоичном круговом прогоне. Незнакомое поле лежит в разобранном сообщении нетронутым и возвращается на место при обратной записи: четыре клетки из четырёх применимых.

Условие существенное. Так ведёт себя разбор двоичных байтов с обратной сериализацией того же сообщения. Стоит переложить данные в JSON, скопировать поле за полем в другой объект или пройти через слой, который собирает сообщение заново, — и неизвестное поле теряется, потому что переносить его больше нечему.

Avro и JSON Schema — нет. У них вообще нет места, где такое поле могло бы пережить чтение: незнакомое поле просто не попадает в результат.

Это оборотная сторона первого вывода. Тот же механизм, из-за которого Protobuf молча подставляет умолчание при смене типа, спасает данные при обратной записи: поле не исчезает, оно уходит в неизвестные и ждёт там.

Реестр смотрит раньше читателя

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

«Способен» здесь не осторожность, а точность. Реестр проверяет совместимость, только если политика включена — по умолчанию она может быть отключена вовсе, — и только если писатель дисциплинированно регистрирует схему перед записью. Числа ниже сняты при политике обратной совместимости на конкретном реестре, версия названа в границах.

Через реестр прогонялись схемы Avro — сравнивать его вердикт имеет смысл с колонкой того же формата. На восьми изменениях из девяти он совпадает по смыслу с тем, что говорит читатель. Расходится ровно там же, где расходятся сами библиотеки — на конфликте псевдонимов, — и даёт тот самый третий ответ.

Практически это значит, что реестр и читатель — две разные защиты, а не одна. Реестр ловит ломающее изменение в момент выката. Читатель ловит то, что реестр пропустил, — и ловит уже на данных.

Чего эта таблица не меряет

Здесь придётся сказать неприятное о собственном методе.

Модель ожидания повторяет правила разрешения схем Avro. Соответствие по имени, затем по псевдонимам, заполнение объявленным умолчанием — это и есть то, как Avro разрешает схемы. Значит там, где модель совпадает с Avro, выходит «прочиталось», а где расходится — Avro отказывает. Неверное чтение у него почти недостижимо по построению, а не по замеру. Его единственная единица в колонке — та, где библиотека повела себя иначе, чем спецификация.

Проще говоря: мы мерили правила Avro его же правилами, и колонку Avro надо читать с этой поправкой.

Симметрично у Protobuf ноль отказов держался на выборе изменений: все первые семь давали либо совместимый провод, либо несовпадение типа провода. Отказ появился, как только изменение затронуло структуру внутри одного типа провода.

Условия. Девять изменений, три нотации, два направления, пять записей на клетку. Версии зафиксированы: Avro 1.12.2, Protobuf 4.36.0, валидатор JSON Schema 3.0.7, реестр 3.3.1. Тройное расхождение привязано к этим версиям.

Чего в таблице нет. Текстов ошибок: у нас они менялись между прогонами без единого изменения кода, поэтому цитировать их как факт о библиотеке нельзя. Исход — можно, формулировку — нет.

Что из этого следует

Спрашивайте у формата не «совместимо ли», а «что он сделает, если нет». Ответы разные, и они важнее самой совместимости.

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

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

Переиспользовать номер удалённого поля нельзя ни при каких условиях. reserved в .proto существует ровно для этого. В обеих клетках этого изменения чтение неверно, и ни в одной нет ни ошибки, ни отказа — только другое значение на выходе.

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

Реестр не заменяет проверку на чтении. Он ловит другое и в другой момент. Две защиты, а не одна.

Ровный ноль в таблице — повод искать контрпример. Оба наших нуля оказались следствием выбора проб, и оба рассыпались, как только их начали ломать целенаправленно.

Начало

Первая часть — про то, сколько форматы стоят в байтах и что нужно иметь, чтобы вообще прочитать запись: «Зачем бинарный формат и что он стоит».

Смежное на сайте

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии