Как выборочно переместить файлы модуля «Диск» Битрикс 24 в отдельное место на диске

Мы поддерживаем крупные внедрения Битрикс 24 с терабайтами загружаемых пользователями файлов. Недавно возникла задача — вынести записи звонков в отдельное файловое хранилище т.к. на диски на сервере записи перестали помещаться и их стало тяжело бекапить. Решение оказалось нетривиальным. Потребовалось разобраться в незадокументированных нюансах внутренней работы Битрикс с файлами и разработать целый набор модулей и скриптов.

Наше решение состоит из двух частей:

  • Новые загружаемые в Битрикс 24 файлы перехватываются и записи о них помещаются в очередь, сами файлы переносятся в фоновом режиме по крону параллельно работающими воркерами.
  • Старые миллионы загруженных файлов разбираются последовательно агентом.

Публикуем технические подробности решения.

Записи разговоров из встроенной телефонии Bitrix 24, вложения чатов, документы CRM — всё, что загружается через модуль «Диск»,
физически ложится в один и тот же каталог upload/disk/, разложенное по хэшированным подкаталогам вида upload/disk/a1b/c2d/.
На активной инсталляции это быстро превращается в миллионы вперемешку лежащих файлов.

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

  • посчитать их суммарный объём;
  • вынести именно их на отдельный (более дешёвый или более ёмкий) диск;
  • применить к ним отдельную политику бэкапа или хранения.

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

Ключевая идея: почему файл вообще можно «перенести»

Bitrix вычисляет физический путь к загруженному файлу исключительно из двух полей таблицы b_file:

путь = upload/SUBDIR/FILE_NAME

Ни модуль «Диск», ни CRM, ни телефония не хранят абсолютных путей — все они обращаются к файлу по его ID и получают путь через CFile. Значит, чтобы переместить файл, достаточно синхронно сделать три вещи:

1. Физически переместить файл на диске (rename()).

2. Обновить b_file.SUBDIR на новое значение.

3. Сбросить кэш путей: CFile::CleanCache($fileId).

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

Куда переносим

Целевую папку удобно держать внутри зоны модуля, с префиксом disk/:

disk/voice_recordings/...

Это не обязательно, но полезно: служебные скрипты платформы, которые сканируют upload/disk/, продолжают видеть файлы, и записи не «выпадают» из области хранения Диска. Внутри целевой папки сохраняем исходную структуру подкаталогов, меняя только «голову» пути.

«Хвост» — это SUBDIR без первого сегмента-модуля:

было: disk/a1b/c2d/file.mp3

хвост: a1b/c2d

стало: disk/voice_recordings/a1b/c2d/file.mp3

Имя файла (FILE_NAME) не меняется.

Порядок операций и самовосстановление

Порядок критичен: сначала двигаем файл, потом обновляем БД.

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

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

​function moveFile(int $fileId): string { [$subdir, $name] = getFileSubdirAndName($fileId); // из b_file if (str_starts_with($subdir, 'disk/voice_recordings')) { return 'already-there'; // идемпотентность } $tail = preg_replace('#^disk/#', '', $subdir); // a1b/c2d $newDir = 'disk/voice_recordings/' . $tail; $src = $_SERVER['DOCUMENT_ROOT'] . '/upload/' . $subdir . '/' . $name $dst = $_SERVER['DOCUMENT_ROOT'] . '/upload/' . $newDir . '/' . $name; if (is_file($src)) { @mkdir(dirname($dst), 0755, true); if (!@rename($src, $dst)) { throw new RuntimeException("move failed: $src -> $dst"); } } elseif (!is_file($dst)) { throw new RuntimeException("orphan: file missing in src and dst"); }// если !is_file($src) && is_file($dst) — упали между шагами, чиним БД ниже if (!updateSubdir($fileId, $newDir)) { // UPDATE b_file if (is_file($dst) && !is_file($src)) { @rename($dst, $src); // откат перемещения } throw new RuntimeException("db update failed for #$fileId"); } CFile::CleanCache($fileId); return 'moved';​ }

Как отличить «свои» файлы

Аудио определяется по MIME-типу из b_file.CONTENT_TYPE:
CONTENT_TYPE LIKE 'audio/%'

Не ориентируемся на расширение имени файла (%.mp3) — оно ненадёжно: телефония и внешние интеграции нередко сохраняют
записи с произвольными именами, а Bitrix всё равно проставляет корректный MIME при загрузке. Для некоторого класса файлов заменяем этот фильтр на свой признак (MODULE_ID, CONTENT_TYPE, связь с конкретной сущностью и т. п.).

Две стратегии переноса

Задача распадается на два разных сценария, и под каждый — своя стратегия.

1. Новые файлы — перехват события в реальном времени

Новые записи ловим на событии модуля «Диск» onAfterAddFile:

\Bitrix\Main\EventManager::getInstance()->addEventHandler( 'disk', 'onAfterAddFile', ['MyHandlers', 'onAfterAddFile'] );

Здесь важна дисциплина: в обработчике события нельзя делать файловый ввод-вывод. Событие onAfterAddFile для телефонии
срабатывает синхронно внутри REST-запроса, который сохраняет запись звонка; любая тяжёлая операция там тормозит ответ и рискует
упереться в таймаут. Поэтому обработчик максимально лёгкий — проверяет MIME и пишет одну строку в очередь:

public static function onAfterAddFile($file) { if (str_starts_with((string)$file['CONTENT_TYPE'], 'audio/')) { enqueue($file['ID'], time() + 60); // available_at = сейчас + 60 сек } }

Небольшая задержка (available_at = NOW() + 60 сек) нужна, чтобы воркер не схватил файл, пока телефония ещё дозагружает его на
диск.

Реальный перенос выполняет отдельный cron-воркер, разбирающий очередь.

Таблица-очередь

Простая таблица в БД покрывает все нужды: несколько параллельных воркеров, повторные попытки, восстановление после сбоя.

Поле: Назначение

file_id: ID записи в b_filestatus new → processing → done / error
claimed_by
: Номер воркера, забравшего строку
claimed_at: Когда забрал (для восстановления «зависших» строк)
attempts
: Счётчик попыток
available_at
: Не раньше этого времени строку можно брать (задержка/бэк-офф)
error
: Текст последней ошибки

Поведение воркера

  • Параллельность. Несколько воркеров работают одновременно; каждый атомарно забирает свою пачку одним UPDATE ... SET
    status='processing', claimed_by=N ... LIMIT 50
    . Дополнительная flock-блокировка на слот воркера не даёт двум экземплярам одного слота работать внахлёст (если предыдущий cron-запуск ещё не завершился).
  • Повторы с экспоненциальным бэк-оффом — до 5 попыток (задержки 2 → 4 → 8 → 16 минут, максимум 1 час). Исчерпав попытки, строка получает статус error и больше не мешает.
  • Восстановление зависших строк. Строку в статусе processing, захваченную более 10 минут назад (значит, воркер упал), можно
    перезабрать. Сравнивем время по часам MySQL (NOW()), а не PHP, — иначе расхождение таймзон приложения и БД даст ложные
    срабатывания.
  • Убираем N+1. SUBDIR для всей пачки тянем одним LEFT JOIN b_file, а не отдельным SELECT на каждую строку.
  • Крайние случаи решаются пометкой done без переноса: запись b_file уже удалена; файл уже лежит в целевой папке.

Воркеров прописываем в cron:

# crontab: два параллельных воркера, раз в минуту * * * * * php /path/to/worker.php --worker=1 * * * * * php /path/to/worker.php --worker=2

2. Исторический бэклог — агент Bitrix с forward-курсором

Событие ловит только новые загрузки. Миллионы уже накопленных записей нужно разгрести отдельно. Здесь удобен штатный механизм
— агент Bitrix (/bitrix/admin/agent_edit.php), при условии, что агенты выполняются на cron, а не на хитах посетителей.

Наивное решение — «просканировать b_file, найти неперенесённое аудио, перенести пачку» — на большой таблице разваливается:
полное сканирование каждую минуту кладёт нагрузку на мастер БД, а курсор постоянно спотыкается о «голову» таблицы, где всё давно
перенесено. Решает проблему forward-only курсор по ID:

  • Агент идёт по b_file строго вперёд: WHERE ID > :cursor ORDER BY ID, запоминая последний обработанный ID в файле
    курсоре.
  • Один раз пройденный диапазон больше не сканируется. Уже перенесённые и «битые» строки не залипают.
  • Свежие загрузки сидят на самых больших auto-increment ID, поэтому курсор доходит и до них — при желании этим агентом можно закрыть и сценарий No1, не включая перехват события вовсе.

Примерно такой запрос к БД:

SELECT ID, SUBDIR, FILE_NAME FROM b_file WHERE ID > :cursor AND MODULE_ID = 'disk' AND CONTENT_TYPE LIKE 'audio/%' AND SUBDIR NOT LIKE 'disk/voice_recordings%' ORDER BY ID LIMIT 200

Ещё несколько деталей реализации нашего решения:

  • Бюджет времени. Через cron агент крутит пачки, пока не израсходует ~50 секунд, затем сохраняет курсор и выходит. На обычном web-хите (если агент всё же сработал там) — ровно одну пачку, чтобы не тормозить посетителя.
  • Cooldown на пустом бэклоге. Если проход не нашёл работы, следующие сканирования пропускаются, скажем, на час. Гонять
    полный b_file каждую минуту «вхолостую» — лишняя нагрузка на мастер и риск словить таймаут name-lock Диска (что, в свою
    очередь, ломает штатное прикрепление новых записей). Как только работа появляется — бэк-офф сбрасывается.
  • Блокировка flock, чтобы два прохода агента не пересеклись.
  • Один итог на проход в лог: сколько moved / reconciled / orphan / error и куда сдвинулся курсор.

Каждую строку агент прогоняет через тот же атомарный примитив, что и воркер, с четырьмя исходами:

  • moved — файл перемещён, SUBDIR обновлён;
  • reconciled — файл уже был в целевой папке, поправлен только SUBDIR в БД;
  • orphan — файла нет ни в источнике, ни в назначении;
  • error — сбой (неожиданный формат SUBDIR, ошибка перемещения/UPDATE).

Перенос на другой физический диск

Отдельный приятный бонус: целевую папку можно сделать симлинком на другой раздел/диск, и никакого спецкода для этого не требуется. PHP rename() при попытке переместить файл через границу файловых систем возвращает ошибку EXDEV, и рантайм сам выполняет copy + unlink. То есть перенос «маленький SSD → большой HDD» работает через тот же rename(), что и перенос внутри одного диска.

Откат

Поскольку примитив симметричен, откат — это тот же алгоритм наоборот: для каждого файла под целевой папкой вернуть его в
исходный upload/disk/<хвост>/, восстановить SUBDIR, сбросить кэш. Разумно предусмотреть параметр --dry-run, который только показывает
план, ничего не трогая.

Об «орфанах»

Орфан — это живая строка b_file (и, возможно, b_disk_object), чьего физического файла нет ни в источнике, ни в целевой папке.
Перемещение файлов орфанов не создаёт. Он не может перенести то, чего нет на диске.

Как правило, орфаны существовали и до переноса: это либо давно битые ссылки, либо следствие дедупликации CFile/Disk (несколько строк b_file ссылались на один физический файл). Перенос лишь делает их заметными. Поэтому полезно завести отдельные read-only отчёты, которые классифицируют неперенесённое аудио на «в источнике / в назначении / орфан» и показывают, где орфан прикреплён (карточка CRM, чат, папка Диска), — чтобы разобраться с ними осознанно, а не вслепую.

Гарантии целостности — коротко

  • Перемещение файла и обновление БД согласованы; сбой между шагами самовосстанавливается на следующем проходе.
  • Пустой исходный подкаталог удаляется только после успешного UPDATE.
  • Операция идемпотентна: повторный запуск на уже перенесённом файле безопасен.
  • Перенос между разными файловыми системами поддерживается «из коробки».

Итог

Нами разработано надежное решение по перемещению файлов модуля «Диск» Битрикс 24.

Весь перенос держится на одном факте о Bitrix: физический путь файла — это производное от b_file.SUBDIR + FILE_NAME, и больше он нигде не продублирован. Отсюда — атомарный примитив «переместить + обновить SUBDIR + сбросить кэш», а поверх него две независимые стратегии: перехват события для новых файлов и forward-курсорный агент для исторического бэклога. Обе идемпотентны и опираются на один и тот же код перемещения, поэтому их можно запускать одновременно, повторно и без страха что-либо испортить.

Решение спроектировал и реализовал разработчик Инитлаб, сертифицированный разработчик Битрикс, Алексей Зубенко.