Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. От базовых понятий до тонких гарантий — в порядке усложнения.
1. Зачем нужна Kafka
В микросервисной архитектуре сервисам нужно обмениваться событиями: «заказ создан», «оплата прошла», «остаток на складе уменьшился». Прямые вызовы по HTTP — это синхронная связность: если PaymentService падает, OrderService тоже не работает.
Kafka — это распределённый лог сообщений. Производители (producers) пишут в него, потребители (consumers) читают — асинхронно, в своём темпе.
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).
Зачем партиции:
- Параллелизм: разные партиции читают разные потребители одновременно.
- Масштабирование: партиции распределяются по разным брокерам.
- Порядок: гарантируется только внутри партиции, не между партициями.
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 выбирает партицию по правилу:
- Если key указан →
partition = hash(key) % numPartitions. Все сообщения с одним ключом всегда попадают в одну партицию. - Если key = null → round-robin (или sticky partitioner в новых версиях).
В нашем маркетплейсе ключ — это orderId. Все события одного заказа гарантированно попадают в одну партицию:
Это важнейшее правило для порядка.
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 распределяет партиции топика между консьюмерами в группе. Каждая партиция назначена ровно одному консьюмеру в группе.
Свойства
- Параллелизм = min(числу партиций, числу консьюмеров). Если партиций 3, а консьюмеров 5 — двое будут простаивать.
- Каждое сообщение в группе обрабатывается ровно одним консьюмером. Это reliable load balancing.
- Несколько групп — независимые читалки одного топика. PaymentService и InventoryService обе читают
order-events, но каждая со своим offset.
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.
Подробно паттерн разобран в статье «Распределённые паттерны». Кратко:
Ничего не теряется (запись атомарна), ничего не отправляется лишнего (relay видит только закоммиченные строки outbox).
10. Replication: что если брокер упадёт
Каждая партиция реплицируется на несколько брокеров. Один из них — leader, остальные — followers.
- Producers и consumers работают только с leader.
- Followers догоняют leader, формируя ISR (In-Sync Replicas).
- Если leader умер — один из ISR становится новым leader.
acks — гарантия записи
Producer указывает, насколько строгое подтверждение нужно:
acks | Гарантия | Цена |
|---|---|---|
0 | fire-and-forget | можно потерять |
1 | leader получил | если 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 устроена внутри
Знание внутренностей помогает понять, почему работают (или не работают) определённые настройки и где искать проблему на проде.
Компоненты брокера
Главные компоненты:
- 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
Ключевые свойства:
- 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:
Используется для хранения актуального состояния (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 операционной системы:
Преимущества:
- Не дублируется в памяти: данные лежат один раз в 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) на сокет. Наивная реализация требует четырёх копирований:
С sendfile() syscall (Linux 2.2+, на JVM — FileChannel.transferTo()) копирования из kernel → user и user → kernel исчезают:
Результат:
- Данные никогда не попадают в 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 в разы).
Дополнительно — компрессия батча целиком (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. Применение в маркетплейсе
Ключевые решения:
- Топик
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 — это распределённый упорядоченный лог. Главное усвоить три вещи:
- Партиции дают параллелизм, ключи — порядок. Один ключ = одна партиция.
- Consumer group распределяет партиции, offset управляет позицией чтения. Manual commit после обработки.
- 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-моделью.