Зачем бинарный формат и что он стоит: JSON, Avro и Protobuf на весах

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

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

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

digital-cookbook/architecture/serialization-formats

Ретрофутуристичная схема-блюпринт: три железнодорожные платформы на одном пути. Самая большая несёт контейнер с табличкой «JSON» и биркой «256», она ничем не связана, рядом подпись «схема не нужна» и пустая рамка с нулём. Вторая платформа меньше, контейнер «Avro» с биркой «126», от неё вниз уходит толстая цепь к вкопанному блоку «СХЕМА» с биркой «162». Третья платформа с контейнером «Protobuf» и биркой «141» прикована такой же цепью к блоку «СХЕМА» с биркой «119», и этот блок заметно меньше предыдущего. В левом верхнем углу врезка «одинаковые байты, разный контроль»: два одинаковых пакета со знаком равенства между ними, подписанные «JSON» и «JSON Schema». Справа внизу сарай с вывеской «схема недоступна»: одни ворота распахнуты и подписаны «JSON», вторые заперты висячим замком и подписаны «Avro и Protobuf». Внизу по центру плита «лёгкая запись, тяжёлая зависимость»

В статье

Контрольное плечо, без которого числа ничего не значат

Утверждение «Avro компактнее» бессмысленно без ответа на вопрос «компактнее чего». Поэтому в стенде есть контроль — обычный JSON без схемы, то, с чего начинают все. Все отношения считаются от него.

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

Записей пять, схема одна, все числа детерминированы схемой и данными — их можно пересчитать на своей машине и получить те же.

Размер записи

Сумма пяти записей, байт:

плечо байт от контроля
JSON без схемы (контроль) 256 1,00
JSON Schema 256 1,00
Avro 126 0,49
Protobuf 141 0,55

Два наблюдения, и второе интереснее первого.

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

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

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

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

Цена схемы: три замера, и вывод перевернулся

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

Схему надо мерить в том виде, в каком она нужна для чтения. Первый замер дал 460 байт у Protobuf против 221 у Avro — «схема бинарного формата вдвое тяжелее». Потом выяснилось, что 120 из этих байт — строки option go_package и option java_package, которые обслуживают кодогенерацию, а стенд её не использует вовсе. Убрали: 340 против 221, «в полтора раза тяжелее».

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

Плюс ещё одна поправка, уже не про Protobuf. Тексту с отступами мы платим за форматирование: .avsc в репозитории весит 221 байт, а в канонической форме, которая определена самой спецификацией Avro, — 162. Сравнивать размеченный человеком текст с компилированным двоичным файлом нельзя.

Итог в одних единицах — каждый формат в своей канонической форме:

плечо схема, байт файл в репозитории
JSON без схемы 0
Protobuf 119 119
Avro 162 221
JSON Schema 226 293

Ноль у контроля — не пропуск, а содержательное число: читателю схема не нужна вовсе.

А главный вывод — с противоположным знаком относительно первого замера: схема Protobuf легче схемы Avro, примерно 0,73 от неё. Мы дважды собирались написать обратное.

Сжатие: запись против пачки

«Всё равно ведь сжимаем» — второй по частоте аргумент в этих спорах. Проверяем.

На одной записи сжатие проигрывает всем плечам сразу. Первая запись в Avro весит 27 байт, а под сжатием — 40; у остальных плеч картина та же. Заголовок кадра дороже всего, что можно сэкономить на двадцати семи байтах. Если бы стенд остановился здесь, вывод был бы «сжимать бесполезно» — и он был бы неверным.

Потому что сжимают не записи, а пачки: именно так работает compression.type в Kafka, где сжатие применяется к батчу. Те же пять записей, склеенные с обрамлением и сжатые целиком:

плечо пачка под сжатием от контроля
JSON без схемы 276 163 1,00
JSON Schema 276 163 1,00
Avro 146 129 0,79
Protobuf 161 141 0,87

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

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

Без чего не прочитать данные

Здесь стенд опроверг допущение, с которого я начинал.

Я был уверен, что напишу так: Avro без схемы писателя не читается, а Protobuf читается по номерам полей. Первая половина верна, вторая — нет.

Проверка сделана недоступностью, а не рассуждением: реестр гасится посреди прогона, схема физически исчезает, запасного пути в коде нет.

плечо схема доступна схема недоступна
JSON без схемы читается читается
JSON Schema читается читается
Avro читается не читается
Protobuf читается не читается

Байты Protobuf действительно можно пройти без схемы — формат самоограничивающий, границы полей видны. Но пройти байты и прочитать запись — разное: без дескриптора номер 2 остаётся номером 2, а не полем name, и тип его неизвестен. Записи на выходе не получается.

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

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

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

Пять байт, о которые спотыкаются

Идентификатор схемы надо как-то передать вместе с данными, и способов несколько: заголовок сообщения, отдельное поле, соглашение по теме. Широко распространился один — конверт, совместимый с форматом Confluent: пять байт перед полезными данными. Первый — версия самого способа сериализации, а не признак того, Avro внутри или Protobuf; остальные четыре — идентификатор схемы в реестре. У Protobuf за ними едет ещё и указание, какое именно сообщение дескриптора взято. Клиент реестра всё это снимает и не вспоминает о нём никогда.

Это соглашение поверх формата, а не часть Avro или Protobuf: те же байты можно возить и без конверта, если идентификатор едет в заголовках.

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

ожидали: {"id": 1, "name": "Анна", "email": "anna@example.com"}
получили: {"id": 0, "name": "", "email": ""}

Ни ошибки, ни исключения — молча обнулённая запись. Пять лишних байт сдвигают разбор, и всё, что он находит дальше, он находит не там.

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

Границы всего сказанного

Условия. Все числа сняты на пяти записях одной формы, при одной схеме и уровне сжатия 3. Версии зафиксированы: Avro 1.12.2, Protobuf 4.36.0, JSON Schema валидатор 3.0.7. Числа детерминированы схемой и данными — они воспроизводятся, а не усредняются.

Чего здесь нет. Скорости кодирования: она зависит от библиотеки и машины сильнее, чем от формата, и требует отдельного разговора про разброс. Сравнения с Thrift, MessagePack и прочими — обзоров форматов сайт не пишет.

Что зависит не только от формата. Сжатый размер — от библиотеки сжатия. Вес схемы — от того, что вы считаете схемой: канонической формой или файлом в репозитории. Обе величины в таблицах приведены.

Насколько малы данные. Пачка стенда — 146–276 байт, а батч в Kafka измеряется килобайтами. На нашей пачке бинарные плечи вышли компактнее контроля на обоих форматах — но это одна форма записи, и переносить отсюда даже знак разницы на другие данные я не берусь.

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

Бинарный формат компактнее текста — но насколько, зависит от данных. На этой записи 0,49 у Avro и 0,55 у Protobuf от обычного JSON. Отношение зависит от данных: короткие имена полей и длинные значения сужают разрыв, длинные имена и мелкие числа — расширяют. Доказано здесь только направление и только на одной форме записи из пяти экземпляров: бинарные компактнее текста. Насколько компактнее — величина, которую надо мерить на своих данных; имена полей, длина значений, номера тегов и вложенность двигают её в обе стороны. Если вы ждали десятикратной экономии, её здесь нет.

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

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

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

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

Сжимаете пачками — разница остаётся; меряете на одной записи — увидите обратное. Заголовок кадра дороже экономии на записи в двадцать семь байт.

Числа о схемах проверяйте на своих файлах. Дважды подряд наш замер веса схемы оказывался неверным — и оба раза потому, что в файл попадало то, что нужно кодогенерации, а не чтению. У вас в .proto те же строки option.

Продолжение

Про то, что происходит с этими форматами, когда схема меняется, — во второй части: «Эволюция схемы: что ломается молча». Там выясняется, что на девяти проверенных изменениях Protobuf отказался читать один раз из шестнадцати, а всё остальное прочитал — иногда не то. И что одно и то же изменение схемы даёт три разных операционных ответа: два декодера одного формата расходятся между собой, а реестр отвечает третье.

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

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

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

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

Комментарии