3. Producer и consumer
Анатомия взаимодействия с распределенным логом
«Чтобы программа работала быстро, заставьте ее делать меньше работы. Чтобы она делала меньше работы, объединяйте операции в пакеты».
— Стивен Борн, создатель Bourne shell
Две стороны одного лога
Любая распределенная система обмена сообщениями рано или поздно упирается в физику: пропускную способность шины PCI Express, задержки сетевых интерфейсов (NIC) и накладные расходы операционной системы на переключение контекста. Apache Kafka проектировалась с глубоким уважением к этим физическим ограничениям — с тем, что Мартин Томпсон назвал Mechanical Sympathy (механическим сочувствием к аппаратуре).
Kafka-клиент состоит из двух симметричных, но архитектурно противоположных сущностей: Producer (отправитель) и Consumer (получатель). Их объединяет фундаментальная цель — максимально эффективно перемещать байты между памятью пользовательских приложений и страничным кэшем ядра Linux, но достигается она противоположными механизмами:
- Продюсер работает как логистический распределительный центр: он агрегирует отдельные прикладные записи в плотно упакованные батчи, маскируя сетевые задержки и амортизируя системные вызовы операционной системы.
- Консьюмер, напротив, использует pull-модель: он самостоятельно вытягивает готовые батчи большими блоками, получая естественный контроль над темпом обработки (backpressure) и позволяя брокеру сбрасывать данные с диска прямо в сетевой сокет через механизм
Zero-Copyбез единого копирования в userspace.
Producer: от вызова Send до битов на проводе
Продюсер — это не просто тонкая обёртка над сетевым сокетом. Внутри каждого промышленного продюсера скрыт сложный асинхронный конвейер, состоящий из сериализаторов, интерцепторов, партиционера, буфера накопления записей (RecordAccumulator), фонового потока ввода-вывода (NetworkClient) и компрессора.
Каждый вызов метода отправки — это не блокирующий сетевой запрос к серверу, а быстрая операция добавления байтов в локальный буфер в памяти.
Внутренний конвейер
Рассмотрим путь сообщения от прикладного кода до сетевого сокета:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph ClientApp["Прикладной код"]
A["Вызов Send<br>с ключом и значением"]:::process
end
subgraph Pipeline["Конвейер Producer"]
B["Интерцепторы<br>на стороне продюсера"]:::process
C["Serializer<br>Key/Value"]:::accent
D["Partitioner<br>выбор партиции"]:::warning
E["RecordAccumulator<br>буферизация по партициям"]:::process
F["Батчер<br>накопление записей"]:::process
G["Compression<br>сжатие батча"]:::process
end
subgraph NetLayer["Сетевой слой (I/O Thread)"]
H["NetworkClient<br>асинхронная отправка"]:::process
end
subgraph Cluster["Кластер Kafka"]
I["Брокер-лидер<br>партиции"]:::process
end
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
G --> H
H --> I
Разберём каждый этап этого конвейера:
- Интерцепторы (ProducerInterceptor) — выполняют сквозную модификацию метаданных, внедрение трейсинг-заголовков (OpenTelemetry W3C Trace Context) или аудит до попадания в сериализатор.
- Сериализация (Serializer) — преобразует строго типизированные прикладные структуры данных (Go-структуры, Java POJO) в байтовые массивы. Для контроля за эволюцией схемы и обратной совместимостью используются Protobuf, Avro или JSON Schema совместно со Schema Registry (11. Schema Registry и эволюция схем).
- Partitioner — определяет, в какую конкретно партицию топика отправится запись:
- Если ключ задан: стандартный алгоритм хэширования
murmur2(key) % num_partitionsгарантирует, что все события по одной сущности (например,order_idилиuser_id) попадут в одну партицию и будут обработаны строго по порядку (5. Ordering и partitioning). - Если ключ пуст (
nil): современный Kafka-клиент используетStickyKeyPartitioner(или встроенный UniformStickyPartitioner). Вместо наивного Round-Robin для каждого сообщения продюсер «прилипает» к одной партиции до тех пор, пока текущий батч не заполнится (batch.size) или не истечет таймер (linger.ms). Это снижает фрагментацию сети и формирует полновесные пакеты.
- Если ключ задан: стандартный алгоритм хэширования
- RecordAccumulator — сердце производительности продюсера. Это специализированный буфер в оперативной памяти, разделенный на очереди батчей по каждой партиции (
TopicPartition). Прикладные горутины складывают сообщения вRecordAccumulatorпочти мгновенно, не дожидаясь ответа брокера. - Батчер — накапливает записи в текущем открытом буфере памяти. Когда размер буфера достигает границы
batch.sizeили истекает таймер задержкиlinger.ms, батч «запечатывается» и помечается готовым к отправке. - Compression — сжатие целого батча с использованием алгоритмов gzip, snappy, lz4 или zstd. Сжатие на стороне продюсера драматически снижает сетевой трафик и дисковую нагрузку: брокер сохраняет сжатый батч на диск как есть, без распаковки, и консьюмер считывает его в сжатом виде, распаковывая уже на своей стороне!
- NetworkClient — отдельный сетевой фоновый поток/горутина, использующий мультиплексирование неблокирующего ввода-вывода (epoll в Linux, kqueue в macOS/BSD, Java NIO). Он собирает готовые батчи для одного и того же брокера (даже если они принадлежат разным топикам и партициям) в один составной TCP-запрос
ProduceRequest.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Accumulator["RecordAccumulator в оперативной памяти"]
direction TB
subgraph P0["Partition 0 Queue"]
B1["Batch 1 (16 KB) [FULL]"]
B2["Batch 2 (8 KB) [OPEN]"]
end
subgraph P1["Partition 1 Queue"]
B3["Batch 1 (16 KB) [FULL]"]
end
end
subgraph Sender["I/O Sender Loop"]
Mux["Объединение батчей<br>для одного брокера"]:::process
end
subgraph Broker1["Брокер 1 (Лидер P0 и P1)"]
Socket["TCP Socket (Single ProduceRequest)"]
end
B1 --> Mux
B3 --> Mux
Mux --> Socket
Mechanical Sympathy: зачем нужен linger.ms и batch.size
Чтобы понять, почему архитектура продюсера устроена именно так, обратимся к физике процессора и операционной системы.
Представьте курьера, которому поручено доставить письма из офиса в архив на другом конце города. Если курьер будет садиться на грузовик и ехать через весь город с одним конвертом в руке каждый раз, когда секретарь распечатывает документ — компания обанкротится на бензине и амортизации машины. Разумный секретарь складывает письма в лоток: если лоток заполнился до 100 писем — вызываем курьера; если лоток не полон, но прошло 10 минут — всё равно отправляем, чтобы адресат не ждал вечность.
В компьютерных системах сетевой вызов write(2) или send(2) на каждый одиночный байт — точно такая же катастрофа. Каждое сетевое сообщение несёт с собой фиксированные накладные расходы:
- Накладные расходы TCP/IP: Заголовки Ethernet (14B), IP (20B) и TCP (20–32B) плюс служебные протокольные заголовки Kafka. Передача 50 байт полезной нагрузки в отдельном пакете означает потерю более 60% пропускной способности канала на служебные заголовки.
- Переключение контекста ядра (Context Switch): Переход процессора из пространства пользователя (Ring 3) в режим ядра (Ring 0) для выполнения системного вызова ввода-вывода сбрасывает часть регистров и конвейера инструкций CPU, отнимая от сотен до тысяч процессорных тактов.
- Прерывания сетевой карты (NIC Interrupts): Обработка миллионов крошечных пакетов нагружает ядра CPU обработкой программных прерываний (softirq).
Батчинг решает эту проблему в корне, находя идеальный баланс между пропускной способностью (throughput) и временем отклика (latency):
batch.size(по умолчанию 16 КБ) — максимальный размер одного батча в памяти для конкретной партиции. Увеличение этого параметра (например, до 64 КБ, 256 КБ или 1 МБ в высоконагруженных системах) повышает пропускную способность за счёт уменьшения числа сетевых пакетов ценой небольшого потребления памяти.linger.ms(по умолчанию 0) — искусственная задержка перед отправкой не полностью заполненного батча. Если продюсер работает сlinger.ms = 0, он отдаст батч сетевому потоку немедленно, как только сокет освободится для записи. Если же установитьlinger.ms=5(или 5–20 мс), продюсер подождёт указанное количество миллисекунд, давая приложению шанс дописать в этот же батч новые сообщения. В высоконагруженных системах с потоковой записьюlinger.ms=5практически не добавляет задержки, но радикально улучшает эффективность использования сети.
| Сценарий | batch.size |
linger.ms |
Поведение системы |
|---|---|---|---|
| Минимальная задержка (Low Latency / Trading) | 16 KB | 0–1 ms | Сообщения отправляются мгновенно; высокий RPS системных вызовов; сеть недоутилизирована |
| Высокая пропускная способность (High Throughput / Logs, Metrics) | 256 KB – 1 MB | 10–50 ms | Сообщения плотно пакуются; отличный коэффициент сжатия lz4/zstd; минимальный CPU overhead |
| Сбалансированный бэкенд (Баланс по умолчанию) | 64 KB | 5–10 ms | Оптимальное соотношение: задержка вырастает всего на единицы миллисекунд, а пропускная способность вырастает в 3–5 раз |
Идемпотентный продюсер (Idempotent Producer)
В распределенных сетях сбои — это норма, а не аномалия. Представим классический сценарий:
- Продюсер отправляет батч сообщений брокеру.
- Брокер успешно записывает батч на диск и реплицирует его в ISR.
- В момент отправки сетевого подтверждения (ACK) сетевой кабель временно теряет связь или происходит сброс TCP-соединения.
- Продюсер получает таймаут, не знает, сохранено ли сообщение, и добросовестно повторяет отправку (retry).
- Результат: дубликат данных в логе!
Чтобы избежать дублирования без перекладывания сложной логики дедупликации на плечи разработчиков, начиная с Kafka 0.11 был представлен идемпотентный продюсер (enable.idempotence=true), который с версии Kafka 3.0 включен по умолчанию.
sequenceDiagram
autonumber
actor App as Продюсер (PID 42)
participant Broker as Брокер-лидер (Партиция 0)
App->>Broker: InitProducerIdRequest
Broker-->>App: Assigned PID = 42, Epoch = 0
rect rgb(19, 29, 51)
Note over App,Broker: Первая отправка (успех)
App->>Broker: Produce(PID=42, SeqNum=0)
Broker->>Broker: Сохранение в лог (offset=100)
Broker-->>App: ACK (offset=100)
end
rect rgb(33, 20, 30)
Note over App,Broker: Потеря сетевого ACK
App->>Broker: Produce(PID=42, SeqNum=1)
Broker->>Broker: Сохранение в лог (offset=101)
Broker--xApp: ACK потерян в сети (Network Drop)
end
rect rgb(20, 35, 30)
Note over App,Broker: Автоматический Retry продюсера
App->>Broker: Produce(PID=42, SeqNum=1) [RETRY]
Broker->>Broker: Проверка: SeqNum 1 уже записан! Дубликат отброшен.
Broker-->>App: ACK (offset=101) [Дубликат не записан]
end
Принцип работы идемпотентного продюсера:
- При старте продюсер отправляет брокеру запрос
InitProducerIdRequestи получает глобально уникальный 64-битный идентификатор — Producer ID (PID) и номер эпохи (Epoch). - Для каждой целевой партиции продюсер поддерживает монотонно возрастающий порядковый номер последовательности — Sequence Number (начиная с 0).
- Принимая батч, брокер проверяет:
IncomingSeqNum == LastStoredSeqNum + 1.- Если совпадает — данные пишутся в лог, а
LastStoredSeqNumобновляется. - Если
IncomingSeqNum <= LastStoredSeqNum— брокер понимает, что это дубликат из-за сетевого ретрая. Он не пишет запись в лог повторно, но возвращает продюсеру успешныйACK. - Если
IncomingSeqNum > LastStoredSeqNum + 1— значит, произошла потеря сообщений в пути; брокер возвращает ошибкуOutOfOrderSequenceException.
- Если совпадает — данные пишутся в лог, а
Это гарантирует exactly-once семантику на уровне одной сессии продюсера в рамках одной партиции без снижения производительности.
Production-ready продюсер на Go (franz-go)
В экосистеме Go исторически использовался клиент Shopify/sarama, однако в современном высоконагруженном бэкенде отраслевым стандартом de-facto стал чистый Go-клиент franz-go (github.com/twmb/franz-go). Он превосходит альтернативы за счет:
- 100% реализации на Go (без CGO-зависимостей от
librdkafka); - Экстремальной оптимизации аллокаций памяти (
sync.Pool); - Полной поддержки всех современных KIP (включая KRaft, KIP-500, гибкие протоколы версионирования).
Ниже приведен эталонный пример отказоустойчивого асинхронного продюсера на Go:
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/twmb/franz-go/pkg/kgo"
)
func main() {
// Создание клиента с production-настройками
cl, err := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
// Включаем подтверждение от всех реплик в ISR (In-Sync Replicas)
kgo.RequiredAcks(kgo.AllISRAcks()),
// Время на автоматические ретраи при сбоях сети или смене лидеров
kgo.RetryTimeout(30*time.Second),
// StickyKeyPartitioner оптимизирует батчинг при отсутствии ключа
kgo.RecordPartitioner(kgo.StickyKeyPartitioner(nil)),
// Накопление батча до 1 МБ
kgo.BatchMaxBytes(1<<20),
// Устанавливаем задержку linger для эффективного накопления записей
kgo.Linger(5 * time.Millisecond),
// Сжатие батчей алгоритмом zstd (баланс скорости и сжатия)
kgo.ProducerBatchCompression(kgo.ZstdCompression()),
)
if err != nil {
log.Fatalf("failed to create kafka client: %v", err)
}
defer cl.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Асинхронная запись: прикладная горутина не блокируется в ожидании сети
record := &kgo.Record{
Topic: "orders",
Key: []byte("cust-12345"),
Value: []byte(`{"order_id":"xyz","amount":250}`),
}
cl.Produce(ctx, record, func(r *kgo.Record, err error) {
if err != nil {
// Обработка критической ошибки после исчерпания всех ретраев
log.Printf("failed to deliver record to topic %s: %v", r.Topic, err)
return
}
fmt.Printf("Delivered to partition %d at offset %d (timestamp: %v)\n",
r.Partition, r.Offset, r.Timestamp)
})
// Принудительный сброс всех накопленных буферов перед завершением сервиса
if err := cl.Flush(ctx); err != nil {
log.Fatalf("flush failed: %v", err)
}
fmt.Println("All accumulated batches have been successfully flushed.")
}
Consumer: pull-based разгрузка лога
В отличие от традиционных брокеров очередей (например, RabbitMQ), которые проталкивают сообщения (push) в TCP-сокеты потребителей, архитектура Kafka фундаментально построена на pull-модели. Консьюмер сам опрашивает брокера, отправляя сетевой запрос FetchRequest и запрашивая следующую порцию данных.
Это даёт потребителю полный контроль над темпом обработки, исключает переполнение его локальных буферов и обеспечивает естественный backpressure (6. Backpressure и контроль нагрузки).
Внутренний цикл консьюмера
Жизненный цикл консьюмера состоит из двух уровней: координации в группе и регулярного цикла опроса лога (Poll loop).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Start["Старт консьюмера"]:::entry --> Join["Присоединение к Consumer Group<br>Handshake с координатором"]
Join --> Assign["Получение назначения партиций<br>от лидера группы"]
Assign --> Poll["Poll loop"]
Poll --> Fetch["Fetcher запрашивает батчи<br>у брокеров-лидеров партиций"]
Fetch --> Deser["Десериализация Key/Value"]
Deser --> Process["Передача сообщений<br>приложению через Poll"]
Process --> Commit{"Коммит offset?"}
Commit -->|"Ручной"| Manual["CommitOffsets<br>после успешной обработки"]
Commit -->|"Автоматический"| Auto["Автокоммит по таймеру"]
Manual --> Poll
Auto --> Poll
Ключевые компоненты и протокольные механизмы консьюмера:
- Координатор группы (Group Coordinator) — один из брокеров кластера Kafka (ответственный за партицию внутреннего топика
__consumer_offsets, в которую попадает хэш имени группы). Он управляет членством в группе, отслеживает heartbeat-сообщения и инициирует ребалансировку. - Лидер группы (Group Leader) — один из консьюмеров группы (обычно первый подключившийся). Координатор передает лидеру список всех живых участников, и лидер вычисляет распределение партиций по выбранной стратегии (Range, RoundRobin, Sticky, CooperativeSticky) и возвращает план координатору.
- Fetcher — внутренний компонент, занимающийся асинхронной предвыборкой (
prefetch) батчей с брокеров. Консьюмер не ждёт ответа брокера на каждый вызовPoll: Fetcher в фоновом режиме запрашивает новые батчи из брокеров-лидеров назначенных партиций, заполняя внутреннюю очередь. - Heartbeat — фоновые сигналы жизнедеятельности (по умолчанию отправляются каждые 3 секунды), подтверждающие координатору, что данный консьюмер жив. Если брокер не получает heartbeat дольше интервала
session.timeout.ms(по умолчанию 45 с), консьюмер признаётся выпавшим из кластера, и координатор объявляет ребалансировку. max.poll.interval.ms— максимальное допустимое время между последовательными вызовами методаPoll(по умолчанию 5 минут). Это защита от «зависших» или перегруженных воркеров. Если приложение заблокировалось при обработке батча (например, на тяжелом SQL-запросе) и не успело вызватьPollдо истечения таймаута, клиент самостоятельно покидает группу, чтобы не тормозить чтение партиции.
Управление смещениями: автоматический vs ручной коммит
Смещение (offset) — это закладка в книге лога. То, как именно консьюмер обновляет эту закладку во внутреннем топике __consumer_offsets, определяет гарантии надёжности обработки данных:
- Автоматический коммит (
enable.auto.commit=true) — каждыеauto.commit.interval.ms(по умолчанию 5 секунд) клиент автоматически фиксирует максимальный offset, полученный при предыдущих вызовахPoll.- Плюсы: Предельная простота прикладного кода.
- Риски: Высокая вероятность потери данных (если воркер получил сообщения, закоммитил смещение по таймеру, но упал с panic до реальной записи в БД) либо дублирования сообщений (если падение произошло до истечения таймера коммита).
- Ручной коммит (
enable.auto.commit=false) — приложение явно вызывает методыCommitOffsets(синхронно или асинхронно) строго после того, как все побочные эффекты (запись в БД, вызов платежного шлюза) успешно зафиксированы.- Плюсы: Надежная семантика доставки at-least-once («как минимум один раз»).
- Стратегия: При сбое узел перезапускается и перечитывает последние незакоммиченные сообщения с момента предыдущей фиксации.
Для построения сквозного exactly-once на стороне консьюмера ручной коммит комбинируют с паттерном Идемпотентного потребителя (4. Idempotent handlers), сохраняя обработанный бизнес-идентификатор в уникальном индексе базы данных в рамках одной локальной транзакции.
Производительный консьюмер на Go
Ниже представлен production-ready консьюмер на franz-go, реализующий конкурентную обработку сообщений по партициям, корректную обработку сигналов завершения ОС (Graceful Shutdown) и безопасный ручной коммит смещений:
package main
import (
"context"
"fmt"
"log"
"os/signal"
"sync"
"syscall"
"time"
"github.com/twmb/franz-go/pkg/kgo"
)
func main() {
// Перехват сигналов завершения для корректного выхода из группы (Graceful Shutdown)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cl, err := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
kgo.ConsumerGroup("order-processors"),
kgo.ConsumeTopics("orders"),
// Отключаем автокоммит для предотвращения потери сообщений
kgo.DisableAutoCommit(),
// Настройки heartbeats и таймаутов обнаружения сбоев
kgo.SessionTimeout(30*time.Second),
kgo.HeartbeatInterval(3*time.Second),
// Cooperative Sticky стратегия позволяет ребалансироваться без Stop-the-World
kgo.Balancers(kgo.CooperativeStickyBalancer()),
)
if err != nil {
log.Fatalf("failed to create consumer client: %v", err)
}
defer cl.Close()
var wg sync.WaitGroup
fmt.Println("Consumer started. Waiting for partition assignment...")
for {
// Опрос брокера: возвращает батчи записей по всем назначенным партициям
fetches := cl.PollFetches(ctx)
if fetches.IsClientClosed() {
break
}
if ctx.Err() != nil {
// Получен сигнал SIGINT/SIGTERM, прекращаем опрос
break
}
if errs := fetches.Errors(); len(errs) > 0 {
log.Printf("fetch warnings/errors: %v", errs)
continue
}
// Параллельная обработка каждой партиции в отдельной горутине.
// Внутри ОДНОЙ партиции порядок сообщений сохраняется строго последовательно!
fetches.EachPartition(func(ftp kgo.FetchTopicPartition) {
wg.Add(1)
go func(p kgo.FetchTopicPartition) {
defer wg.Done()
for _, record := range p.Records {
// Бизнес-логика: идемпотентная обработка заказа
processOrder(record)
}
// Коммит смещений выполняется только после успешной обработки всех записей партиции!
// cl.CommitUncommittedOffsets фиксирует максимальный прочитанный offset
if err := cl.CommitUncommittedOffsets(context.Background()); err != nil {
log.Printf("failed to commit offsets for partition %d: %v", p.Partition, err)
}
}(ftp)
})
}
fmt.Println("Shutting down consumer, waiting for pending partition workers...")
wg.Wait()
fmt.Println("All partition workers finished. Exiting gracefully.")
}
func processOrder(r *kgo.Record) {
fmt.Printf("[Partition %d | Offset %d] Processed key=%s val_len=%d\n",
r.Partition, r.Offset, string(r.Key), len(r.Value))
}
Pull vs Push: почему не push?
Выбор pull-модели для Kafka — не случайность и не исторический курьёз, а фундаментальное архитектурное решение, продиктованное механической симпатией к дисковой и сетевой подсистеме современного оборудования:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph PushModel["Push-модель (RabbitMQ, ActiveMQ)"]
direction TB
B1["Брокер"]:::process -->|"Проталкивает сообщения<br>на полной скорости"| C1["Консьюмер"]
C1 -.->|"Переполнение памяти!<br>Нужен сложный Flow Control"| B1
end
subgraph PullModel["Pull-модель (Kafka)"]
direction TB
C2["Консьюмер"]:::success -->|"Запрашивает ровно N байт<br>когда готов обработать"| B2["Брокер"]
B2 -->|"Ответ через sendfile(2)<br>напрямую из Page Cache"| C2
end
- Естественный Backpressure «из коробки»:
- В традиционных push-системах (RabbitMQ, классические RPC) брокер пытается протолкнуть данные получателю как можно быстрее. Если потребитель замедлился (например, база данных деградировала по задержке), брокер заваливает его сетевыми пакетами. Чтобы не упасть с
Out of Memory, системе требуется сложный двусторонний протокол управления потоком (flow control, credit-based rate limiting, pause/resume). - В pull-модели консьюмер запрашивает новую порцию данных только тогда, когда закончил обработку предыдущей. Замедлился консьюмер — он просто реже вызывает
poll. Брокер даже не замечает этого, сохраняя сообщения на диске. Backpressure возникает автоматически, как фундаментальный закон природы.
- В традиционных push-системах (RabbitMQ, классические RPC) брокер пытается протолкнуть данные получателю как можно быстрее. Если потребитель замедлился (например, база данных деградировала по задержке), брокер заваливает его сетевыми пакетами. Чтобы не упасть с
- Агрессивный сетевой батчинг на чтении:
- Консьюмер запрашивает данные большими порциями (параметры
fetch.min.bytes,fetch.max.bytes, например, до 50 МБ за один сетевой round-trip). В push-модели брокеру пришлось бы принимать решение: отправить ли одно сообщение сейчас или подождать? В pull-модели консьюмер говорит: «Дай мне все накопившиеся записи вплоть до 10 МБ», минимизируя накладные расходы протокола.
- Консьюмер запрашивает данные большими порциями (параметры
- Zero-Copy до последнего байта:
- Поскольку консьюмер запрашивает непрерывный диапазон смещений из конкретной партиции, брокеру не нужно разбирать записи в пользовательской памяти!
- Ядро Linux выполняет системный вызов
sendfile(2)(илиsplice(2)), который переносит данные из страничного кэша (Page Cache) прямо в DMA-буфер сетевой карты, минуя пространство пользователя брокера. Никаких аллокаций в JVM/Go-рантайме брокера, никакой нагрузки на GC!
Заключение и следующие шаги
Внутреннее устройство Producer и Consumer в Kafka подчинено одной доминирующей идее — максимальной эффективности утилизации железа. Батчинг на стороне продюсера через RecordAccumulator, аккуратная балансировка linger.ms и batch.size, аппаратное сжатие целых пакетов и pull-модель консьюмера с опорой на sendfile(2) и страничный кэш ядра превращают Kafka в высокопроизводительную конвейерную магистраль данных.
Теперь, когда механика одиночных отправителей и получателей ясна, мы готовы перейти к масштабированию: как распределить чтение огромного топика из сотен партиций между десятками параллельно работающих сервисов?
В следующей статье — 4. Consumer groups — мы детально разберем внутреннюю кухню координатора групп, протоколы Rebalance (Eager vs Incremental Cooperative) и то, как избежать деградации сервиса при выкатке новых релизов.