Как брокер хранит сообщения на диске и почему это быстрее, чем в памяти
Когда вы первый раз открываете исходники Kafka или разбираетесь, как устроен NATS JetStream, интуиция сопротивляется: зачем брокер сообщений вообще пишет что-то на диск? Диск — это медленно, память — быстро, это очевидно каждому, кто ждал загрузки со старого HDD. Но самые нагруженные очереди сообщений в мире намеренно пишут каждое сообщение на диск синхронно с приёмом — и при этом держат задержки на уровне, сравнимом с чисто оперативными решениями. Разберёмся, почему это не парадокс, а инженерный расчёт, и что из этого следует для вашей системы.
Содержание
Почему кажется, что память обязана быть быстрее
Интуиция берётся из реального опыта: случайное чтение с диска — это операции с задержкой на порядки выше, чем обращение к RAM. Механический диск тратит время на позиционирование головки, у SSD есть накладные расходы контроллера и flash-трансляции. Отсюда вывод: «диск = медленно, значит очередь в памяти обязана быть быстрее очереди на диске».
Проблема в том, что это сравнение смешивает два вопроса. Первый — насколько быстра память по сравнению с диском *при одинаковой операции*. Второй — какую именно операцию выполняет брокер, когда «пишет на диск». Это не то же самое, что открыть файл, найти внутри случайную позицию и переписать несколько байт. Брокер делает нечто гораздо более скромное: он дописывает новые байты в конец одного и того же файла. Это принципиально другая операция — и именно она снимает большинство типичных узких мест дисковой подсистемы.
Последовательная запись против случайной
Когда программа просто продолжает писать с той позиции файла, где остановилась в прошлый раз, происходит несколько вещей:
- Не нужно искать место записи. И у HDD (позиционирование головки), и у SSD (поиск свободных страниц во flash-трансляции, FTL) случайная запись требует дополнительной работы контроллера на каждой операции. Последовательная запись всегда идёт «туда же, куда только что писали».
- Данные ложатся крупными непрерывными блоками, которые файловая система и контроллер накопителя умеют объединять в более крупные физические операции — мелкая случайная запись такого объединения почти не допускает.
- Меньше амплификации записи на SSD. Случайная запись на SSD часто затрагивает часть страницы, что заставляет контроллер перечитывать и переписывать целые блоки flash. Последовательная запись большими порциями этой проблемы избегает почти полностью, хотя конкретная величина эффекта зависит от прошивки накопителя — универсальных цифр здесь нет.
Именно поэтому брокер сообщений устроен как append-only лог: каждое новое сообщение просто добавляется в конец текущего файла-сегмента, без поиска нужной строки и без перезаписи существующих данных. Подробнее о том, почему это так важно на уровне самого накопителя, разобрано в статье про последовательную и случайную запись на SSD.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверPage cache: буфер, который делает диск «почти памятью»
Вторая часть ответа — и более важная — в том, что запись «на диск» в Linux почти никогда не означает немедленную физическую запись на носитель. Когда процесс вызывает write() для обычного файла, ядро по умолчанию не блокирует его до завершения физического ввода-вывода. Данные копируются в page cache — область оперативной памяти, которую ядро использует как буфер между процессами и накопителем. Вызов write() возвращает управление сразу после того, как данные оказались в этом буфере, а физическая запись на диск (writeback) происходит асинхронно, позже, пачками.
С точки зрения задержки, которую видит приложение, «запись на диск» в подавляющем большинстве случаев — это запись в память, только специальным образом организованную. Ядро само решает, когда «грязные» (dirty) страницы нужно скинуть на носитель — по таймеру, по превышению доли грязных страниц, либо когда приложение явно попросит через fsync(). Как устроен этот механизм и почему free -h показывает мало «свободной» памяти, разобрано в статье про page cache и то, куда девается свободная память.
Проверить состояние буфера можно напрямую:
# сколько страниц сейчас "грязные" — ждут сброса на диск
cat /proc/meminfo | grep -i dirty
# пороги, при которых ядро начинает агрессивно сбрасывать кеш на диск
sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs
# живая картина операций записи в реальном времени
iostat -x 1
Отсюда и парадоксальный на первый взгляд эффект: последовательная дозапись в конец файла ощущается приложением почти как запись в оперативную память — потому что на первом шаге это буквально она и есть. Диск включается в игру позже, асинхронно, и делает это самой дешёвой для себя операцией — дозаписью в конец, а не случайным доступом.
Как устроен лог брокера изнутри
Брокер сообщений (Kafka — самый известный пример, но принцип общий и для других логоориентированных систем) хранит каждый топик или партицию как последовательность файлов-сегментов ограниченного размера. Новое сообщение дописывается в текущий активный сегмент, а когда тот достигает лимита — открывается следующий.
/var/lib/kafka/data/orders-0/
├── 00000000000000000000.log # сегмент: сами сообщения подряд
├── 00000000000000000000.index # индекс: offset -> позиция в .log
├── 00000000000000000000.timeindex
├── 00000000000012045231.log # следующий сегмент
└── 00000000000012045231.index
Каждому сообщению присваивается монотонно растущий offset — порядковый номер внутри партиции. Файл .index хранит разреженное соответствие offset → физическая позиция в .log, чтобы можно было быстро найти нужное место, не читая файл с начала. Чтение потребителем тоже дружелюбно к диску: активные читатели обычно находятся рядом с концом файла (tail), то есть тоже читают почти последовательно, а данные там чаще всего ещё лежат в page cache — их не приходится поднимать с физического носителя вообще. Об этом же, но со стороны приёма сообщений, рассказано в статье про то, как устроена очередь сообщений.
Такая организация принципиально отличается от структуры таблицы в реляционной СУБД, где запись может лечь в произвольное место страницы, а страница может обновляться много раз. У брокера сообщений записи неизменяемы: однажды записанное сообщение никогда не переписывается на месте, только помечается к удалению целым сегментом при истечении retention. Это ещё одна причина, почему паттерн доступа к диску у брокера проще и предсказуемее, чем у типичной базы данных.
Персистентность: то, чего не даёт чистая память
Здесь важно не путать «быстро ли» и «переживает ли данные перезапуск». Page cache объясняет, почему запись быстрая, но сам по себе он ничего не гарантирует в плане надёжности — это просто оперативная память, и при потере питания или падении ядра несброшенные страницы пропадут точно так же, как пропала бы любая другая структура в RAM.
Реальную персистентность даёт момент, когда данные физически покидают память и оказываются на энергонезависимом носителе. Это происходит одним из двух путей: пассивно, когда ядро само сбрасывает грязные страницы по таймеру или порогу заполнения (тогда сообщение переживёт падение процесса брокера, но не потерю питания сервера до момента фактического сброса), или активно, когда брокер сам вызывает fsync(), явно требуя от ядра завершить запись прямо сейчас, и не подтверждает клиенту приём, пока вызов не вернётся — ценой дополнительной задержки на каждую такую операцию.
# server.properties — Kafka в основном полагается на репликацию
# между брокерами, а не на fsync после каждого сообщения
log.flush.interval.messages=10000
log.flush.interval.ms=1000
Чем реже вызывается fsync, тем ближе поведение брокера к «почти памяти» по задержке — но тем больше окно, в котором свежие сообщения существуют только в page cache и не переживут отказ питания. Большинство промышленных брокеров решают эту дилемму в первую очередь через репликацию: сообщение считается подтверждённым только после того, как его получили несколько независимых узлов, и синхронный сброс на диск каждого отдельного узла становится менее критичным, потому что данные и так живут в нескольких экземплярах памяти одновременно.
Это же объясняет, чего не хватает очереди, которая живёт только в оперативной памяти — например, простой структуре данных в процессе приложения или Redis без включённого сохранения на диск. Перезапуск процесса, деплой новой версии, OOM-killer или паника в рантайме — и всё, что не успело уйти дальше по цепочке, исчезает безвозвратно. У такой очереди нет и «перемотки назад»: раз сообщение прочитано и удалено, восстановить его для нового потребителя, подключившегося позже, уже нельзя. Лог на диске с offset-ами, наоборот, позволяет любому потребителю читать с произвольной точки истории, пока сегмент не удалён по retention. Это не значит, что оперативные очереди бесполезны — для передачи задач внутри одного процесса, где персистентность не нужна в принципе, они отлично подходят и будут быстрее любого брокера на диске. Речь именно про межсервисную коммуникацию, где сообщение — это единица бизнес-события, потеря которой стоит денег или данных.
Практические следствия для эксплуатации своего брокера
Если вы разворачиваете Kafka, NATS JetStream или похожий брокер на своём сервере, из сказанного выше вытекает несколько конкретных решений:
- Отдельный диск или раздел под
log.dirs. Смешивать сегменты брокера с логами ОС и временными файлами других сервисов — значит вносить случайный доступ туда, где брокер рассчитывает на последовательный. Даже один посторонний процесс, активно пишущий в тот же том, ломает паттерн доступа, на который оптимизирован брокер. - Мониторить не только место на диске, но и утилизацию устройства.
iostat -x 1показывает%utilиawait— если утилизация уходит к 100% при вроде бы небольшом объёме данных, вероятно, в поток записи подмешалась случайная составляющая (например, компакция сегментов или чужой процесс на том же томе). - Настроить retention осознанно, а не оставлять значение по умолчанию.
log.retention.hoursиlog.retention.bytesопределяют, когда старые сегменты удаляются — то есть сколько истории доступно для перечитывания и сколько места брокер займёт на диске в худшем случае. - Не путать fsync-политику с гарантией сохранности на уровне всего кластера. Если брокер один и без реплик, агрессивный
fsyncна каждое сообщение — единственная защита от потери данных при отказе диска. Если реплик несколько и запись подтверждается только после кворума, роль немедленногоfsyncна конкретном узле снижается, и баланс можно сместить в сторону задержки.
Отдельно стоит сразу брать сервер с запасом по объёму диска, а не «на вырост»: перенос брокера сообщений с одного тома на другой без потери сообщений и без простоя — отдельная аккуратная процедура с переносом сегментов и синхронизацией offset-ов, и проще один раз взять диск с запасом, чем потом переезжать под нагрузкой.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если page cache — это тоже память, чем брокер отличается от очереди в оперативной памяти?
Ключевое отличие не в том, где физически лежат данные в момент записи (там оба варианта используют RAM), а в том, что стоит за page cache — файл на энергонезависимом носителе, в который эти данные рано или поздно попадут независимо от того, жив ли процесс брокера. Чисто оперативная структура данных такой страховки не имеет вообще.
Значит, при потере питания часть сообщений всё равно может пропасть?
Да, если полагаться только на пассивный сброс page cache без fsync и без репликации — теоретически может потеряться то, что ядро ещё не успело физически записать на носитель. Поэтому промышленные брокеры комбинируют репликацию между узлами и периодический или пометочный fsync, а не рассчитывают на один-единственный механизм защиты.
Не проще ли использовать RAID-контроллер с батарейкой (BBU), чтобы fsync был мгновенным?
Это рабочий подход: контроллер подтверждает запись, как только данные попали в его защищённый кеш, а не когда они физически легли на flash-память. Но он требует отдельного железа и своей эксплуатационной дисциплины — например, разряженная батарейка сама по себе резко замедлит запись, потому что контроллер переключается в safe-режим и ждёт физического сброса на каждую операцию.
NATS и Kafka хранят сообщения одинаково?
Общий принцип — append-only лог с последовательной записью — у них похож, но конкретная реализация, формат сегментов и набор гарантий отличаются, и выбор зависит от нагрузки и требований к доставке. Сравнение по сценариям — в статье NATS или Kafka: что выгоднее и когда.
Нужно ли отключать page cache для файлов брокера ради полного контроля?
Некоторые системы используют O_DIRECT, обходя page cache и управляя буферизацией самостоятельно — это даёт больше контроля, но требует реализовать своё кеширование, которое ядро иначе предоставило бы бесплатно. Для подавляющего большинства инсталляций Kafka и похожих систем штатный page cache ОС работает лучше и проще самодельной альтернативы.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →