Перейти к содержимому
TelegramGitHub

Designing Data-Intensive Applications, глава 4: Кодирование и эволюция схем

Главная мысль: приложения меняются, а старые и новые версии кода и данных работают одновременно. Формат кодирования должен поддерживать совместимость в обе стороны.

Backward и forward compatibility при rolling upgrade и правила эволюции схемы

При rolling upgrade (постепенное обновление узлов) и в клиентских приложениях разные версии работают вместе.

  • Backward compatibility: новый код читает данные, записанные старым кодом. Обычно это легко.
  • Forward compatibility: старый код читает данные, записанные новым. Сложнее: старый код должен игнорировать то, чего не знает.

Программа держит данные в памяти как объекты и указатели. Для файла или сети нужны байты. Перевод в байты это encoding (serialization), обратный это decoding. Это не шифрование.

Форматы языка (Java Serializable, pickle, Kryo) удобны, но привязывают к языку, небезопасны (декодирование создаёт произвольные классы), плохо работают с версиями и неэффективны. Используйте их только для временных задач.

JSON, XML, CSV это текстовые форматы. Они широко поддерживаются, поэтому подходят для обмена между организациями. Проблемы:

  • Числа неоднозначны. JSON не различает integer и float. Целые больше 2^53 теряют точность в JavaScript (пример: 64-битные ID твитов).
  • Нет бинарных строк. Обходят через Base64.
  • Схема опциональна и сложна. CSV вообще без схемы, а правила экранирования реализуют не все.
  • Бинарные варианты JSON (MessagePack, BSON) почти не экономят место: в примере 66 байт против 81, потому что имена полей остаются.
ФорматИдентификация поляРазмер примераОсобенности
JSON (текст)Имя поля81 байтЧитается человеком, схемы нет
MessagePackИмя поля66 байтБинарный JSON
Thrift BinaryProtocolНомер тега59 байтНужна IDL-схема и генерация кода
Thrift CompactProtocolНомер тега34 байтаТип и тег в одном байте, varint
Protocol BuffersНомер тега33 байтаПохож на Compact, поле repeated вместо списка
AvroПорядок полей в схеме32 байтаНет тегов и типов в данных

Запись это набор полей с тегом и типом. Пустое поле просто не записывается.

  • Имя поля менять можно. Номер тега менять нельзя.
  • Новое поле: новый тег. Оно должно быть optional или иметь default. Тогда и старый код его игнорирует, и новый читает старые данные.
  • Удалять можно только optional поле. Номер тега нельзя использовать повторно.
  • Смена типа рискованна: 32-битное число в 64-битное. Старый код может обрезать значение.
  • В Protocol Buffers можно сделать optional поле repeated.

Avro: схема писателя и схема читателя, правила разрешения различий и источники схемы

В Avro нет номеров тегов. Данные это просто значения подряд. Прочитать их можно только со схемой.

Writer schema и reader schema не обязаны совпадать, только быть совместимы. Библиотека сравнивает их при чтении:

  • Порядок полей не важен, поля сопоставляются по имени.
  • Поле есть у писателя, нет у читателя: игнорируется.
  • Поле есть у читателя, нет у писателя: подставляется default.
  • Добавлять и удалять можно только поле с default.
  • null допустим только через union, например union { null, long }.
  • Смена имени поля возможна через aliases (только backward).

Где взять writer schema:

  • Большой файл: схема один раз в начале (object container file).
  • База с отдельными записями: номер версии в начале записи и реестр схем.
  • Сеть: схема согласуется при соединении.

Dynamically generated schemas. Avro удобен, когда схема строится автоматически, например из таблиц реляционной базы. Колонки становятся полями. При изменении таблицы просто генерируется новая схема. С Thrift и Protobuf теги пришлось бы назначать вручную.

Плюсы схемных форматов:

  • Компактнее бинарного JSON.
  • Схема это актуальная документация.
  • Реестр схем позволяет проверять совместимость до деплоя.
  • Кодогенерация даёт проверку типов в статических языках.

Три режима передачи данных: через базу, через сервисы и через брокер сообщений

Запись в базу это сообщение будущему себе. Нужны обе совместимости: часть процессов работает на старом коде.

  • Потеря неизвестных полей. Старый код читает запись, меняет одно поле и пишет обратно. Новое поле может пропасть, если модель в коде его не знает.
  • Data outlives code. Код меняется за минуты, а данные пятилетней давности остаются. Миграция больших таблиц дорога. Поэтому реляционные базы добавляют колонку с null без переписывания данных.
  • Archival storage. Дамп для бэкапа или warehouse лучше писать в последней схеме. Подходят Avro и колоночные форматы вроде Parquet.
  • Web service это сервис по HTTP. REST это стиль на основе HTTP: URL, кэш, простые форматы. SOAP это XML-протокол с WSDL и множеством стандартов WS-*, сложный и зависящий от инструментов.
  • RPC пытается сделать удалённый вызов похожим на локальный. Это ошибка. Сеть ненадёжна: возможен timeout без ответа, повторный запрос может выполнить действие дважды (нужна idempotence), задержка непредсказуема, типы данных различаются между языками.
  • Современные RPC (gRPC, Thrift, Finagle, Rest.li) честно показывают, что вызов удалённый: futures, потоки.
  • REST чаще для публичных API. RPC чаще между внутренними сервисами.
  • Эволюция: обычно серверы обновляют первыми. Нужна backward compatibility для запросов и forward для ответов.
  • Публичный API нельзя ломать, поэтому живут несколько версий. Версию указывают в URL, в заголовке Accept или на стороне сервера по API-ключу.

Брокер (Kafka, RabbitMQ, ActiveMQ, NATS) хранит сообщение временно. Это что-то среднее между RPC и базой.

  • Буфер при перегрузке получателя.
  • Повторная доставка после сбоя.
  • Отправитель не знает адрес получателя.
  • Одно сообщение нескольким получателям.
  • Отправитель и получатель независимы. Ответа обычно нет: это асинхронность.

Брокер не навязывает формат: сообщение это байты. Используйте совместимый формат. Если consumer публикует сообщения дальше, сохраняйте неизвестные поля.

Distributed actor frameworks (Akka, Orleans, Erlang OTP) соединяют брокер и модель акторов. При rolling upgrade совместимость тоже нужна. Сериализацию по умолчанию часто нужно заменить, например на Protocol Buffers.

Термин (EN)Что значит
Encoding / decodingПеревод структур памяти в байты и обратно
Backward compatibilityНовый код читает старые данные
Forward compatibilityСтарый код читает новые данные
Rolling upgradeПостепенное обновление узлов без простоя
Schema evolutionИзменение схемы с сохранением совместимости
Field tagНомер поля в Thrift и Protobuf
Writer / reader schemaСхема записи и схема чтения в Avro
VarintЧисло переменной длины в байтах
Union typeТип, допускающий несколько вариантов, например null и long
REST / RPCСтиль API поверх HTTP и вызов удалённой процедуры
IdempotenceПовторный запрос не меняет результат
Message brokerПосредник, который временно хранит сообщения
  • Для событий и топиков Kafka используйте Avro или Protobuf со schema registry. Включайте проверку совместимости до деплоя.
  • Новые поля делайте optional или с default. Не меняйте и не переиспользуйте теги и не удаляйте поля без default.
  • Выгружайте данные из баз в Avro или Parquet. Схему генерируйте из таблицы.
  • Следите за тем, чтобы ETL и consumers не теряли неизвестные поля при перезаписи.
  • Для внешних API подходит JSON. Для внутренних систем лучше схемные форматы. Учитывайте проблему больших чисел в JSON.
  • Делайте повторные запросы idempotent: в сети повтор и потеря ответа обычны.
  1. Чем backward compatibility отличается от forward compatibility? Какая сложнее и почему?
  2. Почему в Protocol Buffers нельзя менять номер тега, а в Avro можно обойтись без тегов?
  3. Что такое writer schema и reader schema, и как reader узнаёт writer schema в базе?
  4. Почему RPC нельзя считать обычным вызовом функции?

Предыдущая: Хранение и извлечение. Следующая: Репликация.