«Возьмите Avro, он компактнее» — совет, который звучит на каждом втором обсуждении событийной архитектуры. Компактнее чего, на сколько и какой ценой — обычно не уточняют. А цена есть, и она не в байтах.
Я собрал стенд, где одна и та же запись проходит через четыре плеча: обычный JSON без всякой схемы, тот же JSON под присмотром JSON Schema, Avro и Protobuf. Меряются три величины, и все три — целые байты, а не секунды: сколько весит запись, сколько весит схема, без которой её не прочитать, и что остаётся от разницы, когда данные сжимают.
digital-cookbook/architecture/serialization-formats
В статье
- Контрольное плечо, без которого числа ничего не значат
- Размер записи
- Цена схемы: три замера, и вывод перевернулся
- Сжатие: запись против пачки
- Без чего не прочитать данные
- Пять байт, о которые спотыкаются
- Границы всего сказанного
- Что из этого следует
Контрольное плечо, без которого числа ничего не значат
Утверждение «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 отказался читать один раз из шестнадцати, а всё остальное прочитал — иногда не то. И что одно и то же изменение схемы даёт три разных операционных ответа: два декодера одного формата расходятся между собой, а реестр отвечает третье.
Смежное на сайте
- Контракты событий: schema evolution без боли — событие как публичный контракт: совместимость, версионирование, upcasting.
- Экосистема Kafka: Connect, Schema Registry, ksqlDB — реестр схем в связке с брокером, живьём.
- Версионирование событий и upcasting — что делать, когда старые события уже записаны.
Комментарии