kafka
kafka

Введение

Снова здравствуйте!

Сегодня мы рассмотрим технологии Apache Avro и Schema Registry. Посмотрим на сами технологии и на проблемы, которые они призваны решить.

По ходу написания статьи я понял, что материала получается слишком много. Это и не удивительно. Тема сложная и рассказать надо много. В итоге я решил разбить тему на 2 блока: теоретический и практический. Сегодня поработаем с теорией.

Приятного чтения!

Из-за чего возникла потребность в этих технологиях?

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

Ошибки десериализации и производные проблемы

Предлагаю вспомнить ошибки десериализации, которые мы разбирали в предыдущей статье. Вы наверняка помните, что ошибка десериализации на стороне консьюмера является фатальной. Её мы "решали" при помощи отправки сообщения в DLT.

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

Первая проблема

Пусть у нас есть некоторый JSON-объект, описывающий работника:

{
  "name": "Alexander",
  "surname": "Ivanov",
  "department": "IT",
  "salary": 1200,
  "eyeColor": "Green"
}

Проблема в том, что JSON не содержит информации о типах. То есть в значении поля salary мы можем передавать что угодно, например строку "I don't care about types".

Это приведёт к фатальной ошибке десериализации на уровне маппинга набора токенов в Java-объект. То есть Jackson (в случае работы из Spring Boot) успешно распарсит JSON на токены, но при попытке создать объект выбросит JsonMappingException, так как такую строку нельзя замаппить в целое число.

Если настроен механизм DLT, такое сообщение уйдёт в топик для "мёртвых" сообщений.

Также непонятно, может ли значение быть null. Если консьюмер не учитывает это и в какой-то момент вызовет метод на поле, которое оказалось null, вылетит NullPointerException, и сообщение опять улетит в DLT (повторюсь, если этот механизм настроен).

Вторая проблема

Продюсеру ничего не мешает начать отправлять сообщения следующего вида:

{  
  "name": "Alexander",
  "surname": "Ivanov",
  "department": "IT",
  "salary": 1200,
  "address": "Russia, Krasnodar"
}

Представим, что консьюмер ожидает структуру предыдущего JSON (с полем eyeColor)

Дело в том, что продюсер отправляет валидный JSON. Проблем с десериализацией не будет — Jackson успешно распарсит сообщение. Но возникнут другие проблемы:

  1. Потеря данных

    Поле address будет проигнорировано Jackson, так как в классе, с которым работает консьюмер, его нет. Информация молча потеряется, без ошибок или предупреждений.

  2. Постоянные null-значения

    Поле eyeColor отсутствует в новых сообщениях, поэтому консьюмер всегда получит null в этом поле. Если бизнес-логика ожидает значение, возможны NullPointerException.

Небольшой итог

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

Отдельная группа проблем

Далее поговорим об остальных недостатках JSON, которые не связаны с ошибками десериализации. Они объединены в один подзаголовок, так как все они связаны с производительностью.

Первый — размер данных при передаче по сети. Он достаточно высокий, так как:

  1. Ключи передаются каждый раз

    Вам может показаться, что по-другому никак. Однако далее при рассмотрении Avro мы убедимся, что достаточно передавать в сообщении только значения.

  2. Тратятся байты на кавычки, двоеточия, запятые

  3. Всё передаётся в виде текста

    Например, число 1200 кодируется как 4 байта символов "1200" вместо 2-4 байт бинарного представления.

Второй — сериализация. Она тоже недешёвая с точки зрения CPU, потому что Jackson формирует текстовое представление: преобразует объекты в строки, форматирует числа, добавляет служебные символы (кавычки, запятые, скобки).

Третий — десериализация. Здесь всё тоже не так радужно, потому что Jackson (опять же, в случае работы с Spring Boot) бьёт полученный по сети массив байт на токены, сопоставляет поля с ключами, пытается привести типы к ожидаемым по структуре объектов.

Надеюсь, я вас убедил в том, что в серьёзных распределённых системах использование JSON формата может сильно повлиять на производительность.

Что придёт нам на помощь?

Существует такое волшебное слово — Avro.

Avro — это система бинарной сериализации данных, ос��ованная на схемах. Эти схемы называются Avro-схемами. Они описывают структуру данных, которую мы хотим сериализовать, и записываются в привычном нам формате JSON. Как правило, схемы записываются в файлах с расширением .avsc.

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

Сейчас определение Avro вам может показаться сложным и непонятным, но далее всё станет понятнее.

Про Avro-схемы

Для начала давайте посмотрим на то, как выглядит Avro-схема. Схема будет описывать работника, которого мы описывали с помощью JSON (но будет отличаться от обоих JSON'ов, которыми мы описывали работника выше):

{
  "type": "record",
  "name": "Employee",
  "namespace": "com.example.hr",
  "fields": [
    {
      "name": "id",
      "type": "string"
    },
    {
      "name": "name",
      "type": "string"
    },
    {
      "name": "salary",
      "type": "int"
    },
    {
      "name": "roles",
      "type": {
        "type": "array",
        "items": "string"
      }
    },
    {
      "name": "address",
      "type": ["null", "string"],
      "default": null
    }
  ]
}

Как вы можете видеть, первым полем этого JSON-объекта (нашей Avro-схемы) является тип. В Avro-схемах тип — обязательный атрибут каждого объекта.

В нашем случае типом является record. Этот тип представляет собой набор полей. Вспомните тип record в Java. Здесь логика та же.

Стоит сказать, что если Avro-схема должна описывать объект класса, который мы хотим отправить в Kafka, то всегда используется тип record (так как нам как раз надо передать набор полей).

Далее идёт имя схемы (name) и namespace. Вместе они формируют уникальный идентификатор схемы. Например, в нашем случае, схема будет идентифицироваться строкой com.example.hr.Employee.

Затем идёт набор полей. Это по сути массив JSON-объектов.

Вы наверняка заметили, что каждое поле имеет тип. Значит, появляется строгая типизация. Это большой плюс по сравнению с обычным JSON-объектом.

Вот и всё. Это и есть описание нашего сотрудника с помощью Avro-схемы.

Типы данных в Avro-схемах

Теперь давайте подробнее углубимся в устройство Avro-схем.

Рассмотрим типы данных, которые поддерживают Avro-схемы. Сразу отмечу, что я рассмотрю все типы, чтобы вы представляли, что можно указывать в схемах, но детально описывать их не буду, так как под��обное описание есть в документации. А в её пересказе я смысла не вижу. Если хотите почитать, то перейдите по ссылке.

Начнём с примитивных типов:

  1. null — отсутствие значения

  2. boolean — бинарное значение

  3. int — 32-битное знаковое целое число

  4. long — 64-битное знаковое целое число

  5. float — 32-битное число с плавающей точкой

  6. double — 64-битное число с плавающей точкой

  7. bytes — последовательность 8-битных беззнаковых байтов

  8. string — последовательность Unicode символов

Также есть так называемые "сложные типы":

  1. record — запись (содержит поля с типами)

  2. enum — перечисляемый тип

  3. array — массив

  4. map — отображение

  5. union — объединение типов (даёт возможность присвоить полю значения разных типов)

  6. fixed — поле фиксированного размера для хранения двоичных данных

Сложные типы делятся на именованные и неименованные:

  1. Именованные

    Имеют имя (необходимый атрибут) и представляют собой самостоятельную сущность. В пример можно привести enum:

    {
      "type": "enum",
      "name": "EmployeeStatus",
      "symbols": [
        "ACTIVE",
        "INACTIVE",
        "FIRED"
      ]
    }
  2. Неименованные

    Не имеют имени. Не являются самостоятельными сущностями. Они по сути просто декларируют то, как хранить данные. Примером служит array:

    {
      "type": "array",
      "items": "string"
    }

Вам, наверняка, хочется знать, зачем такое разделение. Здесь на самом деле всё просто, но на всякий случай поясню.

Возьмём вышеописанные примеры именованного и неименованного типа. В случае с enum'ом, мы можем его переиспользовать (он же представляет собой отдельную сущность). То есть один раз объявили тип, а потом можем использовать его по имени. Это похоже на enum в языках программирования. В случае же с массивом, мы его переиспользовать не можем (а зачем?). Действительно, создавать имя для, скажем, массива строк как-то странно. Это похоже на массивы в языках программирования.

Отмечу, что есть возможность присвоить значение полю по умолчанию с помощью атрибута default.

Как выглядит сериализованный объект?

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

Сериализованный объект выглядит как набор значений его полей в бинарном виде. Интересно, не так ли?

Это становится возможным из-за наличия Avro-схем. В самом деле, схема исчерпывающе описывает структуру объекта класса. Поэтому можно просто передавать набор значений в том порядке, в котором они расположены в схеме, и далее доставать значения, согласно схеме.

Выигрыш в производительности

Итак. Теперь посмотрим на Avro с точки зрения производительности.

Мы поняли, что Avro является бинарным и хранит только значения. Так получается, что он на 20-80% меньше аналогичного JSON (зависит от размера набора данных: чем он больше, тем сильнее выигрыш). А чем меньше байт мы гоняем по сети, тем, разумеется, лучше.

Теперь поговорим о сериализации и десери��лизации.

Сериализация становится менее затратной, так как нам не нужно преобразовывать всё в строки, добавлять служебные символы и т.п.

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

Валидация структуры

Итак, проблемы производительности и отсутствия типизации решили. Теперь решим проблему отсутствия валидации структуры.

Помните сценарий с отправкой JSON с другой структурой, который я ранее показывал? Если нет, то давайте напомню:

Ожидается объект со следующей структурой:

{
  "name": "Alexander",
  "surname": "Ivanov",
  "department": "IT",
  "salary": 1200,
  "eyeColor": "Green"
}

Отправляется такой:

{  
  "name": "Alexander",
  "surname": "Ivanov",
  "department": "IT",
  "salary": 1200,
  "address": "Russia, Krasnodar"
}

Это может потянуть за собой некоторые проблемы (ранее я объяснял, какие конкретно).

Это произошло из-за отсутствия валидации структуры. Надо бы сделать так, чтобы у нас был некоторый контракт, который бы обязовывал нас отправлять данные только в соответствии с некоторой структурой.

Мы действительно можем этого добиться. У нас уже есть структура (Avro-схема). Осталось проверять сообщения на то, соответствуют ли они контракту.

Обрадую вас тем, что валидация это часть Avro. Помните, я рассказывал, что Avro это система? Теперь самое время раскрыть то, что же включает в себя эта система. Так вот, она в себя включает следующее:

  1. Формат бинарного представления данных (то, как выглядят сообщения)

  2. Язык описания схем (то, как выглядят Avro-схемы)

  3. Механизм сериализации и десериализации (то, как происходят процессы сериализации и десериализации)

Вы можете спросить меня: "Где среди перечисленного валидация?". Так вот, она является по сути подмножеством третьего пункта. То есть валидация проходит в моменты сериализации/десериализации. Всё это мы с вами далее посмотрим.

Мы решили все проблемы, которые рассматривали в начале. Поздравляю вас!

Эволюция схем

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

Может произойти ситуации, в которой обновилось множество полей у объектов. Продюсеры теперь шлют новые сообщения, консьюмеры также настроены получать сообщения нового формата.

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

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

Есть безопасные и опасные изменения. При безопасных изменениях мы можем, используя новую схему, работать со старыми сообщениями. В случае же опасных изменений обратной совместимости нет.

Безопасное изменение

Например, мы в поле fields Avro-схемы можем добавить следующий объект:

{
  "name": "address",
  "type": ["null", "string"],
  "default": null
}

Это и есть безопасное изменение:

Новые сообщения будут идти со строковым адресом. В то же время старые будут корректно считаны, так как тип у нас — union, объединяющий строку и null, при этом по дефолту у нас null (нужно для того, чтобы было подставлено значение null при десериализации старых сообщений)

Опасное изменение

Опасными изменениями являются, например, смена типа (int -> string) и добавление нового поля в схему, не присвоив ему значение по умолчанию.

Такое изменение не может быть корректно обработано, так как Avro просто не сможет корректно сопоставить схему записи и схему чтения.

Где хранятся схемы?

Знакомимся с Schema Registry

Хотелось бы иметь некоторое централизованное хранилище схем, чтобы в нём хранить все схемы и выполнять с некоторые операции с этими схемами.

Такое хранилище существует и называется Schema Registry (реестр схем).

Schema Registry — это отдельный сервис (REST-сервер), который хранит и управляет версиями схем данных (например, Avro).

Вообще, Schema Registry умеет хранить не только Avro-схемы. Но другие схемы нас не интересуют, так как сейчас мы держим фокус именно на Avro.

Schema Registry позволяет:

  1. Регистрировать новые схемы

  2. Получать схему по её идентификатору

  3. Работать с версиями схем (о версиях поговорим далее)

  4. И т.д.

Чаще всего Schema Registry запускается на отдельной машине и является независимым компонентом, с которым взаимодействуют сериализаторы и десериализаторы (подробнее об этом взаимодействии поговорим чуть позже).

Что конкретно хранит Schema Registry

Schema Registry хранит не просто набор текстов схем. Она хранит следующие тройки:

  1. Текст схемы

  2. Уникальный целочисленный идентификатор (id)

  3. Версию

При этом каждая такая тройка входит в subject (логическую группу). Таких subject, разумеется, несколько.

То есть всю эту историю можно представить в виде двумерного массива arr[][], где arr[i]subject, а arr[i][j] — тройка (текст схемы, id, версия).

Важно отметить, что версия уникальна в рамках одного subject, а id уникален глобально.

subject по умолчанию формируется как <topic>-key, если сериализуем ключ, и <topic>-value, если сериализуем значение.

Проверка совместимости схем в Schema Registry

Schema Registry также отвечает за проверку совместимости схем. Но об этом пока рано. Сначала разберём сериализацию и десериализацию. Позже вернёмся к этому.

Процессы сериализации и десериализации

Давайте рассмотрим сериализацию и десериализацию в случае работы с Avro.

Начнём с сериализации.

Сериализацию выполняет специальный KafkaAvroSerializer. С ним на практике будем работать в следующей статье.

Итак, шаги сериализации:

  1. Для начала берётся avro-схема из объекта. Да-да, из объекта, а не из файла. На практике мы научимся генерировать классы на основе схем. Внутри этих классов и будет представление схемы.

  2. Далее определяется subject по тому принципу, что было описано ранее (<topic>-key для ключа и <topic>-value для значения)

  3. Проверяется, есть ли эта схема в локальном кеше продюсера (чтобы быстро получить её id)

  4. Далее делается POST запрос к Schema Registry следующего вида: POST /subjects/{subject}/versions с текстом схемы в теле запроса. Далее все действия выполняет Schema Registry

  5. Если такого subject ещё нет, то Schema Registry его создаёт. Далее сохраняет схему под версией 1 и возвращает сгенерированный id.

  6. Если такой subject уже есть, то проверяется совместимость с предыдущей (предыдущими) версиями (о совместимости далее). Если всё хорошо, то создаёт новую версию и возвращает id. Иначе сериализация падает с ошибкой.

  7. Получив id схемы, сериализатор формирует сообщение, которое будет передано по сети.

Подробнее про кеширование в продюсере и консьюмере мы поговорим чуть позже.

Перед рассмотрением десериализации хочу обсудить с вами вид сообщения, отправленного по сети.

Оно имеет следующий вид:

[magic byte (1 byte), schema id (4 bytes), avro binary data (aka payload, n bytes)]

Если с id и payload всё понятно, то magic byte на первый взгляд действительно магический, ибо непонятна его роль. Давайте разберём, чтобы понимать, почему протокол сделали именно таким.

По сути этот байт является версией формата сериализации. То есть если дальше структура сообщения изменится (в сообщение будет добавлен какой-то другой компонент или изменится существующий), байт увеличится на 1 (сейчас он равен 0, так как до сих пор формат не менялся) и десериализатор по байту будет различать версию формата и проводить разную логику парсинга в зависимости от версии. Также по этому байту консьюмер может убедиться, что точно читает Confluent Avro.

Да, важно сказать, что на самом деле мы рассматриваем Confluent Avro, а не просто Avro. Confluent Avro — некоторая обёртка над Avro, которая интегрируется с Schema Registry (тоже проект Confluent). Также Confluent Avro предоставляет KafkaAvroSerializer (добавляет magic byte, id, формирует subject из названия топика) и KafkaAvroDeserializer (читает id, ходит в Schema Registry).

Ещё скажу, что сериализация происходит согласно схеме. То есть строго в том порядке, в котором поля располагаются именно в схеме. Вообще, мы обычно генерируем классы из схем. При генерации порядок полей в классе совпадает с порядком полей в схеме. Но если бы теоретически мы поменяли порядок полей, то ничего бы не сломалось. Поля бы просто записались в порядке следования в схеме. А вот если бы мы удалили поле, то сериализация бы упала, так как специальный объект SpecificDatumWriter, который и занимается записью значений в бинарный поток, не нашёл бы в классе поле, которое присутствует в схеме.

Так. Ладно. Теперь десериализация.

Десериализацию выполняет KafkaAvroDeserializer.

Её шаги:

  1. Десериализатор читает magic byte и если он не равен нулю, то десериализация падает

  2. Читает schema id из сообщения

  3. Проверяет, есть ли схема с таким id в локальном кеше консьюмера

  4. Если схемы нет в кеше, делает запрос к Schema Registry , которая возвращает схему с соответствующим id. Если схема в кеше есть, то оттуда она и берётся.

  5. Производит непосредственную десериализацию payload.

Пятый шаг десериализации рассмотрим чуть подробнее.

Схема продюсера называется writer схемой, а консьюмера — reader схемой. При этом writer схема используется для интерпретации пришедших байт, а reader схема — для преобразования байт в объект. Консьюмер в Listener-методе ожидает некоторый объект со схемой (reader-схемой) (опять же, в практической части мы научимся генерировать такие классы) и начинает десериализовывать payload в него. При этом если схемы не совместимы, то десериализация упадёт до попытки десериализации payload. Она упадёт в момент попытки построить резолвер между двумя схемами (объект, который создаёт библиотека для работы с Avro для того, чтобы получить план преобразования данных из writer схемы в reader схему (подставить default значение, если его нет в бинарном потоке, учесть разный порядок полей в writer и reader схемах)).

Про кеширование и его влияние на производительность

Давайте подробно разберём кеширование.

Помните, в самом начале я писал про то, как дорого отправлять JSON по сети?

Так вот. Давайте предста��им, что бы было без кеширования.

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

Не кажется ли вам, что мы снова занимаемся перегоном тяжёлых JSON?

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

Кеширование есть на обеих сторонах (на стороне продюсера и консьюмера). Кеш является локальным и хранится в памяти процесса, следовательно, при перезапуске очищается. Его не делают персистентным, так как усложнять хранением на диске просто не нужно и хватает обычных in-memory структур (в данном случае - Map, так как кеш использует именно Map). Действительно, запрос за схемой происходит один раз на новый id, да и самих схем немного.

Проверка совместимости в Schema Registry

Самое время поговорить о проверке совместимости в Schema Registry.

Для начала нужно понимать некоторые общие моменты. Например, как в принципе наш реестр проверяет совместимость.

Итак. Ранее я рассказывал про subject.

Наш KafkaAvroSerializer отправляет все сериализованные схемы в subject, который соответствует тому топику, куда мы пытаемся отправить сообщение (<topic>-key или <topic>-value в зависимости от того, что сериализуем).

В рамках одного subject (и как следствие, в рамках ключей/значений одного топика) и будут происходить проверки. Заметьте, в рамках всего subject а не в рамках полного имени схемы (name + namespace). Это очень важно.

Первая схема в subject получит версию 1, вторая — 2, и т.д. При этом, как и говорилось ранее, id будет присвоен уникальный глобально.

Все ключи и значения одного топика будут консистентными (в зависимости от выбранного режима совместимости, о которых далее). Однако это не помогает консьюмеру, так как он может захотеть спарсить сообщение в какой-то левый класс (ну, правда, не совсем в левый, так как внутри него должная быть схема) со схемой, которая никак не согласована с writer схемой.

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

Отмечу, что Schema Registry сравнивает схемы не по тексту JSON, а по их каноническому представлению (Resolution Canonical Form). Это нормализованная JSON-версия схемы, в которой игнорируются несущественные различия (например, порядок свойств в JSON). Благодаря этому одинаковые по структуре схемы не считаются разными из-за форматирования.

Ладно, давайте к режимам совместимости.

Таких режимов достаточно много. Целых 7.

  1. None

    Самый простой тип из всех. Он нужен для того, чтобы указать, что не надо делать никаких проверок. То есть разрешены все изменения. Как вы можете понять, выстрелить в ногу в таком случае очень легко, ибо со стороны продюсера мы можем при отправке засунуть в subject всё, что угодно. Консьюмеру это вряд ли понравится. Кстати, это единственный режим, который допускает добавление в subject схемы с совершенно новым full name.

  2. Backward Compatibility

    Означает, что новая схема должна без проблем читать сообщения, отправленные с последней схемой в данном subject. Да, именно с последней. Более ранние версии не проверяются. Может понадобиться, когда, например, пришлось добавить новое поле. Тогда продюсер будет отправлять по этой схеме, а консьюмер — читать.

    При этом поле должно быть добавлено по этому шаблону:

    {
      "name": "address",
      "type": ["null", "string"],
      "default": null
    }

    То есть должно быть дефолтное значение, чтобы оно было подставлено в старые сообщения и всё работало хорошо.

  3. Forward Compatibility

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

    Новая схема может быть лишена какого-то/каких-то опциональных полей (те, что выглядят по типу приведённого мною JSON в Backward режиме). Также в новую схему можно добавить поле, которого ранее не было. Тогда консьюмер просто его проигнорирует (так как в его схеме нет информации об этом поле).

  4. Full Compatibility

    Выполнение условий из Backward Compatibility и Forward Compatibility одновременно. Здесь подробно пояснять не буду, так как думаю, что всё и так понятно.

  5. Backward Transitive Compatibility

    То же самое, что в Backward Compatibility, но проверка идёт не только с последней версией, а со всеми.

  6. Forward Transitive Compatibility

    То же самое, что в Forward Compatibility, но проверка идёт не только с последней версией, а со всеми.

  7. Full Transitive Compatibility

    То же самое, что в Full Compatibility, но проверка идёт не только с последней версией, а со всеми.

Здесь некоторых может смутить момент, связанный с введением транзитивной совместимости.

Неужели может быть такое, что новая схема совместима с последней существующей версией в subject, но не совместима с более ранними?

Ответ на этот вопрос — да.

Давайте рассмотрим пример с тем, как нарушается Backward Transitive Compatibility, но Backward Compatibility при этом не нарушается.

Пусть изначально была такая схема (V1):

{
  "type": "record",
  "name": "Employee",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "name", "type": "string"}
  ]
}

Потом схема поменялась на следующую (V2):

{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": "string", "default": ""}
  ]
}

Эта версия совместима с первой, так как есть дефолтное значение (пустая строчка). При отсутствии значения, оно будет подставлено, и всё будет работать.

Далее вводим более новую версию (V3):

{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": "string"}  // без default!
  ]
}

Как вы можете видеть, обратная совместимость с V2 присутствует, так как схема V3 без проблем читает сообщения, записанные с схемой V2. Они совместимы, потому что в сообщении, сериализованным с использованием схемы V2 гарантированно будет значение, соответствующее полю email (в самом деле, сериализуемый класс был сгенерирован из схемы, в которой email точно есть либо указанный явно, либо пустая строчка по умолчанию).

Однако V3 не имеет обратной совместимости с V1 из-за того, что сообщения, сериализованные с V1 не имеют значения поля email в бинарном потоке. А значения по умолчанию в V3 для email нет. Соответственно, десериализация упадёт.

Заключение

Было непросто, но с теорией мы закончили. Поздравляю!

Постарался здесь рассказать обо всех основных теоретических моментах. Надеюсь, что вы для себя открыли некоторые моменты, о которых ранее не знали.

В общем, спасибо за то, что дочитали!

В следующий раз поработаем с Avro и Schema Registry на практике из Spring Boot.