MAATRIX / Блог / Почему архивация большого каталога упирается не в диск и не в процессор

Почему архивация большого каталога упирается не в диск и не в процессор

MAATRIX

Вы запускаете tar czf archive.tar.gz /data на каталоге с несколькими миллионами мелких файлов — и он идёт часами, хотя iostat показывает, что диск почти простаивает, а top — что ядро процессора не загружено даже наполовину. Интуиция подсказывает «диск медленный» или «сжатие тяжёлое», но приборы говорят обратное. На самом деле в этот момент узкое место — не пропускная способность накопителя и не вычислительная мощность CPU, а последовательная работа с метаданными файловой системы: миллионы отдельных системных вызовов, которые tar обязан сделать по одному на каждый файл.

Что делает tar, когда обходит каталог

tar не читает каталог как единый блок данных — он обходит дерево файлов рекурсивно, файл за файлом, и для каждого выполняет один и тот же набор операций на уровне ядра:

  • lstat() (или fstatat()) на каждую запись каталога — узнать тип файла, размер, права, время модификации;
  • open() — открыть файл для чтения;
  • ещё один fstat() — подтвердить метаданные уже у открытого дескриптора (размер нужен для заголовка архива);
  • один или несколько read() — собственно прочитать содержимое;
  • close() — закрыть дескриптор.

Сам обход каталогов тоже не бесплатный: чтобы получить список записей, ядро вызывает getdents64() — это скрыто за readdir() в user-space, но количество таких вызовов растёт с числом каталогов и подкаталогов, а не файлов, так что для плоской структуры с миллионами файлов в одном каталоге эта часть не главная. Главная — то, что каждый отдельный файл, даже нулевого размера, обходится минимум в пять системных вызовов, каждый из которых пересекает границу между user-space и kernel-space.

Если у вас есть отдельная статья о том, что такое системный вызов и почему он не бесплатен даже без учёта самой операции — почитайте про системные вызовы: там разобрано, откуда берётся фиксированная стоимость перехода в ядро и обратно, независимо от того, что именно вы просите ядро сделать.

Системный вызов на каждый файл: откуда берутся миллионы операций

Возьмите каталог с 5 миллионами файлов. Даже по минимальной оценке в пять syscall на файл (lstat, open, fstat, read, close) — это 25 миллионов пересечений границы ядра только на архивацию, ещё до того, как речь зашла о сжатии или записи архива на диск. Для файлов больше одного read-буфера добавляются дополнительные read() — но для по-настоящему мелких файлов (килобайты) это обычно не критично, они укладываются в один вызов.

Каждый системный вызов стоит фиксированные накладные расходы: переключение контекста между режимами процессора, сохранение и восстановление регистров, для некоторых операций — работу с таблицами страниц. Эта стоимость не зависит от размера файла — она одинакова что для файла в 200 байт, что для файла в 200 мегабайт. Разница в том, что у крупного файла эти накладные расходы размазываются по огромному объёму полезных данных, прочитанных за один open/close, а у мелкого файла — нет: вы платите ту же фиксированную цену за считанные байты полезной нагрузки.

Дальше добавляется работа самого ядра внутри VFS (virtual filesystem layer): каждый lstat()/open() требует найти файл по пути — пройти по всем компонентам пути через dentry cache (кэш записей каталога) и inode cache (кэш инодов). Если нужный dentry или inode уже в кэше — это быстро, микросекунды. Если рабочий набор (миллионы уникальных путей) не помещается в доступную оперативную память под кэш — начинаются промахи кэша, и ядру приходится поднимать структуры файловой системы с накопителя заново. Это уже не последовательное чтение данных файла, а точечные, разрозненные обращения к метаданным — по своей природе они ближе к случайному доступу, чем к последовательному, независимо от того, насколько быстрый у вас накопитель.

Если у вас уже есть статья про то, что такое инод и почему место может кончиться при заполненном диске — механика поиска и хранения метаданных там разобрана подробнее: что такое инод.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Почему это не пропускная способность диска

Сравните два сценария архивации одного и того же суммарного объёма данных, скажем 500 ГБ:

Сценарий А. 5 миллионов файлов по ~100 КБ каждый (логи, сессии, тумбнейлы, объекты кэша).

Сценарий Б. 50 файлов по 10 ГБ каждый (образы дисков, дампы баз, видео).

В сценарии Б tar делает 50 раз по lstat/open/fstat/close — то есть 200 syscall на метаданные суммарно, и всё остальное время уходит на read() большими последовательными кусками. Ядро отлично оптимизировано именно под этот паттерн: включается readahead — упреждающее чтение следующих блоков файла, пока текущие ещё обрабатываются, — и накопитель получает длинные последовательные запросы, на которых он показывает близкую к паспортной пропускную способность. Здесь диск действительно является узким местом: если у него, скажем, 500 МБ/с последовательного чтения, суммарное время чтения будет заметно определяться именно этой цифрой.

В сценарии А происходит обратное. Readahead работает внутри одного файла — он не «знает», что после текущего файла откроется следующий, и не может предсказать, где на диске лежат его блоки. Каждый новый файл — это «холодный старт»: открыть, прочитать сколько есть (обычно меньше одного блока readahead), закрыть. Пока для файла успевает разогнаться упреждающее чтение, файл уже дочитан целиком. Диск здесь почти всё время не занят передачей данных — он либо ждёт следующего запроса (для NVMe это заметно даже на высокой глубине очереди, потому что tar по умолчанию однопоточный и не создаёт параллельных запросов), либо обслуживает точечные обращения к метаданным. Если хотите разобраться, почему глубина очереди и параллелизм запросов вообще имеют значение для быстрых накопителей, взгляните на статью о последовательной и случайной записи на SSD — там показано, почему один и тот же накопитель ведёт себя по-разному в зависимости от паттерна доступа.

Поэтому апгрейд с SATA SSD на NVMe в сценарии А часто даёт разочаровывающе маленький прирост: вы ускоряете именно ту часть, которая и так не была узким местом. Пропускная способность накопителя — это МБ/с при длинных последовательных или хорошо распараллеленных запросах. У вас же — миллионы коротких, по большей части одиночных запросов, где решает задержка на операцию и то, сколько таких операций способна выдержать связка «приложение → VFS → файловая система → драйвер», прежде чем упрётся в собственный однопоточный код обхода.

Почему это не процессорное время на сжатие

Второе интуитивное объяснение — «gzip/zstd не успевает сжимать». Это тоже, как правило, не так, и вот почему. tar сначала последовательно упаковывает содержимое файлов в единый поток байт (сам архивный формат tar — это просто конкатенация файлов с заголовками), и уже этот единый непрерывный поток отдаётся компрессору. Компрессор не «видит» границы между пятью миллионами файлов как что-то особенное — он видит один длинный поток данных и обрабатывает его так, будто это один большой файл. Стоимость сжатия по CPU растёт пропорционально количеству обработанных байт и выбранному уровню сжатия, а не количеству файлов, из которых эти байты собраны.

Да, у потоковых компрессоров есть небольшие накладные расходы на инициализацию словаря и периодические внутренние операции, но они на порядки дешевле цены системного вызова на переход в ядро, и уж тем более дешевле случайного поиска метаданных на диске. Если замерить профиль через perf или strace -c, для каталога с миллионами мелких файлов почти всегда видно, что основная доля времени приходится на open/stat/read/close, а не на код сжатия — а свободные ядра CPU в top это подтверждают: компрессор просто ждёт, пока обход дерева подаст ему следующую порцию данных.

Важная оговорка: если вы одновременно подняли уровень сжатия до максимума (gzip -9, zstd --ultra -22) на действительно больших файлах, CPU вполне может стать узким местом — но это отдельный, самостоятельный случай, который проявляется по-другому: загрузка ядра CPU уходит в 100%, а strace -c покажет, что время в основном не в syscall, а в пользовательском коде компрессора между вызовами write().

Как увидеть узкое место своими глазами

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

Посчитайте количество файлов и оцените масштаб операции:

find /data -type f | wc -l

Посмотрите на распределение системных вызовов по времени выполнения — это самый прямой способ увидеть, куда уходит время:

strace -f -c -o strace_report.txt tar cf /dev/null /data
tail -30 strace_report.txt

В отчёте strace -c есть колонка % time и calls — на каталоге с миллионами мелких файлов вы увидите там огромное число вызовов openat, newfstatat (или lstat/stat в зависимости от версии libc и ядра), read, close, и заметный процент общего времени именно на них, а не «где-то внутри» одного крупного вызова.

Сравните на практике поведение с крупными файлами — создайте тестовый каталог того же суммарного объёма, но из десятка файлов, и заархивируйте оба варианта с замером времени:

time tar cf many_small.tar /data/many-small-files/
time tar cf few_large.tar /data/few-large-files/

Даже без точных цифр (они у вас будут свои — зависят от накопителя, файловой системы, объёма оперативной памяти под кэш метаданных) разница по порядку величины между этими двумя запусками на одинаковом суммарном объёме обычно и есть наглядное подтверждение того, что дело не в объёме данных.

Проверьте также, насколько холодный кэш метаданных влияет на результат — повторный запуск сразу после первого почти всегда заметно быстрее именно потому, что dentry/inode уже в памяти:

tar cf /dev/null /data   # первый прогон, кэш холодный
tar cf /dev/null /data   # второй прогон, кэш прогрет

Если второй прогон ощутимо быстрее первого на той же машине — это подтверждает, что часть времени в первом прогоне уходила именно на подъём метаданных с накопителя, а не на что-то, что зависело бы от CPU.

Что можно сделать, если каталог с миллионами файлов нужно бэкапить

Полностью убрать стоимость обхода метаданных нельзя — это фундаментальное свойство архивации по одному файлу за раз. Но можно снизить её влияние или обойти:

Параллелизуйте обход. tar сам по себе однопоточный: он не начнёт открывать следующий файл, пока не закрыл текущий. Если у вас NVMe с высокой глубиной очереди и файлы разбросаны по нескольким подкаталогам, можно разбить дерево на независимые части и архивировать их параллельно, например через find + xargs -P с несколькими процессами tar, каждый на свой подкаталог, с последующим объединением архивов. Выигрыш ощутим только если накопитель и файловая система действительно способны обслуживать несколько параллельных потоков метаданных без взаимной блокировки — на сетевых файловых системах (NFS, SMB) параллелизм часто упирается в задержку самого протокола, а не помогает.

Используйте инструменты, которые ходят по данным иначе, чем tar. Инструменты вроде restic или borgbackup при повторных запусках опираются на локальный индекс уже обработанных файлов и умеют пропускать не изменившиеся с прошлого раза — то есть полный обход метаданных на всех файлах происходит один раз (или ощутимо реже), а не при каждом бэкапе. Честная оговорка: первый полный запуск на миллионах файлов у них тоже упрётся в ту же самую стоимость обхода — волшебства нет, просто цена платится не каждый раз.

Рассмотрите архивацию на уровне файловой системы, а не файлов. Если под данными лежит ZFS или Btrfs, снапшот и zfs send/btrfs send передают внутреннее представление файловой системы (блоки и метаданные так, как они физически организованы), а не выполняют пользовательский обход дерева файл за файлом через VFS. Для каталогов с огромным количеством мелких файлов это может быть на порядок быстрее именно потому, что не тратится время на миллионы отдельных open/close.

Прогревайте кэш заранее, если операция повторяется регулярно. Периодический холостой обход (find /data > /dev/null) незадолго до архивации может подтянуть часть метаданных в dentry/inode cache, если рабочий набор помещается в доступную память. На разовой полной архивации огромного дерева это обычно не спасает — кэш просто не резиновый.

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

Для самой инфраструктуры это тоже вопрос выбора сервера: если бэкапы каталогов с большим числом мелких файлов — регулярная задача, важнее не столько абсолютная скорость накопителя, сколько запас по CPU и оперативной памяти под кэш метаданных, а также отсутствие сетевой файловой системы между сервером и данными там, где это возможно.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Поможет ли переход на NVMe, если у меня медленно архивируется каталог с миллионами файлов?

Скорее всего, ощутимо не поможет, если узкое место — обход метаданных, а не пропускная способность. NVMe снижает задержку на отдельный запрос по сравнению с SATA SSD, что даёт какой-то выигрыш, но кардинально проблему не решает: она в количестве последовательных операций, а не в скорости их обслуживания накопителем.

Почему du -sh по тому же каталогу тоже выполняется долго?

По той же причине — du тоже обходит дерево и делает stat на каждый файл, чтобы узнать его размер. Это ещё один пример операции, которая упирается в количество файлов, а не в объём данных.

Если я архивирую без сжатия (tar cf вместо tar czf), станет заметно быстрее?

Отключение сжатия убирает нагрузку на CPU, но если узкое место было в обходе метаданных, а не в сжатии, выигрыш будет небольшим — вы уберёте то, что и так не было основным ограничением.

Можно ли ускорить именно фазу lstat/open, не меняя набор файлов?

Отчасти — за счёт большего объёма оперативной памяти под кэш dentry/inode (если рабочий набор метаданных не помещался в память раньше), а также за счёт файловой системы с более эффективной организацией метаданных для больших каталогов. Полностью убрать стоимость по одному вызову на файл нельзя, пока архивация идёт файл за файлом.

Одинаково ли это работает на ext4, XFS и ZFS?

Общий принцип — да, обход через VFS одинаково платит за каждый open/stat/close. Различия — в том, насколько эффективно конкретная файловая система хранит и кэширует метаданные больших каталогов и насколько быстро сама выполняет поиск записи по имени; это влияет на абсолютные цифры, но не отменяет саму механику.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →