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 контроллера стал на порядок быстрее, а кластер держит миллионы партиций вместо пары сотен тысяч.
__consumer_offsets,
а лаг считается как разница между концом лога и этим указателем.Почему Kafka быстрая
Вопрос любят задавать, потому что ответ проверяет понимание работы с диском и сетью, а не знание Kafka. Четыре причины, по убыванию значимости.
- Последовательная запись append-only. Нет обновлений на месте, нет B-дерева, нет случайных seek. Последовательная запись на обычный HDD даёт сотни МБ/с, что сопоставимо со случайным доступом к памяти. Kafka нарочно спроектирована так, чтобы обращаться к диску только последовательно.
- Page cache вместо своего кэша. Kafka не держит данные в JVM heap, а пишет и читает через страничный кэш ядра. GC от этого не страдает, кэш переживает перезапуск брокера, а «горячие» читатели (те, кто у хвоста лога) вообще не касаются диска. Отсюда практическое правило: брокеру дают маленький heap (6–8 ГБ), а всю остальную память отдают ОС.
- Zero-copy. Консьюмеру данные отдаются через
sendfile(2): page cache → сокет, без копирования в user space и обратно. Это экономит два копирования и два переключения контекста. Оговорка: zero-copy отваливается, если включён TLS (данные надо шифровать в user space) или если брокеру приходится конвертировать формат записи под старого клиента. Отсюда и ответ на вопрос «почему включение TLS уронило пропускную способность вдвое». - Батчинг и единый формат. Продюсер собирает 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) | Дефолт для новых сервисов: нет глобальной паузы |
Обработка одного сообщения замедлилась (тормозит внешний 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.
Всё сказанное выше относится к классической 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 запоминается уже при выдаче, так что коммит может обогнать
обработку и без всяких горутин.
// 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 блокирует и ретраит до успеха. Надёжно, но каждый батч получает лишний
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 дней».
- Восстановление состояния с нуля. Новый сервис подписался на compacted-топик
user-profiles, прочитал его целиком и получил актуальные профили всех пользователей, не трогая чужую БД. На этом построены CQRS-проекции и KTable в Kafka Streams. - Служебные топики самой Kafka.
__consumer_offsetsи__transaction_statecompacted, иначе они росли бы вечно. - 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 storm | max.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
}
}
}
- Ошибки бывают двух видов, и путать их нельзя. Транзиентные (таймаут БД, 503 от соседа) ретраят. Постоянные (невалидный JSON, нет обязательного поля, бизнес-запрет) ретраить бессмысленно, их сразу отправляют в DLQ. Без этого разделения retry-цепочка просто откладывает неизбежное и тратит ресурсы.
- Retry-топики ломают порядок. Сообщение, ушедшее на ретрай, вернётся позже своих соседей по ключу. Если порядок критичен, придётся останавливать обработку всего ключа до разбора или осознанно принять расхождение.
- DLQ без алерта и без процесса разбора превращается в мусорку. Нужны метрика количества, алерт на «первое сообщение за N минут», ретеншен побольше основного топика и инструмент повторной подачи (reprocess из DLQ обратно в основной топик).
Транзакции и exactly-once
Транзакции Kafka закрывают ровно один сценарий: read-process-write внутри Kafka. Прочитали из топика A, посчитали, записали в топики B и C, и всё это либо видно потребителям целиком, либо не видно вовсе.
Механика по шагам:
- Продюсер задаёт
transactional.id, стабильный идентификатор, переживающий рестарт (например,billing-worker-p3, привязанный к партиции, а не случайный UUID). initTransactions()находит transaction coordinator и получает PID с новым epoch. Все старые продюсеры с тем жеtransactional.idи меньшим epoch получаютProducerFenced. Так работает zombie fencing: подвисший старый инстанс не сможет дописать в транзакцию после того, как его заменили.- Записи внутри транзакции пишутся в целевые партиции сразу, но помечены как транзакционные.
sendOffsetsToTransaction(offsets, consumer.groupMetadata())кладёт коммит входных offset в ту же транзакцию, и связка «прочитал и записал» становится атомарной.commitTransaction(): координатор пишет решение в__transaction_stateи рассылает во все затронутые партиции control batch (маркер COMMIT или ABORT).
На стороне чтения нужен isolation.level=read_committed. Такой консьюмер видит
записи только до LSO
и отфильтровывает записи прерванных транзакций по маркерам. А по умолчанию стоит
read_uncommitted, отсюда классический подвох «включили транзакции, а дубликаты и
грязные данные остались»: консьюмера просто забыли переключить.
Как только обработчик делает что-то за пределами 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Снизу вверх
- Сегмент складывается из файла
.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{}. Возьмёшь не тот - ключ вроде бы задан,
а сообщения одного заказа расползаются по партициям относительно соседнего сервиса,
и «порядок гарантирован» превращается в тыкву. Проверяется это за минуту: прогнать
сто сообщений с одним ключом и посмотреть распределение по партициям.
Кто и как распределяет
Координатор группы (брокер, выбранный по хешу 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: там консьюмеров может быть больше, чем партиций, но
порядка внутри партиции уже нет.
Где хранится
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.
«А если консьюмер работает в несколько горутин и коммитит по завершении каждой?» Тогда можно закоммитить offset 50, пока обработка 47 ещё идёт, и падение потеряет 47–49. При параллельной обработке коммитить надо сам минимальный незавершённый offset: он и есть следующая запись для чтения; держать map незавершённых и двигать «водяной знак» только непрерывно. Такое проговорит только тот, кто реально писал консьюмеры.
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прыгнул в конец, молча потеряв данные. Это инцидент, а не самоизлечение.
Чем мерить
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), явно зафиксировав,
что часть событий не будет обработана. Последнее уже управленческое решение, а не
техническое.
Почему «просто не коммитить» не работает
Партиция встаёт колом на одном ядовитом сообщении - 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 это всегда пишут руками, и такой код стоит вынести в общую библиотеку на всю компанию.
Механика
- Продюсер задаёт стабильный
transactional.id, переживающий рестарт. initTransactions()находит transaction coordinator и получает PID с новым epoch. Старые продюсеры с тем же id и меньшим epoch получаютProducerFenced, и zombie fencing отсекает подвисший предыдущий инстанс.- Записи пишутся в партиции сразу, но помечены транзакционными.
sendOffsetsToTransaction(offsets, consumer.groupMetadata())включает коммит входных offset в ту же транзакцию; на этом и держится атомарность связки «прочитал и записал».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.