Как выборочно переместить файлы модуля «Диск» Битрикс 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 ещё старый), следующий проход это распознаёт и просто чинит запись в БД.
Операция получается идемпотентной: повторный запуск на уже перенесённом файле безопасен. Это фундамент всей надёжности —
обе стратегии переноса ниже опираются на один и тот же атомарный примитив:
Как отличить «свои» файлы
Аудио определяется по MIME-типу из b_file.CONTENT_TYPE:
CONTENT_TYPE LIKE 'audio/%'
Не ориентируемся на расширение имени файла (%.mp3) — оно ненадёжно: телефония и внешние интеграции нередко сохраняют
записи с произвольными именами, а Bitrix всё равно проставляет корректный MIME при загрузке. Для некоторого класса файлов заменяем этот фильтр на свой признак (MODULE_ID, CONTENT_TYPE, связь с конкретной сущностью и т. п.).
Две стратегии переноса
Задача распадается на два разных сценария, и под каждый — своя стратегия.
1. Новые файлы — перехват события в реальном времени
Новые записи ловим на событии модуля «Диск» onAfterAddFile:
Здесь важна дисциплина: в обработчике события нельзя делать файловый ввод-вывод. Событие onAfterAddFile для телефонии
срабатывает синхронно внутри REST-запроса, который сохраняет запись звонка; любая тяжёлая операция там тормозит ответ и рискует
упереться в таймаут. Поэтому обработчик максимально лёгкий — проверяет MIME и пишет одну строку в очередь:
Небольшая задержка (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:
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, не включая перехват события вовсе.
Примерно такой запрос к БД:
Ещё несколько деталей реализации нашего решения:
- Бюджет времени. Через 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-курсорный агент для исторического бэклога. Обе идемпотентны и опираются на один и тот же код перемещения, поэтому их можно запускать одновременно, повторно и без страха что-либо испортить.
Решение спроектировал и реализовал разработчик Инитлаб, сертифицированный разработчик Битрикс, Алексей Зубенко.