Kafka: модель брокеров, топиков и логов

Kafka часто называют очередью сообщений, но это слишком узкое описание. Практически Kafka - это распределённый append-only лог событий. Producer записывает события в конец лога, Kafka хранит их некоторое время, consumer читает события в своём темпе и запоминает позицию чтения.

Главный сдвиг в мышлении: сообщение не исчезает после чтения. Оно лежит в логе до тех пор, пока его не удалит политика хранения. Поэтому одно и то же событие могут читать разные сервисы, аналитика, нотификации, биллинг и аудит.

text
Producer | v +------------------- Kafka cluster -------------------+ | Broker 1 Broker 2 Broker 3 | | | | topic: payments | | partition 0: [0][1][2][3][4] | | partition 1: [0][1][2][3] | | partition 2: [0][1][2][3][4][5] | +-----------------------------------------------------+ | v Consumers read by offset

Базовые сущности

ТерминЧто означает
BrokerСервер Kafka. Хранит партиции, принимает записи, отдаёт данные consumers.
ClusterНабор брокеров, работающих как одна Kafka-система.
TopicЛогическая категория событий: orders, payments, user-events.
PartitionФизический append-only лог внутри topic. Topic состоит из одной или нескольких partitions.
RecordОдно сообщение: key, value, headers, timestamp и metadata.
OffsetНомер record внутри partition. Уникален только в паре topic + partition.
Consumer groupГруппа consumers, совместно читающих topic. Каждая partition назначается одному consumer внутри группы.
RetentionПолитика хранения сообщений по времени или размеру.

Важно: Kafka масштабируется не "топиком", а партициями. Если topic имеет 12 partitions, то внутри одной consumer group его могут параллельно читать максимум 12 active consumers. Тринадцатый consumer будет простаивать, пока не появятся свободные partition.


Record: что реально хранится

Kafka record обычно выглядит так:

text
topic: payments partition: 2 offset: 1042 timestamp: 2026-04-30T12:00:00Z key: order-42 headers: event-type=PaymentCaptured, schema-version=2 value: {"order_id":"order-42","amount":9900,"currency":"RUB"}

key нужен не только "для удобства". По key producer выбирает partition. Если все события по order-42 должны идти в правильном порядке, у них должен быть одинаковый key. Тогда Kafka отправит их в одну partition, а порядок внутри partition будет сохранён.

headers удобны для технической metadata: correlation id, trace id, версия схемы, тип события, tenant id. Не стоит класть туда основную бизнес-нагрузку.

Не кладите в record всё, что "может пригодиться". Kafka-событие часто читают несколько команд и хранят дольше, чем живёт исходный request, поэтому payload становится контрактом. Для production-события заранее фиксируют владельца topic, смысл key, версию схемы, PII-поля, retention и правила совместимости.


Kafka как лог

Partition - это не список задач, который consumer "разбирает". Это файлоподобный лог, куда Kafka дописывает новые records в конец.

text
payments-2 offset: 0 1 2 3 4 5 +----+----+----+----+----+----+ value: | p1 | p2 | p3 | p4 | p5 | p6 | +----+----+----+----+----+----+ ^ consumer group billing committed offset = 4

Consumer не удаляет record. Он хранит свой committed offset: "я обработал всё до такой позиции". Если consumer упал, новый consumer из той же группы продолжит читать с committed offset.

Эта модель даёт важные свойства:

  • можно перечитать историю, если retention ещё не удалил данные;
  • разные consumer groups не мешают друг другу;
  • Kafka хорошо работает с последовательным чтением и записью;
  • хранение сообщений становится частью архитектуры, а не временным буфером.

Retention: почему сообщения не живут вечно

Kafka хранит данные по политике retention. Две самые частые настройки:

НастройкаСмысл
retention.msСколько времени хранить records.
retention.bytesСколько данных хранить на partition.

Если retention.ms=7d, сообщение может быть удалено через неделю независимо от того, прочитали его consumers или нет. Kafka не ждёт отстающих consumers бесконечно. Поэтому consumer lag - это production-метрика, а не просто "график для красоты".

Отдельный режим - log compaction. В compacted topic Kafka оставляет последнюю запись для каждого key:

text
key=user-1 value=email=a@old key=user-2 value=email=b@example key=user-1 value=email=a@new После compaction логически важно: user-1 -> email=a@new user-2 -> email=b@example

Compaction подходит для changelog-сценариев: текущее состояние пользователя, настройки, feature flags, индексы. Для событий аудита compaction обычно опасен: там важна вся история, а не последнее значение.


Use cases

Kafka хорошо подходит, когда система должна передавать много событий между независимыми потребителями.

СценарийКак Kafka помогает
Event-driven backendСервис заказов публикует OrderCreated, склад, оплата и уведомления реагируют независимо.
Audit logСобытия пишутся как неизменяемая история действий.
Data pipelineBackend отправляет события в ClickHouse, S3, DWH или stream processing.
Integration busНесколько сервисов обмениваются доменными событиями без прямых HTTP-вызовов.
Async workloadsТяжёлую обработку можно вынести из request-response пути.
CDCИзменения из БД превращаются в поток событий.

Kafka хуже подходит, если нужна простая очередь на 100 сообщений в день, сложная маршрутизация per-message, delayed jobs с точными таймерами или request-response. Для таких случаев часто проще Redis Streams, RabbitMQ, task queue или обычный HTTP.

Kafka также не должна заменять обычный вызов внутри одного bounded context. Если код находится в одном сервисе и должен завершиться в одной transaction, event bus добавит eventual consistency, дубли и операционную стоимость без пользы.


Kafka vs классические очереди

В очереди сообщение обычно забирает один consumer, после успешной обработки оно удаляется или помечается как acked.

text
Queue: [msg1][msg2][msg3] --> worker A [msg2][msg3] --> worker B [msg3]

В Kafka сообщение остаётся в partition, а каждая consumer group ведёт свою позицию:

text
Kafka topic: partition: [0][1][2][3][4][5] ^ ^ | | analytics billing offset=1 offset=4
ВопросQueueKafka
Что происходит после чтенияСообщение обычно исчезает после ackRecord остаётся до retention
Модель масштабированияWorkers конкурируют за задачиPartitions распределяются между consumers
Повторное чтениеОбычно сложнееЕстественно, если данные ещё есть
ПорядокЗависит от очереди и режимаГарантирован внутри partition
Fan-outЧасто через exchange/routingЧерез разные consumer groups
Хранение историиНе основная задачаОдна из ключевых идей

Kafka - не замена всем очередям. Она сильна там, где события становятся долговременным потоком данных, который читают разные подсистемы.


Мини-пример события в Go

Даже если producer появится в следующем уроке, полезно сразу увидеть форму события как часть контракта.

go
package events import "time" type OrderCreated struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Version int `json:"version"` Occurred time.Time `json:"occurred"` OrderID string `json:"order_id"` UserID string `json:"user_id"` Amount int64 `json:"amount"` Currency string `json:"currency"` } func (e OrderCreated) Key() string { return e.OrderID }

OrderID как key означает: все события одного заказа попадут в одну partition и сохранят порядок относительно друг друга. EventID пригодится для идемпотентности на стороне consumer. Version нужен для эволюции схемы.

Для RateDesk похожий контракт мог бы быть RateUpdated v1: key = currency pair или provider+pair, event_id = уникальный id изменения, occurred = время факта, value содержит новую версию курса и источник. В статье важно понять критерии выбора; конкретный contract и MR остаются в проектном задании Kafka-модуля.


Production-инварианты события

Перед тем как topic попадает в production, ответьте на вопросы:

  • кто владеет схемой и принимает breaking changes;
  • какой key сохраняет нужный порядок и не создаёт горячую partition;
  • какие consumers могут безопасно replay старые records;
  • какие поля являются персональными или секретными и не должны попадать в payload/logs;
  • сколько времени retention обязан покрывать расследование и восстановление;
  • как consumer отличит новую версию события от неизвестного типа.

Если эти ответы не записаны, Kafka превращается в скрытый distributed contract без review.


Типичные ошибки

  • Делать один topic на каждый маленький action без понятной модели владения.
  • Использовать случайный key и потом удивляться, что события одного заказа обрабатываются не по порядку.
  • Считать Kafka "бесконечной базой данных" и не думать о retention.
  • Читать всё одной consumer group, хотя разным подсистемам нужны независимые offsets.
  • Хранить в value не событие, а "команду другому сервису", жёстко связывая producer и consumer.
  • Публиковать PII, токены или полный request body, а потом обнаружить это в DLQ, логах и backup topic.
  • Менять JSON-поля без версии и contract tests, полагаясь на то, что "все consumers обновятся одновременно".

Вопросы на собеседовании

  • Почему Kafka называют append-only log?
  • Чем topic отличается от partition?
  • Что такое offset и где он уникален?
  • Почему сообщение в Kafka не удаляется после чтения?
  • Чем Kafka отличается от RabbitMQ или обычной очереди задач?
  • Как retention влияет на отстающих consumers?
  • Когда нужен log compaction?
  • Почему key важен для порядка сообщений?

Практика

  1. Нарисуйте модель Kafka для интернет-магазина: topics, keys и consumer groups для заказов, оплаты и уведомлений.
  2. Опишите событие PaymentCaptured в Go-структуре: добавьте event_id, version, occurred, бизнес-поля и метод Key().
  3. Для трёх сценариев выберите Kafka или обычную очередь: email-рассылка, аудит действий пользователя, генерация PDF-чека.
  4. Объясните, что произойдёт с consumer, который отстал на 10 дней при retention.ms=7d.

Интерактивная практика

Quiz+10 XP

Почему Kafka в production чаще описывают как распределённый лог, а не как обычную очередь?

  • Потому что Kafka всегда удаляет сообщение сразу после первого чтения
  • Потому что события хранятся по retention и могут читаться разными consumer groups независимо
  • Потому что Kafka не умеет хранить порядок сообщений
  • Потому что producer напрямую вызывает consumer
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func retentionState(ageDays int, retentionDays int) string { if ageDays > retentionDays { return "expired" } return "available" } func main() { fmt.Println(retentionState(3, 7)) fmt.Println(retentionState(10, 7)) }
Задача+20 XP

Реализуй EventKey: для событий заказа ключом должен быть orderID, для пользовательских событий — userID, для остальных событий верни audit.