MAATRIX / Блог / Потолок rsync на миллионах файлов: где обход дерева дороже самого копирования

Потолок rsync на миллионах файлов: где обход дерева дороже самого копирования

MAATRIX

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

Как rsync устроен внутри: три фазы одной синхронизации

rsync — это не потоковое копирование, а протокол сравнения двух деревьев файлов, и у него всегда есть три последовательные (местами перекрывающиеся) фазы.

Первая фаза — построение списка файлов (file list generation). Отправитель обходит дерево каталогов рекурсивно, для каждого файла делает stat() (или lstat() для симлинков), получает размер, mtime, права, владельца — и складывает всё в единый список в памяти. При двусторонней синхронизации то же самое (или сокращённый вариант) делает и приёмник. Данные файлов пока не читаются — это чистый обход метаданных.

Вторая фаза — сравнение (matching). rsync сопоставляет два списка по имени пути и для каждой пары решает, нужно ли передавать файл: если размер и mtime совпадают (эвристика quick check), файл считается идентичным и пропускается без чтения содержимого. С флагом -c/--checksum вместо quick check используется контрольная сумма содержимого, что требует полного чтения файла с обеих сторон ещё до решения о передаче — дороже, но надёжнее при недоверии к mtime.

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

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

Где именно возникает узкое место на миллионах файлов

Узкое место — не в самом rsync как таковом, а в системных вызовах, из которых складывается обход дерева, и в объёме метаданных, которые нужно удержать и сравнить.

Стоимость одного stat() умножается на количество файлов. Каждый stat() — отдельный системный вызов, а на сетевой или сильно фрагментированной ФС (NFS, локальный ext4/XFS с холодным кэшем метаданных на HDD) — ещё и отдельная операция чтения inode. На SSD с тёплым кэшем stat стоит микросекунды; на холодном кэше или сетевой ФС — миллисекунды. Умножьте на 10-50 миллионов файлов — и разница между «микросекунды» и «единицы миллисекунд» превращается в разницу между минутами и часами ещё до передачи данных.

Список файлов целиком живёт в памяти. rsync хранит весь список (пути, размеры, времена, права) в RAM процесса — на отправителе, а при двусторонней синхронизации и на приёмнике. На файл уходит порядка сотни байт метаданных плюс накладные расходы структуры. На 10 миллионах файлов это сотни мегабайт-единицы гигабайт; на 50-100 миллионах — цифра, с которой на скромной VPS можно упереться в своп, а своп на этой фазе ощущается как полное зависание.

Проход по дереву однопоточный по умолчанию. Классический rsync не распараллеливает обход внутри одного вызова — один процесс идёт по дереву рекурсивно. Если у вас 16 ядер и NVMe на сотни тысяч IOPS, а обход утилизирует одно ядро и малую долю возможностей накопителя — вы платите временем, пока железо простаивает.

Много мелких файлов — боль и для самой ФС, независимо от rsync: их метаданные разбросаны сильнее, чем у крупных, обход занимает больше seek-ов на HDD и больше случайных обращений даже на SSD. Если у вас в принципе миллионы мелких файлов на одном томе, стоит отдельно посмотреть, как устроено хранение при таком количестве файлов — часто узкое место дублируется и на уровне ФС, и в rsync.

Итог: до сотен тысяч файлов rsync ведёт себя предсказуемо, время почти линейно определяется объёмом передаваемых данных. На миллионах файлов доля времени на построение списка и сравнение растёт быстрее доли на передачу — особенно если реально изменившихся файлов немного, как в типичном инкрементальном бэкапе.

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

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

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

Почему это не баг, а математика сложности обхода дерева

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

Сложность построения списка близка к O(N) по числу файлов N — минимум одна операция метаданных на файл, столько же для сравнения (хэш-таблица по путям). Проблема не в асимптотике самой по себе — O(N) звучит безобидно, — а в константе: на классической механике и сетевых ФС стоимость одной операции метаданных на порядки больше, чем на локальном NVMe с тёплым кэшем, и при N в десятки миллионов эта константа перестаёт быть незаметной.

Отдельно стоит связка «rsync + очень большая директория» — если миллионы файлов лежат не в дереве, а в одной плоской директории, к стоимости обхода добавляется деградация самой ФС на операциях с такой директорией. Это уже вопрос к структуре хранения, а не к rsync — если он у вас актуален, разберите где именно ломается директория с миллионом файлов и разложите файлы по поддиректориям (шардирование по первым символам имени/хэша — распространённый приём).

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

Параллельный rsync: делим дерево, а не гонимся за флагами

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

Практическая схема — на верхнем уровне дерева получить список поддиректорий и раздать их пулу воркеров через xargs -P или GNU parallel:

# список поддиректорий верхнего уровня источника
find /data/src -mindepth 1 -maxdepth 1 -type d > /tmp/dirs.txt

# запускаем rsync параллельно, по одному процессу на директорию,
# ограничиваем параллелизм 8 воркерами
cat /tmp/dirs.txt | xargs -P 8 -I{} \
  rsync -a --delete {}/ user@remote:/data/dst/$(basename {})/

Или с GNU parallel, если он есть в системе:

find /data/src -mindepth 1 -maxdepth 1 -type d | \
  parallel -j 8 rsync -a --delete {}/ user@remote:/data/dst/{/}/

Важные нюансы:

  • Уровень разбиения выбирайте по размеру поддеревьев, а не формально. Если 90% файлов лежат в одной из ста директорий верхнего уровня, разбиение на этом уровне не даст параллелизма — один воркер заберёт почти всю работу. Иногда осмысленнее спуститься на уровень глубже или заранее прикинуть число файлов по каждой директории (find dir -type f | wc -l).
  • Число процессов ограничивайте по факту, а не «побольше». Каждый rsync — это ядра под свои операции плюс нагрузка на метаданные ФС и, если через сеть, на SSH-туннель. Обычно рабочий диапазон — от числа ядер до удвоенного, дальше рост параллелизма упирается в тот же диск и только увеличивает случайность доступа.
  • --delete работает только внутри своего поддерева — при таком разбиении rsync не видит файлов, удалённых из чужих поддеревьев, для полной консистентности нужен отдельный проход или сверка списков директорий.
  • Логи каждого воркера — в отдельный файл, иначе вывод восьми параллельных rsync в одном терминале нечитаем: rsync ... > /var/log/rsync-$(basename {}).log 2>&1.

Подход не меняет сложность обхода — суммарно системных вызовов столько же, — но использует параллелизм железа (ядра, очереди NVMe, несколько TCP-соединений), который однопоточный rsync оставлял простаивать. Даёт кратный выигрыш, если у диска и сети есть запас параллелизма, и почти ничего не даёт на одном вращающемся HDD, где параллельные обходы только добавляют случайных seek-ов.

rclone и другая архитектура: листинг вместо построчного stat

rclone изначально проектировался под объектные хранилища (S3, Backblaze B2 и подобные), где нет POSIX-семантики stat на файл, а есть listing-запросы, возвращающие пачками метаданные сразу многих объектов. Эта же архитектура — параллельный листинг и параллельная передача из коробки — оказывается выгодной и для чисто файловых сценариев с большим количеством файлов, в том числе локальных.

Ключевое архитектурное отличие от классического rsync: rclone по умолчанию распараллеливает и листинг, и передачу через собственный планировщик задач, управляемый флагами --transfers (параллельные передачи) и --checkers (параллельные проверки/сравнения):

rclone sync /data/src /data/dst \
  --transfers 16 --checkers 32 \
  --fast-list \
  --progress

--fast-list для объектных бэкендов особенно важен: он использует листинг с меньшим числом API-запросов ценой большего потребления памяти (весь список объектов держится сразу) — обратный компромисс тому, что делает построчный stat() в rsync, но для объектных хранилищ обычно выгодный, поскольку там листинг дешевле поштучного stat.

Для чисто локальной или SFTP-синхронизации выигрыш rclone над многопоточным rsync не гарантирован — оба упираются в те же системные вызовы обхода локальной ФС, разница в качестве реализации параллелизма, а не в другой физике. Где rclone реально меняет картину — источник или приёмник является объектным хранилищем с собственным API листинга: там сравнение с rsync вообще некорректно, он к таким бэкендам напрямую не подключается. Готовый образ для развёртывания — rclone в docker-compose.

Отдельно стоит rclone check: если задача не «синхронизировать», а «убедиться, что копии совпадают», это часто быстрее полного sync — можно ограничиться сравнением размеров и хэшей без реальной передачи, тоже параллельно.

Снапшоты ФС вместо построчного сравнения: меняем задачу, а не алгоритм

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

ZFS send/receive. Если источник и приёмник на ZFS (или хотя бы источник, а приёмник получает поток и записывает его в свой пул), инкрементальная синхронизация выглядит так: делаете снапшот, при повторной синхронизации делаете новый снапшот и отправляете только разницу между двумя снапшотами как блочный поток:

# первая полная передача
zfs snapshot tank/data@sync1
zfs send tank/data@sync1 | ssh remote zfs receive backup/data

# следующая — только разница между снапшотами
zfs snapshot tank/data@sync2
zfs send -i tank/data@sync1 tank/data@sync2 | ssh remote zfs receive backup/data

Здесь ZFS сама знает, какие блоки изменились между снапшотами (это уже есть в метаданных пула из-за copy-on-write), и не нужно ни обходить дерево файлов, ни сравнивать миллионы stat — счёт идёт на изменённые блоки, а не на файлы. Это принципиально другой подход к задаче «синхронизировать данные», а не ускорение той же операции. Подробнее — в материале про ZFS send и receive для репликации.

Ограничение очевидно: подход требует ZFS (или Btrfs) на обеих сторонах и реплицирует датасет целиком по внутренней структуре, а не произвольные пути с фильтрами — гибкость выбора «эти файлы, но не эти» у rsync выше.

LVM-снапшоты снимают нагрузку с самого процесса сравнения, а не заменяют его. Даже без ZFS полезно снимать снапшот тома перед долгим rsync и работать с ним, а не с «живым» деревом: снапшот не меняется во время обхода (нет гонки с приложением, которое пишет файлы во время синхронизации), и это развязывает окно бэкапа с окном доступности продакшен-тома. Стоимость обхода миллионов файлов при этом не уменьшается — LVM не даёт ZFS-подобного дифа по блокам, — но снимается риск несогласованного снимка и конкуренция за I/O с боевой нагрузкой. Заранее стоит понимать ограничения и риски LVM-снапшотов, в частности деградацию при длинных снапшотах.

Практический ориентир: для регулярной репликации того же дерева на одной ФС с поддержкой send/receive снапшот-подход обычно выгоднее rsync по времени и предсказуемости — он убирает саму фазу сравнения. Для разовой синхронизации между разнородными ФС/облаками или с тонкой фильтрацией путей практичнее остаются rsync (параллельный) или rclone.

Как выбрать подход под свою ситуацию

СценарийЧто оправданоПочему
До ~500 тыс. файлов, разовая или редкая синхронизацияОбычный rsyncФаза обхода не доминирует, усложнение не окупается
Единицы-десятки миллионов файлов, регулярный запуск, есть запас по CPU/IOPSПараллельный rsync по поддиректориямИспользует простаивающий параллелизм железа без смены инструмента
Источник или приёмник — объектное хранилище / облакоrclone с --transfers/--checkersАрхитектурно спроектирован под листинг вместо построчного stat
Регулярная репликация, обе стороны на ZFS/Btrfssend/receive снапшотовУбирает фазу сравнения вообще, считает по изменённым блокам
Нужна согласованность снимка при живой записи, без ZFSСнапшот тома (LVM) + rsync/tar по немуРазвязывает окно бэкапа с рисками гонки, не ускоряет обход

Если сомневаетесь, с чего начать — измерьте, где реально уходит время: rsync -a --dry-run --stats покажет число файлов в списке и время до начала передачи отдельно от времени самой передачи, и часто этого достаточно, чтобы понять, обход у вас узкое место или нет, ещё до того, как менять инструмент.

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

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

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

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

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

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

Есть ли у rsync встроенный флаг для многопоточного обхода одного дерева?

Нет, внутри одного вызова классический rsync однопоточен на этапе построения списка. Параллелизм — только запуском нескольких независимых процессов на разные поддеревья.

--checksum ускорит или замедлит на миллионах файлов?

Замедлит фазу сравнения ещё сильнее — вместо дешёвого сравнения размера и mtime он требует полного чтения содержимого каждого файла на обеих сторонах. Используйте точечно, когда не доверяете mtime.

--inplace и --whole-file помогают при миллионах мелких файлов?

Они меняют фазу передачи, но не влияют на фазу обхода и сравнения — а именно она узкое место в этом сценарии.

rclone всегда быстрее rsync на больших деревьях?

Нет. На объектных хранилищах и облачных API — как правило да, потому что листинг там дешевле построчного stat. На локальной или SFTP-синхронизации преимущество не гарантировано — тестируйте на своих данных.

Снапшоты ZFS заменяют rsync полностью?

Только если обе стороны на ZFS/Btrfs и вас устраивает реплицировать датасет целиком, а не произвольный набор путей с фильтрами. Для гибкой выборочной синхронизации rsync и rclone практичнее.

Стоит ли архивировать дерево в один tar перед копированием?

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

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

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

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