Kafka: модель брокеров, топиков и логов
Kafka часто называют очередью сообщений, но это слишком узкое описание. Практически Kafka - это распределённый append-only лог событий. Producer записывает события в конец лога, Kafka хранит их некоторое время, consumer читает события в своём темпе и запоминает позицию чтения.
Главный сдвиг в мышлении: сообщение не исчезает после чтения. Оно лежит в логе до тех пор, пока его не удалит политика хранения. Поэтому одно и то же событие могут читать разные сервисы, аналитика, нотификации, биллинг и аудит.
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 обычно выглядит так:
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 в конец.
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:
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 pipeline | Backend отправляет события в 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.
Queue:
[msg1][msg2][msg3] --> worker A
[msg2][msg3] --> worker B
[msg3]
В Kafka сообщение остаётся в partition, а каждая consumer group ведёт свою позицию:
Kafka topic:
partition: [0][1][2][3][4][5]
^ ^
| |
analytics billing
offset=1 offset=4
| Вопрос | Queue | Kafka |
|---|---|---|
| Что происходит после чтения | Сообщение обычно исчезает после ack | Record остаётся до retention |
| Модель масштабирования | Workers конкурируют за задачи | Partitions распределяются между consumers |
| Повторное чтение | Обычно сложнее | Естественно, если данные ещё есть |
| Порядок | Зависит от очереди и режима | Гарантирован внутри partition |
| Fan-out | Часто через exchange/routing | Через разные consumer groups |
| Хранение истории | Не основная задача | Одна из ключевых идей |
Kafka - не замена всем очередям. Она сильна там, где события становятся долговременным потоком данных, который читают разные подсистемы.
Мини-пример события в Go
Даже если producer появится в следующем уроке, полезно сразу увидеть форму события как часть контракта.
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 важен для порядка сообщений?
Практика
- Нарисуйте модель Kafka для интернет-магазина: topics, keys и consumer groups для заказов, оплаты и уведомлений.
- Опишите событие
PaymentCapturedв Go-структуре: добавьтеevent_id,version,occurred, бизнес-поля и методKey(). - Для трёх сценариев выберите Kafka или обычную очередь: email-рассылка, аудит действий пользователя, генерация PDF-чека.
- Объясните, что произойдёт с consumer, который отстал на 10 дней при
retention.ms=7d.
Интерактивная практика
Почему Kafka в production чаще описывают как распределённый лог, а не как обычную очередь?
Что выведет этот код?
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))
}
Реализуй EventKey: для событий заказа ключом должен быть orderID, для пользовательских событий — userID, для остальных событий верни audit.