← назад к разделу

Когда сервисов в системе становится больше одного, им нужно обмениваться событиями. Kafka — один из самых распространённых инструментов для этого. Разберём с нуля: зачем она нужна, как работает и где подводные камни.

Проблема: сервисы связаны напрямую

Представьте: пользователь оформляет заказ. OrderService должен уведомить PaymentService, InventoryService и NotificationService. Самый простой вариант — HTTP-вызовы напрямую.

Но у этого подхода есть проблема: если PaymentService недоступен, OrderService тоже не может завершить работу. Один падающий сервис тянет за собой другие. Это называют синхронной связностью — сервисы зависят друг от друга в реальном времени.

Kafka решает это иначе. Это распределённый лог сообщений: OrderService записывает событие «заказ создан», а все заинтересованные сервисы читают его самостоятельно — каждый в своём темпе. Если NotificationService временно упал, он просто догонит пропущенные события после перезапуска.

diagram

OrderService не знает, кто его читает. Новые потребители добавляются без изменений в коде отправителя.

Топик, партиция, offset

Топик

Топик — это именованный лог сообщений. Сервисы пишут в топики и читают из них. Имя отражает домен: order-events, payment-events, inventory-changes.

Каждое сообщение — пара (ключ, значение) плюс метаданные (timestamp, headers, offset):

ключ:    "order-12345"
значение: {"type":"OrderCreated","orderId":"12345","total":1500.00}

Партиция

Топик физически разбит на партиции. Партиция — это упорядоченный неизменяемый лог: сообщения только добавляются в конец, никогда не меняются и не удаляются (до истечения срока хранения).

diagram

Зачем партиции:

  • Параллелизм — разные потребители читают разные партиции одновременно.
  • Масштабирование — партиции распределяются по разным брокерам (серверам).
  • Порядок — гарантируется только внутри одной партиции.

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 выбирает партицию по правилу:

  1. Ключ указанpartition = hash(ключа) % количество_партиций. Все сообщения с одинаковым ключом всегда попадают в одну партицию.
  2. Ключ не указан → сообщения распределяются по партициям по очереди.

Это важно для порядка. Если все события одного заказа должны быть в правильном порядке — используйте orderId как ключ:

// Правильно: ключ — orderId, все события заказа в одной партиции
kafka.send("order-events", event.orderId(), payload);

// Неправильно: без ключа события разлетятся по разным партициям
kafka.send("order-events", null, payload);

Важный нюанс: если увеличить количество партиций в существующем топике, формула hash(ключа) % N даст другие результаты. Часть сообщений попадёт в другую партицию — порядок нарушится. Поэтому количество партиций планируют заранее с запасом и не меняют на ходу без миграции данных.

Порядок сообщений

Внутри одной партиции порядок гарантирован — сообщения хранятся и доставляются именно в том порядке, в котором были записаны.

Между партициями порядок не определён. Если события одного заказа попали в разные партиции, они могут быть прочитаны в произвольном порядке.

Вывод: хотите упорядоченную обработку всех событий какого-то объекта — используйте его идентификатор как ключ сообщения.

Consumer Groups: как потребители делят работу

Consumer Group — это несколько потребителей с одинаковым groupId. Kafka распределяет партиции топика между ними: каждая партиция назначается ровно одному потребителю в группе.

diagram

Что это даёт:

  • Параллелизм ограничен числом партиций: если партиций 3, а потребителей 5 — двое будут простаивать.
  • Каждое сообщение в группе обрабатывается ровно одним потребителем — нагрузка делится, а не дублируется.
  • Разные группы независимы: PaymentService и InventoryService обе читают order-events, но каждая со своим offset — как если бы у каждой была своя копия топика.
diagram

Когда потребитель падает или добавляется новый, 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.

diagram

Ничего не теряется (запись атомарна), ничего лишнего не отправляется (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 как канал между командной и читающей моделью.