Эволюция схем
В Schema Registry мы научили producer и consumer договариваться через Schema Registry: один регистрирует схему, другой по schema_id её достаёт. Пока схема одна — всё тихо. Но контракт Order живёт вместе с Brew. Через месяц приходит запрос: «Brew пошла в города с другой валютой — добавим currency в Order». Через полгода — «запускаем доставку курьером и программу лояльности, нужны адрес и скидка». Через год кто-то предложит поменять total_cents на string, потому что фронту удобнее. И вот тут начинается интересное.
Эта лекция — про дисциплину изменений. Что в Protobuf можно менять безопасно, что — никогда. Какие compatibility-режимы умеет SR. Что делает buf breaking и зачем он нужен в CI. Как это всё ложится на rolling deployment, когда producer-3 и consumer-1 одновременно живут в проде.
Четыре режима совместимости
Schema Registry хранит per-subject настройку compatibility. Это правило, по которому SR разрешает или запрещает регистрировать новую версию схемы под существующим subject'ом. Вариантов четыре:
- NONE — не проверять. Любая схема пройдёт. Дальше как повезёт.
- BACKWARD (дефолт в Confluent SR) — новая схема должна уметь читать данные, написанные старой. Это про апгрейд consumer'ов: catch up к новой версии можно постепенно, потому что новый код понимает старые сообщения.
- FORWARD — старая схема должна уметь читать данные, написанные новой. Это про апгрейд producer'ов: новый код пишет, старые consumer'ы читают.
- FULL — и то и другое. Самый строгий режим, при нём evolution идёт совсем мелкими шагами.
В реальности 90% сред выбирают BACKWARD: catch up consumer'ов проще, чем катить новый producer и держать старых клиентов вечно. Но если у тебя десятки команд читают один топик и обновляются на разной скорости, FORWARD или FULL — это страховка от ситуации «выкатили producer с новым полем, и все читатели разом легли».
В нашем стенде compat по умолчанию глобальный (/config), но переопределяется per-subject (/config/<subject>). Лекция явно ставит BACKWARD на subject — без этого все попытки «зарегистрировать v4» будут зависеть от глобального настройки конкретного запуска.
Что Protobuf считает совместимым
Protobuf на wire-уровне — это пары (tag, value). Tag — это field_number << 3 | wire_type. Никаких имён в payload'е нет — имя поля живёт только в схеме, на проводе уезжает только номер. Из этого вытекают правила.
Безопасные изменения:
- Добавить новое поле с новым номером. Старые consumer'ы не знают тэга, складывают байты в unknown fields. Новые видят значение. BACKWARD ✅, FORWARD ✅.
- Удалить поле, которое больше никто не пишет. Старые читатели его не увидят (получат default), новые писатели его не отправят. Обычно safer — пометить
reservedна номер, чтобы случайно не переиспользовать. BACKWARD ✅. - Переименовать поле без смены номера. Имя живёт только в схеме, wire format не меняется. BACKWARD ✅, FORWARD ✅.
Опасные изменения:
- Сменить тип поля. Был
int64, сталstring— wire type разный (varint vs length-delimited), payload не разберётся. BACKWARD ❌. - Сменить номер поля. Тэг другой — старые байты не найдутся. BACKWARD ❌.
- Удалить поле и переиспользовать его номер под другим типом. Никогда. Используй
reserved. - Изменить enum: добавить запрещено только в редких компиляторах, чаще безопасно. Удалить значение — опасно, старые сообщения с этим тэгом разберутся в
0(UNSPECIFIED).
В нашей лекции v1 → v2 → v3 — это серия безопасных шагов: каждый раз добавляются поля (валюта, потом адрес доставки и лояльность). v4_breaking меняет тип поля 4 и переносит поле 5 на номер 9. SR это поймает, buf breaking поймает, любая адекватная CI поймает.
Что лежит в proto/
Четыре .proto-файла. Структура такая:
proto/orders/
├── v1/order.proto # 4 поля
├── v2/order.proto # +currency
├── v3/order.proto # +delivery_address (+ DeliveryAddress) + лояльность
└── v4_breaking/order.proto # сломанная попытка v3v1, v2, v3 — это нормальные версии с отдельными package'ами orders.v1, orders.v2, orders.v3. Каждая порождает свой Go-пакет в gen/. v4_breaking хитрее — он специально объявляет package orders.v3, потому что только при совпадающем fully-qualified name buf breaking будет сравнивать. Чтобы main buf-модуль не упал на «DeliveryAddress declared multiple times», v4_breaking исключён из модуля через buf.yaml:
modules:
- path: proto
excludes:
- proto/orders/v4_breakingGo-кода для v4_breaking, понятно, не генерируется — мы и не хотим, чтобы кто-то по случайности этим типом пользовался. Файл нужен ровно для двух демонстраций: make proto-breaking-check и make try-register-v4.
Subject и proto-package: тонкое место
Confluent SR проверяет совместимость в рамках одного subject'а. Внутри subject'а у всех версий схем должен совпадать proto-package — иначе compat-check отвергает регистрацию с ошибкой PACKAGE_CHANGED. Это важная деталь.
В лекции у v1 пакет orders.v1, у v3 — orders.v3. Это сделано ради чистоты Go-кода: каждая версия порождает свой Go-пакет (gen/orders/v1, gen/orders/v3), и producer-v1 с producer-v3 работают с разными Order типами. Из-за разных пакетов SR не пустит обе версии в один subject. Поэтому лекция работает с двумя subject'ами:
lecture-05-04-orders-v1-value— туда регистрируется v1 (пакетorders.v1).lecture-05-04-orders-v3-value— туда регистрируется v3 (пакетorders.v3), и туда жеmake try-register-v4пытается встать второй версией.
В реальной жизни такое не делают: по-нормальному .proto-файл живёт в одном пакете и эволюционирует через добавление полей. Версии — это git-коммиты, не разные пакеты. В лекции отдельные пакеты появились только чтобы у учебных бинарников были разные Go-типы для иллюстрации forward compatibility на wire-уровне.
buf breaking — gate в CI
buf breaking сравнивает два состояния схемы и репортит несовместимости по выбранному набору правил. У нас в buf.yaml стоит breaking: use: FILE — это самый строгий набор у buf'а (FILE ⊃ PACKAGE ⊃ WIRE_JSON ⊃ WIRE), ловит и изменение wire-формата, и переименования полей, и удаление полей, и смену имени файла. Проверяет тип, номер, обязательность, наличие — всё по списку правил, который buf публикует в своих доках.
В живом проекте обычно сравнивают «текущий PR» и «main». В лекции инфраструктуры с git-ref'ами нет, поэтому Makefile делает это руками: копирует proto/orders/v3/order.proto и proto/orders/v4_breaking/order.proto в tmp-каталог, собирает их в buf-image'ы и натравливает breaking друг на друга:
proto-breaking-check:
@tmpdir=$$(mktemp -d); \
trap 'rm -rf $$tmpdir' EXIT; \
mkdir -p $$tmpdir/v3 $$tmpdir/v4; \
cp proto/orders/v3/order.proto $$tmpdir/v3/; \
cp proto/orders/v4_breaking/order.proto $$tmpdir/v4/; \
( cd $$tmpdir && \
buf build v3 -o v3.bin && \
buf build v4 -o v4.bin && \
buf breaking v4.bin --against v3.bin ); \
rc=$$?; \
...Запуск выдаёт примерно такое:
order.proto:32:1:Previously present field "5" with name "currency" on message "Order" was deleted.
order.proto:36:3:Field "4" with name "total_cents" on message "Order" changed type from "int64" to "string".
OK: buf корректно зарепортил несовместимость v3 → v4_breakingЛогика в Makefile инвертирует код возврата: buf breaking возвращает 100 при найденных нарушениях, а в нашем демо это и есть желаемый исход. Если buf вернул 0 — значит мы случайно поменяли v4_breaking так, что он стал совместимым, и тест демо сломан. Сообщение в обе стороны.
В реальном CI buf breaking ставят отдельным шагом до push, обычно buf breaking --against '.git#branch=main'. На push в main, либо на PR — если ломается, PR не мерджится. Это дешёвая страховка ровно от того, что SR ловит на runtime.
SR и compat check
Когда buf breaking-check мы прогнали локально, дальше идём в SR. Там тоже compatibility-проверка — но другой природы. Buf смотрит на абстрактные правила («тип поля изменился»), SR смотрит, что проходит реальные ограничения сериализатора Confluent (про Protobuf — оно близко к buf'овским FILE, но не один-в-один).
Workflow для v3-subject'а в Makefile разложен явно:
make register-v3— регистрируем v3 вlecture-05-04-orders-v3-value. Получаем version 1.make sr-set-compat-backward-v3— фиксируем режим. Без этого глобальный дефолт может сыграть на нас, лекция этого не хочет.make try-register-v4— отправляем v4_breaking в тот же subject. SR применяет compat-check, видит изменение типаtotal_centsint64 → string, отвечает 409:
{
"error_code": 409,
"message": "Schema being registered is incompatible with an earlier schema for subject \"lecture-05-04-orders-v3-value\", details: [{errorType:\"FIELD_SCALAR_KIND_CHANGED\", description:\"The kind of a SCALAR field at path '#/Order/4' in the new schema does not match its kind in the old schema\"}, ...]"
}Это и есть то, что мы хотим увидеть. Subject продолжает жить с версией 1 (v3), v4_breaking в реестр не попал, никакой producer не сможет получить под него schema_id.
Если поставить make sr-set-compat-none-v3 — тот же try-register-v4 пройдёт. SR тогда не проверяет ничего, и мир получает «версию 2, которая разъехалась с предыдущими версиями». На этом обычно и горят проды, в которые забыли заглянуть в compat-настройки.
make register-v1 живёт отдельно — он регистрирует v1 в своём subject'е (lecture-05-04-orders-v1-value). В лекции это нужно ровно для того, чтобы subject существовал, и под ним работал producer-v1. К compat-демонстрации register-v1 не относится.
Sliding deployment в живую
В лекции три бинарника: producer-v1, producer-v3, consumer-v1. У каждого producer'а свой топик и свой subject. Сценарий, ради которого всё затевалось:
- Запускаем
producer-v3— пишет 5 Order'ов вlecture-05-04-orders-v3со всеми восемью полями. - Запускаем
consumer-v1(по умолчанию подписан на тот же топик,-topic=lecture-05-04-orders-v3). - consumer-v1 читает сообщения и видит первые четыре поля. Currency, delivery_address и поля лояльности уезжают в unknown fields, программа не падает.
Producer-v3 регистрирует v3-схему в SR (получает свой schema_id) и пишет в Confluent wire format с этим id. Consumer-v1 — намеренно «глупый», он не зовёт SR, срезает первые 5 байт заголовка плюс protobuf message-index, а остаток скармливает proto.Unmarshal в *ordersv1.Order:
schemaID, payload, err := stripWireFormatHeader(rec.Value)
// ...
var order ordersv1.Order
if err := proto.Unmarshal(payload, &order); err != nil {
logger.Error("unmarshal v1", "err", err)
return
}И вот тут проявляется forward compatibility Protobuf'а. Consumer не знает про новые поля, ему всё равно. proto-runtime аккуратно складывает байты неизвестных тэгов в unknown_fields структуры. Программа работает, поля из v1 раскладываются как раньше:
--- lecture-05-04-orders-v3/2@1 key=ord-v3-00003 schema_id=15 ---
order_id = ord-v3-00003
shop_id = shop-041
customer_id = cus-052
total_cents = 12345
unknown = 47 bytes (поля v3: currency, delivery_address, лояльность — v1 их не знает)Schema_id в логе виден — он показывает, что под этим id в SR живёт уже v3. Но consumer-v1 им не пользовался для разбора.
Это и есть rolling deployment: producer обновили, consumer'ы обновятся когда смогут. Никто не лежит. Когда дойдут руки — выкатят consumer-v3, который начнёт читать новые поля. До тех пор данные не теряются: они в логах Kafka, новый код их прочтёт когда появится.
В обратную сторону — producer-v1 пишет, consumer-v3 читает — тоже работает. Consumer-v3 увидит первые четыре поля, currency будет пустой строкой, delivery_address — nil, поля лояльности — нулевыми. Это и есть default values при отсутствии тэга в payload'е.
Что важно держать в голове
Compat в SR — это runtime gate. Он спасает от того, что кто-то случайно зарегистрировал кривую схему. Но он не заставит твой Go-код помнить про unknown fields, не научит твоё приложение работать с пропусками, не починит логику. Schema Registry даёт совместимость на уровне сериализации, не на уровне семантики.
Buf breaking — это compile-time gate. Он быстрее, дешевле и ставится в CI до того, как новая схема вообще доехала до SR. Хорошая практика — оба шага: buf breaking в CI плюс SR-compat в проде. Один ловит ошибки до merge'а, другой — на регистрации.
Если у тебя evolution частая (раз в неделю и чаще) — стоит подумать про FORWARD или FULL compat-режим, особенно если читателей много и они на разных циклах деплоя. Если редкая (раз в квартал) — BACKWARD достаточно.
И последнее. Если уж зашёл туда, где schema sliding ломается — обычно правильный путь не «как протащить мимо compat», а новый subject. orders-v2-value рядом с orders-v1-value, два топика, два набора consumer'ов, миграция на стороне приложений. Это дороже, но честнее: сломанная совместимость в одном subject'е — это тихая бомба, которая рванёт где-нибудь в середине ночи.
Файлы
proto/orders/v1/order.proto— стартовая версия, 4 поляproto/orders/v2/order.proto— +currencyproto/orders/v3/order.proto— +delivery_address (вложенный DeliveryAddress) + лояльностьproto/orders/v4_breaking/order.proto— сломанная вариация v3cmd/producer-v1/main.go— пишет Order'ы по схеме v1cmd/producer-v3/main.go— пишет Order'ы по схеме v3cmd/consumer-v1/main.go— читает топик в*v1.Order, демонстрирует unknown fieldsbuf.yaml,buf.gen.yaml— настройка модуля, lint, breaking-check, codegenMakefile— все цели для запуска
Запуск
Стенд из корня репозитория должен быть поднят (docker compose up -d).
make proto-gen # сгенерировать gen/orders/{v1,v2,v3}
make proto-lint # buf lint
make proto-breaking-check # сверить v3 и v4_breaking, ожидать репорт buf'а
make topic-create-v1
make topic-create-v3
# SR-compat демо в subject'е v3
make register-v3 # положить v3 как версию 1
make sr-set-compat-backward-v3 # зафиксировать compat-режим
make try-register-v4 # v4_breaking — отбой 409
make sr-list-versions-v3 # увидеть, что в subject'е лежит только v3 (одна версия)
# Wire-level forward compat демо
make register-v1 # регистрация v1 в своём subject'е
make run-consumer-v1 # стартовать consumer'а (подписан на topic v3)
make run-producer-v3 # 5 Order'ов по v3 (consumer-v1 читает их и видит unknown fields)
make run-producer-v1 # для контраста: 5 Order'ов по v1 в свой топик
make clean # удалить топики, subject'ы и gen/Соседние лекции
- Schema Registry — wire format и базовая регистрация
- Protobuf в Go —
.proto, buf, кодген - Зачем контракты и wire-форматы — зачем вообще схемы