Kafka устроена как распределённый append-only лог: топик разбит на партиции, каждая из которых является упорядоченным логом, а консьюмеры группы делят партиции между собой и хранят прогресс как оффсет на каждую партицию. RabbitMQ устроен как очередь сообщений: сообщения маршрутизируются в очереди, подтверждаются по одному и удаляются.
Словарь Kafka
Партиция: единица порядка и параллелизма. Хеш ключа выбирает партицию, поэтому сообщения с одним ключом остаются по порядку.
Consumer group: каждую партицию читает ровно один участник; участники сверх числа партиций простаивают.
Оффсет и коммит: коммит означает «всё до оффсета N обработано»; после падения или ребаланса группа продолжает с этого места.
Retention: сообщения хранятся заданное время или объём независимо от того, прочитаны ли они, так что новый консьюмер может перечитать историю.
Гарантии доставки
At-most-once: коммит до обработки. At-least-once: коммит после (то, что обычно нужно). Exactly-once работает только внутри Kafka (идемпотентный продюсер плюс транзакции); для побочных эффектов снаружи нужен идемпотентный консьюмер.
Kafka и RabbitMQ
Kafka: высокая пропускная способность, перечитывание, порядок внутри партиции, консьюмеры сами забирают данные (pull). Подходит для потоков событий, логов, CDC.
RabbitMQ: маршрутизация через exchange, подтверждение и повторная доставка каждого сообщения, приоритеты, TTL и dead-letter exchange из коробки. Подходит для очередей задач.
Стримы RabbitMQ и share groups в Kafka 4.x сокращают разрыв, но выбор по умолчанию прежний: лог событий ведут в Kafka, а очередь задач в RabbitMQ.
GoMiddle
Брокеры сообщений: Kafka и RabbitMQ
Партиции, consumer groups и оффсеты, репликация и acks, гарантии доставки, идемпотентный продюсер, retention и compaction, exchange и подтверждения в RabbitMQ, схемы событий
Закрыто вопросов
0 / 19
- 0Повторить
Вопросы
Продюсер публикует сообщение в exchange, а не в очередь, и exchange копирует его в каждую очередь, чей биндинг подходит. Direct сравнивает routing key на точное совпадение, topic сопоставляет его с шаблонами, fanout игнорирует его и отдаёт сообщение во все привязанные очереди, а headers смотрит на заголовки сообщения.
Биндинг: связывает очередь с exchange по binding key (QueueBind); каждая подходящая очередь получает свою копию сообщения.
Direct: routing key равен binding key; так устроены команды и очереди задач. Exchange по умолчанию "" привязывает каждую очередь под её собственным именем, поэтому публикация с именем очереди в качестве ключа попадает в эту очередь.
Topic: ключ состоит из слов через точку; * соответствует ровно одному слову, # нулю или нескольким. order.*.created получает order.eu.created, а order.# получает все события заказов.
Fanout: широковещательная рассылка. Каждому подписчику нужна своя очередь, потому что консьюмеры одной общей очереди делят сообщения между собой.
Headers: сравнивает значения заголовков (x-match равен all или any); нужен редко.
Нет совпадений: сообщение молча отбрасывается, если его не опубликовали с mandatory (тогда брокер вернёт его) и у exchange нет alternate exchange.
ZooKeeper хранил метаданные кластера (брокеры, топики, лидеров партиций, конфиги, ACL) и выбирал контроллер. KRaft переносит эти метаданные в саму Kafka: небольшой кворум узлов-контроллеров хранит их как лог, реплицируемый по Raft, а в Kafka 4.0 режим ZooKeeper удалён полностью.
Что делал ZooKeeper: брокеры держали в нём сессию, поэтому истёкшая сессия означала мёртвый брокер, и он выбирал единственный брокер-контроллер, который назначал лидеров партиций. Оффсеты консьюмеров к тому времени давно переехали в топик __consumer_offsets.
KRaft: три или пять контроллеров (process.roles=controller) реплицируют метаданные во внутреннем топике __cluster_metadata; брокеры читают его как любой другой лог.
Что это даёт: одна система в эксплуатации и защите вместо двух; быстрое переключение контроллера, потому что резервные контроллеры уже держат метаданные, тогда как контроллер эпохи ZooKeeper на большом кластере перечитывал их минутами; и намного больше партиций на кластер.
Миграция: KRaft готов к продакшену с версии 3.3. Кластер на ZooKeeper нужно мигрировать на bridge-релизе 3.x (последний из них 3.9), и только потом обновлять до 4.0.
Go-клиентов это не касается: они всегда общались только с брокерами.
Kafka превращает любую нагрузку в крупный последовательный ввод-вывод: каждая партиция представляет собой append-only файл, чтение и запись идут через page cache ОС, записи передаются сжатыми батчами, а брокер отдаёт лог консьюмерам через sendfile, не копируя данные через JVM.
Append-only лог: запись дописывается в конец активного сегмента, чтение идёт вперёд от оффсета. Последовательный ввод-вывод быстр даже на HDD, и нет B-дерева, которое надо обновлять на каждое сообщение, только разреженный индекс оффсетов.
Page cache: в куче JVM нет кеша сообщений и нет fsync на каждую запись; файлы кеширует ОС, поэтому консьюмеры у хвоста лога читают из памяти, кеш переживает рестарт брокера, а GC видит маленькую кучу. Надёжность обеспечивает репликация.
Батчинг: продюсер собирает записи по партициям (linger.ms, по умолчанию 5 мс начиная с Kafka 4.0, и batch.size) и сжимает батч целиком (lz4, zstd); брокер хранит батчи в том виде, в каком они пришли, так что один запрос переносит тысячи записей.
Zero-copy: формат файла совпадает с форматом на проводе, поэтому sendfile переносит байты из page cache в сокет, минуя пользовательское пространство. TLS отключает этот путь, потому что шифрование выполняется в пользовательском пространстве.
Дешёвое чтение: брокер хранит один оффсет на группу и партицию, без состояния каждого сообщения, а чтение ничего не удаляет, поэтому ещё одна consumer group почти ничего не стоит.
Kafka удаляет данные по возрасту или размеру, но никогда не потому, что их прочитали: retention.ms (по умолчанию 7 дней) и retention.bytes удаляют старые сегменты целиком. Log compaction (cleanup.policy=compact) вместо этого сохраняет как минимум последнюю запись для каждого ключа, и топик работает как таблица текущих значений.
Политика delete
Retention удаляет сегменты целиком (segment.bytes 1 GiB, segment.ms 7 дней), и только когда самая новая запись сегмента вышла за срок. Топик с редкими, но постоянными записями закрывает активный сегмент только через segment.ms, поэтому старые записи в нём могут пережить retention.ms на столько же.
retention.bytes задаёт лимит на партицию, по умолчанию без ограничения.
Консьюмер, отставший больше чем на retention, теряет данные, а где он продолжит, решает auto.offset.reset: ставьте алерт на лаг, измеренный во времени, задолго до границы retention.
Compaction
Фоновый cleaner оставляет в старых сегментах только самую новую запись для каждого ключа. Оффсеты не меняются, поэтому консьюмеры видят пропуски; активный сегмент не компактится, поэтому свежие дубли остаются. Запись без ключа отклоняется.
Значение null служит tombstone и удаляет ключ. Сам tombstone удаляется через delete.retention.ms (по умолчанию 1 день), поэтому консьюмер, который восстанавливает состояние с начала медленнее этого срока, может пропустить удаление.
Подходит для текущего состояния по id: профили, CDC-ченджлоги, состояние Kafka Streams и сам __consumer_offsets. Новый сервис читает такой топик с начала и получает последнее значение каждого ключа.
cleanup.policy=compact,delete совмещает оба режима.
Закоммиченные оффсеты хранятся во внутреннем компактируемом топике __consumer_offsets, по одной записи на группу, топик и партицию. auto.offset.reset срабатывает, только когда у партиции нет действительного закоммиченного оффсета: latest (по умолчанию в Java) прыгает в конец, earliest начинает с самой старой записи, none падает с ошибкой.
Когда срабатывает сброс: совсем новая группа; закоммиченный оффсет, который уже удалил retention; группа, простоявшая пустой дольше offsets.retention.minutes (7 дней): её оффсеты истекли, и при возвращении она молча перескакивает в конец или перечитывает всё.
Дефолты клиентов различаются: новая группа в Java пропускает всё, что было записано до её старта, а kafka-go по умолчанию ставит StartOffset в kafka.FirstOffset и читает всю историю. Задавайте значение явно.
Переиграть группу с момента времени: у каждой партиции есть индекс по времени, поэтому Kafka находит первый оффсет не раньше заданной метки. Остановите консьюмеров группы и выполните kafka-consumer-groups.sh --bootstrap-server kafka:9092 --group billing --topic orders --reset-offsets --to-datetime 2026-10-20T09:00:00.000 --execute (сначала с --dry-run); --to-earliest и --shift-by -100 работают так же.
Разовая задача: ридер вне группы сам выставляет позицию (SetOffsetAt в kafka-go) и не трогает оффсеты группы.
Повтор должен быть безвреден: переобработать день событий безопасно, только если хендлеры идемпотентны.
Kafka упорядочивает сообщения только внутри партиции, а сообщения без ключа разлетаются по всем двенадцати, поэтому два события одного заказа попадают в разные партиции и обгоняют друг друга. А каждую партицию читает один участник группы, так что участникам с 13-го по 24-й ничего не досталось.
**Ключом служит order_id**: партиция выбирается как hash(key) % partitions, поэтому все события одного заказа идут в одну партицию и читаются в том порядке, в каком записаны.
В segmentio/kafka-go Writer.Balancer по умолчанию равен RoundRobin, который игнорирует ключ: задайте &kafka.Hash{} или &kafka.Murmur2Balancer{}, чтобы совпасть с Java-продюсерами.
Добавление партиций потом меняет hash(key) % n для существующих ключей и разрезает историю одного заказа между старыми и новыми партициями. Закладывайте запас заранее.
Чтобы выйти за число партиций, обрабатывайте параллельно внутри одного консьюмера с воркером на хеш ключа, и порядок по ключу сохранится.
Ребаланс: когда участник входит, выходит или перестаёт слать heartbeat, партиции переезжают, а незакоммиченная работа заново доставляется новому владельцу. Кооперативный ребаланс и новый протокол консьюмеров Kafka 4.0 (KIP-848, Java-клиент) не останавливают всю группу.
С acks=1 лидер подтверждает запись раньше, чем её скопировал хоть один фолловер, поэтому, когда этот лидер падает, у нового лидера записи нет. Запись переживает потерю брокера с acks=all, фактором репликации 3 и min.insync.replicas=2: она подтверждается, только когда её держат минимум две реплики.
ISR: синхронные реплики, то есть лидер плюс фолловеры, догнавшие его в пределах replica.lag.time.max.ms (30 с). Лидером может стать только участник ISR, если не включён unclean.leader.election.enable (по умолчанию он выключен).
**acks**: 0 не ждёт ничего, 1 ждёт локальной записи лидера, all (-1) ждёт каждую реплику, которая сейчас в ISR.
**Почему одного acks=all мало**: когда ISR сжался до лидера, all означает одну копию. min.insync.replicas=2 заставляет лидера отклонять записи с acks=all, возвращая NOT_ENOUGH_REPLICAS, пока синхронных реплик меньше двух (на acks=1 он не влияет): запись останавливается, вместо того чтобы данные потерялись позже.
RF 3 и min ISR 2 продолжают принимать запись, когда один брокер лежит из-за сбоя или rolling-обновления; RF 2 и min ISR 2 останавливают запись при каждом рестарте.
Какие дефолты проверить: Java-продюсер использует acks=all начиная с Kafka 3.0, но min.insync.replicas у брокера равен 1, а нулевое значение Writer.RequiredAcks в kafka-go равно RequireNone, то есть fire-and-forget.
Память, а не диск: подтверждённая запись лежит в page cache каждой реплики, поэтому размещайте реплики в стойках или зонах, которые не падают одновременно.
Он не даёт собственным ретраям продюсера записать батч дважды: брокер выдаёт продюсеру id, каждый батч несёт порядковый номер (sequence number) в своей партиции, и лидер отбрасывает батч, который у него уже есть. Он не спасает от повторной отправки вашим кодом, от перезапуска продюсера и от консьюмера, который обработал запись дважды.
Какой дубль он убирает: батч записан, ответ потерялся, продюсер ретраит. Лидер видит номер, который у него уже есть, и отвечает успехом, не записывая повторно.
Порядок при ретраях: при 5 запросах в полёте обычный продюсер может записать батч 3, а затем повтор упавшего батча 2. С идемпотентностью лидер отклоняет батч, который идёт не следующим по номеру, и продюсер переотправляет их по порядку; без неё порядок требовал max.in.flight.requests.per.connection=1.
Требования: acks=all и включённые ретраи. В Java-продюсере это включено по умолчанию начиная с Kafka 3.0.
Что не покрыто: повторная отправка после таймаута (новая запись, новый номер), рестарт (новый id продюсера, если сессии не связывает transactional.id) и дубли на стороне консьюмера. Для них нужен идемпотентный консьюмер.
В Go: franz-go идемпотентен по умолчанию, confluent-kafka-go требует "enable.idempotence": true, а у Writer в kafka-go такого режима нет.
Транзакции (transactional.id) строятся поверх него и атомарно пишут в несколько партиций вместе с оффсетами консьюмера.
WriteMessages блокируется, пока его батч не отправлен, а writer отправляет батч, когда тот набирает BatchSize (по умолчанию 100 сообщений) или когда истекает BatchTimeout (по умолчанию 1 секунда). Одно сообщение на запрос никогда не заполняет батч, поэтому каждый вызов выжидает целую секунду.
Дело не в сети: брокер отвечает за миллисекунды; время уходит на ожидание сообщений, которые так и не приходят. Параллельные вызовы в одну партицию делят батч, поэтому под нагрузкой задержка уменьшается, а хуже всего она при низком трафике.
**BatchTimeout**: несколько миллисекунд (5 * time.Millisecond, как linger.ms в Java-клиенте) сводят одиночное сообщение примерно к одному round trip, а под нагрузкой батчи всё равно собираются.
**BatchSize**: батч уходит и раньше, как только наберёт 100 сообщений, но так бывает только под нагрузкой. Пакетная задача должна передавать все свои сообщения в один вызов WriteMessages: тогда они ждут одной отправки на партицию, а не одной на каждое сообщение.
**Async: true**: WriteMessages возвращается сразу, а ошибки попадают только в колбэк Completion; без него неудавшаяся запись бесследно пропадает. Допустимо для метрик или логов кликов, но не для события заказа.
Ещё одна ошибка здесь: RequiredAcks не задан, а его нулевое значение равно RequireNone, так что брокер даже не подтверждает запись. Задайте kafka.RequireAll.
Для нового сервиса franz-go: чистый Go, активная разработка и поддержка почти всего протокола, включая идемпотентность и транзакции. У kafka-go самый простой API, но нет ни того ни другого, confluent-kafka-go полный, но тянет cgo, а sarama самый старый и самый многословный.
**twmb/franz-go**: идемпотентная запись и acks=all по умолчанию, транзакции, кооперативный ребаланс, пакеты для админки (kadm) и schema registry (sr). Цена: большой API на опциях, который придётся изучить.
**segmentio/kafka-go**: маленький API из Writer и Reader. Нет идемпотентного и транзакционного продюсера, а дефолты отличаются от Java: acks RequireNone, BatchTimeout в 1 с, FirstOffset для новой группы.
**confluentinc/confluent-kafka-go**: обёртка над librdkafka, C-библиотекой, на которой построено большинство не-Java клиентов, поэтому он полный и проверен боем. Цена в cgo: CGO_ENABLED=1, собранная librdkafka только для распространённых платформ (для Alpine нужен -tags musl), более медленная сборка, сложная кросс-компиляция и падения внутри C, которые Go-стектрейс не объясняет.
**IBM/sarama** (бывший Shopify/sarama): чистый Go и долгая жизнь в продакшене, но consumer group требует реализовать ConsumerGroupHandler (Setup, Cleanup, ConsumeClaim) и настроить большой Config.
Что бы вы ни выбрали: задайте acks, стартовый оффсет и режим коммита явно, спрячьте клиент за своим небольшим интерфейсом и тестируйте против настоящего брокера в контейнере.
Коммит до завершения работы делает консьюмер at-most-once: группа уже записала эти оффсеты как обработанные, поэтому всё, что стояло в очереди или выполнялось, умерло вместе с подом и больше не будет доставлено. Коммитьте только после того, как эффект надёжно сохранён.
Нигде ничего не падает, поэтому такие дыры находят только сверкой данных.
Коммит после обработки даёт at-least-once: падение стоит повторной доставки, которую поглощает идемпотентный хендлер.
Оффсет обозначает позицию, а не множество: коммит 100, пока 95 ещё выполняется, помечает 95 как сделанное. С пулом воркеров коммитьте по каждой партиции только до наименьшего оффсета, который ещё в работе.
В kafka-go ReadMessage с GroupID коммитит за вас; FetchMessage плюс CommitMessages позволяют выбрать момент самому.
Ребаланс действует как падение: участник, не уложившийся в сессию (SessionTimeout в kafka-go), теряет партиции посреди пачки.
Доставку at-least-once плюс идемпотентный консьюмер. Exactly-once доставка по сети невозможна (потерянное сообщение и потерянное подтверждение для отправителя выглядят одинаково), поэтому однократным делают эффект.
Exactly-once semantics в Kafka (идемпотентный продюсер плюс транзакции) покрывает цикл «прочитал, обработал, записал» только внутри Kafka. Ваша база и процессинг карт находятся снаружи.
Храните ключ дедупликации (id сообщения или бизнес-ключ) под уникальным ограничением в той же транзакции, что и эффект. Повторная доставка упирается в ограничение, хендлер возвращает успех, оффсет двигается дальше.
Предпочитайте естественно идемпотентные записи: SET status = 'paid' безопасно повторять, а balance = balance - 10 нельзя.
Для эффекта, который нельзя откатить (письмо, списание с карты), передавайте id сообщения провайдеру как ключ идемпотентности.
Храните строки дедупликации дольше самого длинного возможного окна повторной доставки.
Отделите постоянные ошибки от временных, ретраите только временные с ограниченным backoff, а остальное паркуйте в dead-letter топик с текстом ошибки и исходным оффсетом, после чего коммитьте и идите дальше. Одно сообщение, которое никогда не пройдёт, не должно блокировать партицию.
Постоянные и временные: payload, который не декодируется или не проходит валидацию, падает каждый раз; таймаут базы может пройти. Ретраи первого вида только тратят время.
Retry-топики: для долгих пауз перепубликуйте в orders.retry.1m или orders.retry.10m вместо сна, чтобы основная партиция продолжала двигаться.
Dead-letter топик (DLQ): сохраняйте ключ, значение и заголовки с текстом ошибки, исходными топиком, партицией и оффсетом и числом попыток. Ставьте алерт на его рост и держите инструмент переигрывания: DLQ, который никто не читает, означает ту же потерю данных, только сложнее.
Порядок: запаркованное событие обгоняют следующие события того же ключа. Если порядок важен, паркуйте ключ целиком: отправляйте в DLQ и его следующие сообщения, пока первое не исправлено.
Если не удалась сама запись в DLQ, не коммитьте: лучше остановиться, чем потерять сообщение.
Очередь должна быть durable, сообщение опубликовано как persistent (DeliveryMode: 2), а продюсер должен дождаться confirm, потому что только confirm означает, что брокер принял сообщение. Чтобы пережить потерю самого узла, очередь должна быть ещё и quorum-очередью, реплицируемой между узлами по Raft.
Durable и persistent: не-durable очередь удаляется при рестарте, а classic-очередь при восстановлении выбрасывает transient-сообщения, даже если она durable (quorum-очередь сохраняет все сообщения). Exchange тоже должен быть durable, иначе его биндинги исчезнут.
Publisher confirms: в режиме подтверждений (ch.Confirm(false)) брокер подтверждает сообщение, когда его приняли очереди, куда оно маршрутизировано: на диске для persistent-сообщения в classic-очереди, на большинстве реплик в quorum-очереди. Без подтверждений nil от Publish означает лишь, что байты покинули ваш процесс.
Nack и немаршрутизируемые сообщения: nack означает, что публиковать нужно снова. Сообщение, не попавшее ни в одну очередь, тоже подтверждается и отбрасывается, если только его не опубликовали с mandatory и вы не обрабатываете возвраты.
Quorum-очереди: classic-очередь живёт на одном узле (зеркалирование classic-очередей удалено в RabbitMQ 4.0). Quorum-очередь (x-queue-type: quorum) всегда durable, держит реплики на трёх или пяти узлах, переживает потерю меньшинства из них и отбрасывает poison-сообщение или отправляет его в dead-letter после лимита доставок (по умолчанию 20 начиная с 4.0).
Цена: persistence и подтверждения снижают пропускную способность, поэтому не ждите каждое подтверждение по очереди, а собирайте их пачками.
Consume вызван с autoAck, равным true, поэтому брокер считает сообщение обработанным в момент, когда записал его в сокет, и удаляет его. Всё, что воркер рендерил, и всё, что накопилось в msgs, умирает вместе с подом. Используйте ручные подтверждения и вызывайте Ack только после успешной работы.
Почему durability не помогла: durable-очереди и persistent-сообщения защищают сообщения, которые лежат в очереди. При auto-ack доставленного сообщения в очереди уже нет.
Не одно сообщение: у auto-ack нет лимита prefetch, поэтому брокер вталкивает бэклог в память клиента так быстро, как позволяет сеть; падение теряет его целиком.
Исправление: autoAck: false, ch.Qos(10, 0, false), чтобы ограничить число неподтверждённых доставок у одного воркера, и d.Ack(false) после того, как render вернулся. Когда канал или соединение закрывается с неподтверждёнными сообщениями, RabbitMQ возвращает их в очередь для другого воркера.
Второй баг: при ошибке рендера цикл только пишет в лог, так что и эта задача теряется. Nack(false, false) отправляет сообщение в dead-letter exchange очереди, а если его нет, отбрасывает; бесконечный возврат в очередь превратился бы в цикл повторных доставок.
Теперь at-least-once: если под убит после рендера, но до Ack, сообщение доставляется повторно с выставленным d.Redelivered, поэтому хендлер должен быть идемпотентным. Quorum-очередь также забирает доставку, не подтверждённую дольше consumer_timeout (по умолчанию 30 минут); classic-очереди с RabbitMQ 4.3 этого не делают.
Консьюмеры одной очереди конкурируют: каждое сообщение достаётся ровно одному из них, по очереди и в пределах prefetch каждого консьюмера. Сама очередь остаётся FIFO, но параллельная обработка и повторные доставки ломают порядок, в котором заканчивается работа; его возвращает single active consumer или отдельная очередь на ключ.
Раздача: round-robin между консьюмерами со свободными слотами prefetch (Qos). С неограниченным prefetch медленный консьюмер получает столько же сообщений, сколько быстрый, а первый подписавшийся может забрать весь бэклог. Prefetch 1 самый справедливый и самый медленный; документация RabbitMQ советует от 100 до 300 для быстрых хендлеров и намного меньше для долгих задач.
Очередь задач, а не рассылка: чтобы каждый подписчик видел каждое сообщение, каждый объявляет свою очередь, привязанную к fanout- или topic-exchange.
Как ломается порядок: два консьюмера одновременно обрабатывают сообщения 1 и 2, и 2 заканчивается первым; сообщение после nack или удержанное упавшим консьюмером доставляется заново, когда более поздние уже обработаны.
Single active consumer (x-single-active-consumer: true): подписываются все инстансы, но очередь отдаёт сообщения одному за раз и переключается на следующего, когда тот отключается. Порядок сохраняется ценой пропускной способности одного консьюмера.
Порядок по ключу при параллелизме: несколько очередей, у каждой свой single active consumer, с маршрутизацией по хешу ключа (плагин exchange x-consistent-hash). Это партиции Kafka, собранные вручную; если порядок по ключу является главным требованием, проще взять Kafka.
Тонкое событие (order_id и версия) только уведомляет, поэтому каждый консьюмер обращается обратно в сервис заказов, а это связанность, нагрузка и гонки. Толстое событие несёт нужное консьюмерам состояние, и они работают самостоятельно ценой больших сообщений и payload, который становится публичным контрактом.
Тонкие события (уведомления)
Каждый консьюмер делает синхронный вызов владельцу: падение владельца останавливает их всех, а всплеск событий превращается во всплеск GET к нему.
Вызов возвращает текущее состояние, а не состояние на момент события: два быстрых обновления могут оба прочитать вторую версию, и консьюмер не понимает, что изменилось.
Они уместны, когда payload большой или чувствительный или когда консьюмеру всё равно нужны свежие данные.
Толстые события (event-carried state transfer)
Консьюмер держит свою копию (read model) и никогда не обращается обратно; перечитывание топика восстанавливает её, а компактируемый топик с ключом order_id хранит последнее состояние каждого заказа.
Payload теперь является контрактом: каждое опубликованное поле придётся сохранять, а персональные данные расходятся по базам всех консьюмеров и лежат в Kafka весь срок retention.
Сообщения растут: килобайты допустимы, файлы нет (отправляйте ссылку).
Что нужно обоим вариантам
version, который растёт с каждым изменением у источника: консьюмер применяет событие, только если оно новее того, что у него есть, поэтому повторные доставки и перестановки не откатывают состояние.
Полное состояние вместо дельт (status changed to shipped): потерянное или переставленное событие с полным состоянием исправит следующее, а потерянная дельта останется ошибкой навсегда.
Названия из бизнеса (OrderShipped), а не диффы таблиц, чтобы владелец мог менять свою схему, не ломая консьюмеров.
Вносите только совместимые изменения и в таком порядке, чтобы работала каждая запущенная версия: добавляйте поля со значениями по умолчанию и никогда не переименовывайте, не меняйте тип и не переиспользуйте поле. Schema registry следит за этим: каждая новая схема регистрируется до использования, и registry отклоняет её, если она нарушает правило совместимости subject.
Совместимые и ломающие изменения
Безопасно: новое поле со значением по умолчанию (Avro) или с новым номером (Protobuf). Старые консьюмеры его игнорируют, новые видят значение по умолчанию в старых сообщениях.
Ломает: переименование поля, смена типа, превращение необязательного поля в обязательное или удаление поля, которое консьюмеры ещё читают. Номера полей Protobuf подчиняются тому же правилу reserved, что и в gRPC.
JSON без схемы не проверяется никак. encoding/json в Go игнорирует неизвестные поля, а консьюмер, включивший DisallowUnknownFields, превращает каждое добавленное поле в инцидент; минимально нужен контрактный тест в CI.
Порядок деплоя
**BACKWARD (по умолчанию в Confluent Schema Registry): новая схема читает данные, записанные предыдущей, поэтому сначала обновляют консьюмеров, потом продюсеров.
FORWARD: предыдущая схема читает данные, записанные новой, поэтому первыми идут продюсеры. FULL** означает оба условия сразу, и деплоиться первой может любая сторона.
Варианты _TRANSITIVE проверяют совместимость со всеми прежними версиями, а не только с последней; это важно, когда топик перечитывают с начала.
Что делает registry
Он хранит версии схем по subject (по умолчанию <topic>-value); сериализатор добавляет к каждому сообщению magic byte и 4-байтовый id схемы, поэтому консьюмер один раз получает точную схему писателя и кеширует её.
Несовместимое изменение падает при регистрации, в CI или при старте продюсера, а не в консьюмере в 3 часа ночи. В Go: пакет schemaregistry из confluent-kafka-go или sr из franz-go.
По-настоящему ломающее изменение уходит в новый топик (orders.v2): продюсер какое-то время пишет в оба, консьюмеры переезжают, затем v1 выводят из эксплуатации.