1. Kafka. Архитектура и модель log based системы
Введение: Kafka как распределённый commit log
Apache Kafka — это распределённая, горизонтально масштабируемая, устойчивая к сбоям система для публикации и подписки на потоки данных, которая в корне отличается от классических брокеров сообщений вроде 1. RabbitMQ. Архитектура и концепции.
Если RabbitMQ строился вокруг модели «умного брокера» с динамической маршрутизацией, сложными биндингами и подтверждениями на уровне каждого отдельного сообщения, то создатели Kafka в LinkedIn пошли по диаметрально противоположному пути: «Умный продюсер и умный консьюмер при максимально простом («глупом»), но феноменально быстром брокере», реализующем модель распределённого append-only commit-лога.
flowchart LR
subgraph SmartBroker ["1. Парадигма RabbitMQ (Smart Broker)"]
direction TB
RB["Брокер следит за каждым сообщением:<br>Routing, Unacked State, Ack/Nack, DLX.<br>Сообщения удаляются после Ack."]
end
subgraph DumbBroker ["2. Парадигма Apache Kafka (Commit Log)"]
direction TB
KB["Брокер — немодифицируемый журнал на диске:<br>Только дозапись в конец (Append-Only).<br>Консьюмер сам хранит свой Offset.<br>Данные хранятся днями/неделями."]
end
classDef rmq fill:#312e81,stroke:#818cf8,stroke-width:1px,color:#f8fafc;
classDef kafka fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc;
class RB rmq;
class KB kafka;
Чтобы понять внутреннюю природу Kafka, нужно мысленно отбросить концепцию традиционной «очереди» и взглянуть на систему как на распределённый, секционированный журнал транзакций, аналогичный Write-Ahead Log (WAL) в реляционных базах данных.
Все сообщения, отправляемые продюсерами, дописываются строго в конец этого журнала и никогда не модифицируются. Консьюмеры вычитывают записи последовательно, самостоятельно отслеживая своё текущее положение (offset).
Это снимает с брокера колоссальную вычислительную нагрузку по поддержанию таблиц доставки: брокеру не нужно помнить, кто именно прочитал сообщение, а кто отстал — он просто отдаёт поток байт по запрошенному смещению.
Философия log-based системы
Фундаментальным строительным кирпичом архитектуры Kafka является Partition (партиция) — логический, упорядоченный, неизменяемый журнал (лог) сообщений. Топик (категория потока данных) представляет собой именованную группу партиций, распределённых по брокерам кластера.
- Топик (Topic): Логическая абстракция, объединяющая сообщения с общей предметной семантикой (например,
orders,user-events,payment-transactions). - Партиция (Partition): Физический журнал (набор упорядоченных файлов-сегментов на файловой системе брокера), в который строго последовательно дописываются входящие сообщения. Каждая партиция абсолютно упорядочена внутри себя.
- Offset (Смещение): Монотонно возрастающее 64-битное целое число, служащее уникальным порядковым номером сообщения строго в рамках конкретной партиции. Консьюмеры используют offset как закладку в книге, чтобы возобновить чтение ровно с того места, где остановились.
Такая модель восходит к классическим принципам LSM-деревьев (Log-Structured Merge-tree) и WAL-журналов СУБД. Преимущество log-based архитектуры — в бескомпромиссной производительности последовательного дискового ввода-вывода (Sequential I/O), отсутствии накладных расходов на случайную индексацию b-деревьев и идеальной согласованности с механизмами кэширования операционной системы.
Архитектура высокого уровня
Кластер Kafka состоит из нескольких ключевых компонентов, взаимодействующих по бинарному протоколу поверх TCP:
flowchart TD
subgraph Producers ["Продюсеры (Go Apps)"]
P1["Producer 1<br>(Orders API)"]
P2["Producer 2<br>(Payment Service)"]
end
subgraph KafkaCluster ["Кластер Apache Kafka (KRaft Mode)"]
subgraph Brokers ["Брокеры данных"]
B1["Broker 1<br>(Leader: orders-0, Follower: orders-1)"]
B2["Broker 2<br>(Leader: orders-1, Follower: orders-0)"]
B3["Broker 3<br>(Follower: orders-0, orders-1)"]
end
subgraph Quorum ["KRaft Контроллеры (Metadata Quorum)"]
K1["Controller 1 (Raft Leader)"]
K2["Controller 2 (Raft Follower)"]
K3["Controller 3 (Raft Follower)"]
end
end
subgraph Consumers ["Потребители (Consumer Groups)"]
C1["Consumer Group: Billing<br>(Worker A, Worker B)"]
C2["Consumer Group: Analytics<br>(DWH Ingestion)"]
end
P1 -->|"Запись (Append)"| B1
P2 -->|"Запись (Append)"| B2
B1 <==>|"Репликация (ISR)"| B2
B2 <==>|"Репликация (ISR)"| B3
Quorum -.->|"Управление метаданными и лидерами"| Brokers
B1 -->|"Чтение (Poll)"| C1
B2 -->|"Чтение (Poll)"| C2
classDef prod fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef broker fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc;
classDef kraft fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
classDef cons fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc;
class P1,P2 prod;
class B1,B2,B3 broker;
class K1,K2,K3 kraft;
class C1,C2 cons;
Ключевые участники кластера:
- Продюсер (Producer): Клиентское приложение, публикующее события в топик. Продюсер самостоятельно вычисляет целевую партицию (по хэшу ключа сообщения, через round-robin или кастомный партиционер).
- Брокер (Broker): Экземпляр серверного процесса Kafka, отвечающий за прием записей, их запись в сегменты на диск, репликацию и отдачу консьюмерам.
- Консьюмер (Consumer): Клиентское приложение, последовательно вычитывающее сообщения из закрепленных за ним партиций.
- Контроллер (Controller): Специализированный узел кластера, управляющий глобальным состоянием: назначением лидеров партиций, фиксацией состава реплик и мониторингом живости брокеров.
Модель хранения под капотом
Партиция на файловой системе — это не бесконечный гигантский файл (файловые системы операционных систем плохо справляются с файлами терабайтного объема). Партиция физически делится на сегменты (segments) — файлы фиксированного размера (по умолчанию 1 ГБ или 7 дней накопления).
Каждый сегмент на диске состоит из тройки файлов:
.log: Файл данных, содержащий непосредственно бинарные фреймы сообщений, упакованные в пакеты (RecordBatches)..index: Разреженный позиционный индекс (Sparse Offset Index), связывающий логическийoffsetс физическим смещением в байтах внутри.log-файла..timeindex: Разреженный временной индекс, сопоставляющий timestamp сообщения с его логическимoffset.
# Физическая раскладка партиции на накопителе
/var/lib/kafka/data/orders-0/
├── 00000000000000000000.log
├── 00000000000000000000.index
├── 00000000000000000000.timeindex
├── 00000000000000012345.log
├── 00000000000000012345.index
└── 00000000000000012345.timeindex
flowchart TD
subgraph SegmentLayout ["Анатомия сегмента партиции на диске"]
Index[".index (Sparse Offset Index)<br>Offset 1000 -> Byte Pos: 0<br>Offset 1004 -> Byte Pos: 4096<br>Offset 1008 -> Byte Pos: 8192"]
LogFile[".log (Data File)<br>[Batch 1: Offset 1000..1003]<br>[Batch 2: Offset 1004..1007]<br>[Batch 3: Offset 1008..1011]"]
TimeIndex[".timeindex (Time-based Index)<br>Timestamp 1718000000 -> Offset 1000<br>Timestamp 1718000050 -> Offset 1004"]
end
Index -->|"Бинарный поиск O(log N)<br>+ Позиционирование"| LogFile
TimeIndex -.->|"Поиск по времени"| Index
classDef seg fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
class Index,LogFile,TimeIndex seg;
При поступлении запроса на вычитывание брокер выполняет быстрый бинарный поиск по индексному файлу в оперативной памяти, мгновенно находит физическое смещение в .log файле и последовательно считывает данные.
Старые неактивные сегменты удаляются или уплотняются согласно политикам Retention (по времени retention.ms или размеру retention.bytes) и Compaction (уплотнение по ключу), что подробно рассматривается в статье 8. Retention и compaction.
Mechanical Sympathy: почему это безумно быстро
Как Kafka удается прокачивать миллионы событий в секунду через обычные дисковые накопители? Ответ кроется в глубоком механическом сочувствии к архитектуре операционной системы и кремния.
Современные дисковые контроллеры и накопители (как традиционные HDD, так и современные NVMe SSD) оптимизированы под последовательный ввод-вывод (Sequential I/O). Линейная запись на диск сопоставима по скорости с оперативной памятью, поскольку контроллер накопителя задействует аппаратную конвейеризацию, упреждающее чтение (read-ahead) и пакетную запись без случайных скачков головок или перемещений блоков. Kafka принципиально исключает случайный I/O: запись всегда идет строго в конец активного файла.
flowchart LR
subgraph TraditionalPath ["Традиционный ввод-вывод (4 переключения контекста, 3 копирования)"]
direction TB
D1["Диск"] -->|"1. DMA Read"| K1["Буфер ядра (Page Cache)"]
K1 -->|"2. CPU Copy"| U1["Буфер приложения (Userspace Heap)"]
U1 -->|"3. CPU Copy"| S1["Буфер сокета ядра (Socket Buffer)"]
S1 -->|"4. DMA Write"| NIC1["Сетевая карта (NIC)"]
end
subgraph ZeroCopyPath ["Zero-Copy sendfile (2 переключения контекста, 0 копий CPU)"]
direction TB
D2["Диск"] -->|"1. DMA Read"| K2["Page Cache ядра Linux"]
K2 ==>|"2. Direct DMA Transfer (sendfile)"| NIC2["Сетевая карта (NIC)"]
end
classDef trad fill:#450a0a,stroke:#f87171,stroke-width:1px,color:#fecaca;
classDef zc fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc;
class D1,K1,U1,S1,NIC1 trad;
class D2,K2,NIC2 zc;
Распределённая модель: лидеры, фолловеры и ISR
Каждая отдельная партиция в Kafka имеет фиксированный фактор репликации (Replication Factor, например 3). Из пула брокеров-реплик ровно один узел назначается Лидером (Leader).
- Лидер партиции: Принимает 100% операций записи от продюсеров и (по умолчанию) обслуживает все операции чтения консьюмеров.
- Последователи (Followers): Выступают в роли обычных консьюмеров: они непрерывно отправляют запросы
Fetchлидеру и дописывают полученные пачки в свои локальные журналы.
In-Sync Replicas (ISR)
Чтобы гарантировать сохранность данных при внезапном падении лидера, Kafka вводит динамическое понятие In-Sync Replicas (ISR) — список реплик, которые не отстают от лидера дольше настроенного порога (replica.lag.time.max.ms, по умолчанию 30 секунд).
Продюсер при отправке сообщения определяет требуемый уровень надежности с помощью флага acks:
acks=0(Fire-and-Forget): Продюсер не ждет ответа от брокера. Максимальная пропускная способность, но риск потери данных при малейшем сетевом сбое.acks=1(Компромисс): Продюсер ждет подтверждения только от лидера партиции. Сообщение надежно записано лидером, но может быть утеряно, если лидер упадет до того, как фолловеры успели его зареплицировать.acks=all(или-1, Золотой стандарт): Продюсер получает подтверждение только после того, как запись зафиксировали все реплики, входящие в текущий список ISR (с учетом параметраmin.insync.replicas).
Консьюмеры и Consumer Groups
В отличие от классических очередей (RabbitMQ), где брокер удаляет сообщение сразу после получения Ack, Kafka сохраняет сообщения на протяжении всего настроенного периода хранения (Retention Period).
Это дает фундаментальную архитектурную свободу:
- Вы можете в любой момент сдвинуть свой
offsetназад во времени и заново перечитать историю за последнюю неделю для повторного расчета аналитики или исправления бага в коде. - Десятки независимых сервисов могут параллельно читать один и тот же поток событий со своей собственной индивидуальной скоростью, не мешая друг другу.
flowchart TD
subgraph TopicPartitions ["Topic: 'orders' (4 партиции)"]
P0["Partition 0"]
P1["Partition 1"]
P2["Partition 2"]
P3["Partition 3"]
end
subgraph ConsumerGroup ["Consumer Group: 'order-workers' (3 воркера)"]
C1["Consumer 1<br>(Назначены P0, P1)"]
C2["Consumer 2<br>(Назначена P2)"]
C3["Consumer 3<br>(Назначена P3)"]
end
P0 --> C1
P1 --> C1
P2 --> C2
P3 --> C3
classDef part fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc;
classDef cons fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc;
class P0,P1,P2,P3 part;
class C1,C2,C3 cons;
Consumer Group (Группа потребителей) — это объединение нескольких процессов-консьюмеров для параллельной совместной обработки топика.
- Каждая отдельная партиция внутри группы назначается строго одному консьюмеру.
- Один консьюмер может обслуживать несколько партиций одновременно (как
Consumer 1на схеме выше). - При падении воркера или добавлении нового узла брокер запускает процедуру перебалансировки (rebalance), перераспределяя партиции между живыми членами группы.
Топология хранения и обработки в Go
В экосистеме Go исторически существовало три основных библиотеки для работы с Kafka:
Shopify/sarama: Старейшая библиотека на чистом Go. Широко распространена, но страдает от исторического наследия: высокого объема аллокаций памяти, неоптимального использования каналов и сложности настройки под капотом.confluent-kafka-go: Официальный клиент от Confluent. Представляет собой обертку над нативной C-библиотекойlibrdkafka. Обеспечивает высокую скорость, но требует включения CGO, что кратно замедляет сборку, усложняет кросс-компиляцию и делает бинарники зависимыми от версийglibcв Docker-контейнерах.franz-go(github.com/twmb/franz-go): Современный индустриальный стандарт де-факто для Go. Написан на 100% чистом Go без CGO, специально спроектирован с учетом механики планировщика Go, пулов памятиsync.Poolи обеспечивает производительность, сопоставимую сlibrdkafka.
Production-Ready пример консьюмера на Go (franz-go)
Ниже приведен эталонный пример реализации консьюмера на franz-go с ручным управлением смещениями (Manual Commit) и graceful shutdown:
package main
import (
"context"
"fmt"
"log"
"os/signal"
"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()
// Инициализация современного чистого Go-клиента
cl, err := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
kgo.ConsumerGroup("order-processors"),
kgo.ConsumeTopics("orders"),
kgo.DisableAutoCommit(), // Ручной коммит для предотвращения потери данных
)
if err != nil {
log.Fatalf("Ошибка инициализации Kafka-клиента: %v", err)
}
defer cl.Close()
log.Println("Консьюмер успешно запущен. Ожидание пакетов сообщений...")
for {
// Опрос брокера на получение пачки записей с учетом контекста
fetches := cl.PollFetches(ctx)
if fetches.IsClientClosed() {
log.Println("Клиент закрыт, завершение цикла.")
break
}
// Проверка ошибок выборки
if errs := fetches.Errors(); len(errs) > 0 {
log.Printf("Ошибки при получении сообщений: %v", errs)
continue
}
// Итерируемся по партициям и сообщениям
fetches.EachPartition(func(ftp kgo.FetchTopicPartition) {
for _, record := range ftp.Records {
processRecord(record)
}
// Коммитим смещения строго ПОСЛЕ успешной обработки пачки
commitCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := cl.CommitUncommittedOffsets(commitCtx); err != nil {
log.Printf("Сбой фиксации offset для партиции %d: %v", ftp.Partition, err)
}
cancel()
})
}
}
func processRecord(record *kgo.Record) {
fmt.Printf("Обработано сообщение: topic=%s partition=%d offset=%d key=%s value=%s\n",
record.Topic, record.Partition, record.Offset, string(record.Key), string(record.Value))
}
Kafka vs традиционные очереди: смена парадигмы
| Архитектурный аспект | RabbitMQ / Традиционные брокеры | Apache Kafka |
|---|---|---|
| Модель хранения | Кольцевой буфер (сообщение стирается после Ack) |
Распределенный немодифицируемый журнал (Commit Log) |
| Управление состоянием | Брокер хранит статус каждого сообщения (Unacked) |
Консьюмер сам управляет своим указателем (Offset) |
| Гарантия порядка | В рамках очереди (теряется при пуле воркеров) | Строгий порядок внутри конкретной партиции |
| Масштабирование чтения | Подключение новых конкурирующих воркеров | Шардирование топика на партиции внутри Consumer Group |
| Throughput | Десятки тысяч сообщений/сек | Миллионы сообщений/сек (Zero-Copy + Sequential IO) |
| Перечитывание истории | Невозможно (данные уничтожены) | Встроено: чтение с любого offset или по времени |
Заключение и дальнейшие шаги
Архитектура распределенного коммит-лога, лежащая в основе Apache Kafka — это осознанный инженерный выбор. Мы сознательно отказываемся от сложной серверной маршрутизации и умного отслеживания отдельных сообщений, получая взамен колоссальную пропускную способность, горизонтальную масштабируемость и возможность использовать лог как неизменяемый источник истины (Single Source of Truth).
Такая парадигма идеально ложится на событийно-ориентированные системы (3. Event Driven Architecture), паттерн Event Sourcing (4. Event sourcing и брокеры) и распределенные аналитические конвейеры.
В следующей лекции 2. Topics, partitions и offsets мы детально исследуем математику и геометрию партиционирования, разберем анатомию оффсетов и научимся проектировать ключи шардирования для продакшена.