Миллион файлов в одной директории: где именно ломается ls, find и бэкап
Рано или поздно в проекте появляется директория, куда всё складывается «плоско»: превьюшки, сессии, письма очереди, логи по одному файлу на событие, объекты кеша. Работает нормально, пока файлов тысячи. А потом кто-то запускает ls в этой директории, ждёт минуту, жмёт Ctrl+C — и начинает разбираться, что вообще происходит. Дальше в статье разбираем, на чём именно спотыкаются ls, find и инкрементный бэкап при огромном числе файлов в одной директории, почему это происходит на уровне файловой системы, и что с этим делать — без выдуманных цифр «во сколько раз медленнее», потому что на разных дисках и файловых системах эффект отличается качественно, а не по единой формуле.
Содержание
Что вообще происходит внутри такой директории
Директория — это не «папка» в бытовом смысле, а специальный файл, который хранит список пар «имя файла → номер inode». Когда вы делаете open("million-files/photo123.jpg"), ядро должно найти в этом списке запись photo123.jpg и получить её inode — а дальше уже читать метаданные и данные самого файла.
Вопрос в том, как устроен этот список внутри директории-файла:
- в простом (линейном) варианте это просто последовательность записей, и поиск имени — это перебор от начала до конца в худшем случае;
- в индексированном варианте (хеш-дерево, B-дерево) поиск имени идёт не перебором, а по структуре, похожей на индекс в базе данных.
Здесь и разница между файловыми системами: ext4 без dir_index и многие сетевые/встраиваемые ФС ведут себя как первый вариант, современные ext4 (с включённым dir_index, это значение по умолчанию уже много лет), XFS и ZFS — как второй. Но даже у индексированных директорий операции, которые перебирают все записи (а не ищут одну по имени), всё равно упираются в общее число записей — просто потому, что прочитать миллион строк списка нельзя быстрее, чем миллион раз прочитать по одной записи.
Второй важный слой — кеш ядра. Каждая найденная запись «имя → inode» оседает в dentry cache и inode cache, чтобы повторный доступ был мгновенным. Но кеш конечен (см. cat /proc/sys/vm/vfs_cache_pressure и текущий размер через slabtop | grep -i dentry), и когда рабочий набор — миллион записей одной директории — не помещается в кеш целиком, каждый «холодный» проход снова бьёт по диску за метаданными. На вращающихся дисках это особенно заметно: чтение метаданных миллиона файлов — это, по сути, миллион мелких случайных операций ввода-вывода, если данные не в кеше. Подробнее о том, как ядро вообще ищет файл по пути и при чём тут dentry-кеш, разобрано в отдельной статье — как ядро ищет файл по пути.
Где именно ломается ls
ls без флагов делает не «прочитать список», а куда больше:
- вызывает
getdents64()в цикле, пока не получит весь список записей директории (для миллиона файлов это уже не один системный вызов, а тысячи, каждый возвращает буфер записей); - по умолчанию сортирует весь полученный список в памяти — а значит, ничего не выведет на экран, пока не прочитает директорию целиком;
- если вызван с
-l,--color(что часто включено алиасомls='ls --color=auto'в.bashrc) или-F, дополнительно делаетstat()(илиlstat()) для каждого файла отдельно — чтобы узнать тип, права, размер, время изменения.
Третий пункт — самый дорогой. Даже если сам список имён файлов в директории читается быстро, отдельный stat() на файл — это ещё один поход за метаданными, и если inode файла не в кеше, это ещё одно случайное чтение с диска. На миллион файлов это миллион дополнительных операций, и именно здесь ls -la в такой директории «зависает», а не на самом чтении списка.
Практическая проверка на своём сервере:
# без сортировки и без stat — только имена
time ls -f /path/to/dir | wc -l
# с сортировкой и stat на каждый файл — самый дорогой вариант
time ls -la /path/to/dir | wc -l
# что реально делает ls — сколько раз и какие вызовы
strace -c ls -la /path/to/dir > /dev/null
Разница между первой и второй командой в такой директории обычно наглядно показывает, что дело не в «медленном диске вообще», а именно в объёме метаданных, которые ls -l вынужден собрать перед выводом. Если нужен просто факт «файл существует / список имён» — ls -f (без сортировки, без stat) или printf '%s\n' */ в шелле работают ощутимо мягче к диску и к глазам, ожидающим первую строку вывода.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГде именно ломается find
find устроен экономнее, чем ls -l: он не сортирует результат (выводит по мере обхода) и по умолчанию делает lstat() только один раз на запись — то есть избыточных проходов меньше. Тем не менее и find упирается в тот же фундаментальный лимит: обход директории — это getdents64() по всему списку записей, и если единственная директория содержит миллион файлов, find физически не может пропустить ни одной записи, даже если ищет по маске типа find . -name '*.tmp'.
Отдельная боль — сочетание find с -exec без +:
# запускает отдельный процесс rm на КАЖДЫЙ файл — миллион fork/exec
find /path/to/dir -type f -name '*.tmp' -exec rm {} \;
# группирует аргументы и запускает rm пачками — на порядки меньше процессов
find /path/to/dir -type f -name '*.tmp' -exec rm {} +
# ещё один рабочий вариант с явным контролем размера пачки
find /path/to/dir -type f -name '*.tmp' -print0 | xargs -0 -n 1000 rm
-exec ... \; создаёт новый процесс на каждое совпадение — при миллионе файлов это миллион fork()+exec(), и накладные расходы на сам запуск процессов начинают доминировать над временем самой операции. -exec ... + и xargs с пачками — это не про файловую систему, а про то, чтобы не платить за создание процесса на каждый файл отдельно.
И ещё нюанс: find без -maxdepth на такой директории всё равно должен полностью её обойти, прежде чем перейти к следующей ветке дерева, если директорий несколько на одном уровне — то есть на один «раздутый» каталог обход может занять заметно больше времени, чем на весь остальной проект целиком.
Где именно ломается инкрементный бэкап
Инкрементный бэкап — это, по сути, задача «определить, что изменилось с прошлого раза», и решают её по-разному, но почти все распространённые инструменты так или иначе перебирают файлы каталога:
rsyncбез демона на удалённой стороне строит список файлов источника (тот самый обход директории сstat()на каждый файл — время изменения, размер), затем сравнивает его со списком приёмника, и только потом решает, что копировать. Построение списка — это O(число файлов), и оно происходит до того, как передан хоть один байт полезных данных;- обычный
tarдля создания архива каталога тоже последовательно обходит директорию и делаетstat()/чтение на каждый файл — единым потоком, без параллелизма по умолчанию; - инструменты на основе снапшотов (
restic,borg,kopia) всё равно должны сначала перечислить файлы, чтобы понять, какие блоки/объекты уже есть в хранилище, а какие нужно дописать — сам список строится тем же обходом директории.
На практике это означает: если у вас «плоская» директория с миллионом файлов и инкрементный бэкап каждую ночь, то каждую ночь бэкап-инструмент заново перебирает миллион записей метаданных ещё до того, как решит, что копировать нечего, потому что ничего не изменилось. Даже нулевой прирост данных не спасает от полного прохода по каталогу. Разбор похожей ситуации с tar на большом каталоге — в статье tar большого каталога упирается не в диск: там видно, что узкое место — не пропускная способность накопителя, а количество отдельных операций с метаданными.
Что усугубляет ситуацию: одна директория с миллионом записей — это ещё и одна точка блокировки. Пока идёт полный обход для бэкапа, кеш метаданных заполняется этими записями и вытесняет то, что «горячее» для самого приложения — то есть ночной бэкап может ощутимо просаживать отклик боевого сервиса именно из-за конкуренции за dentry/inode-кеш, а не из-за нагрузки на CPU или диск в привычном понимании.
Почему файловые системы ведут себя по-разному
Ключевая причина всей проблемы — то, как файловая система индексирует записи внутри директории:
| Файловая система | Структура директории | Поведение при большом числе файлов в одной директории |
|---|---|---|
ext4 без dir_index | линейный список | поиск по имени и вставка — линейны от числа файлов, деградация растёт стабильно с ростом каталога |
ext4 с dir_index (по умолчанию) | хешированное B-дерево (htree) | поиск по имени быстрее линейного, но обход всей директории (readdir) — всё равно проход по всем записям; на очень больших каталогах возможны хеш-коллизии, требующие доп. чтений |
| XFS | B+-дерево | масштабируется заметно ровнее ext4 на больших каталогах, изначально проектировалась под большие директории |
| ZFS | расширяемое хеширование (fat ZAP при росте директории) | хорошо держит большие каталоги, но платит памятью на ARC под метаданные |
| Сетевые ФС (NFS, некоторые CIFS-конфигурации) | зависит от сервера + сетевая задержка на каждую операцию | обычно хуже всех — к стоимости самого поиска добавляется round-trip по сети на каждую метаданные-операцию |
Проверить, что у вас на ext4, можно так:
tune2fs -l /dev/sdXN | grep -i "dir_index\|htree"
Важно понимать: даже «хорошая» индексированная директория не отменяет проблему из разделов про ls, find и бэкап — индекс ускоряет поиск конкретного имени, но не ускоряет полный обход всех записей, а именно полный обход и делают ls, find без точного имени и почти все бэкап-инструменты. Разница между ext4, XFS и ZFS в этом сценарии — это разница между «плохо» и «терпимо», а не между «плохо» и «мгновенно». О выборе файловой системы под конкретную нагрузку — в статье ext4 или XFS: как выбрать.
Как перестроить структуру: иерархия поддиректорий
Единственное решение, которое снимает проблему в корне, а не смягчает её — не складывать файлы «плоско» в один каталог, а развести их по поддиректориям так, чтобы в каждой конечной директории оставалось разумное число записей (условно — тысячи, а не сотни тысяч и не миллионы).
Самый универсальный способ — шардирование по хешу или по первым символам имени файла:
# двухуровневое шардирование по первым символам md5 от имени файла
name="photo123.jpg"
hash=$(printf '%s' "$name" | md5sum | cut -c1-4)
dir1=${hash:0:2}
dir2=${hash:2:2}
mkdir -p "storage/$dir1/$dir2"
mv "$name" "storage/$dir1/$dir2/$name"
Два уровня по два hex-символа дают 256 × 256 = 65 536 конечных директорий — при миллионе файлов это в среднем около 15 файлов на директорию, что уже не создаёт описанных выше проблем ни для ls, ни для find, ни для бэкапа. Это не абстрактная схема — так устроено хранилище объектов в Git (.git/objects/ab/cdef...), так работает cache_dir в Squid и параметр levels в кеше Nginx (proxy_cache_path ... levels=1:2) — идея одна и та же: превратить одну директорию-«бутылочное горлышко» в дерево небольших директорий.
Альтернативы, если переписывать логику хранения дорого прямо сейчас:
- шардирование по дате —
2026/08/28/вместо одной директории на все дни; хорошо подходит для логов и событий, где имя файла и так содержит время; - разнести старое и активное — если проблема не в структуре как таковой, а в том, что старые файлы годами не удаляются, часть можно унести в архивную иерархию, оставив в «горячей» директории только недавние записи;
- уйти от файловой системы как индекса вообще — если счёт объектов идёт на миллионы и они по сути неструктурированный BLOB-набор (аватарки, вложения, превью), объектное хранилище с плоским API (S3-совместимое) избавляет от самой концепции «одна директория — X файлов», потому что там нет
readdirв привычном смысле — есть список по префиксу ключа, который сервис хранения строит иначе, чем ФС строит список inode.
Важная оговорка: шардирование не бесплатно — оно усложняет код (нужно вычислять путь по имени/хешу при каждой операции) и требует миграции существующих файлов, которая сама по себе — операция над той самой раздутой директорией, и её стоит планировать заранее, в окно с низкой нагрузкой, а не «на живую» под пиковым трафиком.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
С какого числа файлов в директории вообще стоит беспокоиться?
Универсального порога нет — он зависит от файловой системы, диска (SSD/NVMe заметно терпимее к случайным операциям, чем HDD) и от того, какие именно операции вы делаете (точечный open() по известному имени переносит проблему легче, чем ls -l или полный find). Практический сигнал — не число само по себе, а то, что операции над каталогом стали заметно медленнее, чем раньше при том же относительном приросте файлов.
Поможет ли просто переехать на SSD/NVMe?
Частично — случайные операции ввода-вывода на NVMe значительно дешевле, чем на HDD, и субъективно ситуация станет заметно легче. Но линейный или почти линейный характер операций (полный обход директории для ls/find/бэкапа) быстрый диск не отменяет — он просто отодвигает момент, когда это станет заметно.
Можно ли включить dir_index на существующем ext4-разделе задним числом?
Да, tune2fs -O dir_index /dev/sdXN, но существующие директории нужно ещё и переиндексировать через e2fsck -fD /dev/sdXN (только на отмонтированном разделе). Это разовая операция, но лучше выполнять её после бэкапа раздела, а не «на живую» на боевой системе.
Что делать, если файлы уже лежат плоско и переносить их некуда — временно?
Начать с малого: если проблема в первую очередь в ls, замените привычку набирать голый ls на ls -f или find . -maxdepth 1 -printf '%f\n', которые не сортируют и не делают лишний stat(). Это не решает проблему бэкапа и общего роста каталога, но снимает боль в повседневной работе, пока идёт подготовка к реструктуризации.
Реорганизация в поддиректории — это разовая задача или её нужно закладывать в архитектуру заранее?
Правильнее закладывать заранее: если система с самого начала пишет файлы по схеме шардирования (по хешу имени или по дате), проблема просто не возникает при росте. Переделка «задним числом» на уже наполненной директории — это миграция миллиона файлов, которая сама создаёт нагрузку того же рода, что описана выше, и её стоит проводить постепенно или в окно обслуживания.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →