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;
  

Ключевые участники кластера:

  1. Продюсер (Producer): Клиентское приложение, публикующее события в топик. Продюсер самостоятельно вычисляет целевую партицию (по хэшу ключа сообщения, через round-robin или кастомный партиционер).
  2. Брокер (Broker): Экземпляр серверного процесса Kafka, отвечающий за прием записей, их запись в сегменты на диск, репликацию и отдачу консьюмерам.
  3. Консьюмер (Consumer): Клиентское приложение, последовательно вычитывающее сообщения из закрепленных за ним партиций.
  4. Контроллер (Controller): Специализированный узел кластера, управляющий глобальным состоянием: назначением лидеров партиций, фиксацией состава реплик и мониторингом живости брокеров.

Модель хранения под капотом

Партиция на файловой системе — это не бесконечный гигантский файл (файловые системы операционных систем плохо справляются с файлами терабайтного объема). Партиция физически делится на сегменты (segments) — файлы фиксированного размера (по умолчанию 1 ГБ или 7 дней накопления).

Каждый сегмент на диске состоит из тройки файлов:

  • .log: Файл данных, содержащий непосредственно бинарные фреймы сообщений, упакованные в пакеты (RecordBatches).
  • .index: Разреженный позиционный индекс (Sparse Offset Index), связывающий логический offset с физическим смещением в байтах внутри .log-файла.
  • .timeindex: Разреженный временной индекс, сопоставляющий timestamp сообщения с его логическим offset.
Bash
# Физическая раскладка партиции на накопителе
/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).

Это дает фундаментальную архитектурную свободу:

  1. Вы можете в любой момент сдвинуть свой offset назад во времени и заново перечитать историю за последнюю неделю для повторного расчета аналитики или исправления бага в коде.
  2. Десятки независимых сервисов могут параллельно читать один и тот же поток событий со своей собственной индивидуальной скоростью, не мешая друг другу.
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:

  1. Shopify/sarama: Старейшая библиотека на чистом Go. Широко распространена, но страдает от исторического наследия: высокого объема аллокаций памяти, неоптимального использования каналов и сложности настройки под капотом.
  2. confluent-kafka-go: Официальный клиент от Confluent. Представляет собой обертку над нативной C-библиотекой librdkafka. Обеспечивает высокую скорость, но требует включения CGO, что кратно замедляет сборку, усложняет кросс-компиляцию и делает бинарники зависимыми от версий glibc в Docker-контейнерах.
  3. 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:

Go
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 мы детально исследуем математику и геометрию партиционирования, разберем анатомию оффсетов и научимся проектировать ключи шардирования для продакшена.