Skip to content
Learning Platform

Парадокс быстрого диска

Когда инженер впервые слышит, что Apache Kafka пишет каждое сообщение на диск и при этом прокачивает гигабайты в секунду на одном брокере, возникает законное недоумение. Диск — это медленно. Целые поколения систем были спроектированы вокруг того, чтобы не трогать диск: кэши в куче, in-memory структуры, аккуратное батчирование записи. А Kafka делает ровно наоборот — она опирается на диск как на фундамент и при этом обгоняет многие чисто-памятные брокеры сообщений.

Секрет не в магии, а в том, что Kafka точно знает физику носителя и операционной системы под собой. Вместо того чтобы бороться с диском, она использует ровно те паттерны доступа, на которых диск и ядро Linux работают на пределе пропускной способности железа. Три кита, на которых всё держится: append-only log, страничный кэш ядра и системный вызов sendfile(), дающий так называемый zero-copy.

Эта статья — бесплатный самостоятельный разбор того, почему Kafka так устроена. Она пересекается с одним из модулей нашего платного курса Apache Kafka, но читать её можно отдельно: всё, что нужно, объясняется здесь с нуля и до уровня системных вызовов.

Партиция — это лог на диске

Начнём с фундаментальной единицы хранения. Топик в Kafka делится на партиции, и каждая партиция — это не абстракция, а вполне конкретная директория на диске брокера. Внутри неё лежит не один файл, а серия файлов-сегментов. Партиция физически представляет собой append-only лог: новые записи всегда дописываются в конец активного сегмента, и ничего никогда не переписывается в середине.

Каждое сообщение в партиции получает монотонно возрастающий целочисленный идентификатор — offset. Offset не сбрасывается и не повторяется в пределах партиции; именно он позволяет консьюмеру сказать “я прочитал до записи номер 4815162342, продолжай отсюда”.

Лог не хранится одним гигантским файлом — это было бы неудобно для ротации и удаления старых данных. Вместо этого лог нарезается на сегменты фиксированного размера (по умолчанию около гигабайта, параметр segment.bytes). Один сегмент — активный, в него идёт запись; остальные закрыты и доступны только на чтение. Рядом с каждым файлом данных лежат индексные файлы.

Если зайти в каталог данных брокера и посмотреть на одну партицию, картина будет такой:

$ ls -la /var/lib/kafka/data/orders-0/

-rw-r--r-- 1 kafka kafka 10485760 Jun  1 10:42 00000000000000000000.index
-rw-r--r-- 1 kafka kafka  1073741 Jun  1 10:42 00000000000000000000.log
-rw-r--r-- 1 kafka kafka 10485760 Jun  1 10:42 00000000000000000000.timeindex
-rw-r--r-- 1 kafka kafka 10485760 Jun  1 11:05 00000000000000524288.index
-rw-r--r-- 1 kafka kafka  1073741 Jun  1 11:05 00000000000000524288.log
-rw-r--r-- 1 kafka kafka 10485760 Jun  1 11:05 00000000000000524288.timeindex
-rw-r--r-- 1 kafka kafka 10485760 Jun  1 11:30 00000000000001048576.index
-rw-r--r-- 1 kafka kafka      512 Jun  1 11:30 00000000000001048576.log
-rw-r--r-- 1 kafka kafka 10485760 Jun  1 11:30 00000000000001048576.timeindex

Разберём, что мы видим. Каталог orders-0 — это партиция 0 топика orders. Имя каждого файла — это базовый offset сегмента, то есть offset первой записи в нём, дополненный нулями до 20 знаков. Файлы с расширением .log хранят сами сообщения. Файлы .index — это разреженный индекс, отображающий относительный offset в физическую позицию (байтовое смещение) внутри .log-файла. Файлы .timeindex делают то же самое, но по временной метке, чтобы консьюмер мог искать “с какого момента читать”.

Индекс именно разреженный: Kafka не хранит запись в индексе для каждого сообщения, а ставит отметки через интервал (index.interval.bytes). Поиск нужного offset — это бинарный поиск по индексу до ближайшей отметки, а дальше линейное досканирование .log-файла. Индексные файлы вдобавок отображаются в память через mmap, так что обращение к ним практически бесплатно.

Старый сегмент целиком закрыт и не меняется. Когда настаёт время удалить устаревшие данные (по retention.ms или retention.bytes), Kafka просто удаляет файл сегмента целиком — это операция уровня файловой системы, она не требует переписывания или дефрагментации лога. Эта неизменяемость сегментов — то, что делает возможным zero-copy, но об этом чуть позже.

Почему append-only — это быстро

Чтобы понять преимущество, надо вспомнить, чем последовательный доступ к диску отличается от случайного. На вращающемся HDD случайная запись означает физическое перемещение головки и ожидание, пока нужный сектор подъедет под неё — это десятки миллисекунд на одну операцию. На SSD механики нет, но и там случайные мелкие записи по разным адресам бьют по контроллеру, странице flash и износу. Последовательная же запись — это поток байтов в смежные адреса, и для него и HDD, и SSD, и контроллер, и планировщик ввода-вывода ядра работают в идеальном для себя режиме.

Append-only лог по определению производит только последовательную запись: новые байты всегда идут в конец активного сегмента, в смежные блоки. Здесь нет операций “найди запись X и обнови её на месте”, нет фрагментации, нет случайных seek’ов. Разница между последовательным и случайным доступом на одном и том же диске может составлять два-три порядка по пропускной способности.

Есть и второй, не менее важный эффект. Поскольку Kafka не переписывает данные в середине и не делает обновлений на месте, ей не нужны сложные структуры вроде B-деревьев с их перебалансировкой и случайными обращениями к страницам. Лог — это самая простая на свете структура данных, и она дружит с железом по построению.

Стоит отметить нюанс про durability. Kafka по умолчанию не делает fsync после каждой записи — она дописывает в .log-файл, но реальный сброс на физический носитель остаётся на усмотрение ядра. Гарантию сохранности при этом обеспечивает не fsync, а репликация: запись считается надёжной, когда её приняли реплики из ISR. К репликации мы вернёмся в конце.

Page cache: Kafka сознательно не кэширует в куче JVM

Вот первый момент, который ломает интуицию инженера, привыкшего к JVM-приложениям. Kafka написана на Scala и Java и работает на JVM, но она намеренно не держит данные сообщений в куче. Никакого большого кэша горячих сообщений в heap нет. Вместо этого Kafka целиком полагается на page cache операционной системы.

Логика тут глубоко прагматичная. Когда любой процесс пишет в файл, ядро не отправляет байты сразу на диск — оно складывает их в страничный кэш, область оперативной памяти, где ОС держит страницы файлов. Запись становится “грязной” страницей, которую фоновый поток ядра сбросит на диск позже. Когда кто-то читает тот же файл вскоре после записи, данные с огромной вероятностью всё ещё в page cache, и чтение вообще не доходит до диска.

Теперь смотрите, что это даёт Kafka. Типичный паттерн потоковой обработки — консьюмеры читают свежие сообщения почти сразу после того, как продюсер их записал. Это значит, что записанные продюсером страницы ещё горячие в page cache, и консьюмер читает их прямо из RAM, минуя диск. Брокеру не нужно строить собственный кэш — ядро уже делает это эффективнее, чем смог бы прикладной слой.

Почему это лучше, чем кэш в куче JVM:

  • Нет дублирования. Если бы Kafka держала свой heap-кэш, те же данные лежали бы в памяти дважды: в куче приложения и в page cache ядра. Опора на page cache убирает эту двойную трату RAM.
  • Нет давления на сборщик мусора. Многогигабайтная куча с живыми объектами-сообщениями — это кошмар для GC. Долгие паузы сборки мусора напрямую бьют по латентности. Держа данные вне кучи, Kafka обходится скромным heap и стабильными паузами GC.
  • Кэш переживает рестарт процесса. Page cache живёт в ядре, а не в процессе брокера. Перезапустили Kafka — page cache никуда не делся, горячие данные сразу доступны без прогрева.
  • Ядро лучше знает про вытеснение. Алгоритмы вытеснения страниц в ядре отлажены десятилетиями и учитывают давление памяти всей системы.

Практическое следствие: чем больше у брокера RAM, тем больший кусок горячего лога умещается в page cache и тем реже чтения уходят на диск. Поэтому Kafka-брокеры любят много оперативной памяти — но эта память идёт операционной системе под кэш, а не в -Xmx JVM. Типичная конфигурация — небольшая куча (несколько гигабайт) и вся остальная RAM, оставленная ядру под страничный кэш.

Zero-copy: как sendfile отдаёт байты из page cache прямо в сокет

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

Разберём классический путь “прочитать файл и записать в сокет”, который делает почти любое приложение через read() и write(). На каждый кусок данных происходит вот что:

  1. read() — ядро копирует данные с диска (или из page cache) в буфер ядра, затем копирует из буфера ядра в буфер приложения в пользовательском пространстве. Это уже две копии и переключение контекста между ядром и пользователем.
  2. write() в сокет — приложение копирует данные из своего буфера обратно в буфер сокета в ядре, затем ядро копирует из буфера сокета в буфер сетевой карты. Ещё две копии и ещё переключения контекста.

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

Здесь на сцену выходит системный вызык sendfile() (в Java он доступен как FileChannel.transferTo()). sendfile() говорит ядру: “перенеси N байт из этого файлового дескриптора прямо в этот сокет”. Приложение байты не трогает вообще. Ядро берёт данные из page cache и передаёт их сетевому стеку, не делая ни одной копии в пользовательское пространство. На современных ядрах с поддержкой gather-операций сетевой карты данные даже не копируются между буфером page cache и буфером сокета — в сокет уходят дескрипторы, указывающие на страницы кэша, а DMA сетевой карты читает прямо оттуда.

Вот сравнение двух путей на одну порцию данных:

ХарактеристикаКлассический путь (read + write)Zero-copy (sendfile)
Копий данных4 (диск→ядро, ядро→user, user→сокет, сокет→NIC)0–1 (только дескрипторы; данные не входят в user space)
Переключений контекста4 (по два на read и write)2 (вход в sendfile и выход)
Проход через user spaceДа, полный буфер в куче приложенияНет, байты не покидают ядро
Нагрузка на GC и heapВысокая (буферы в JVM)Отсутствует
Кто читает содержимоеПриложение видит все байтыНикто, чистая пересылка

Эффект тем сильнее, чем больше консьюмер читает данных, которые уже лежат в page cache. Холодное чтение всё равно сходит на диск (одна копия диск→page cache неизбежна), но горячий путь — самый частый в потоковой обработке — становится практически бесплатным для CPU.

Важная деталь, которая связывает всё воедино: zero-copy работает именно потому, что сегменты неизменяемы и данные на диске уже лежат ровно в том бинарном формате, в каком их надо отдать по сети. Kafka не перекодирует сообщения при отдаче — формат записи на диске совпадает с форматом передачи (это называется log-format на проводе). Если бы брокеру пришлось дешифровать, переупаковывать или трансформировать данные, ему пришлось бы поднять их в пользовательское пространство, и zero-copy сломался бы. Именно поэтому, например, шифрование на стороне брокера (а не сквозное на клиентах) лишает Kafka преимущества sendfile.

Последствия: где на самом деле узкое место

Сложив три механизма вместе, мы получаем неожиданный, но логичный вывод о том, на что упирается Kafka под нагрузкой.

Throughput ограничен диском и сетевой картой, а не CPU. Поскольку запись последовательная, а отдача идёт через zero-copy, процессор брокера почти не участвует в перекладывании байтов. CPU тратится в основном на разбор запросов, координацию и сжатие, если оно включено. На больших потоках брокер упирается в пропускную способность диска (на запись) и в пропускную способность NIC (на чтение консьюмерами), а не в загрузку ядер.

Много RAM ускоряет за счёт page cache. Это прямое следствие предыдущих разделов. Чем больше памяти отдано ядру под страничный кэш, тем больше горячего лога читается из RAM через zero-copy и тем реже консьюмеры дёргают физический диск. Отсюда практическое правило: не раздувайте heap JVM у брокера, оставляйте память операционной системе.

Сжатие — разумный размен. Продюсер может сжимать батчи (compression.type), и сжатые батчи хранятся и отдаются по сети в сжатом виде — то есть zero-copy сохраняется, а по проводу летит меньше байтов. Распаковка происходит уже на консьюмере. Это переносит небольшую долю CPU-работы на клиентов в обмен на экономию диска и сети на брокере.

Репликация работает по тем же законам. Реплики-фолловеры — это, по сути, обычные консьюмеры, которые читают лог лидера. Они тоже получают данные через тот же быстрый путь. Запись считается зафиксированной, когда её подтвердили все реплики из набора ISR (In-Sync Replicas). Если реплика отстаёт и выпадает из ISR, она перестаёт учитываться в кворуме подтверждения, пока не догонит лидера. Так Kafka совмещает высокую пропускную способность с настраиваемыми гарантиями надёжности через параметр acks и min.insync.replicas.

Получается стройная картина: append-only лог даёт идеальный для железа паттерн записи, page cache превращает оперативную память в бесплатный кэш чтения, а zero-copy убирает CPU с пути отдачи данных. Ни одно из этих решений по отдельности не выглядит революционным — революционно то, что они складываются в систему, которая работает на пределе возможностей железа, а не прикладного кода.

Куда копать дальше

Мы прошли путь от директории партиции на диске до системного вызова sendfile() в ядре. Это лишь один срез внутреннего устройства Kafka — за кадром остались контроллер на KRaft, протокол репликации в деталях, идемпотентные и транзакционные продюсеры, перебалансировка консьюмер-групп и многое другое.

Если хочется разобрать это с той же глубиной до железа, посмотрите наш курс Apache Kafka — вводные уроки модуля открыты бесплатно, так что начать можно прямо сейчас, без оплаты. А чтобы увидеть, как Kafka вписывается в общую картину инженерии данных рядом с ClickHouse, форматами хранения и оркестрацией, загляните в направление Data Engineering — оно бесплатное и открыто для всех.

Понимание того, почему система быстрая, гораздо ценнее, чем заучивание её настроек. Когда вы знаете, что Kafka упирается в диск и NIC, а не в CPU, вы перестаёте угадывать конфигурацию и начинаете осознанно проектировать кластер под свою нагрузку.

Ещё в направлении · Data Engineering

Все материалы направления →