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

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

У этого вопроса есть системный ответ — пара понятий про совместимость и короткий список правил, как менять формат данных безопасно. Правила эти одни и те же для базы данных, для 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 и родня) не годится ни для чего долгоживущего — она привязана к языку, не даёт нормально эволюционировать формат и имеет известные дыры в безопасности при разборе недоверенных данных.

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

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

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

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

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