voidlearn
 

8.2Kafka

Представь Kafka как упорядоченный лог с независимыми offset, а не как очередь, где сообщение исчезает после чтения. Свяжи partition key с порядком, consumer group с распределением работы и commit offset с повторной доставкой.

Что такое лог и чем он не очередь

Большинство недоразумений вокруг Kafka растёт из одного места: человек читает «Kafka - брокер сообщений» и достраивает привычную картинку «положил, забрал, исчезло». В Kafka сообщение никуда не исчезает.

1. Лог - это файл, в который только дописывают

Слово лог здесь означает не «логи приложения», а структуру данных: последовательность записей, к которой можно только дописать в конец. Изменить или удалить запись в середине нельзя - такой операции просто нет. По-английски это называется append-only log, «журнал только на дозапись».

Так устроен бумажный кассовый журнал. Каждая операция пишется новой строкой, строки нумеруются подряд, вымарывать написанное запрещено. Ошибся - пишешь новую строку «отмена операции №17». Такой журнал можно перечитать с начала и восстановить всю историю: из него ничего не пропало.

2. Читатель - это закладка, а не потребитель

В очереди чтение сообщение расходует: забрал - его больше нет. В логе чтение не меняет ничего. Каждый читатель держит собственный номер строки, на которой он остановился; этот номер и называется offset («смещение»). Прочитать запись означает взять строку под своим номером и сдвинуть номер на единицу вперёд.

Отсюда сразу три свойства, которых у очереди нет. Читателей может быть сколько угодно, и друг другу они не мешают: у каждого своя закладка. Историю можно перечитать: отмотать закладку назад и обработать вчерашние события заново, например после исправления бага. И отставание выражается числом: конец лога минус твоя закладка и есть всё, что ты ещё не разгрёб.

3. Чем это отличается от очереди - практически

Очередь (RabbitMQ)Лог (Kafka)
Что делает чтениеЗабирает сообщение из очередиДвигает закладку; лог не меняется
Когда данные удаляютсяПосле подтверждения консьюмеромПо сроку или размеру - независимо от того, прочитал их кто-то или нет
Кто помнит прогрессБрокер, по каждому сообщению отдельноБрокер помнит одно число на группу читателей
Перечитать историюНельзя: сообщений уже нетМожно, пока не истёк срок хранения
Что значит «сообщение потеряно»Брокер его выбросил или отдал не томуКонсьюмер не успел прочитать до истечения срока хранения

В эксплуатации это значит, что Kafka не знает, «сколько сообщений в очереди». Она знает, где конец лога и где чья закладка. Разница между ними называется lag, и это единственная метрика, которая тут вообще имеет смысл.

Архитектура: из чего это собрано

Кластер состоит из нескольких брокеров (процессов JVM), у каждого свой набор дисков. Топик остаётся логическим именем, физического воплощения у него нет. Хранятся данные в партициях: партиция занимает каталог на диске конкретного брокера, внутри лежит append-only лог. Топик с 12 партициями даёт 12 независимых логов, размазанных по кластеру.

Партиция нарезана на сегменты, каждый из трёх файлов:

  • 00000000000000012345.log хранит сами записи, батчами, ровно в том виде, в каком их прислал продюсер;
  • .index - разреженный индекс «относительный offset → байтовое смещение в .log» (запись примерно раз в index.interval.bytes = 4 КБ);
  • .timeindex переводит «timestamp → offset» и нужен для retention.ms и для offsetsForTimes() при перемотке по времени.

Число в имени файла, base offset, совпадает с offset первой записи сегмента. Пишут всегда только в активный сегмент; он закрывается и создаётся новый по segment.bytes (1 ГБ по умолчанию) или segment.ms (7 суток). Удаление и компакция работают сегментами, а не записями, и активный сегмент не трогают никогда. При низком трафике «протухшее» сообщение может пролежать сильно дольше retention.ms.

Offset служит номером записи внутри партиции: номера монотонно растут, назначает их лидер. Он не глобальный: offset 100 в P0 и offset 100 в P1 не связаны ничем. Запись по offset брокер ищет так: бинарным поиском по именам сегментов, затем по .index, затем линейным досканированием внутри 4 КБ. Выходит, что чтение с произвольного offset стоит примерно столько же, сколько чтение с конца.

У каждой партиции есть лидер и фолловеры (replication.factor). Весь трафик записи и (по умолчанию) чтения идёт через лидера; фолловеры непрерывно тянут данные тем же fetch-протоколом, что и обычные консьюмеры. В ISR входят реплики, которые не отстали больше чем на replica.lag.time.max.ms (30 с). High watermark отмечает максимальный offset, реплицированный на все реплики из ISR; консьюмеры видят записи только до HW, иначе после смены лидера они могли бы прочитать то, чего в новом лидере нет.

Координацию (кто лидер, где партиции, кто в ISR) раньше вёл ZooKeeper. После KIP-500 метаданные живут в самой Kafka, в режиме KRaft: выделенные controller-узлы держат Raft-кворум и ведут собственный лог метаданных. Production-ready с 3.3, миграция с 3.6, в Kafka 4.0 (2025) ZooKeeper удалён полностью. На практике failover контроллера стал на порядок быстрее, а кластер держит миллионы партиций вместо пары сотен тысяч.

topic: orders · partitions = 3 · ключ сообщения = order_id Буква в ячейке - ключ записи. Одинаковый ключ всегда ложится в одну партицию, поэтому порядок событий одного заказа сохраняется. offset 0 1 2 3 4 5 6 7 P0 лидер b1 A D A A D D A A committed offset = 5 log end offset = 8 P1 лидер b2 B E B E B E B E committed = 3 P2 лидер b3 C F C C F C F C committed = 6 lag = 2 consumer group: billing C1 C2 владеет P0, P1 владеет P2 Внутри группы партиция принадлежит ровно одному консьюмеру. Три партиции на двух консьюмеров = 2 + 1: идеального баланса не бывает. Четвёртый консьюмер в этой же группе будет простаивать - параллелизм внутри группы ограничен числом партиций. Группа analytics читает тот же лог со своими offset и про billing ничего не знает: это pub/sub поверх одного лога.
Топик, партиции, offset, группа. После чтения записи не удаляются, двигается только указатель. Committed offset хранится в служебном топике __consumer_offsets, а лаг считается как разница между концом лога и этим указателем.

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

Вопрос любят задавать, потому что ответ проверяет понимание работы с диском и сетью, а не знание Kafka. Четыре причины, по убыванию значимости.

  1. Последовательная запись append-only. Нет обновлений на месте, нет B-дерева, нет случайных seek. Последовательная запись на обычный HDD даёт сотни МБ/с, что сопоставимо со случайным доступом к памяти. Kafka нарочно спроектирована так, чтобы обращаться к диску только последовательно.
  2. Page cache вместо своего кэша. Kafka не держит данные в JVM heap, а пишет и читает через страничный кэш ядра. GC от этого не страдает, кэш переживает перезапуск брокера, а «горячие» читатели (те, кто у хвоста лога) вообще не касаются диска. Отсюда практическое правило: брокеру дают маленький heap (6–8 ГБ), а всю остальную память отдают ОС.
  3. Zero-copy. Консьюмеру данные отдаются через sendfile(2): page cache → сокет, без копирования в user space и обратно. Это экономит два копирования и два переключения контекста. Оговорка: zero-copy отваливается, если включён TLS (данные надо шифровать в user space) или если брокеру приходится конвертировать формат записи под старого клиента. Отсюда и ответ на вопрос «почему включение TLS уронило пропускную способность вдвое».
  4. Батчинг и единый формат. Продюсер собирает record batch, сжимает его целиком (а не каждое сообщение), брокер кладёт батч на диск как есть и как есть отдаёт консьюмеру. Распаковки и перепаковки на брокере нет, и стоимость сетевого и дискового вызова размазывается на тысячи сообщений.
Что ещё можно упомянуть

Бинарный протокол без текстового парсинга. Индексов по содержимому нет: искать сообщение Kafka умеет только по offset и времени, за счёт этого она и быстрая. Масштабирование горизонтальное, через партиции, и на пути данных нет общего координатора. И offset потребителя хранится отдельно от сообщений, в __consumer_offsets, так что брокеру не нужно менять уже записанные данные, чтобы отметить «прочитано». В RabbitMQ, наоборот, состояние сообщения меняется, и потому его модель принципиально дороже.

Producer: путь одной записи

Send() в Kafka-клиенте не отправляет ничего. Он сериализует ключ и значение, спрашивает у partitioner номер партиции, кладёт запись в аккумулятор (буфер батчей, по батчу на партицию) и возвращает управление. Отдельный поток-отправитель забирает готовые батчи, группирует их по брокерам-лидерам и шлёт один запрос на брокер.

acks и min.insync.replicas

acksКогда считается успехомЧто теряемСтоимость
0Как только запись ушла в сокетВсё: брокер мог быть недоступен, ошибку не увидимМаксимальная скорость, fire-and-forget
1Лидер записал в свой лог (в page cache)Лидер упал до репликации - запись пропалаОдна сетевая задержка
all (-1)Все реплики из текущего ISR подтвердилиТолько при одновременной потере всего ISR+ время репликации на самую медленную реплику ISR
Главный подвох

acks=all сам по себе ничего не гарантирует. Если реплики отстали и ISR схлопнулся до одной (самого лидера), acks=all становится ровно acks=1. Страхует от этого min.insync.replicas=2 на топике: при меньшем размере ISR продюсер получает NotEnoughReplicasException и запись не проходит. Рабочая тройка для прода: replication.factor=3, min.insync.replicas=2, acks=all. С ней кластер переживает падение одного брокера без потерь и без остановки записи. Частая ошибка: поставить min.insync.replicas=3 при RF=3. Тогда любой рестарт брокера останавливает запись в топик.

Идемпотентный продюсер

Продюсер отправил батч, брокер записал, ответ потерялся в сети, продюсер повторил, и в логе две одинаковые записи. Идемпотентность (enable.idempotence=true, включена по умолчанию с Kafka 3.0) это лечит: продюсер при старте получает PID и ведёт sequence number отдельно на каждую партицию. Брокер помнит последний принятый номер по паре (PID, партиция): пришёл тот же номер - DuplicateSequenceNumber, батч молча отбрасывается с успешным ответом; пришёл номер с разрывом - OutOfOrderSequenceException.

Побочный эффект, за который её и любят: идемпотентность чинит порядок при ретраях. Без неё при max.in.flight.requests.per.connection > 1 повторно отправленный первый батч приедет после второго. С ней брокер по sequence number отклоняет батч с разрывом в номерах, продюсер отправляет заново по порядку, и до 5 in-flight безопасно. Границы гарантии: одна сессия продюсера, одна партиция. Перезапустили процесс - новый PID, дедупликация не работает. На другую партицию она тоже не распространяется.

Ключ, партиция и порядок

// Ключ есть   -> partition = murmur2(key) % numPartitions  (детерминированно)
// Ключа нет   -> Java-клиент: sticky partitioner, льёт в одну партицию, пока не закроется
//                батч, и переключается на следующую. kafka-go Murmur2Balancer берёт
//                для записи без ключа случайную партицию.
w := &kafka.Writer{
    Addr:     kafka.TCP("kafka-1:9092", "kafka-2:9092"),
    Topic:    "orders",
    Balancer: &kafka.Murmur2Balancer{}, // как в Java-клиенте. kafka.Hash{} считает FNV-1a
                                      // и совпадает с Sarama, но не с Java
    RequiredAcks: kafka.RequireAll,   // acks=all
    Async:    false,                  // Async=true = потеря при падении процесса
    BatchTimeout: 10 * time.Millisecond, // аналог linger.ms
    BatchSize:    100,
    Compression:  kafka.Lz4,
}
err := w.WriteMessages(ctx, kafka.Message{
    Key:   []byte(orderID),           // всё про один заказ -> одна партиция -> порядок
    Value: payload,
    Headers: []kafka.Header{
        {Key: "event_id",    Value: []byte(eventID)},   // для дедупликации у потребителя
        {Key: "traceparent", Value: []byte(traceID)},   // трейсинг сквозь брокер
    },
})
Три следствия ключа
  • Порядок. Все записи с одним ключом попадают в одну партицию, и порядок для этой сущности гарантирован.
  • Перекос (hot partition). Если 40 % трафика идёт от одного крупного клиента, его партиция станет узким местом, и добавление консьюмеров не поможет. Лечится составным ключом (tenant_id + bucket) ценой потери порядка внутри тенанта.
  • Изменение числа партиций ломает раскладку. murmur2(key) % N при другом N даёт другую партицию, и события одного заказа окажутся в двух логах. Уменьшить число партиций нельзя вообще. Увеличить можно, но это разовая осознанная операция, а не «докинем на пиковую нагрузку». Обычно партиции закладывают с запасом сразу.

Батчинг, linger.ms и компрессия

batch.size (16 КБ) ограничивает батч на партицию по байтам. linger.ms (с Kafka 4.0 по умолчанию 5 мс, раньше 0) говорит, сколько ждать доукомплектования батча перед отправкой. Даже ноль не означает «без батчинга»: батч всё равно собирается за время, пока предыдущий запрос в полёте. Дешевле всего поднять пропускную способность в разы, увеличив linger.ms до 5–20 мс: батчи крупнее, компрессия эффективнее, запросов меньше. Платим фиксированной добавкой к задержке.

Сжимается батч целиком, и чем он крупнее, тем выгоднее компрессия. На практике lz4 почти всегда правильный дефолт (быстрый, приличная степень); zstd (с Kafka 2.1) жмёт заметно лучше при чуть большем CPU и хорош для JSON и дорогого трафика между зонами; gzip самый медленный, брать его не стоит; snappy остался легаси-компромиссом. Сжатый батч так и лежит на диске и в том же виде уходит консьюмеру: брокер его не распаковывает (кроме случаев, когда включена валидация или конвертация формата).

Consumer groups и ребалансировка

Consumer group объединяет несколько процессов, которые вместе читают один топик и делят его партиции: каждая партиция достаётся ровно одному участнику группы. Группа распараллеливает обработку, не ломая порядок внутри партиции. Ребалансировкой называют перераздачу партиций между участниками: кто-то пришёл, кто-то ушёл или перестал отвечать, и надо заново решить, кто какие партиции читает. Похоже на официантов, которые делят столы в зале: вышел один покурить, его столы срочно раздают остальным, и на время передела обслуживание встаёт.

Консьюмеры объединяются в группу по group.id. Один брокер становится для этой группы group coordinator (выбирается по хешу group.id): ведёт список членов, раздаёт партиции и хранит offset. А вот считает раздачу лидер группы, один из консьюмеров: координатор присылает ему список всех членов и подписок, тот раскладывает партиции по выбранной стратегии и возвращает план. Логика назначения остаётся в клиенте, и её можно менять без обновления кластера.

Ребалансировка запускается, когда меняется состав группы или набор партиций:

  • новый консьюмер вызвал Subscribe и прислал JoinGroup;
  • консьюмер вышел штатно (Close шлёт LeaveGroup);
  • консьюмер перестал слать heartbeat дольше session.timeout.ms (45 с в новых версиях, heartbeat идёт из отдельного потока каждые heartbeat.interval.ms = 3 с);
  • консьюмер не вызвал poll() дольше max.poll.interval.ms (5 минут) - такой считается зависшим, даже если heartbeat идёт;
  • в топик добавили партиции или изменилась подписка по регулярке.

Стратегии назначения

СтратегияКак делитКогда брать
RangeAssignor (дефолт легаси)Партиции каждого топика по порядку режутся диапазонамиКогда нужна ко-локация одинаковых партиций разных топиков (join по ключу). Даёт перекос: при 3 партициях и 2 консьюмерах первый берёт 2
RoundRobinAssignorВсе партиции всех топиков по кругуРовный баланс, но при любом изменении раскладка едет целиком
StickyAssignorРовный баланс + минимум переездов относительно прошлого планаПочти всегда лучше RoundRobin
CooperativeStickyAssignorТо же плюс инкрементальная ребалансировка (KIP-429)Дефолт для новых сервисов: нет глобальной паузы
Eager (Range / RoundRobin / Sticky): группа встаёт целиком работает ребалансировка: revoke ВСЕГО работает по новому плану C1 P0 P1 P2 простой: не читает ничего P0 P1 C2 P3 P4 P5 простой: не читает ничего P2 P3 C3 ещё не в группе JoinGroup, ждёт SyncGroup P4 P5 Пауза длится от JoinGroup до SyncGroup всех участников. Один зависший консьюмер тянет её до rebalance.timeout.ms - то есть до минут. Cooperative sticky (KIP-429): отзываются только переезжающие партиции работает ребалансировка: revoke только лишнего работает по новому плану C1 P0 P1 P2 P0 P1 читает дальше, отдал P2 P0 P1 C2 P3 P4 P5 P3 читает, P4 P5 отданы P2 P3 C3 ещё не в группе ждёт свои партиции P4 P5 Останавливается только то, что реально переезжает. Цена - два раунда ребалансировки вместо одного, зато без общей паузы.
Чем опасна ребалансировка. При eager-стратегии простаивает вся группа, и лаг за это время растёт на весь входящий поток. Кооперативная стратегия убирает глобальную паузу, оставляя её только владельцам переезжающих партиций.
Как выглядит rebalance storm в проде

Обработка одного сообщения замедлилась (тормозит внешний API), консьюмер не успевает вызвать poll() за max.poll.interval.ms, координатор считает его мёртвым и запускает ребалансировку. Она отбирает партиции у всех, лаг растёт, после перераспределения каждый получает больше работы, снова не успевает, и цикл замыкается: группа ребалансируется бесконечно и не обрабатывает ничего. Лечение по порядку: уменьшить max.poll.records (обрабатывать меньше за раз), увеличить max.poll.interval.ms, вынести медленную работу в отдельный пул с pause()/resume() партиции, включить CooperativeStickyAssignor и static membership (group.instance.id, KIP-345). Тогда рестарт пода при деплое вообще не вызывает ребалансировку, пока укладывается в session.timeout.ms.

Правило про параллелизм

Максимум параллельных консьюмеров в одной группе = число партиций топика. При 12 партициях тринадцатый консьюмер будет просто простаивать с пустым назначением. Отсюда правило планирования: партиций закладывают с запасом на будущий рост (например, 24–48 на топик среднего размера), потому что увеличить их можно, но это ломает раскладку по ключу, а уменьшить нельзя вообще. Но и партиции не бесплатны: каждая стоит файловых дескрипторов, памяти на брокере и времени на восстановление при failover.

Share groups: очереди в Kafka

Всё сказанное выше относится к классической consumer group, и дальше в статье «группа» значит именно её. С Kafka 4.2 (февраль 2026) готова к продакшену вторая модель, share group (KIP-932, «очереди в Kafka»). Её участники читают одни и те же партиции вместе, поэтому консьюмеров может быть больше, чем партиций. Подтверждают каждую запись отдельно: принять, вернуть на повторную доставку (тот самый requeue) или отклонить. Попытки брокер считает сам и после пятой (по умолчанию) снимает запись с доставки. Платят порядком: внутри партиции записи могут прийти не по очереди, особенно при повторах. Из Go-клиентов share groups уже поддерживает franz-go.

Коммит offset

Committed offset равен offset следующей записи, которую надо прочитать, то есть «последняя обработанная + 1». Хранится он в служебном топике __consumer_offsets (50 партиций, cleanup.policy=compact) под ключом (group.id, topic, partition). Компакция нужна, чтобы топик не рос бесконечно: по каждому ключу важно только последнее значение.

Авто-коммит (enable.auto.commit=true, интервал 5 с) коммитит не по таймеру в фоне, а внутри вызова poll(): если с прошлого коммита прошло больше интервала, коммитится всё, что было выдано предыдущим poll. Пока цикл синхронный, эти записи к тому моменту уже обработаны, и выходит тот же at-least-once. Опасно, когда обработку отдают в другие горутины: тогда коммит подтверждает записи, обработку которых ты ещё не завершил, и падение в этом окне теряет сообщение навсегда. В librdkafka и в kafka-go с ReadMessage offset запоминается уже при выдаче, так что коммит может обогнать обработку и без всяких горутин.

Упал ДО коммита ручной коммит после обработки время poll() отдал запись с offset 42 обработка: списали 100 руб процесс убит: OOM, деплой, паника рестарт: последний коммит = 42 poll() снова отдаёт offset 42 списали 100 руб ВТОРОЙ раз дубликат - это и есть at-least-once Упал ПОСЛЕ коммита тот же ручной коммит время poll() отдал запись с offset 42 обработка: списали 100 руб commit(43) записан в __consumer_offsets процесс убит рестарт: последний коммит = 43 poll() отдаёт offset 43, идём дальше ни потерь, ни дублей А если enable.auto.commit=true с интервалом 5 секунд? Коммит внутри poll() подтверждает выданное прошлым poll - при синхронном цикле оно обработано. Если обработка ушла в фон, падение в этом окне - ПОТЕРЯ: offset уже сдвинут.
Где рождается дубликат и где потеря. Ручной коммит после обработки даёт at-least-once, и в худшем случае запись повторится. Авто-коммит опасен там, где подтверждение обгоняет обработку: при обработке в фоне, в librdkafka, в kafka-go с ReadMessage. Тогда он превращается в at-most-once с реальной потерей данных.
// segmentio/kafka-go: ручной коммит после успешной обработки.
r := kafka.NewReader(kafka.ReaderConfig{
    Brokers:  []string{"kafka-1:9092"},
    GroupID:  "billing",              // GroupID включает управление группой
    Topic:    "orders",
    MinBytes: 10e3,                   // fetch.min.bytes
    MaxBytes: 10e6,
    MaxWait:  500 * time.Millisecond, // fetch.max.wait.ms
    // CommitInterval = 0: CommitMessages коммитит синхронно
})
defer r.Close()

for {
    // FetchMessage, а не ReadMessage: ReadMessage коммитит сам
    m, err := r.FetchMessage(ctx)
    if err != nil {
        return err                    // ctx отменён или reader закрыт
    }
    if err := handle(ctx, m); err != nil {
        // continue здесь потерял бы сообщение: коммит следующего сдвинет
        // offset и за него. Откладываем в retry-топик, потом коммитим.
        if err := toRetry(ctx, m); err != nil {
            return err                // не отложили - выходим без коммита
        }
    }
    if err := r.CommitMessages(ctx, m); err != nil {
        slog.Error("commit", "err", err)
    }
}
commitSync vs commitAsync

commitSync блокирует и ретраит до успеха. Надёжно, но каждый батч получает лишний round-trip. commitAsync намеренно не ретраит: запоздалый ретрай мог бы записать меньший offset поверх большего и вызвать переобработку. На практике делают так: асинхронный коммит в цикле, а синхронный в defer при выходе и в колбэке onPartitionsRevoked перед ребалансировкой. Коммитить каждое сообщение дорого. Обычно коммитят раз в N записей или раз в секунду и сознательно расширяют окно возможных дублей.

Retention и log compaction

Retention определяет, сколько времени или байт Kafka держит записи, прежде чем выбросить. Log compaction убирает иначе: вместо «выбросить старое по времени» брокер выбрасывает устаревшие версии, оставляя по каждому ключу только последнюю запись. Аналогия из магазина: retention говорит «храним чеки за три месяца, дальше в мусор», а compaction - «по каждому товару держим только актуальный ценник, старые выбрасываем, сколько бы им ни было лет».

Общее для обеих политик: Kafka удаляет данные по cleanup.policy, а не по факту прочтения. Никто не прочитал - всё равно удалит по сроку; все прочитали - всё равно будет хранить до срока.

  • delete (дефолт): целые сегменты удаляются, когда все их записи старше retention.ms (7 суток) либо когда суммарный размер партиции превысил retention.bytes. Обе границы работают одновременно: что наступит раньше. Считать надо на партицию, а не на топик: retention.bytes=1GB при 24 партициях дают 24 ГБ на топик, и ещё умножить на replication factor.
  • compact - размер ограничен не временем, а числом уникальных ключей: для каждого ключа хранится последнее значение. Топик превращается из журнала изменений в материализованный снимок состояния, который можно перечитать с нуля и восстановить всю таблицу.
  • compact,delete включает обе: последнее значение по ключу, но не старше retention. Так делают, например, «состояние за последние 30 дней».
До compaction - полная история изменений, cleanup.policy=compact offset 0 1 2 3 4 5 6 7 8 9 key value k1 k2 k1 k3 k2 k1 k4 k3 k2 k1 v1 a1 v2 c1 a2 v3 d1 null a3 v4 Зелёные - последняя запись по своему ключу. Жёлтая - tombstone: запись с value=null, то есть «ключ удалён». log cleaner После compaction - по одному последнему значению на ключ, offsets не перенумеровываются эти offsets теперь просто отсутствуют k4 k3 k2 k1 d1 null a3 v4 6 7 8 9 Tombstone держится ещё delete.retention.ms (сутки) и только потом исчезает - чтобы отставшие консьюмеры успели узнать об удалении ключа.
Compaction. Лог перестаёт быть журналом и становится снимком: «текущее значение каждого ключа». Offsets при этом сохраняются, и в нумерации появляются дыры - консьюмер должен быть к этому готов и не рассчитывать, что offset+1 всегда существует.
Зачем нужна компакция
  • Восстановление состояния с нуля. Новый сервис подписался на compacted-топик user-profiles, прочитал его целиком и получил актуальные профили всех пользователей, не трогая чужую БД. На этом построены CQRS-проекции и KTable в Kafka Streams.
  • Служебные топики самой Kafka. __consumer_offsets и __transaction_state compacted, иначе они росли бы вечно.
  • Change data capture. Debezium публикует изменения таблицы с primary key в качестве ключа сообщения; compacted-топик оказывается зеркалом таблицы.
Что ломает компакцию

Сообщения без ключа. В compacted-топике запись с key=null считается ошибкой: сжимать её не по чему. Ожидание, что дубликатов не останется совсем. Компакция идёт в фоне и «лениво»: активный сегмент не трогается никогда, а остальные чистятся, только когда доля «грязных» байт превысит min.cleanable.dirty.ratio (0.5). Поэтому в compacted-топике вполне может лежать три версии одного ключа, и потребитель обязан корректно обрабатывать их по порядку. И tombstone надо уметь читать: сообщение с value=null означает «удалить», а не «пустое значение».

Consumer lag

Lag показывает, сколько записей в партиции уже написано, но этой группой ещё не обработано. Формально lag = log end offset − committed offset для каждой пары (группа, партиция): конец лога минус закладка. Это как стопка непрочитанных писем в ящике: важно не то, что там что-то лежит, а растёт она или тает.

По лагу судят о здоровье асинхронной системы: он показывает не «сколько сообщений в очереди», а насколько потребитель отстал от реальности.

Чем мерить

  • kafka-consumer-groups.sh --describe --group billing - быстро и руками, показывает лаг по каждой партиции.
  • kafka-exporter отдаёт в Prometheus метрику kafka_consumergroup_lag с разбивкой по партициям (у kafka-lag-exporter она называется kafka_consumergroup_group_lag). Алертить надо на производную (лаг растёт N минут подряд), а не на абсолютное значение: 50 000 при потоке 100k/с дают всего полсекунды отставания, и это нормально.
  • Lag в единицах времени лучше всего подходит для SLO: «отстаём на 8 секунд» понятно бизнесу, а «отстаём на 400 000 сообщений» нет. Считается по .timeindex или разностью timestamp последней обработанной записи и текущего времени.
  • Burrow (LinkedIn) смотрит не на сам лаг, а на его тренд, и выдаёт статус OK/WARN/ERR без ручных порогов.

Лаг растёт: что смотреть по порядку

СимптомПричинаЧто делать
Растёт на всех партициях равномерноКонсьюмеров банально не хватает под потокДобавить инстансы до числа партиций; дальше - увеличивать партиции
Растёт на одной-двух партицияхПерекос ключа: hot partition, крупный тенантИзменить ключ (составной, с бакетом), вынести тенанта в отдельный топик
Лаг пилой: падает и снова растётRebalance stormmax.poll.records вниз, cooperative sticky, static membership
Консьюмеров хватает, CPU простаиваетЖдём внешнюю систему: БД, HTTP, блокировкиБатчить запись в БД, поднять пул соединений, параллелить внутри по hash(key)
Лаг скачком уменьшился самRetention съел непрочитанноеПроверить records-lag вместе с OffsetOutOfRange: данные потеряны, это инцидент
Лаг растёт после деплояНовая версия обрабатывает медленнее, либо сбросился offsetСравнить process-latency до и после; проверить auto.offset.reset

Ретраи и DLQ

У consumer group в Kafka нет ни встроенного requeue, ни встроенного DLQ. Нельзя «вернуть сообщение в очередь»: указатель всего один, offset, и он либо двигается, либо нет. Вернуть отдельную запись умеет только share group из Kafka 4.2 (см. выше). Остаются два примитивных подхода, и оба плохи:

  • Не коммитить и читать заново. Партиция встаёт колом на одном ядовитом сообщении (head-of-line blocking): за ним копятся тысячи корректных, а мы в бесконечном цикле падаем на одном. Вдобавок так быстро превышается max.poll.interval.ms, и начинается ребалансировка.
  • Ретраить в цикле внутри обработчика. То же самое: пока спим между попытками, не вызываем poll(). Работает только при коротком общем бюджете (несколько секунд) и обязательно с pause() партиции и дальнейшими вызовами poll().

В проде делают цепочку retry-топиков с возрастающей задержкой и финальный DLQ.

# Топология
orders                 -> основной консьюмер
orders.retry.5s        -> консьюмер, который перед обработкой спит до (produced_at + 5s)
orders.retry.1m
orders.retry.10m
orders.DLQ             -> никто не читает автоматически; читают глазами и руками

# Заголовки, которые надо переносить вместе с сообщением:
x-original-topic       # откуда пришло изначально
x-retry-count          # сколько попыток уже было
x-first-failed-at      # когда сломалось впервые
x-last-error           # тип и текст последней ошибки
x-original-offset      # координаты оригинала, чтобы найти его в логе
// Консьюмер retry-топика спит до времени готовности сообщения.
// У всех сообщений в orders.retry.1m одинаковая задержка,
// поэтому топик упорядочен по времени готовности и достаточно
// подождать голову: приоритетный разбор не нужен.
func (c *RetryConsumer) run(ctx context.Context) error {
    for {
        m, err := c.reader.FetchMessage(ctx)
        if err != nil {
            return err
        }
        ready := m.Time.Add(c.delay)
        if d := time.Until(ready); d > 0 {
            // kafka-go держит членство в группе фоновым heartbeat, спать можно.
            // Java-клиенту на это время нужны pause() и дальнейшие вызовы poll().
            select {
            case <-time.After(d):
            case <-ctx.Done():
                return ctx.Err()
            }
        }
        var perr error
        switch err := c.handle(ctx, m); {
        case err == nil:
            // успех
        case errors.Is(err, ErrPermanent) || retryCount(m) >= c.maxRetries:
            perr = c.produce(ctx, c.dlqTopic, m) // в DLQ без дальнейших попыток
        default:
            perr = c.produce(ctx, c.nextTopic, m) // на следующую ступень задержки
        }
        if perr != nil {
            return perr // не переложили - не коммитим, прочитаем снова
        }
        if err := c.reader.CommitMessages(ctx, m); err != nil {
            return err
        }
    }
}
Что сказать про DLQ
  • Ошибки бывают двух видов, и путать их нельзя. Транзиентные (таймаут БД, 503 от соседа) ретраят. Постоянные (невалидный JSON, нет обязательного поля, бизнес-запрет) ретраить бессмысленно, их сразу отправляют в DLQ. Без этого разделения retry-цепочка просто откладывает неизбежное и тратит ресурсы.
  • Retry-топики ломают порядок. Сообщение, ушедшее на ретрай, вернётся позже своих соседей по ключу. Если порядок критичен, придётся останавливать обработку всего ключа до разбора или осознанно принять расхождение.
  • DLQ без алерта и без процесса разбора превращается в мусорку. Нужны метрика количества, алерт на «первое сообщение за N минут», ретеншен побольше основного топика и инструмент повторной подачи (reprocess из DLQ обратно в основной топик).

Транзакции и exactly-once

Транзакции Kafka закрывают ровно один сценарий: read-process-write внутри Kafka. Прочитали из топика A, посчитали, записали в топики B и C, и всё это либо видно потребителям целиком, либо не видно вовсе.

Механика по шагам:

  1. Продюсер задаёт transactional.id, стабильный идентификатор, переживающий рестарт (например, billing-worker-p3, привязанный к партиции, а не случайный UUID).
  2. initTransactions() находит transaction coordinator и получает PID с новым epoch. Все старые продюсеры с тем же transactional.id и меньшим epoch получают ProducerFenced. Так работает zombie fencing: подвисший старый инстанс не сможет дописать в транзакцию после того, как его заменили.
  3. Записи внутри транзакции пишутся в целевые партиции сразу, но помечены как транзакционные.
  4. sendOffsetsToTransaction(offsets, consumer.groupMetadata()) кладёт коммит входных offset в ту же транзакцию, и связка «прочитал и записал» становится атомарной.
  5. commitTransaction(): координатор пишет решение в __transaction_state и рассылает во все затронутые партиции control batch (маркер COMMIT или ABORT).

На стороне чтения нужен isolation.level=read_committed. Такой консьюмер видит записи только до LSO и отфильтровывает записи прерванных транзакций по маркерам. А по умолчанию стоит read_uncommitted, отсюда классический подвох «включили транзакции, а дубликаты и грязные данные остались»: консьюмера просто забыли переключить.

Граница exactly-once

Как только обработчик делает что-то за пределами Kafka (пишет в Postgres, дёргает платёжный шлюз, отправляет письмо), транзакция Kafka это действие не покрывает. Откатить письмо нельзя. Поэтому для «Kafka → БД» правильный ответ не «транзакции», а идемпотентная запись: апсерт по бизнес-ключу либо inbox-таблица, где вставка ключа сообщения и бизнес-данные идут в одной транзакции Postgres, а offset коммитится после. Цена EOS тоже не нулевая: дополнительный round-trip к координатору, control-батчи в логе и, что неприятнее всего, read_committed консьюмер не может читать дальше LSO: одна зависшая транзакция блокирует чтение партиции до transaction.timeout.ms.

Библиотеки Go

БиблиотекаПлюсыМинусыКогда брать
segmentio/kafka-goЧистый Go, простое API (Reader/Writer), лаконичный код, хорошая работа с contextНет транзакций (EOS) и cooperative-ребалансировки, часть тонких настроек недоступнаДефолт для типового сервиса: читать, обрабатывать, писать. 80 % задач
IBM/sarama (бывш. Shopify)Самая старая и распространённая, покрывает почти весь протокол, много готовых примеровМногословный API, легко выстрелить в ногу (ConsumerGroupHandler, ручное управление сессией), исторически шероховатая обработка ошибокЛегаси-кодовая база; когда нужна функция, которой нет в kafka-go
twmb/franz-goСамая полная поддержка протокола на чистом Go: транзакции, EOS, cooperative sticky, KIP-848, самые высокие бенчмаркиAPI мощнее и потому объёмнее; меньше готовых рецептов в интернетеКогда нужны транзакции, максимальная производительность или свежие KIP
confluent-kafka-goОбёртка над librdkafka: эталонное поведение, все фичи Confluent, Schema Registry из коробкиcgo: усложняет кросс-компиляцию и статическую сборку, тяжелее контейнер, сложнее профилироватьЕсли в компании стандарт на Confluent-стек и cgo не пугает
Как отвечать про библиотеки

Ответ «работал с segmentio/kafka-go, потому что чистый Go и простой Reader; когда понадобились транзакции, посмотрели на franz-go» звучит на порядок лучше, чем перечисление названий. И полезно назвать конкретную грабельку: в kafka-go ReadMessage коммитит offset сам, поэтому для at-least-once нужен FetchMessage + CommitMessages; а Writer с Async: true молча теряет сообщения при падении процесса и не возвращает ошибку доставки.

Вопросы

9
Суть: Kafka - распределённый append-only лог. Топик логичен, партиция физична: это каталог с сегментами на диске конкретного брокера. Offset - номер записи внутри партиции, и только внутри неё есть порядок.

Снизу вверх

  • Сегмент складывается из файла .log и двух разреженных индексов: .index (offset → байт) и .timeindex (время → offset). В имени файла стоит base offset. Пишут только в активный сегмент, он ротируется по segment.bytes (1 ГБ) или segment.ms (7 суток).
  • Партиция - упорядоченная последовательность сегментов, единица параллелизма, репликации и упорядочивания. Живёт на одном брокере-лидере плюс на фолловерах.
  • Топик просто объединяет партиции под одним именем. Физически его не существует.
  • Брокер держит диски и по одним партициям работает лидером, по другим фолловером. Метаданными ведает кворум контроллеров KRaft, обычно на выделенных узлах.

Offset

Монотонный номер, который назначает лидер партиции. Не глобальный: offset 100 в P0 никак не соотносится с offset 100 в P1. Ищется offset бинарным поиском по именам сегментов, затем по .index, затем досканированием внутри 4 КБ. Поэтому чтение с начала лога стоит примерно столько же, сколько чтение с конца, и «перечитать всё заново» остаётся обычной операцией, а не аварией.

Репликация

replication.factor реплик на партицию, одна из них лидер. Фолловеры тянут данные тем же fetch-протоколом. В ISR входят реплики, отставшие не больше чем на replica.lag.time.max.ms (30 с). High watermark равен максимальному offset, который есть у всех реплик ISR; консьюмер видит только до него, иначе после смены лидера прочитал бы данные, которых в новом лидере нет.

Чем добить

Упомянуть переход с ZooKeeper на KRaft (KIP-500): метаданные и выборы лидера теперь в Raft-логе контроллеров внутри самой Kafka. GA с 3.3, миграция с 3.6, в Kafka 4.0 ZooKeeper удалён. Практический эффект: failover контроллера занимает доли секунды вместо десятков, и кластер держит миллионы партиций. И назвать unclean.leader.election.enable=false как выбор CP: при потере всего ISR партиция становится недоступной, а не расходящейся.

Суть: acks определяет, что считать успешной записью; он работает только в паре с min.insync.replicas. Идемпотентность (PID + sequence number) убирает дубли от ретраев и чинит порядок. Ключ детерминированно выбирает партицию - отсюда и порядок, и перекос.

acks

0 - ушло в сокет, ошибок не увидим вообще. 1 - лидер записал к себе (в page cache), падение лидера до репликации = потеря. all - подтвердили все реплики текущего ISR. Последнее и есть ловушка: если ISR схлопнулся до одного лидера, acks=all эквивалентен acks=1. Спасает min.insync.replicas=2 - при меньшем ISR продюсер получит NotEnoughReplicasException. Прод-конфиг: RF=3, min.insync.replicas=2, acks=all. Ставить min.insync=3 при RF=3 нельзя, потому что любой рестарт брокера остановит запись.

Идемпотентный продюсер

enable.idempotence=true (дефолт с Kafka 3.0). Продюсер получает PID и нумерует записи sequence number по каждой партиции; брокер помнит последний принятый номер и отбрасывает повтор, отвечая успехом. Идемпотентность убирает дубли от собственных ретраев продюсера, в пределах одной сессии и одной партиции. Перезапустили процесс - новый PID, гарантии нет. Полезный побочный эффект: с идемпотентностью безопасно держать max.in.flight.requests.per.connection до 5, потому что брокер восстанавливает порядок по sequence number; без неё ретрай первого батча приедет после второго.

Ключ, партиция, порядок

Есть ключ - murmur2(key) % numPartitions, детерминированно. Нет ключа - sticky partitioner: льём в одну партицию, пока не закроется батч, потом в следующую (батчи крупнее, задержка ниже, раскладка всё равно ровная). Три следствия: порядок гарантирован для одного ключа; неудачный ключ даёт hot partition; изменение числа партиций меняет раскладку и разрывает историю сущности между двумя логами.

Частая ошибка на практике

Разные клиенты считают хеш по-разному, и одного «включить хеширование» мало - важно, какой именно хеш. В segmentio/kafka-go по умолчанию стоит round-robin, а не хеш. Но и явный &kafka.Hash{} не решает задачу: по документации он считает FNV-1a и совпадает с Sarama, а не с Java. Java-клиент и librdkafka используют murmur2, и в kafka-go ему соответствует &kafka.Murmur2Balancer{}. Возьмёшь не тот - ключ вроде бы задан, а сообщения одного заказа расползаются по партициям относительно соседнего сервиса, и «порядок гарантирован» превращается в тыкву. Проверяется это за минуту: прогнать сто сообщений с одним ключом и посмотреть распределение по партициям.

Суть: внутри группы партиция принадлежит ровно одному консьюмеру, поэтому параллелизм упирается в число партиций. Ребалансировка при eager-стратегиях останавливает всю группу, и в патологическом случае группа зацикливается на перераспределении вместо работы.

Кто и как распределяет

Координатор группы (брокер, выбранный по хешу group.id) ведёт членство и хранит offset. Само распределение считает лидер группы, то есть один из консьюмеров: координатор присылает ему список членов и подписок, тот возвращает план, координатор рассылает его в SyncGroup. Логика назначения живёт в клиенте, поэтому её можно менять, не обновляя кластер.

Триггеры ребалансировки

  • консьюмер зашёл (JoinGroup) или вышел (LeaveGroup при Close);
  • пропали heartbeat дольше session.timeout.ms (45 с);
  • не вызывался poll() дольше max.poll.interval.ms (5 мин). В проде это самый частый триггер: обработка затянулась, а heartbeat при этом шёл;
  • добавили партиции в топик.

Чем опасна

При eager-стратегии (Range, RoundRobin, Sticky) все консьюмеры отзывают все партиции и ждут нового плана. Для группы это stop-the-world: лаг растёт на весь входящий поток, незакоммиченная работа переделывается заново другим консьюмером (дубликаты), а прогретые локальные кэши обесцениваются. Хуже всего rebalance storm: медленная обработка → превышение max.poll.interval.ms → ребалансировка → у оставшихся ещё больше партиций → ещё медленнее → снова ребалансировка.

Лечение

  • CooperativeStickyAssignor (KIP-429): отзываются только переезжающие партиции, общей паузы нет. Дефолт для новых сервисов.
  • Static membership (group.instance.id, KIP-345): при рестарте пода консьюмер возвращается со своим прежним назначением, ребалансировки не происходит вовсе, если уложился в session.timeout.ms. Резко упрощает деплой.
  • Уменьшить max.poll.records, вынести медленную работу в отдельный пул с pause()/resume().
  • С Kafka 4.0 доступен новый серверный протокол ребалансировки KIP-848: план считает брокер, переходы инкрементальны.
Формулировка про параллелизм

«Партиция - единица параллелизма. Консьюмеров в группе может быть больше, чем партиций, но лишние будут простаивать с пустым назначением. Значит, потолок горизонтального масштабирования группы задаётся при создании топика.» Дальше уместно добавить: партиции можно только увеличивать, и это ломает раскладку по ключу, поэтому их закладывают с запасом; а внутри одного консьюмера можно параллелить обработку по hash(key) % workers, сохраняя порядок на ключ. Так пробивают потолок, не меняя топик. Если разговор зайдёт о Kafka 4.2+, назови share group: там консьюмеров может быть больше, чем партиций, но порядка внутри партиции уже нет.

Суть: ручной коммит после обработки = at-least-once: падение до коммита даёт дубликат, падение после - ничего страшного. Авто-коммит при синхронном цикле даёт тот же at-least-once, а при обработке в фоне подтверждает ещё не обработанное и даёт потерю.

Где хранится

Committed offset равен offset следующей записи для чтения, то есть «последняя обработанная + 1». Живёт в служебном топике __consumer_offsets (50 партиций, cleanup.policy=compact) под ключом (group.id, topic, partition). Компакция нужна, чтобы топик не рос: значимо только последнее значение.

Падение ДО коммита

Прочитали offset 42, обработали (списали деньги), упали до commit(43). После рестарта координатор отдаёт последний закоммиченный offset 42, консьюмер читает ту же запись и списывает деньги второй раз. Для at-least-once это нормальное поведение, и защищает от него только идемпотентный обработчик, а не настройки Kafka.

Падение ПОСЛЕ коммита

Обработали, закоммитили 43, упали. После рестарта читаем с 43 - ни потерь, ни дублей. При ручном управлении правилен только один порядок: «сначала обработать, потом коммитить».

Авто-коммит

enable.auto.commit=true, auto.commit.interval.ms=5000. Коммит происходит внутри poll(): если интервал истёк, подтверждается всё, что выдал предыдущий poll. При синхронном цикле эти записи уже обработаны, и получается at-least-once. Если же обработка идёт в фоне, подтвердятся и записи, которые ещё в работе: упал в этом окне - offset уже сдвинут, брокер ничего не переотправит, сообщение потеряно. Авто-коммит допустим только там, где потеря дешевле кода (метрики, логи).

Тонкости ручного коммита

  • commitSync блокирует и ретраит; commitAsync намеренно не ретраит, иначе повтор мог бы записать меньший offset поверх большего и вызвать переобработку.
  • Рабочий рецепт: commitAsync в цикле + commitSync в defer при выходе и в колбэке перед отзывом партиций.
  • Коммитить каждое сообщение дорого; обычно коммитят раз в N записей или раз в секунду, сознательно расширяя окно возможных дублей.
  • В segmentio/kafka-go метод ReadMessage коммитит сам; для at-least-once нужен FetchMessage + явный CommitMessages.
Follow-up, который любят

«А если консьюмер работает в несколько горутин и коммитит по завершении каждой?» Тогда можно закоммитить offset 50, пока обработка 47 ещё идёт, и падение потеряет 47–49. При параллельной обработке коммитить надо сам минимальный незавершённый offset: он и есть следующая запись для чтения; держать map незавершённых и двигать «водяной знак» только непрерывно. Такое проговорит только тот, кто реально писал консьюмеры.

Суть: Kafka удаляет данные по политике, а не по факту прочтения. delete режет старые сегменты по времени и размеру; compact оставляет по одному последнему значению на ключ, превращая лог в снимок состояния.

cleanup.policy=delete

Удаляются целые сегменты, все записи которых старше retention.ms (7 суток по умолчанию), либо когда размер партиции превысил retention.bytes. Обе границы действуют одновременно, срабатывает та, что наступит раньше. Считать надо на партицию: retention.bytes=1GB при 24 партициях и RF=3 дают 72 ГБ на кластер. Активный сегмент не трогается никогда, поэтому при слабом трафике данные живут дольше настройки.

cleanup.policy=compact

Фоновый log cleaner проходит по неактивным сегментам и оставляет для каждого ключа только последнюю запись. Offsets при этом не перенумеровываются: в логе появляются дыры, и консьюмер должен быть к этому готов. Чтобы удалить ключ, пишут tombstone, запись с value=null; она хранится ещё delete.retention.ms (сутки), чтобы отставшие потребители успели узнать об удалении, и только потом исчезает.

Зачем компакция

  • Новый сервис перечитывает compacted-топик с нуля и получает актуальный снимок всех сущностей. На этом стоят CQRS-проекции и KTable в Kafka Streams.
  • Сама Kafka использует её для __consumer_offsets и __transaction_state.
  • CDC: Debezium пишет изменения с primary key в ключе, и compacted-топик становится зеркалом таблицы.
Три ловушки
  • Компакция не гарантирует уникальность немедленно. Активный сегмент не чистится, остальные - только когда доля грязных байт превысит min.cleanable.dirty.ratio (0.5). Три версии одного ключа в топике для неё штатная ситуация.
  • Записи без ключа в compacted-топике недопустимы, их не по чему сжимать.
  • Если лаг «сам уменьшился», радоваться рано. Консьюмер отстал сильнее retention, непрочитанные сегменты удалили, он получил OffsetOutOfRange и по auto.offset.reset=latest прыгнул в конец, молча потеряв данные. Это инцидент, а не самоизлечение.
Суть: lag = log end offset − committed offset по каждой партиции. Алертить надо не на абсолютное значение, а на устойчивый рост и на лаг в секундах; лечение начинается с вопроса «лаг на всех партициях или на одной».

Чем мерить

  • kafka-consumer-groups.sh --describe --group billing - руками, по партициям.
  • kafka_consumergroup_lag из kafka-exporter в Prometheus - основной способ.
  • Клиентские JMX-метрики records-lag-max и fetch-latency показывают картину со стороны консьюмера, включая паузы на ребалансировку.
  • Burrow оценивает тренд лага и выдаёт статус без ручных порогов.
  • Лаг во времени («отстаём на 8 секунд») и есть правильная метрика для SLO; его считают по .timeindex или как разницу текущего времени и timestamp последней обработанной записи.

Диагностика по симптому

  • Растёт равномерно на всех партициях - не хватает мощности. Добавить консьюмеров до числа партиций, а дальше увеличивать партиции.
  • Растёт на одной-двух - перекос ключа, hot partition. Менять ключ (составной с бакетом) или выносить тяжёлого тенанта в свой топик.
  • Пилообразный график выдаёт rebalance storm. Смотреть логи координатора, уменьшать max.poll.records, включать cooperative sticky и static membership.
  • Если CPU консьюмера простаивает, упёрлись во внешнюю систему. Батчить запись в БД, увеличить пул соединений, параллелить внутри процесса по hash(key).
  • Лаг упал скачком без деплоя - retention съел непрочитанное. Инцидент.

Экстренные меры

Когда лаг измеряется часами и растёт, а быстро масштабироваться некуда: запустить временную группу-«ускоритель», которая только перекладывает сообщения в несколько параллельных топиков; временно отключить необязательные шаги обработки (обогащение, аналитику); если данные совсем неактуальны, осознанно перемотать offset на конец (kafka-consumer-groups.sh --reset-offsets --to-latest), явно зафиксировав, что часть событий не будет обработана. Последнее уже управленческое решение, а не техническое.

Суть: у consumer group в Kafka нет ни requeue, ни встроенного DLQ — есть только offset (вернуть запись на повторную доставку умеет только share group, с Kafka 4.2). Стандартное решение - цепочка retry-топиков с растущей задержкой плюс финальный DLQ, с переносом метаданных об ошибке в заголовках.

Почему «просто не коммитить» не работает

Партиция встаёт колом на одном ядовитом сообщении - head-of-line blocking: за ним копятся тысячи корректных. Плюс бесконечный цикл падений быстро превышает max.poll.interval.ms и запускает ребалансировку, которая делает только хуже. Ретраить в цикле прямо в обработчике тоже нельзя дольше пары секунд, ведь во время сна мы не вызываем poll().

Схема с retry-топиками

Топики orders.retry.5s, orders.retry.1m, orders.retry.10m, orders.DLQ. Обработчик при транзиентной ошибке публикует сообщение в следующий retry-топик и коммитит оригинал - партиция освобождается сразу. Консьюмер retry-топика перед обработкой спит до produced_at + delay (в Java-клиенте - поставив партицию на pause() и продолжая вызывать poll()); поскольку у всех сообщений одного retry-топика одинаковая задержка, топик остаётся упорядоченным по времени готовности, и достаточно ждать голову.

Заголовки

Обязательно переносить: x-original-topic, x-original-offset, x-retry-count, x-first-failed-at, x-last-error. Без них DLQ бесполезен - непонятно, что именно сломалось и куда возвращать.

Разделение ошибок

Транзиентные (таймаут БД, 503 соседа, дедлок) имеет смысл ретраить. Постоянные (невалидная схема, отсутствующее обязательное поле, бизнес-запрет) отправляют сразу в DLQ: ретраи их не вылечат и только съедят ресурсы. В коде это разные типы ошибок, а не строковое сравнение.

Что делает ответ сильным
  • Назвать, что retry-топики ломают порядок: сообщение вернётся позже соседей по тому же ключу. Если порядок критичен, остаётся либо блокировать весь ключ до разбора, либо принять расхождение и сделать обработчик устойчивым по версии.
  • Сказать, что DLQ без алерта и процесса разбора становится просто мусоркой. Нужны метрика, алерт на первое сообщение за N минут, увеличенный retention и инструмент повторной подачи в основной топик.
  • Упомянуть альтернативу: Kafka Connect и Spring Kafka имеют DLQ и DefaultErrorHandler из коробки, а в чистом Go это всегда пишут руками, и такой код стоит вынести в общую библиотеку на всю компанию.
Суть: транзакции дают атомарность read-process-write внутри Kafka: запись в несколько партиций и коммит входных offset - одним решением. За пределы Kafka гарантия не распространяется.

Механика

  1. Продюсер задаёт стабильный transactional.id, переживающий рестарт.
  2. initTransactions() находит transaction coordinator и получает PID с новым epoch. Старые продюсеры с тем же id и меньшим epoch получают ProducerFenced, и zombie fencing отсекает подвисший предыдущий инстанс.
  3. Записи пишутся в партиции сразу, но помечены транзакционными.
  4. sendOffsetsToTransaction(offsets, consumer.groupMetadata()) включает коммит входных offset в ту же транзакцию; на этом и держится атомарность связки «прочитал и записал».
  5. commitTransaction(): координатор фиксирует решение в __transaction_state и пишет control batch (маркер COMMIT/ABORT) во все затронутые партиции.

Сторона чтения

Нужен isolation.level=read_committed. Такой консьюмер видит записи только до LSO (last stable offset, offset первой незавершённой транзакции) и отбрасывает записи прерванных транзакций. Дефолт у него read_uncommitted, и из-за забытой настройки случается классическое «включили EOS, а грязные данные всё равно видны».

Границы и цена

Гарантия действует, пока весь эффект обработки сводится к записям в Kafka. Письмо, платёж или запись в чужую БД откатить нельзя, значит exactly-once там достигается только идемпотентностью или inbox-таблицей. Цена: дополнительный round-trip к координатору на каждую транзакцию, control-батчи в логе, повышенная задержка и, главное, read_committed консьюмер не читает дальше LSO, поэтому одна зависшая транзакция блокирует чтение партиции до transaction.timeout.ms.

Как это звучит на собесе

«Exactly-once в Kafka - это не про доставку, а про атомарность конвейера внутри Kafka. Идемпотентный продюсер убирает дубли от ретраев, транзакции добавляют атомарность записи в несколько партиций вместе с коммитом offset, а read_committed прячет незавершённое от читателей. Для интеграции с внешним миром это ничего не даёт, там остаётся at-least-once плюс идемпотентность.»

Суть: segmentio/kafka-go - простой дефолт на чистом Go; sarama - старая и распространённая, но многословная; franz-go - самая полная по протоколу (транзакции, cooperative sticky, KIP-848); confluent-kafka-go - обёртка над librdkafka с cgo.

segmentio/kafka-go

Идиоматичный Go: Reader и Writer, всё через context, минимум церемоний. Нет транзакций, часть тонких настроек недоступна. Две грабли, которые стоит назвать вслух: ReadMessage коммитит offset сам (для at-least-once нужен FetchMessage плюс CommitMessages), а Writer с Async: true теряет сообщения при падении процесса и не возвращает ошибку доставки. Кроме того, по умолчанию балансировщик работает round-robin, а не по хешу, поэтому для совместимости с Java-клиентами нужен явный &kafka.Murmur2Balancer{}: напрашивающийся &kafka.Hash{} считает FNV-1a и совпадает с Sarama, а не с Java.

IBM/sarama

Исторически основная библиотека, покрывает почти весь протокол. Мешает многословный API с ConsumerGroupHandler и ручным управлением сессией: там легко неправильно обработать Setup/Cleanup и получить утечку горутин при ребалансировке. Обычно встречается в легаси-коде.

twmb/franz-go

Самая современная реализация на чистом Go: транзакции и EOS, cooperative sticky, поддержка свежих KIP, лучшие бенчмарки, аккуратная работа с батчами. Берут, когда нужны транзакции или максимальная производительность.

confluent-kafka-go

Обёртка над C-библиотекой librdkafka: эталонное поведение и все фичи Confluent, включая Schema Registry. За это платят cgo: сложнее кросс-компиляция и статическая сборка, тяжелее образ, хуже профилирование и трассировка.

Как отвечать, если опыта немного

Честно назвать одну библиотеку, с которой реально работал, и конкретную проблему, на которую наступил. «Использовали kafka-go; сначала читали через ReadMessage и удивлялись, почему при падении пода часть сообщений теряется - оказалось, он коммитит сам; перешли на FetchMessage с явным коммитом» работает лучше любого сравнительного обзора, потому что показывает практику, а не чтение README.

Что стоит запомнить

  • Kafka хранит записи до окончания retention; чтение не удаляет их.
  • Порядок гарантируется внутри partition, а одинаковый key обычно направляет связанные события в одну partition.
  • Offset фиксирует прогресс consumer group; ручной commit после успешной обработки позволяет повторить незавершённую работу.

Частые ошибки

  • Не используй автоcommit до завершения бизнес-обработки, если потеря сообщения недопустима.
  • Увеличение числа partitions может менять распределение ключей и ограничивается числом параллельных consumers в группе.
  • Retry и DLQ меняют порядок исходного потока и требуют явной политики возврата.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Посчитай параллелизм группы

В топике orders 6 partitions, consumer group orders-workers состоит из 8 экземпляров reader. Сколько экземпляров одновременно получат partition, а сколько останутся без неё? Здесь каждый экземпляр создаёт reader из такой конфигурации:

cfg := kafka.ReaderConfig{
    Brokers: []string{"kafka:9092"},
    GroupID: "orders-workers",
    Topic: "orders",
}
r := kafka.NewReader(cfg)
// Этот код запущен в каждом из 8 экземпляров.
Ответ и разбор

В группе одновременно назначаются не более 6 readers, потому что partition в рамках одной consumer group принадлежит одному consumer в момент назначения. Два из восьми экземпляров останутся без partition до изменения числа partitions или состава группы. Реальный assignment может временно быть неполным во время rebalance; producer balancer к этому назначению отношения не имеет.

Код-ревью

2. Проверь auto-commit

Consumer вызывает ReadMessage, затем списывает платёж в БД. Если процесс падает после внутреннего commit offset, но до записи платежа, какое поведение вероятно? Исправь подход.

m, err := r.ReadMessage(ctx)
if err != nil { return err }
return chargeDB(ctx, m)
Ответ и разбор

При настроенной consumer group ReadMessage автоматически коммитит offset; если процесс упал после этого, а до записи платежа, группа уже может перейти дальше и платёж потерян. Используй FetchMessage, запиши платёж и inbox/idempotency key одной DB-транзакцией, затем вызови CommitMessages. Если commit offset потеряется, сообщение придёт повторно и будет остановлено inbox-уникальностью. DB commit и offset commit всё равно не атомарны.

Применение

3. Спроектируй ключ и порядок событий

События OrderCreated, ItemAdded и OrderPaid должны сохранять порядок для одного заказа, но разные заказы обрабатываются параллельно. Как выбрать partition key?

Ответ и разбор

Используй order_id как message key, чтобы события одного заказа направлялись в одну partition и сохраняли порядок в ней. Разные order_id распределятся по partitions и смогут обрабатываться параллельно. Число partitions и изменение partitioning нужно продумать заранее: повторное распределение ключа усложняет порядок старых и новых событий.

Применение

4. Спланируй retry и DLQ

Обработчик получает постоянную ошибку схемы сообщения. Почему бесконечный retry topic вреден и какой путь выберешь?

Ответ и разбор

Постоянная ошибка не исчезнет от повторов; сообщение будет занимать ресурс и отставит поток. После ограниченного числа попыток отправь его в DLQ с исходным ключом, причиной, числом попыток и контекстом, затем подтверди исходное сообщение. Исправив причину, оператор должен иметь безопасный replay.