Когда сервисов в системе становится больше одного, им нужно обмениваться событиями. Kafka — один из самых распространённых инструментов для этого. Разберём с нуля: зачем она нужна, как работает и где подводные камни.
Проблема: сервисы связаны напрямую
Представьте: пользователь оформляет заказ. OrderService должен уведомить PaymentService, InventoryService и NotificationService. Самый простой вариант — HTTP-вызовы напрямую.
Но у этого подхода есть проблема: если PaymentService недоступен, OrderService тоже не может завершить работу. Один падающий сервис тянет за собой другие. Это называют синхронной связностью — сервисы зависят друг от друга в реальном времени.
Kafka решает это иначе. Это распределённый лог сообщений: OrderService записывает событие «заказ создан», а все заинтересованные сервисы читают его самостоятельно — каждый в своём темпе. Если NotificationService временно упал, он просто догонит пропущенные события после перезапуска.
OrderService не знает, кто его читает. Новые потребители добавляются без изменений в коде отправителя.
Топик, партиция, offset
Топик
Топик — это именованный лог сообщений. Сервисы пишут в топики и читают из них. Имя отражает домен: order-events, payment-events, inventory-changes.
Каждое сообщение — пара (ключ, значение) плюс метаданные (timestamp, headers, offset):
ключ: "order-12345"
значение: {"type":"OrderCreated","orderId":"12345","total":1500.00}
Партиция
Топик физически разбит на партиции. Партиция — это упорядоченный неизменяемый лог: сообщения только добавляются в конец, никогда не меняются и не удаляются (до истечения срока хранения).
Зачем партиции:
- Параллелизм — разные потребители читают разные партиции одновременно.
- Масштабирование — партиции распределяются по разным брокерам (серверам).
- Порядок — гарантируется только внутри одной партиции.
Offset
Каждое сообщение в партиции имеет порядковый номер — offset. Потребитель запоминает, до какого offset он дочитал, и продолжает именно оттуда. Это позволяет перечитать старые сообщения или начать с начала.
Отправитель и потребитель
Producer — клиент, который пишет в Kafka. Consumer — клиент, который читает из Kafka.
// Отправитель
@Service
@RequiredArgsConstructor
public class OrderEventPublisher {
private final KafkaTemplate<String, String> kafka;
public void publish(OrderCreatedEvent event) {
kafka.send("order-events", event.orderId(), toJson(event));
}
}
// Потребитель
@Component
public class PaymentListener {
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void onOrderCreated(ConsumerRecord<String, String> record) {
OrderCreatedEvent event = fromJson(record.value(), OrderCreatedEvent.class);
// обработка...
}
}
Куда попадает сообщение: роль ключа
Producer выбирает партицию по правилу:
- Ключ указан →
partition = hash(ключа) % количество_партиций. Все сообщения с одинаковым ключом всегда попадают в одну партицию. - Ключ не указан → сообщения распределяются по партициям по очереди.
Это важно для порядка. Если все события одного заказа должны быть в правильном порядке — используйте orderId как ключ:
// Правильно: ключ — orderId, все события заказа в одной партиции
kafka.send("order-events", event.orderId(), payload);
// Неправильно: без ключа события разлетятся по разным партициям
kafka.send("order-events", null, payload);
Важный нюанс: если увеличить количество партиций в существующем топике, формула hash(ключа) % N даст другие результаты. Часть сообщений попадёт в другую партицию — порядок нарушится. Поэтому количество партиций планируют заранее с запасом и не меняют на ходу без миграции данных.
Порядок сообщений
Внутри одной партиции порядок гарантирован — сообщения хранятся и доставляются именно в том порядке, в котором были записаны.
Между партициями порядок не определён. Если события одного заказа попали в разные партиции, они могут быть прочитаны в произвольном порядке.
Вывод: хотите упорядоченную обработку всех событий какого-то объекта — используйте его идентификатор как ключ сообщения.
Consumer Groups: как потребители делят работу
Consumer Group — это несколько потребителей с одинаковым groupId. Kafka распределяет партиции топика между ними: каждая партиция назначается ровно одному потребителю в группе.
Что это даёт:
- Параллелизм ограничен числом партиций: если партиций 3, а потребителей 5 — двое будут простаивать.
- Каждое сообщение в группе обрабатывается ровно одним потребителем — нагрузка делится, а не дублируется.
- Разные группы независимы:
PaymentServiceиInventoryServiceобе читаютorder-events, но каждая со своим offset — как если бы у каждой была своя копия топика.
Когда потребитель падает или добавляется новый, Kafka перераспределяет партиции — это называется rebalance. На время перераспределения группа не обрабатывает сообщения. Частые rebalance замедляют работу, поэтому стараются их избегать.
Commit offset: отметка о прогрессе
Потребитель сообщает Kafka, какие сообщения обработал — это называется commit offset. Без этого после перезапуска Kafka не знала бы, откуда продолжать.
Автоматический commit (по умолчанию) фиксирует offset каждые 5 секунд независимо от того, обработано ли сообщение. Удобно, но опасно: если упасть после получения, но до обработки — offset уже зафиксирован, сообщение потеряется.
Ручной commit — надёжнее:
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
try {
processEvent(record);
ack.acknowledge(); // фиксируем только после успешной обработки
} catch (Exception e) {
throw e; // не фиксируем → перечитаем при следующем запросе
}
}
С ручным commit'ом потребитель может получить одно сообщение дважды (при перезапуске после сбоя). Это называют at-least-once — и это стандартный подход. Цена: потребитель должен уметь обрабатывать одно и то же сообщение несколько раз без побочных эффектов (быть идемпотентным).
Гарантии доставки
Kafka поддерживает три режима:
| Режим | Смысл | Применение |
|---|---|---|
| At-most-once | Доставлено или потеряно — без дублей | Метрики, логи, телеметрия |
| At-least-once | Доставлено хотя бы один раз, возможны дубли | Большинство бизнес-сценариев |
| Exactly-once | Ровно один раз, без потерь и дублей | Финансовые операции, критичные к дублям |
At-least-once + идемпотентный потребитель — стандартный выбор для бизнес-событий. Exactly-once технически реализуемо (transactional producer + read-committed consumer), но сложнее и медленнее.
Idempotent Producer
Обычный producer может отправить сообщение дважды, если подтверждение не дошло. С настройкой enable.idempotence=true Kafka присваивает каждому сообщению уникальный порядковый номер и отбрасывает повторы:
spring:
kafka:
producer:
properties:
enable.idempotence: true
acks: all
retries: 2147483647
Это защищает от дублей при повторных попытках отправки. Но если сам сервис перезапустился и отправляет сообщение заново — это уже другое сообщение с точки зрения Kafka. Поэтому потребители всё равно должны быть идемпотентными.
Атомарность: запись в базу и отправка события
Распространённая задача: нужно сохранить заказ в базе данных и одновременно отправить событие в Kafka. Если сначала сохранить, а потом отправить — при сбое между этими шагами событие будет потеряно.
Решение — Transactional Outbox: событие записывается в ту же транзакцию базы данных, что и сам заказ. Отдельный фоновый процесс читает необработанные записи и отправляет их в Kafka.
Ничего не теряется (запись атомарна), ничего лишнего не отправляется (relay видит только завершённые записи). Подробнее этот паттерн разобран в статье «Распределённые паттерны».
Репликация: что если сервер упадёт
Каждая партиция хранится не на одном сервере, а на нескольких. Один из них — лидер, остальные — реплики. Producers и consumers работают только с лидером. Реплики догоняют лидера и формируют ISR (In-Sync Replicas — синхронизированные реплики). Если лидер падает, одна из реплик становится новым лидером.
Producer указывает, насколько строгое подтверждение ему нужно (acks):
acks | Что значит | Риск |
|---|---|---|
0 | Отправил и забыл | Можно потерять |
1 | Лидер получил | Потеря при падении лидера до репликации |
all | Все синхронизированные реплики получили | Надёжно |
Для бизнес-событий — всегда acks=all.
Срок хранения сообщений
Kafka — это лог, и у него есть настраиваемый срок хранения:
- По времени (
retention.ms) — например, 7 дней. - По размеру (
retention.bytes) — например, 1 ТБ на партицию. - Компакция (
cleanup.policy=compact) — хранится только последнее сообщение для каждого ключа. Подходит для хранения текущего состояния: остатков на складе, актуальных цен, профилей пользователей.
# Топик с компакцией — актуальные остатки
inventory-current:
cleanup.policy: compact
# Обычный топик событий — 7 дней
order-events:
cleanup.policy: delete
retention.ms: 604800000
Когда Kafka не нужна
Kafka хорошо работает для потоков событий с высокой нагрузкой. Но она не для всего:
- Нужен ответ на запрос — Kafka однонаправленная, запрос-ответ через неё громоздко. Лучше HTTP.
- Мало событий — Kafka оптимизирована для высокой нагрузки; для нескольких событий в день это избыточно.
- Нужна сложная маршрутизация по правилам — RabbitMQ с routing-правилами подойдёт лучше.
Коротко
- Топик — именованный лог событий. Партиция — его физическая часть, упорядоченная и неизменяемая.
- Ключ сообщения определяет партицию: один ключ → одна партиция → порядок гарантирован.
- Offset — позиция потребителя. Commit offset после обработки, не до.
- Consumer Group делит партиции между потребителями. Несколько групп читают один топик независимо.
- Стандартный выбор: at-least-once + ручной commit + идемпотентный потребитель.
- Для атомарности «база + Kafka» — паттерн Transactional Outbox.
- Репликация защищает от потери данных при падении сервера. Для надёжности —
acks=all. - Срок хранения настраивается по времени или размеру; компакция хранит только последнюю версию для каждого ключа.
Что почитать дальше
- Kafka в production — Spring Kafka, Dead Letter Queue, Schema Registry, consumer lag, настройка производительности, безопасность, KRaft.
- Распределённые паттерны — Outbox + polling-relay, Saga, Idempotent Consumer подробно.
- CQRS — Kafka как канал между командной и читающей моделью.