Apache Kafka

Apache Kafka от простого к сложному: топики, партиции, ключи, consumer groups, гарантии доставки. Подходит для подготовки к собеседованию.

Apache Kafka

Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. От базовых понятий до тонких гарантий — в порядке усложнения. Статья подходит для подготовки к собеседованиям middle/senior на Java-бэкенд.

1. Зачем нужна Kafka

В микросервисной архитектуре сервисам нужно обмениваться событиями: «заказ создан», «оплата прошла», «остаток на складе уменьшился». Прямые вызовы по HTTP — это синхронная связность: если PaymentService падает, OrderService тоже не работает.

Kafka — это распределённый лог сообщений. Производители (producers) пишут в него, потребители (consumers) читают — асинхронно, в своём темпе.

diagram

OrderService не знает, кто потребитель. Если NotificationService упал — это его проблема: восстановится и продолжит читать с того места, где остановился. OrderService продолжит работать.

2. Базовые понятия

Topic (топик)

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

Message (сообщение)

Каждое сообщение — это пара (key, value) плюс метаданные (timestamp, headers, offset).

key:   "order-12345"
value: {"type":"OrderCreated","orderId":"12345","total":1500.00}

Partition (партиция)

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

diagram

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

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

Offset (смещение)

Каждое сообщение в партиции имеет порядковый номер — offset. Потребитель помнит, с какого offset продолжать чтение. Offset монотонно растёт.

Producer и Consumer

  • Producer — клиент, который пишет в Kafka.
  • Consumer — клиент, который читает из Kafka.
// Producer
@Service
@RequiredArgsConstructor
public class OrderEventPublisher {
    private final KafkaTemplate<String, String> kafka;

    public void publish(OrderCreatedEvent event) {
        kafka.send("order-events", event.orderId(), toJson(event));
    }
}

// Consumer
@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);
        // обработка...
    }
}

3. Партицирование: куда попадает сообщение

Producer выбирает партицию по правилу:

  1. Если key указанpartition = hash(key) % numPartitions. Все сообщения с одним ключом всегда попадают в одну партицию.
  2. Если key = null → round-robin (или sticky partitioner в новых версиях).

В нашем маркетплейсе ключ — это orderId. Все события одного заказа гарантированно попадают в одну партицию:

diagram

Это важнейшее правило для порядка.

4. Порядок сообщений: что гарантируется и что нет

Гарантия

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

Не гарантируется

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

Практическое следствие

Хотите упорядоченную обработку всех событий заказа? Используйте orderId как ключ. Тогда все его события — в одной партиции, в правильном порядке.

// ПРАВИЛЬНО: ключ — orderId
kafka.send("order-events", event.orderId(), payload);

// НЕПРАВИЛЬНО: ключ null → события разлетятся по партициям
kafka.send("order-events", null, payload);

Подвох: смена количества партиций

hash(key) % numPartitions зависит от числа партиций. Если добавить партиций в существующий топик — старые ключи могут попасть в другую партицию. Появятся «дыры» в порядке.

Правило: число партиций фиксируем заранее с запасом. Не меняем его на горячую без миграции.

5. Consumer Groups: как читают потребители

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

diagram

Свойства

  • Параллелизм = min(числу партиций, числу консьюмеров). Если партиций 3, а консьюмеров 5 — двое будут простаивать.
  • Каждое сообщение в группе обрабатывается ровно одним консьюмером. Это reliable load balancing.
  • Несколько групп — независимые читалки одного топика. PaymentService и InventoryService обе читают order-events, но каждая со своим offset.
diagram

Rebalance

Когда консьюмер падает или добавляется — Kafka перераспределяет партиции между оставшимися. Это rebalance. На время rebalance группа не обрабатывает сообщения. Поэтому частые rebalance вредны (ловят сетевыми флапами или долгой обработкой).

6. Commit offset: где мы остановились

Консьюмер должен сообщать Kafka, какие сообщения обработал. Это называется commit offset.

Auto-commit (по умолчанию)

spring:
  kafka:
    consumer:
      enable-auto-commit: true
      auto-commit-interval: 5000

Каждые 5 секунд consumer commit-ит максимальный offset, который вернул poll(). Просто, но опасно: если упасть после poll() но до обработки, offset уже зафиксирован — сообщение потеряется.

Manual commit

@KafkaListener(topics = "order-events", groupId = "payment-service")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
    try {
        processEvent(record);
        ack.acknowledge();   // commit только после успешной обработки
    } catch (Exception e) {
        // не commit'аем → перечитаем при следующем poll
        log.error("Failed to process: {}", record, e);
        throw e;
    }
}

Ручной commit + at-least-once гарантия = надёжно. Цена: возможны дубликаты, нужна идемпотентность.

7. Гарантии доставки

At-most-once (не более одного раза)

Сообщение либо доставлено, либо потеряно — но не дублируется. Достигается через auto-commit ДО обработки.

Подходит для: метрик, логов, телеметрии — где потеря отдельного сообщения не критична.

At-least-once (как минимум один раз) — стандарт

Сообщение доставлено хотя бы один раз. Может быть продублировано. Достигается через manual commit ПОСЛЕ обработки.

Подходит для: большинства бизнес-сценариев. Цена — потребители обязаны быть идемпотентными.

Exactly-once (ровно один раз)

Сообщение обработано ровно один раз — без потерь и дублей. Достигается через transactional producer + read-committed consumer + idempotent consumer. Сложнее и дороже.

В большинстве маркетплейс-сценариев достаточно at-least-once + idempotent consumer.

8. Idempotent Producer

Стандартный producer может отправить сообщение дважды, если ack не дошёл. С enable.idempotence=true Kafka присваивает каждому сообщению уникальный sequence number и отбрасывает дубли:

spring:
  kafka:
    producer:
      properties:
        enable.idempotence: true
        acks: all
        retries: 2147483647

Это решает «дубли при retries», но не решает проблему повторной отправки на уровне приложения (если сервис упал и перезапустился — он отправит то же сообщение заново). Поэтому потребители всё равно должны быть идемпотентными.

9. Transactional Producer + Outbox

Чтобы запись в БД и отправка события были атомарны — используют Transactional Outbox + Kafka transactions.

Подробно паттерн разобран в статье «Распределённые паттерны». Кратко:

diagram

Ничего не теряется (запись атомарна), ничего не отправляется лишнего (Debezium читает только закоммиченные изменения).

10. Replication: что если брокер упадёт

Каждая партиция реплицируется на несколько брокеров. Один из них — leader, остальные — followers.

diagram
  • Producers и consumers работают только с leader.
  • Followers догоняют leader, формируя ISR (In-Sync Replicas).
  • Если leader умер — один из ISR становится новым leader.

acks — гарантия записи

Producer указывает, насколько строгое подтверждение нужно:

acksГарантияЦена
0fire-and-forgetможно потерять
1leader получилесли leader упал до репликации — потеря
allвсе ISR получилимедленнее, но надёжно

Для бизнес-событий — всегда acks=all.

min.insync.replicas

Сколько реплик должны подтвердить запись, чтобы acks=all считался выполненным. Обычно 2 для топиков с replication factor 3 — допускает падение одной реплики.

11. Retention: сколько хранить

Kafka — это лог, и у лога есть retention. Настраивается на топик:

  • retention.ms — по времени (например, 7 дней).
  • retention.bytes — по размеру (например, 1 ТБ на партицию).
  • Compaction — режим, когда хранится только последнее сообщение для каждого ключа. Подходит для read-моделей, где важно текущее состояние.
# Топик с compaction для актуальных остатков
inventory-current:
  cleanup.policy: compact
  min.cleanable.dirty.ratio: 0.1

# Топик событий с обычным retention
order-events:
  cleanup.policy: delete
  retention.ms: 604800000   # 7 дней

12. Применение в маркетплейсе

diagram

Ключевые решения:

  • Топик order-events партицирован по orderId — все события одного заказа в порядке.
  • Топик inventory-changes компактится по productId — потребитель Search всегда видит актуальный остаток.
  • payment-events с acks=all — нельзя терять платёжные события.

13. Частые вопросы на собеседовании

В: Что гарантируется по порядку в Kafka? О: Порядок гарантируется только внутри партиции. Между партициями — нет. Чтобы все события одного объекта были упорядочены, используем его ID как ключ сообщения.

В: Что произойдёт, если консьюмер упадёт во время обработки? О: Если он не закоммитил offset — после перезапуска перечитает сообщение. Поэтому потребитель должен быть идемпотентным.

В: Чем отличаются at-least-once и exactly-once? О: At-least-once допускает дубли, exactly-once — нет. Exactly-once реализуется через transactional producer + read-committed consumer + idempotent consumer. На практике обычно достаточно at-least-once + идемпотентность.

В: Как обеспечить atomic запись в БД и отправку события? О: Transactional Outbox + CDC (Debezium). Подробнее — в статье про распределённые паттерны.

В: Что такое ISR и зачем он нужен? О: In-Sync Replicas — реплики, которые догнали leader. acks=all ждёт подтверждения от всех ISR. min.insync.replicas — минимальное число ISR для успешной записи. Защищает от data loss при падении leader.

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

В: Чем consumer group отличается от просто consumer? О: Consumer group распределяет партиции между своими консьюмерами — каждая партиция читается ровно одним консьюмером в группе. Несколько групп читают один топик независимо, каждая со своим offset.

В: Что такое compacted topic? О: Топик с cleanup.policy=compact хранит только последнее сообщение для каждого ключа. Удобно для read-моделей текущего состояния (остатки, цены, профили пользователей).

В: Когда не стоит использовать Kafka? О: Когда нужны мгновенные синхронные вызовы (запрос-ответ), когда сообщений мало (Kafka — для high-throughput), когда нужна сложная маршрутизация по правилам (это RabbitMQ / другие брокеры).

Итого

Kafka — это распределённый упорядоченный лог. Главное усвоить три вещи:

  1. Партиции дают параллелизм, ключи — порядок. Один ключ = одна партиция.
  2. Consumer group распределяет партиции, offset управляет позицией чтения. Manual commit после обработки.
  3. At-least-once + идемпотентность — стандартный набор для бизнес-событий. Outbox + CDC решают атомарность с БД.

Ссылки