← назад к разделу

Приложение меняется постоянно: новое поле в заказе, новый статус, переименованная сущность. Каждое такое изменение упирается в один и тот же вопрос: что случится с данными и кодом, которые про изменение ещё не знают? У этого вопроса есть системный ответ — понятия совместимости и набор правил эволюции схем. Они одни и те же для базы, REST-запроса и события в Kafka, хотя болят в каждом канале по-своему.

Почему версии всегда живут парами

Сервис не обновляется мгновенно. Плавающий выкат (rolling upgrade) заменяет узлы по одному: несколько минут — а при постепенной раскатке на процент пользователей и несколько дней — в проде одновременно работают старая и новая версия кода. С клиентскими приложениями хуже: мобильное приложение пользователь может не обновлять месяцами.

Значит, любые данные в движении должны быть читаемы в обе стороны:

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

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

Три канала данных и их ловушки

База данных. Запись в базу — «сообщение самому себе в будущее», и с ней нужны обе совместимости: новый код читает старые строки (обратная), а во время выката старый код читает строки, записанные новым (прямая). Здесь живёт самая коварная ловушка главы: старый код читает запись с новым полем, изменяет её и сохраняет обратно — и молча затирает поле, о котором не знает. Данные теряются без единой ошибки в логах. Защита — аккуратность на уровне приложения: обновлять только свои поля (UPDATE … SET status = …, а не «прочитал объект целиком — записал целиком»), а при чтении-модификации-записи документов сохранять неопознанные поля.

Синхронное API (REST, gRPC). Здесь есть упрощающее допущение: обычно сначала обновляют серверы, потом клиентов, поэтому по запросам достаточно обратной совместимости, по ответам — прямой. На практике это правила «новый параметр запроса — только необязательный» и «новое поле в ответе клиент обязан игнорировать». Когда совместимость сохранить нельзя — включается версионирование API, и старую версию приходится держать живой, пока не уйдёт последний потребитель: заставить чужих клиентов обновиться вы не можете.

События (Kafka, RabbitMQ). Продьюсер и потребитель — разные сервисы разных команд, версии расходятся неизбежно, а событие в топике переживает не один деплой потребителей. Поэтому именно здесь совместимость охраняют инфраструктурно — Schema Registry проверяет каждую новую схему на совместимость с предыдущими и просто не даст продьюсеру зарегистрировать ломающее изменение. Отдельная грабля: потребитель, который перекладывает событие в другой топик, обязан сохранять поля, которых не понимает, — иначе он тот же «старый код, затирающий новое».

Правила эволюции

Во всех форматах со схемой (Protobuf, Avro, JSON Schema) правила сводятся к короткому списку:

  • Новое поле — только необязательное или со значением по умолчанию. Обязательное новое поле ломает обратную совместимость: новый код не сможет прочитать старые записи, где поля нет.
  • Идентификаторы полей неприкосновенны. В Protobuf это номер тега: закодированные данные ссылаются на поля по номерам, и переиспользование номера превращает старые записи в мусор. Имя поля менять можно (данные на имена не ссылаются), номер — никогда.
  • Удалять можно только необязательные поля, и их идентификатор навсегда выходит из оборота.
  • Смена типа — осторожно: расширение (int32 → int64) новый код переживёт, а старый молча усечёт длинное значение.

Проверять соблюдение правил руками не нужно — на то и схема: реестр схем или проверка контрактов в CI ловят ломающее изменение до выката.

JSON или бинарный формат со схемой

JSON победил как формат интеграций — читаем глазами, работает везде. Но у него всё вышеописанное болит сильнее: схема неявная (что старый код сделает с новым полем — зависит от каждой библиотеки), числа больше 2⁵³ теряют точность в JavaScript-мире (Twitter отдаёт id твита строкой и числом одновременно), двоичные данные едут через Base64 с +33% объёма, а имена полей повторяются в каждой записи.

Бинарные форматы со схемой — Protobuf (теги полей, кодогенерация) и Avro (схема записи + схема чтения, дружит с динамически генерируемыми схемами) — компактнее в разы и, главное, делают совместимость проверяемой: схема — это документация, которая не может протухнуть, и контракт, который CI может сверить. Практическая рамка: наружу — JSON/REST (совместимость дисциплиной и версионированием), между своими сервисами и в событиях — формат со схемой и реестром. И отдельно: встроенная сериализация языка (java.io.Serializable и родня) — не вариант ни для чего долгоживущего: привязка к языку, никакой эволюции и известные дыры безопасности при десериализации недоверенных данных.

Где это применяется

Каждый инцидент вида «после выката посыпались десериализационные ошибки» или «у части пользователей пропало поле» — это несоблюдённая совместимость. Проверочный вопрос перед любым изменением формата: «что сделает с этим код, который ещё не обновился, и данные, которые уже записаны?» Если ответа два («прочитает» и «переживёт») — изменение безопасно.

Где спотыкаются начинающие:

  • Добавляют обязательное поле — и новый код падает на первой же старой записи. Только optional / default.
  • Думают о совместимости только для API, забывая базу и события: старый код, затирающий новые поля при перезаписи, ломает данные тише всех.
  • Переиспользуют освободившийся номер поля в Protobuf — старые записи начинают читаться как бессмыслица.
  • Держат схему «в голове» при JSON-интеграции: совместимость, которую никто не проверяет автоматически, рано или поздно нарушат.

Что почитать дальше: Schema Registry в Kafka — инфраструктурная проверка совместимости событий; gRPC и protobuf — формат со схемой на практике; версионирование REST API — когда совместимость сохранить нельзя. Первоисточник — Мартин Клеппман, «Высоконагруженные приложения», глава 4.