7. Kafka storage под капотом
Анатомия сегментов, разреженные индексы и Zero-Copy
«Философия Unix состоит в том, чтобы проектировать системы, делающие одну вещь и делающие ее хорошо, полагаясь на операционную систему в том, что она умеет лучше всего».
— Дуг Макилрой, один из отцов Unix
Почему понимание хранилища критично
Производительность Apache Kafka часто описывают в рекламных проспектах фразами вроде «миллионы сообщений в секунду на одном сервере». Но за этими цифрами нет ни «магии», ни сверхсложных самодельных алгоритмов в кодовой базе брокера. Напротив: архитектурное ядро хранилища Kafka поражает своей предельной, почти спартанской простотой.
Создатели Kafka осознали фундаментальную истину вычислительной техники: не нужно соревноваться с ядром операционной системы в управлении памятью и дисками. Вместо того чтобы городить в адресном пространстве процесса сложные B-деревья, пользовательские буферные пулы (Buffer Pools) и самодельные LRU-кэши (как это делают традиционные реляционные СУБД), Kafka была спроектирована как тонкий сетевой диспетчер над неизменяемым последовательным файловым логом.
Kafka буквально эксплуатирует то, что современная аппаратура и ядро Linux делают быстрее всего:
- Последовательную (sequential) запись в конец файла;
- Агрессивное кэширование страниц операционной системой (Linux Page Cache);
- Аппаратную прямую передачу байтов из оперативной памяти в сетевую карту (Zero-Copy через
sendfile(2)).
Физическая организация партиции
Если зайти по SSH на сервер брокера Kafka и открыть рабочий каталог с данными (обычно /var/lib/kafka/data/), вы не обнаружите никакой сложной проприетарной базы данных. Логическая партиция — это просто обычная директория на файловой системе (например, ext4 или xfs), названная по шаблону <topic_name>-<partition_id>.
Внутри этой директории партиция нарезана на цепочку неизменяемых файлов-сегментов (Segments). Каждый логический сегмент физически представлен тройкой (или четверкой) файлов:
/var/lib/kafka/data/orders-0/
├── 00000000000000000000.log
├── 00000000000000000000.index
├── 00000000000000000000.timeindex
├── 00000000000000000250.log
├── 00000000000000000250.index
├── 00000000000000000250.timeindex
├── leader-epoch-checkpoint
Назначение файлов в сегменте:
.log— основной файл данных, в который последовательно дописываются бинарные батчи сообщений (RecordBatch)..index— бинарный разреженный индекс (Offset Index), сопоставляющий логическое смещение (offset) с физическим байтовым смещением внутри файла.log..timeindex— бинарный разреженный индекс (Time Index), сопоставляющий временную метку (Unix timestamp) со смещениемoffset.leader-epoch-checkpoint— служебный файл, отслеживающий смены лидеров партиции для безопасного восстановления при сбоях (KIP-101).
Имя каждого файла в сегменте формируется из базового смещения (Base Offset) — смещения самой первой записи, сохраненной в данном сегменте, дополненного ведущими нулями до ровно 20 цифр (64-битное беззнаковое число).
Файл 00000000000000000250.log означает, что первое сообщение в этом файле имеет offset 250.
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 PartitionDir["Директория: /orders-0/"]
direction LR
S1["Сегмент 0<br>00000000000000000000.log<br>00000000000000000000.index<br>00000000000000000000.timeindex"]:::data
S2["Сегмент 250<br>00000000000000000250.log<br>00000000000000000250.index<br>00000000000000000250.timeindex"]:::data
S3["Активный сегмент (Active)<br>00000000000000000500.log<br>(Идет запись!)"]
end
S1 --> S2 --> S3
Жизненный цикл сегмента (Segment Rolling)
В каждый момент времени для партиции существует строго один активный сегмент (Active Segment). Только в него продюсер дописывает новые байты. Все остальные предшествующие сегменты навсегда закрыты для записи и доступны исключительно на чтение!
Сегмент «запечатывается» (rolls over), и создается новый активный сегмент при наступлении любого из двух событий:
- Превышение размера: Размер файла
.logдостигает порогаlog.segment.bytes(по умолчанию 1 ГБ). - Превышение времени: С момента первой записи в активный сегмент прошло больше времени, чем задано в
log.roll.msилиlog.roll.hours(по умолчанию 7 дней).
Зачем сегментировать лог на диске? Почему нельзя писать в один огромный 10-терабайтный файл?
Потому что удаление устаревших данных (Retention) в сегментированном логе сводится к тривиальному системному вызову ядра unlink(2) целого файла! Никакой сборки мусора на диске, никакой фрагментации файловой системы и никакого дефрагментирующего перемещения блоков данных.
Формат данных на диске: RecordBatch v2
Начиная с Kafka 0.11, на диске используется высокоэффективный формат RecordBatch v2 (введенный в KIP-98). Главная особенность этого формата — структурный батчинг: служебные метаданные (заголовки, контрольные суммы, флаги продюсера) вынесены на уровень батча, а индивидуальные записи внутри батча кодируются компактными дельтами:
+-------------------------------------------------------------------------------+
| RecordBatch Header |
| BaseOffset (8B) | Length (4B) | PartitionLeaderEpoch (4B) | MagicByte (1B) |
| CRC32C (4B) | Attributes (2B) | LastOffsetDelta (4B) |
| BaseTimestamp (8B) | MaxTimestamp (8B) | ProducerId (8B) | ProducerEpoch (2B) |
| BaseSequence (4B) | RecordsCount (4B) |
+-------------------------------------------------------------------------------+
| Records Payload |
| Record 1: Length (Varint) | Attributes (1B) | TimestampDelta (Varint) |
| OffsetDelta (Varint) | KeyLength | Key | ValueLength | Value |
| Record 2: ... |
| Record N: ... |
+-------------------------------------------------------------------------------+
Разбор полей RecordBatch:
baseOffset(8 байт) — логическое смещение первой записи в пакете.baseTimestamp(8 байт) — временная метка создания пакета.attributes(2 байта) — битовые флаги: алгоритм сжатия (None, GZIP, Snappy, LZ4, ZSTD), признак транзакционности, признак управляющего пакета (Control Batch для commit/abort маркеров).crc(4 байта) — контрольная сумма CRC32C заголовка и записей для проверки целостности.ProducerId(8 байт) иProducerEpoch(2 байта) — параметры для поддержки идемпотентности и транзакций.records— поток отдельных сообщений. Каждое сообщение сохраняет свой offset как компактную дельтуoffset delta = record.offset - baseOffset(в виде Protobuf-подобногоvarint), что экономит до 7 байт на каждое сообщение!
Разреженные индексы
Если бы Kafka сохраняла смещение каждого отдельного сообщения в B-дереве или хэш-таблице (как классические базы данных), то для лога из 10 миллиардов сообщений сам индекс весил бы сотни гигабайт и требовал огромного объема дорогой оперативной памяти.
Вместо этого Kafka использует разреженные индексы (Sparse Indexes).
Индексные файлы .index и .timeindex проецируются напрямую в адресное пространство процесса с помощью системного вызова mmap(2) (в Java — MappedByteBuffer).
Индексная запись добавляется не на каждое сообщение, а только тогда, когда объем записанных в лог данных превышает интервал log.index.interval.bytes (по умолчанию 4096 байт — размер одной страницы памяти Linux!).
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 SparseIndex["Файл 0000.index (mmap)"]
direction TB
I1["Offset: 0 -> Position: 0"]:::process
I2["Offset: 25 -> Position: 4096"]:::process
I3["Offset: 52 -> Position: 8192"]:::process
I4["Offset: 80 -> Position: 12288"]:::process
end
subgraph LogFile["Файл 0000.log на диске"]
direction TB
L1["Байты 0..4095: Записи с offset 0 по 24"]:::process
L2["Байты 4096..8191: Записи с offset 25 по 51"]:::process
L3["Байты 8192..12287: Записи с offset 52 по 79"]:::process
L4["Байты 12288..: Записи с offset 80+"]:::process
end
I1 -.-> L1
I2 -.-> L2
I3 -.-> L3
I4 -.-> L4
Offset Index: поиск смещения за O(log N)
Каждая запись в файле .index имеет фиксированный размер ровно 12 байт:
- Относительное смещение (Relative Offset, 4 байта): Смещение сообщения относительно базового смещения сегмента (
offset - baseOffset). Использование 4 байт вместо 8 байт позволяет экономить 50% размера индекса. - Физическая позиция (Physical Position, 4 байта): Точный байтовый офсет внутри файла
.log.
Алгоритм поиска смещения $X$:
- Бинарный поиск по сегментам: По имени файлов сегментов в памяти находится нужный сегмент, где
BaseOffset <= X < NextSegmentBaseOffset. - Бинарный поиск по
.index: Векторизованный бинарный поиск по проецированному в памятьmmap-индексу находит наибольшее проиндексированное смещение, меньшее или равное $X$ (например, смещение 52 с физической позицией 8192). - Линейное дочитывание: Брокер прыгает на позицию 8192 в файле
.logи последовательно читает максимум 4 КБ данных, пока не встретит точный offset $X$.
Поскольку размер индексного блока составляет всего 4 КБ, линейное сканирование занимает доли микросекунды и полностью умещается в кэше процессора L1/L2!
Time Index
Файл .timeindex устроен полностью аналогично, но оперирует записями по 12 байт: 8 байт Unix Timestamp + 4 байта Relative Offset.
Он используется, когда приложению необходимо перемотать консьюмер на определенный момент времени — например, через API offsetsForTimes(timestamp). Kafka находит по времени целевой offset, а затем по .index — физическую позицию на диске.
Путь сообщения от продюсера до диска
Когда сетевой поток брокера получает TCP-пакет ProduceRequest, происходит строго детерминированный цикл операций:
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
A["Сетевой пакет ProduceRequest"]:::process --> B["Валидация заголовков и CRC32C"]
B --> C["Проверка прав ACL и квот (Quotas)"]
C --> D["FileChannel.write() в активный .log"]
D --> E["Запись в .index (если прошло > 4 KB)"]
E --> F["Данные в Linux Page Cache!"]
F --> G["Репликация в ISR фолловерам"]
G --> H["Смещение High Watermark обновлено"]
H --> I["Отправка ACK продюсеру"]
- Валидация: Проверка контрольной суммы CRC32C и прав доступа.
- Последовательный append: Вызов
FileChannel.writeдобавляет байты в конец активного сегмента.log. Это системный вызовwrite(2). - Обновление индекса: Если накопилось более 4096 байт с прошлой отметки, в
.indexи.timeindexдобавляется новая точка. - Попадание в Page Cache: Данные оказываются в оперативной памяти ОС (страничный кэш).
- Репликация: Фолловеры вычитывают новый батч; когда кворум ISR подтверждает запись,
High Watermarkсдвигается вперед, и продюсер получает сетевой ACK.
Заметьте: брокер не вызывает fsync перед отправкой ответа продюсеру! Почему? Об этом ниже.
Путь от диска до консьюмера: Zero-Copy и sendfile
При запросе консьюмера (Fetch request) брокер выполняет следующие действия:
- Определяет offset, с которого нужно читать.
- Находит нужный сегмент и физическую позицию внутри
.logчерез бинарный поиск по индексу. - Использует
FileChannel.transferTo(в Java), который в среде Linux компилируется непосредственно в системный вызовsendfile. Эта функция копирует данные из страничного кеша (или с диска, если данных нет в кеше) напрямую в сокетный буфер, минуя промежуточный буфер в userspace.
В классических серверах (например, традиционных веб-серверах старого поколения или брокерах на чистом userspace) передача файла с диска в сетевой сокет выглядит как чудовищно неэффективная растрата аппаратных ресурсов:
sequenceDiagram
autonumber
participant Disk as Диск / Page Cache ядра
participant KernelBuf as Буфер ядра Linux (Kernel Space)
participant UserBuf as Память приложения (User Space)
participant SocketBuf as Сокетный буфер (Socket Buffer)
participant NIC as Сетевая карта (NIC DMA)
Note over Disk,NIC: Классический подход (read + write): 4 переключения контекста, 4 копирования!
Disk->>KernelBuf: 1. DMA Copy с диска в память ядра
KernelBuf->>UserBuf: 2. CPU Copy из ядра в буфер приложения (read)
UserBuf->>SocketBuf: 3. CPU Copy из приложения в сокетный буфер (write)
SocketBuf->>NIC: 4. DMA Copy из памяти ядра в сетевую карту
При традиционном read() + write():
- Процессор выполняет 4 переключения контекста (User Mode $\leftrightarrow$ Kernel Mode);
- Данные дважды копируются процессором через шину памяти RAM (из ядра в userspace, затем из userspace в ядро), сжигая драгоценную пропускную способность шины памяти и загрязняя кэши L1/L2/L3 CPU.
Магия системного вызова sendfile (Zero-Copy)
В Apache Kafka чтение консьюмера реализовано через Java-метод FileChannel.transferTo, который в среде Linux компилируется непосредственно в системный вызов sendfile(2) (или splice(2)):
sequenceDiagram
autonumber
participant PageCache as Linux Page Cache (DRAM)
participant Kernel as Ядро Linux
participant Broker as Брокер (User Space)
participant NIC as Сетевая карта (NIC DMA)
Broker->>Kernel: sendfile(out_fd, in_fd, offset, count)
Note over Kernel: 2 переключения контекста вместо 4!<br>0 копирований через CPU!
PageCache->>NIC: Прямой DMA-трансфер из Page Cache в сетевой буфер карты!
Kernel-->>Broker: Успех (число переданных байт)
Как работает Zero-Copy:
- Брокер вызывает
sendfile(socket_fd, file_fd, offset, length). - Процессор переключается в режим ядра всего один раз.
- Данные берутся напрямую из Page Cache ядра Linux и с помощью контроллера прямого доступа к памяти (Scatter-Gather DMA) отправляются непосредственно в кольцевой буфер сетевого адаптера!
- В пространство пользователя брокера не копируется ни единого байта! Процессор вообще не участвует в перемещении данных.
Это означает, что скорость отдачи сообщений консьюмерам в Kafka ограничена исключительно физической пропускной способностью сетевой карты (например, 25 Gbps или 100 Gbps), а не мощностью процессора сервера!
Долговечность и стратегии fsync
Существует распространенное заблуждение: «Если Kafka не делает fsync на каждую запись, значит, она может потерять данные при отключении питания серверов!»
Системный вызов fsync(2) принудительно сбрасывает грязные страницы из кэша памяти на физические пластины или ячейки флэш-памяти. Однако вызов fsync на каждое сообщение превратил бы производительность современного накопителя из сотен тысяч записей в секунду в жалкие 500–2000 IOPS из-за задержек контроллера.
Kafka предлагает выбор через параметры конфигурации брокера:
log.flush.interval.messages— принудительный сброс на диск каждые N сообщений;log.flush.interval.ms— принудительный сброс на диск каждые N миллисекунд.
Mechanical Sympathy: почему это работает
Подведем итог mechanical sympathy подсистемы хранения Kafka:
- Последовательный доступ вместо случайного: Скорость последовательной записи на современные NVMe SSD достигает 3–7 ГБ/с, в то время как случайная запись мелкими блоками быстро упирается в лимиты контроллера и вызывает дорогостоящий Write Amplification.
- Отсутствие нагрузки на GC брокера: Поскольку брокер не держит кэш сообщений в памяти виртуальной машины, куча JVM остается компактной (4–8 ГБ даже на серверах с 256 ГБ RAM). Нет многосекундных пауз Stop-the-World сборщика мусора!
- Бесплатный кэш ОС: Весь неиспользуемый объем RAM автоматически отдается операционной системой под Page Cache. Если брокер перезапустить, его кэш не сбрасывается — он сохраняется в ядре Linux!
Влияние Go-клиентов
Со стороны клиентов на Go (таких как franz-go) архитектура хранения Kafka находит идеальное отражение.
Когда брокер сбрасывает данные через sendfile, консьюмер на Go получает гигантские непрерывные TCP-пакеты. Клиент franz-go использует мультиплексирование ввода-вывода и системный вызов syscall.Read непосредственно в горутине, читающей сокет, помещая данные в пулы переиспользуемых байтовых срезов из sync.Pool.
Это сводит выделение памяти на стороне Go-приложения к абсолютному минимуму и предотвращает деградацию GC Go-рантайма даже при обработке гигабайтных потоков данных.
Вопросы для собеседования
Заключение
Хранилище Kafka — это триумф простоты: неизменяемый лог, сегменты на файловой системе, разреженные индексы и полное доверие страничному кешу ОС. Никаких самодельных буферных менеджеров, никаких сложных структур в памяти — только линейная запись, линейное чтение и sendfile. Именно эта архитектура позволяет одному брокеру обслуживать миллионы сообщений в секунду, не превращаясь в бутылочное горлышко.
Теперь, когда мы разобрали, как данные хранятся и извлекаются, перейдём к тому, как Kafka управляет жизненным циклом этих данных: 8. Retention и compaction.