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

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

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

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. Как Kafka устроена внутри

Знание внутренностей помогает понять, почему работают (или не работают) определённые настройки и где искать проблему на проде.

Компоненты брокера

diagram

Главные компоненты:

  • Network threads — обрабатывают TCP-соединения, разбирают/сериализуют байты. По умолчанию 3 потока на брокер.
  • I/O threads (request handlers) — выполняют логику запросов: append в лог, чтение, метаданные. По умолчанию 8 потоков.
  • LogManager — управляет файлами сегментов: создаёт новые, удаляет старые, делает recovery после рестарта.
  • Page Cache (OS) — главный кэш Kafka. Брокер не имеет своего кэша, доверяет ядру операционной системы. Это ключевой архитектурный выбор.
  • ReplicaFetcher — фоновый thread на follower-брокерах, пуллит данные от leader.
  • Controller — выбирает leader для партиций, хранит метаданные кластера. С 3.3 — встроенный KRaft (Raft), до этого — ZooKeeper.

Лог на диске: сегменты, индексы

Партиция физически — это директория с файлами. Внутри — сегменты, на каждый по три файла:

/kafka-logs/order-events-0/
├── 00000000000000000000.log         ← payload: реальные сообщения
├── 00000000000000000000.index       ← разреженный offset-index
├── 00000000000000000000.timeindex   ← разреженный time-index
├── 00000000000000123456.log
├── 00000000000000123456.index
├── 00000000000000123456.timeindex
├── 00000000000000246810.log         ← активный сегмент (туда пишет broker сейчас)
├── 00000000000000246810.index
└── 00000000000000246810.timeindex
diagram

Ключевые свойства:

  • Append-only — в активный сегмент только дописываем в конец. Никаких updates, никаких deletes отдельных записей.
  • Закрытие сегмента при достижении размера (log.segment.bytes, default 1 GB) или времени (log.roll.hours).
  • Удаление при retention — удаляется целый сегмент, никакого vacuum/compaction по строкам. Дёшево.
  • Индексы разреженные — на каждые ~4 KB лога одна запись в .index (offset → file position). Lookup для конкретного offset = два бинарных поиска (index → log) + локальный скан.

Этот формат хранения — главный источник производительности Kafka. Подробно в следующей секции.

Compaction — другая стратегия retention

В compacted-топиках Kafka хранит только последнее сообщение для каждого ключа. Реализация — фоновый поток log cleaner:

diagram

Используется для хранения актуального состояния (read-models, change streams). Старые версии перезаписываются, occupies меньше места, при этом всё ещё append-only по природе.

13. Почему Kafka быстрая

Современный кластер Kafka на обычном железе тянет миллионы сообщений в секунду на узел. Не «магия» — пять конкретных архитектурных решений работают вместе.

1. Sequential disk I/O вместо random

Disk seek (random access) на SSD — ~0.1 ms, на HDD — ~10 ms. Sequential write на тех же дисках — сотни мегабайт в секунду. Разница в 100-1000 раз.

Append-only лог даёт только sequential writes. Никаких random updates по offset'у. Это значит, что даже HDD может обслуживать сотни тысяч сообщений в секунду.

2. Page Cache вместо собственного кэша

Брокер Kafka не имеет своего кэша в JVM heap. Все записи и чтения идут через page cache операционной системы:

diagram

Преимущества:

  • Не дублируется в памяти: данные лежат один раз в page cache. В JVM-кэше дублирования нет.
  • Hot path = памя: типичный consumer читает данные, которые только что записал producer — они ещё в page cache. Запросы к диску фактически не происходят, читается из RAM.
  • Не нужен GC: вся «кэшированная» память — за пределами JVM heap. Никаких GC-пауз из-за большого кэша.
  • Пишем в page cache синхронно, fsync на диск асинхронно: producer получает ack быстро, ядро ОС само решает, когда сбросить на диск.

Это объясняет, почему Kafka рекомендует 64 GB RAM на узел — почти всё под page cache.

3. Zero-copy через sendfile()

Когда Kafka отдаёт сообщения консьюмеру, данные идут с диска (или page cache) на сокет. Наивная реализация требует четырёх копирований:

diagram

С sendfile() syscall (Linux 2.2+, на JVM — FileChannel.transferTo()) копирования из kernel → user и user → kernel исчезают:

diagram

Результат:

  • Данные никогда не попадают в JVM — никакой нагрузки на heap, на GC.
  • CPU экономится — DMA-контроллер копирует без участия CPU.
  • Latency меньше — два syscalls вместо четырёх.

Это работает только для plain bytes — никакой трансформации payload'а Kafka на лету не делает. Если включена end-to-end encryption или сжатие на чтении — zero-copy теряется (TLS — частичное, есть kTLS в новых ядрах для сохранения zero-copy через шифрование).

4. Batching и compression на producer'е

Producer не отправляет каждое сообщение по сети мгновенно. Он накапливает их в батч для каждой партиции, и отправляет одним TCP-запросом, когда:

  • набралось batch.size байт (default 16 KB), или
  • прошло linger.ms (default 0 — без задержки, но с настройкой 5-20 ms повышается throughput в разы).
diagram

Дополнительно — компрессия батча целиком (lz4 / zstd / snappy). Один батч = один блок сжатия, что даёт высокий compression ratio (3-5× на JSON-сообщениях).

Главное: сжатый батч хранится на диске и шлётся консьюмерам в том же виде — никакой re-compression на брокере. Broker — это просто «фильтр», не процессор сообщений.

5. Партиционирование как горизонтальное масштабирование

Одна партиция = одна последовательная очередь. Несколько партиций = независимая параллельная обработка:

  • Producer пишет в N партиций → N независимых append-операций → линейно масштабируется write throughput.
  • Consumer Group читает N партиций N консьюмерами → N параллельных потоков чтения → линейно масштабируется read throughput.
  • Партиции распределены по разным брокерам → нагрузка размазана по железу кластера.

В сумме: добавил брокер + увеличил число партиций → throughput кластера растёт линейно. Это горизонтальное масштабирование без перекомпиляции системы.

Итог: что Kafka жертвует

Эти решения дают throughput, но жертвуют некоторые сценарии:

  • Random access по _id невозможен — нужен только последовательный consume или lookup по offset (после полного скана партиции).
  • Updates единичной записи нельзя — только append новой версии (с тем же ключом для compacted-topic).
  • Маленькие сообщения дороги — overhead заголовков (~30-50 байт) близок к payload'у при размере <100 байт. Лучше батчи.
  • Cross-partition transactions ограничены — distributed transaction'ы между партициями (enable.idempotence + Kafka transactions) дают exactly-once, но дороги.

Это сознательный trade-off: Kafka заточена под high-throughput streaming, не под key-value store или БД.

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

diagram

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

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

15. Частые вопросы

В: Что гарантируется по порядку в 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 + polling-relay в коде сервиса (@Scheduled). Подробнее — в статье про распределённые паттерны.

В: Что такое 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 + polling-relay в коде решают атомарность с БД.

Ссылки

  • Kafka в production — Spring Kafka, DLQ, Schema Registry, consumer lag, тюнинг, security, KRaft.
  • Распределённые паттерны — Outbox + polling-relay, Saga, Idempotent Consumer.
  • Кейс: маркетплейс — Kafka в боевой архитектуре.
  • CQRS — Kafka как канал между write- и read-моделью.