Фундамент Apache Kafka: распределенный лог

Углублённый курс по внутреннему устройству Kafka, объясняющий переход от классических очередей к модели распределённого лога. Вы изучите механизмы хранения данных на диске, принципы работы смещений и физическую организацию потоков данных.

1. Смена парадигмы: почему Kafka — это не очередь, а распределённый лог

Смена парадигмы: почему Kafka — это не очередь, а распределённый лог

Представьте, что вы разрабатываете банковскую систему. Вы используете RabbitMQ: сервис транзакций отправляет событие о переводе средств, сервис антифрода его успешно считывает, подтверждает обработку, и... сообщение навсегда удаляется из брокера. А теперь представьте, что через месяц к вам приходит отдел Data Science и просит: «Выгрузите нам все транзакции за последние 30 дней, мы хотим обучить новую нейросеть». В парадигме классических очередей вам нечего им дать — данные исчезли в момент прочтения.

Чтобы решить эту проблему, нам придется полностью изменить взгляд на то, как системы обмениваются данными. Мы переходим от концепции «временного буфера» к концепции «вечной летописи».

Иллюзия очереди: почему RabbitMQ не подходит для хранения

В предыдущем курсе мы глубоко изучили RabbitMQ. Мы выяснили, что это невероятно гибкий маршрутизатор (Exchange) и надежный буфер (Queue). Но фундаментальная философия RabbitMQ и протокола AMQP заключается в разрушающем чтении (destructive read).

Как только Consumer забирает сообщение и отправляет подтверждение, брокер физически удаляет это сообщение из памяти и с диска. Очередь в RabbitMQ стремится быть пустой. Идеальное состояние RabbitMQ — это ноль сообщений в очередях, что означает, что система справляется с нагрузкой.

> Традиционный брокер сообщений (RabbitMQ) — это почтовое отделение. Письмо лежит там ровно до тех пор, пока за ним не придет адресат. После вручения почта забывает о существовании письма.

Если вам нужно, чтобы одно и то же событие (например, order.created) получили три разных сервиса, RabbitMQ решает это через паттерн Publish/Subscribe (Fanout или Topic Exchange). Он создает три физические копии сообщения (или ссылки на него) в трех разных очередях. Каждый сервис читает и опустошает свою личную очередь.

Но что, если сервисов станет тридцать? А если новый сервис появится через год и захочет прочитать исторические данные с самого начала?

Распределенный лог: пишем в камень

Apache Kafka изначально создавалась в LinkedIn не как очередь сообщений, а как распределенный журнал событий (Distributed Commit Log).

Лог (журнал) — это самая простая структура данных в информатике. Это файл, в который записи добавляются строго последовательно, одна за другой.

У лога есть два железных правила:

  • Append-only (только добавление): Новые данные всегда записываются только в самый конец файла.
  • Иммутабельность (неизменяемость): То, что уже записано, нельзя изменить или удалить (по крайней мере, точечно).
  • !Сравнение архитектуры Queue и Log

    В Kafka поток сообщений определенного типа называется Topic (топик). Логически топик — это и есть тот самый бесконечный лог. Когда Producer отправляет сообщение в Kafka, брокер просто приписывает его в конец файла на диске.

    И здесь кроется главное отличие: Kafka не удаляет сообщения после того, как их прочитали. Данные лежат на диске днями, неделями или годами, пока не сработает глобальная политика очистки (Retention policy), например: «удалять всё, что старше 7 дней».

    Инверсия ответственности: от умного брокера к умному клиенту

    В RabbitMQ брокер был «умным»: он помнил, какое сообщение выдано, какое находится в состоянии Unacked, а какое пора отправить в Dead Letter Exchange. Клиент (Consumer) был относительно «глупым» — он просто просил «дай мне следующее сообщение».

    В Kafka всё наоборот. Брокер «глупый» — это просто высокопроизводительное хранилище файлов на диске. А вот Consumer становится «умным».

    Поскольку сообщения не удаляются, как Consumer понимает, что он уже прочитал, а что нет? С помощью концепции Offset (смещение).

    Offset — это просто порядковый номер сообщения в логе. Первое сообщение получает offset 0, второе — 1, сотое — 99. Каждый Consumer сам (или с помощью клиентской библиотеки) запоминает: «Я прочитал этот лог вплоть до offset 42. В следующий раз я начну читать с offset 43».

    !Анимация добавления в лог и движения курсоров Offset

    Такая архитектура дает колоссальные преимущества:

  • Бесплатный Publish/Subscribe: Нам не нужно копировать сообщение в разные очереди. Десятки разных Consumer'ов могут читать один и тот же лог одновременно. У каждого из них будет свой собственный, независимый указатель (Offset).
  • Отсутствие влияния на соседей: Если один Consumer упал, завис или работает очень медленно, его Offset просто перестает двигаться вперед. Это никак не мешает другим Consumer'ам читать лог на максимальной скорости.
  • Снижение нагрузки на брокер: Kafka не тратит процессорное время на отслеживание статуса каждого сообщения для каждого клиента. Она просто отдает куски файла с диска.
  • !Проверка понимания неразрушающего чтения

    Машина времени: переигровка событий

    Самая мощная возможность, которую открывает парадигма лога — это Event Replay (переигровка событий).

    Поскольку данные лежат на диске и не удаляются после чтения, курсор (Offset) можно двигать не только вперед, но и назад. Если вы выкатили новую версию сервиса аналитики и поняли, что в коде была ошибка, из-за которой последние сутки данные считались неверно, в RabbitMQ это была бы катастрофа — оригинальные сообщения уже удалены, их не вернуть.

    В Kafka вы просто останавливаете Consumer, сбрасываете его Offset на значение, которое было 24 часа назад, исправляете баг в коде и запускаете сервис снова. Consumer заново прочитает все вчерашние события из лога, как будто видит их впервые, и пересчитает данные правильно.

    Сравнительная таблица парадигм

    | Характеристика | RabbitMQ (Message Queue) | Apache Kafka (Distributed Log) | | :--- | :--- | :--- | | Судьба сообщения | Удаляется после подтверждения (ACK) | Хранится на диске до истечения срока (Retention) | | Маршрутизация | Сложная, на стороне брокера (Exchanges, Bindings) | Простая, Producer пишет напрямую в Topic | | Отслеживание прогресса | Брокер помнит статус каждого сообщения (Unacked) | Consumer помнит свой порядковый номер (Offset) | | Множественные читатели | Требуется дублирование сообщений в разные очереди | Читают один и тот же файл, используя разные Offset | | Переигровка событий | Невозможна (без сложных костылей) | Встроена в архитектуру (перемотка Offset назад) |

    Что дальше?

    Мы выяснили, что топик в Kafka — это последовательный лог. Но если все данные одного топика (например, миллиарды кликов пользователей) писать в один файл на одном сервере, мы быстро упремся в лимиты жесткого диска и пропускную способность сети.

    Чтобы Kafka могла обрабатывать миллионы сообщений в секунду и распределять нагрузку на множество серверов, этот гигантский лог нужно разрезать на части. В следующей главе мы разберем концепцию Partitions (партиций) — главного механизма масштабирования в Apache Kafka.

    2. Анатомия топика (Topic): логическая группировка потоков данных

    Анатомия топика (Topic): логическая группировка потоков данных

    Представьте высоконагруженную систему крупного банка, которая генерирует 100 000 транзакций в секунду. Если бы распределенный лог был просто одним текстовым файлом на жестком диске, скорость записи уперлась бы в физические лимиты шпинделя или контроллера SSD уже в первые секунды работы. Тем не менее, брокеры способны переваривать миллионы событий в секунду без задержек. Секрет кроется в том, что топик — это иллюзия для разработчика.

    От логики к физике: Топик и Партиции

    Топик в Kafka — это исключительно логическая абстракция. Это имя, ярлык или папка, которая объединяет семантически связанные данные. Когда Producer отправляет сообщение, он указывает имя топика, а когда Consumer хочет прочитать данные, он подписывается на это имя.

    Однако на уровне файловой системы сервера брокера не существует файла с именем топика. Физически каждый топик разделен на одну или несколько партиций (Partitions).

    Партиция — это и есть тот самый физический Append-Only лог, о котором шла речь ранее. Это конкретный файл (или набор файлов-сегментов) на жестком диске конкретного сервера.

    !Топик как логический контейнер для физических партиций

    Если топик состоит из партиций, это означает, что поток данных расщепляется на независимых логов. Брокер может разместить эти логи на разных серверах кластера. Именно этот механизм позволяет обойти ограничения одного диска: запись идет параллельно в несколько файлов, распределенных по разным машинам.

    > Топик — это логический интерфейс для приложения. Партиция — это физическая единица хранения и масштабирования для брокера.

    Анатомия события: структура Record

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

    В отличие от RabbitMQ, где брокер анализирует заголовки (Headers) и ключи маршрутизации (Routing Key) для перекладывания сообщения из Exchange в Queue, Kafka использует структуру записи иначе.

    !Анатомия Kafka Record

    Каждый Record состоит из четырех основных компонентов:

  • Value (Значение / Payload): Сами бизнес-данные. Для брокера это просто массив байтов. Брокеру абсолютно все равно, лежит там JSON, строка текста, бинарный файл или сериализованный Protobuf-объект.
  • Key (Ключ): Опциональное поле, также представляющее собой массив байтов. Ключ не используется для фильтрации или маршрутизации между разными топиками. Его главная и единственная задача — определить, в какую именно партицию внутри топика попадет это сообщение. Если ключ одинаковый (например, user_id = 42), все сообщения с этим ключом гарантированно попадут в одну и ту же партицию.
  • Timestamp (Метка времени): Время создания сообщения (устанавливается Producer-ом) или время добавления в лог (устанавливается брокером). Критически важно для политик очистки старых данных и потоковой аналитики.
  • Headers (Заголовки): Метаданные в формате ключ-значение (добавлены в Kafka версии 0.11). Используются для передачи служебной информации (например, идентификаторов распределенной трассировки), чтобы не загрязнять ими бизнес-логику в Value.
  • Принципы проектирования топиков

    Поскольку топик — это логическая группа, возникает архитектурный вопрос: как именно группировать данные? Создать один гигантский топик all_system_events или миллион маленьких топиков для каждого чиха системы?

    Оба крайних подхода ошибочны. Проектирование топиков подчиняется правилу семантического единства и ожиданий схемы данных.

    1. Один топик = Одна схема данных

    Потребитель (Consumer), читающий топик, должен заранее знать, как десериализовать массив байтов из поля Value. Если в топик events сыплются и JSON-документы о регистрации пользователей, и бинарные фотографии профилей, Consumer будет вынужден писать сложную логику if-else для угадывания типа данных. Правильный подход: user.registrations.json — один топик, user.avatars.binary — другой.

    2. Разделение по бизнес-процессам

    События, имеющие разный жизненный цикл и разную частоту генерации, должны жить в разных топиках. Например, в приложении такси координаты водителя обновляются каждую секунду (огромный поток, данные устаревают мгновенно). А событие завершения поездки происходит раз в 30 минут (редкий поток, данные критичны для биллинга и должны храниться годами). Смешивать их в одном топике driver_events нельзя — для них потребуются абсолютно разные настройки хранения (Retention) на уровне физических логов.

    3. Соглашение об именовании (Naming Convention)

    В BigTech компаниях топики часто называют по иерархическому принципу, чтобы любой разработчик сразу понимал суть потока. Популярный шаблон: [домен].[сущность].[тип_события].[версия]

    Примеры:

  • billing.invoice.paid.v1
  • logistics.courier.location_updated.v2
  • !Семантическое разделение топиков

    Архитектура топика задает логические границы потоков данных в вашей системе. Но настоящая магия производительности начинается тогда, когда мы спускаемся на уровень ниже. То, как именно данные распределяются по партициям и как это позволяет читать миллионы сообщений параллельно, определяет всю суть масштабирования в современных брокерах.

    3. Партиционирование (Partitions): единица масштабирования и параллелизма

    Партиционирование (Partitions): единица масштабирования и параллелизма

    Один физический жесткий диск способен записывать данные со скоростью около 100–200 МБ в секунду. Если ваш поток событий (например, телеметрия со всех автомобилей такси в мегаполисе) генерирует 1 ГБ данных каждую секунду, ни один, даже самый мощный сервер, не сможет сохранить этот Append-Only лог. Физика накладывает жесткий лимит на пропускную способность одного узла.

    Чтобы преодолеть этот барьер, концепция единого лога разбивается на части. Логический топик в Kafka — это не один файл, а коллекция независимых физических логов, которые называются партициями (Partitions).

    Разрыв физических ограничений

    Партиция — это фундаментальная единица масштабирования в Kafka. Когда вы создаете топик, вы указываете, из скольких партиций он будет состоять.

    Если топик состоит из одной партиции, все данные пишутся на один сервер (брокер). Но если топик разбит на 10 партиций, Kafka может разместить их на 10 разных брокерах. Теперь потоки записи и чтения распределены: каждый брокер обрабатывает только 10% от общей нагрузки. Общая пропускная способность системы становится суммой пропускных способностей всех узлов.

    !Архитектура распределения партиций по брокерам и консьюмерам

    Помимо масштабирования записи, партиции определяют максимальный уровень параллелизма при чтении. В традиционных очередях сообщений (как RabbitMQ) сотни консьюмеров могут читать одну очередь одновременно (паттерн Competing Consumers). В Kafka действует жесткое правило: одну партицию может читать только один консьюмер из одной логической группы.

    Это означает, что если в вашем топике 4 партиции, вы можете запустить максимум 4 параллельных процесса-обработчика. Если вы запустите 5 консьюмеров, пятый будет простаивать, так как для него не останется свободной партиции. Партиция — это неделимый квант данных с точки зрения параллельной обработки.

    Механика распределения: как Producer выбирает партицию

    Поскольку топик физически разделен на несколько логов, при отправке каждого нового сообщения (Record) Producer должен принять решение: в какую именно партицию записать эти данные?

    Решение принимается на основе ключа (Key) сообщения. Возможны два фундаментально разных сценария.

    Сценарий 1: Балансировка нагрузки (ключ отсутствует)

    Если сообщение отправляется без ключа (Key = null), главная цель системы — максимально равномерно размазать нагрузку по всем доступным партициям.

    В этом случае Producer использует алгоритм Round-Robin (или его оптимизированные вариации). Первое сообщение отправляется в Партицию 0, второе — в Партицию 1, третье — в Партицию 2, и так далее по кругу. Это гарантирует, что ни один сервер не будет перегружен, а данные будут распределены идеально ровно. Этот подход идеален для независимых событий, таких как логи серверов или метрики, где неважно, в каком порядке они будут обработаны.

    Сценарий 2: Семантическая маршрутизация (ключ задан)

    Если данные логически связаны, их обработка часто требует строгой последовательности. Например, в банковской системе события по одному счету («Пополнение на 100 руб.», «Списание 50 руб.») должны обрабатываться строго друг за другом. Если они попадут в разные партиции, два разных консьюмера могут прочитать их одновременно и попытаться списать деньги до того, как они будут зачислены.

    Чтобы этого избежать, Producer использует хеширование ключа. Формула выбора партиции выглядит так:

    Где:

  • — итоговый номер партиции.
  • — математическая функция (в Kafka по умолчанию используется алгоритм murmur2), которая превращает любую строку ключа (например, user_123) в огромное псевдослучайное число. Важное свойство хеш-функции: одинаковый вход всегда дает одинаковый выход.
  • — операция взятия остатка от деления, где — общее количество партиций в топике.
  • Благодаря этой формуле все сообщения с одинаковым ключом (например, все транзакции пользователя user_123) всегда будут попадать в одну и ту же партицию. А поскольку одну партицию читает только один консьюмер, эти события будут обработаны строго последовательно, одно за другим.

    !Визуализация распределения сообщений с ключом и без ключа

    Подводный камень: изменение количества партиций

    Формула скрывает в себе одну из главных архитектурных ловушек Kafka.

    !Что произойдет при изменении количества партиций

    Добавление новых партиций в работающий топик — это деструктивная операция для логики, завязанной на ключи. Если вы используете ключи для строгой маршрутизации (например, user_id), золотое правило проектирования Kafka гласит: закладывайте избыточное количество партиций (over-partitioning) при создании топика.

    Если вы предполагаете, что вам понадобится 10 партиций через год, создайте 50 партиций сегодня. Накладные расходы на пустые партиции минимальны, но это избавит вас от необходимости пересоздавать топик и мигрировать терабайты данных в будущем, когда потребуется масштабирование.

    Иллюзия глобального порядка

    Понимание партиций разрушает популярный миф о том, что Kafka гарантирует строгий порядок сообщений (FIFO).

    В распределенной системе глобальный порядок несовместим с горизонтальным масштабированием. Если бы Kafka гарантировала строгий порядок абсолютно всех сообщений в топике, ей пришлось бы синхронизировать чтение между всеми партициями и серверами, что убило бы всю производительность.

    > Kafka гарантирует порядок сообщений исключительно в рамках одной партиции.

    Если события A и B попали в Партицию 0 (потому что у них одинаковый ключ), консьюмер гарантированно прочитает A раньше, чем B. Но если событие C попало в Партицию 1, а событие D в Партицию 2, предсказать, какое из них будет обработано первым, физически невозможно — они читаются параллельно разными процессами.

    Именно поэтому выбор правильного ключа — это не просто вопрос маршрутизации, это вопрос бизнес-логики. Ключ определяет границу, внутри которой система обязана сохранять причинно-следственные связи.