Зачем контракты и wire-форматы
В первых четырёх модулях курса Brew писала в Kafka заказы как придётся. Иногда JSON, иногда наспех собранные байты без особого внимания к форме. Это работало, потому что order-service, кухня и платежи были у одной команды под рукой, и договорённость о форме Order держали в голове. Дальше так не получится.
Как только в систему добавляется новый сервис — аналитика заказов, написанная другим человеком, курьерская интеграция на другом языке или потребитель, который подключится через два года, — ваша «договорённость» оказывается тем, что разваливает интеграцию. Чьё-то поле переименовано. Где-то ожидался int, а пришла string. Кто-то просто забыл, что это поле нужно. У Brew это не абстракция: кухня читает brew.orders.v1, аналитика — тоже, и обе обязаны понимать один и тот же Order.
Лекция о том, как этого избежать. Точнее — про инструмент, которым избегают: контракт сообщения, плюс wire-формат и схему как способ его зафиксировать в коде. Дальше по делу.
Почему JSON ломается на масштабе
JSON — это, по сути, текст с фигурными скобками. Сериализуешь объект, получаешь строку, кладёшь в Kafka. Глазами читается, в любом языке поддерживается, отлаживать одно удовольствие. До определённого момента это лучшее, что есть.
Момент наступает быстро. Поля в JSON — это просто имена. Если у вас два сервиса говорят «order_amount», и однажды кто-то у себя в коде написал «orderAmount» — никто не упадёт. Просто на той стороне будет null или нулевое значение, и вы это заметите спустя N часов в проде, когда уже накопится поломанных заказов.
В JSON нет различия между «поля нет» и «поле явно null». Нет различия между числом 42 и строкой "42". Нет понимания, что у вас тут timestamp, а не просто int64. Нет валидации — вы можете в любой момент послать {"id": [1,2,3]} вместо {"id": "ord-001"}, и Kafka это спокойно примет, потому что для брокера это всё equally bytes.
Размер. JSON хранит имена полей в каждом сообщении. Поле customer_id в миллионе сообщений — это customer_id миллион раз. Сжатие на уровне топика помогает, но первичный размер payload'а растёт линейно с количеством полей и длиной их имён.
Что такое схема
Схема — это контракт о форме сообщения, заданный отдельно от самого сообщения. У вас где-то лежит файл, в котором написано: «Order — это структура с полями order_id (string), shop_id (string), customer_id (string), total_cents (int64), валюта (string), список items». Все продьюсеры обязаны слать ровно эту форму. Все консьюмеры опираются на неё. Если кто-то меняет схему — это видимое изменение: через PR, через ревью, через автоматическую проверку совместимости и (часто) через миграцию registry.
Дальше — сам формат. Текст или бинарь. Имена полей внутри сообщения или в отдельной схеме. Как кодируются числа, строки, массивы, nested-объекты, опциональные поля. Это и есть wire-format.
В Kafka-экосистеме чаще всего сравнивают четыре варианта: голый JSON (без схемы — антипаттерн на масштабе, но встречается), JSON Schema (тот же JSON, но с отдельной схемой и валидацией), Avro и Protobuf. Курс выбирает Protobuf — почему, ниже.
Семь критериев сравнения
Когда выбираете формат, сравнивайте по делу. Вот опорные точки.
Размер payload'а. Сколько байт занимает одна запись на wire. Здесь JSON проигрывает, Avro и Protobuf плотные. В нашем бенче (см. ниже) Avro оказывается чуть компактнее Protobuf'а на коротких полях — потому что Avro вообще не пишет имена полей в payload, только значения по порядку из схемы. Protobuf пишет field-tag (число) перед каждым непустым значением.
Скорость кодирования и декодирования. На современных Go-библиотеках различия в пределах разумного — JSON чуть медленнее, Avro и Protobuf быстрее. Замерять надо на своём профиле — числовые поля идут одной скоростью, строки и nested-структуры — другой. На больших объёмах поток данных всё равно упрётся в сеть/диск раньше, чем в CPU.
Контракт. JSON по умолчанию контракта не имеет. Avro требует схему обязательно — без неё данные не декодировать. Protobuf — то же самое, обязателен .proto-файл и сгенерированный код. С Avro и Protobuf вы не сможете «по ошибке» поломать контракт, потому что data и schema живут вместе.
Совместимость. Что произойдёт, если продьюсер обновится раньше консьюмера. JSON без правил совместимости → как повезёт. JSON Schema даёт правила, но дисциплина на разработчике. Avro и Protobuf имеют формальные понятия BACKWARD/FORWARD/FULL compatibility, которые проверяются автоматически (Schema Registry в Avro-мире, buf breaking в Protobuf'е). Подробно про это — в Эволюция схем.
Эволюция. Можно ли добавить поле, удалить, переименовать без слома. У Avro и Protobuf для этого есть формальные правила. Новое поле — обязательно на новый field-номер. Удалённый номер переиспользовать запрещено никогда. Переход required→optional и обратно проверяется отдельно. JSON формально разрешает что угодно, но по факту вы откладываете боль на потом.
Интероп. На скольких языках поддерживается. JSON — везде. Avro — основные языки плюс JVM-стек прежде всего. Protobuf — везде, потому что его придумал Google и тащат за собой все большие инфраструктурные проекты (gRPC, Envoy, Kubernetes, etcd). Если у вас зоопарк языков — это серьёзный аргумент.
Tooling и экосистема. Schema Registry для всех трёх работает, но Avro был его первым гражданином (это Confluent-инструмент, рос вместе с Avro). Protobuf тут чуть моложе, но IDE-tooling, генерация кода, линтеры — у Protobuf'а сильнее. gRPC — это Protobuf, и это огромный плюс, если у вас уже gRPC между сервисами (модуль 06).
Почему курс выбирает Protobuf
Решение принимается. Курс мог бы взять Avro — тоже хороший выбор, особенно если у вас Schema Registry уже стоит и аналитика на JVM. Но мы выбрали Protobuf по трём причинам.
- Интероп с gRPC. Модуль 06 целиком про коммуникацию. Сначала gRPC, потом гибрид gRPC + Kafka. Если уже пишем
.protoдля сервисных контрактов, логично переиспользовать те же типы для событий. Один.proto-файл — два канала: синхронный (gRPC) и асинхронный (Kafka). Не нужно вести две параллельных вселенных. - Tooling.
buf(которым мы займёмся в Protobuf в Go) — это современный, удобный инструмент: lint, breaking-change detection, форматирование, удобный workflow. У Avro эквивалентов меньше и они тяжелее. - IDE и кодген. Generated Go-код от Protobuf'а — это нормальные типизированные struct'ы, по которым Go-toolchain видит всё как обычный пакет. Avro в Go тоже работает (
hamba/avroотличная библиотека), но требует больше ручной работы со схемами в рантайме.
Avro лучше Protobuf'а в одном чётком сценарии: если ваша основная нагрузка — это аналитика/data lake (Spark, Flink, Iceberg, Hive), там Avro нативный. Если основная нагрузка — это межсервисное взаимодействие — Protobuf.
Что показывает наш бенч
Смотрим в cmd/format-bench/main.go. Программа делает три вещи. Сначала генерирует один и тот же набор Order'ов с фиксированным seed'ом — это критично, иначе сравнение нечестное. Потом сериализует каждый Order тремя способами и пишет в три разных топика. В конце спрашивает у Kafka размеры на диске.
orders := generateOrders(*count, *itemsPerOrder)
for i := range stats {
t0 := time.Now()
bytes, err := publishAll(ctx, cl, stats[i].topic, orders, encoders[i])
if err != nil { ... }
stats[i].bytesOnWire = bytes
stats[i].duration = time.Since(t0)
}encoders — это слайс из трёх функций. JSON через encoding/json, Avro через hamba/avro со схемой из avro/order.avsc, Protobuf через google.golang.org/protobuf/encoding/protowire — кодирование вручную, без code-gen'а.
Почему protobuf вручную, а не через protoc-gen-go? Потому что эта лекция про wire-формат как идею. Хочется показать, что под капотом у protobuf-байтов нет никакой магии — там простой формат tag (field_number << 3 | wire_type) + value. Сама encodeProto влезает в 15 строк:
func encodeProto(o *Order) ([]byte, error) {
var buf []byte
buf = appendString(buf, 1, o.OrderID)
buf = appendString(buf, 2, o.ShopID)
buf = appendString(buf, 3, o.CustomerID)
buf = appendInt64(buf, 4, o.TotalCents)
buf = appendString(buf, 5, o.Currency)
buf = appendInt64(buf, 6, o.CreatedAtUnix)
for i := range o.Items {
item := encodeOrderItem(&o.Items[i])
buf = protowire.AppendTag(buf, 7, protowire.BytesType)
buf = protowire.AppendBytes(buf, item)
}
return buf, nil
}Числа 1, 2, 3, 4, 5, 6, 7 — это field-номера из proto/order.proto. Никакой больше связи с .proto-файлом тут нет — мы вручную делаем то, что обычно делает кодген. В Protobuf в Go заменим всё это на сгенерированный proto.Marshal.
appendString — обёртка для proto3-семантики «пустые строки на wire не пишутся»:
func appendString(buf []byte, fieldNum protowire.Number, v string) []byte {
if v == "" {
return buf
}
buf = protowire.AppendTag(buf, fieldNum, protowire.BytesType)
return protowire.AppendString(buf, v)
}Это и есть proto3-default-omission: если поле равно zero-value типа, его на wire нет. Декодер при чтении подставит zero-value сам. Экономия — на пустых полях не уходят байты.
Avro работает иначе. Там схема снаружи (в .avsc), payload — голые значения по порядку:
encoders[1] = func(o *Order) ([]byte, error) {
return avro.Marshal(avroSchema, o)
}Если у вас два десятка полей с короткими значениями — Avro будет компактнее Protobuf'а, потому что не пишет field-tag'и. Зато при потере схемы байты Avro нерасшифровываемы. Protobuf частично декодируется и без .proto-файла — tag'и подсказывают типы.
Размеры
Прогон с дефолтами (make run, count=100000, items=3) на нашем стенде даёт примерно такую картину:
=== payload bytes ===
format payload avg/rec
JSON 28_829_000 ~288 B
Avro 8_440_000 ~84 B
Protobuf 10_080_000 ~101 BJSON в три раза больше Avro. Protobuf чуть больше Avro — за счёт field-tag'ов. На длинных строках разрыв сглаживается, на коротких числовых сообщениях Avro выигрывает плотнее. На make run COUNT=1000000 числа умножатся на 10 — линейно.
Дальше можно посмотреть, что на диске:
make du-topicsЭта цель лезет в /var/lib/kafka/data каждой ноды через docker exec и считает размеры партиций. Внутри стенда RF=3, так что суммарный размер на трёх нодах = ~3× от размера одной реплики.
Когда какой формат брать
В реальной жизни всё неоднозначно, но грубые правила работают.
JSON без схемы — только когда система маленькая, временная или вы пишете прототип. Не сложился стек — поставьте JSON, увидите, что система живёт пол-года — переезжайте на схему. Дольше тянуть болезненно.
JSON Schema — компромисс для команд, у которых уже всё на JSON и нет ресурса на полную миграцию. Schema Registry работает с JSON Schema; основной плюс — валидация payload'а против схемы при каждой записи. Минус — не решает проблему размера и плохо ловит эволюцию.
Avro — если у вас аналитический pipeline вокруг JVM, Spark, Iceberg. Schema Registry для Avro — самый зрелый. Если ваш Kafka в первую очередь питает data lake — Avro здравый выбор.
Protobuf — если у вас межсервисное взаимодействие через gRPC, мульти-язычный стек, и события в Kafka — это просто другой канал коммуникации между теми же сервисами. Это — наш случай в курсе.
Что важно зафиксировать
Wire-формат — это решение, которое принимается один раз и потом тащит за собой огромную цепочку: от инструментов разработки до того, как себя ведёт система при инциденте «продьюсер обновили, консьюмеры — нет». Не тащите JSON в продакшн только потому, что «и так понятно». Понятно ровно один раз — на старте. Дальше всем нужно понимать, какая форма у Order'а сегодня, через год и через пять лет.
Дальше в модуле 05 разберём Protobuf в Go (Protobuf в Go), Schema Registry с magic byte и schema_id (Schema Registry) и эволюцию схем без поломки совместимости (Эволюция схем). К концу модуля у вас будет рабочий setup, в котором изменение .proto-файла проверяется CI и регистрируется в Registry автоматически.
Запуск
# поднять стенд из корня репозитория, если ещё не поднят
docker compose up -d
# из этой папки
make run # дефолт — 100k Order'ов, payload+disk-таблица
make run COUNT=1000000 # миллион — числа становятся выпуклее
make du-topics # независимая проверка размеров через du
make topic-delete-all # убрать за собойproto-gen — намеренно no-op в этой лекции. В Protobuf в Go заменим вручное wire-кодирование на сгенерированный protoc-gen-go код, и proto-gen станет настоящей целью с buf.