430 слов | 3 минуты

Очистка осиротевших файлов в b_file и /upload/iblock

Инструкция по поиску и удалению неиспользуемых записей в b_file и соответствующих физических файлов.

Контекст задачи: в b_file ~965к записей и столько же файлов в /upload/iblock, но реально инфоблоки используют только ~28к. Остальное — мусор, накопившийся за годы.

Общая логика

Подход — whitelist, а не blacklist:

  1. Создаём временную таблицу b_file_used со всеми FILE_ID, которые реально используются (инфоблоки + UF + пользователи).
  2. Кандидатами на удаление считаем записи b_file, которых нет в b_file_used.
  3. Дополнительно ограничиваем удаление только MODULE_ID='iblock' — чтобы не задеть файлы других модулей (landing, sender, fileman, sale и т.д.), даже если они тоже могут быть осиротевшими.
  4. Удаляем через CFile::Delete() пакетами, чтобы корректно почистить и физические файлы, и кеши ресайзов.

1. Подготовка: бэкапы

Обязательно перед началом:

# Бэкап БД
mysqldump -u USER -p DBNAME > /backup/db_$(date +%F).sql

# Бэкап /upload/iblock (по желанию, но рекомендуется)
du -sh /home/bitrix/www/upload/iblock/   # прикинуть объём
tar czf /backup/upload_iblock_$(date +%F).tar.gz /home/bitrix/www/upload/iblock/

Если есть стейджинг с копией БД — прогнать сначала на нём.

2. Сбор используемых FILE_ID

2.1. Создание таблицы

DROP TABLE IF EXISTS b_file_used;
CREATE TABLE b_file_used (
    FILE_ID INT UNSIGNED NOT NULL,
    SOURCE  VARCHAR(64) NOT NULL,
    PRIMARY KEY (FILE_ID, SOURCE),
    KEY idx_file (FILE_ID)
) ENGINE=InnoDB;

2.2. Превью и детальные картинки элементов

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT PREVIEW_PICTURE, 'iblock_element_preview'
FROM b_iblock_element
WHERE PREVIEW_PICTURE IS NOT NULL AND PREVIEW_PICTURE > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT DETAIL_PICTURE, 'iblock_element_detail'
FROM b_iblock_element
WHERE DETAIL_PICTURE IS NOT NULL AND DETAIL_PICTURE > 0;

2.3. Картинки разделов

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT PICTURE, 'iblock_section_picture'
FROM b_iblock_section
WHERE PICTURE IS NOT NULL AND PICTURE > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT DETAIL_PICTURE, 'iblock_section_detail'
FROM b_iblock_section
WHERE DETAIL_PICTURE IS NOT NULL AND DETAIL_PICTURE > 0;

2.4. Файловые свойства инфоблоков версии 1

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT DISTINCT ep.VALUE, 'iblock_prop_v1'
FROM b_iblock_element_property ep
INNER JOIN b_iblock_property p ON p.ID = ep.IBLOCK_PROPERTY_ID
WHERE p.PROPERTY_TYPE = 'F'
  AND ep.VALUE REGEXP '^[0-9]+$'
  AND ep.VALUE > 0;

2.5. Файловые свойства инфоблоков версии 2

Сначала смотрим, какие инфоблоки в v2 и какие у них файловые свойства:

SELECT 
    ib.ID AS iblock_id,
    ib.NAME AS iblock_name,
    p.ID AS prop_id,
    p.CODE AS prop_code,
    p.MULTIPLE,
    CASE 
        WHEN p.MULTIPLE = 'Y' THEN CONCAT('b_iblock_element_prop_m', ib.ID)
        ELSE CONCAT('b_iblock_element_prop_s', ib.ID)
    END AS storage_table,
    CONCAT('PROPERTY_', p.ID) AS column_name
FROM b_iblock ib
INNER JOIN b_iblock_property p ON p.IBLOCK_ID = ib.ID
WHERE ib.VERSION = 2
  AND p.PROPERTY_TYPE = 'F'
ORDER BY ib.ID, p.ID;

На основе результата генерируем INSERT'ы. Пример для каталога IBLOCK_ID=7 (4 множественных свойства):

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT VALUE, 'iblock_7_prop_193_pictures'
FROM b_iblock_element_prop_m7
WHERE IBLOCK_PROPERTY_ID = 193
  AND VALUE REGEXP '^[0-9]+$' AND VALUE > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT VALUE, 'iblock_7_prop_472_more_photo'
FROM b_iblock_element_prop_m7
WHERE IBLOCK_PROPERTY_ID = 472
  AND VALUE REGEXP '^[0-9]+$' AND VALUE > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT VALUE, 'iblock_7_prop_473_files'
FROM b_iblock_element_prop_m7
WHERE IBLOCK_PROPERTY_ID = 473
  AND VALUE REGEXP '^[0-9]+$' AND VALUE > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT VALUE, 'iblock_7_prop_804_photos_vk'
FROM b_iblock_element_prop_m7
WHERE IBLOCK_PROPERTY_ID = 804
  AND VALUE REGEXP '^[0-9]+$' AND VALUE > 0;

Шаблон для немножественных (если попадутся): брать данные из b_iblock_element_prop_s{IBLOCK_ID}, колонка PROPERTY_{PROP_ID}.

2.6. Пользователи

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT PERSONAL_PHOTO, 'user_photo'
FROM b_user
WHERE PERSONAL_PHOTO IS NOT NULL AND PERSONAL_PHOTO > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT WORK_LOGO, 'user_logo'
FROM b_user
WHERE WORK_LOGO IS NOT NULL AND WORK_LOGO > 0;

2.7. UserField'ы типа "файл"

Сначала смотрим, какие есть:

SELECT 
    uf.ID,
    uf.ENTITY_ID,
    uf.FIELD_NAME,
    uf.MULTIPLE,
    LOWER(CONCAT('b_uts_', REPLACE(uf.ENTITY_ID, '\\', '_'))) AS single_table,
    LOWER(CONCAT('b_utm_', REPLACE(uf.ENTITY_ID, '\\', '_'))) AS multi_table
FROM b_user_field uf
WHERE uf.USER_TYPE_ID = 'file'
ORDER BY uf.ENTITY_ID, uf.FIELD_NAME;

Пример для секций IBLOCK_7_SECTION (одно множественное поле + четыре одиночных):

-- Множественное UF_IMAGES (ID=2)
INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT VALUE, 'uf_iblock_7_section_images'
FROM b_utm_iblock_7_section
WHERE FIELD_ID = 2
  AND VALUE REGEXP '^[0-9]+$' AND VALUE > 0;

-- Одиночные из b_uts_iblock_7_section
INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT UF_PRICE, 'uf_iblock_7_section_price'
FROM b_uts_iblock_7_section
WHERE UF_PRICE IS NOT NULL AND UF_PRICE > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT UF_SECTION_MENU_ICON, 'uf_iblock_7_section_menu_icon'
FROM b_uts_iblock_7_section
WHERE UF_SECTION_MENU_ICON IS NOT NULL AND UF_SECTION_MENU_ICON > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT UF_SVG_ICO, 'uf_iblock_7_section_svg_ico'
FROM b_uts_iblock_7_section
WHERE UF_SVG_ICO IS NOT NULL AND UF_SVG_ICO > 0;

INSERT IGNORE INTO b_file_used (FILE_ID, SOURCE)
SELECT UF_SVG_ICO_HOVER, 'uf_iblock_7_section_svg_ico_hover'
FROM b_uts_iblock_7_section
WHERE UF_SVG_ICO_HOVER IS NOT NULL AND UF_SVG_ICO_HOVER > 0;

3. Финальные счётчики

-- Сколько уникальных файлов используется
SELECT COUNT(DISTINCT FILE_ID) AS used_files FROM b_file_used;

-- Всего записей в b_file
SELECT COUNT(*) AS total_files FROM b_file;

-- Сколько кандидатов на удаление по модулю iblock
SELECT COUNT(*) AS iblock_orphans
FROM b_file f
LEFT JOIN b_file_used u ON u.FILE_ID = f.ID
WHERE u.FILE_ID IS NULL
  AND f.MODULE_ID = 'iblock';

-- Распределение использования по источникам (для контроля)
SELECT SOURCE, COUNT(*) AS cnt
FROM b_file_used
GROUP BY SOURCE
ORDER BY cnt DESC;

-- Распределение "осиротевших" по MODULE_ID — подскажет, не упустили ли источник
SELECT f.MODULE_ID, COUNT(*) AS cnt
FROM b_file f
LEFT JOIN b_file_used u ON u.FILE_ID = f.ID
WHERE u.FILE_ID IS NULL
GROUP BY f.MODULE_ID
ORDER BY cnt DESC;

Ожидаемая картина:

  • used_files ≈ 28 000
  • total_files ≈ 965 000
  • iblock_orphans ≈ 934 000

Если в распределении по модулям видны крупные группы для модулей, которыми реально пользуются (например, landing, sender, fileman) — нужно дописать сбор для этих модулей или просто не трогать их при удалении (фильтр MODULE_ID='iblock' это и обеспечивает).

4. Удаление осиротевших файлов

Стратегия

Удаление через CFile::Delete($id), пакетами по 500. Этот метод:

  • удаляет физический файл с диска,
  • удаляет запись из b_file,
  • чистит ресайзы (b_file_preview + /upload/resize_cache/).

Альтернатива (DELETE FROM b_file ... + rm) быстрее, но оставляет хвосты в b_file_preview и в resize_cache — придётся чистить вручную.

Скрипт cleanup_files.php

Создать файл /home/bitrix/www/local/php_interface/cleanup_files.php:

<?php
define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('STOP_STATISTICS', true);
define('DisableEventsCheck', true);

require($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');

set_time_limit(0);
ini_set('memory_limit', '1024M');

$batchSize = 500;
$logFile = __DIR__ . '/cleanup_log_' . date('Y-m-d_H-i-s') . '.log';
$totalDeleted = 0;
$totalErrors  = 0;

function logMsg($msg, $logFile) {
    $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
    echo $line;
    file_put_contents($logFile, $line, FILE_APPEND);
}

logMsg("=== Старт очистки ===", $logFile);

global $DB;

while (true) {
    // Берём пакет ID кандидатов
    $sql = "
        SELECT f.ID
        FROM b_file f
        LEFT JOIN b_file_used u ON u.FILE_ID = f.ID
        WHERE u.FILE_ID IS NULL
          AND f.MODULE_ID = 'iblock'
        LIMIT {$batchSize}
    ";

    $res = $DB->Query($sql);
    $ids = [];
    while ($row = $res->Fetch()) {
        $ids[] = (int)$row['ID'];
    }

    if (empty($ids)) {
        logMsg("Кандидатов больше нет. Готово.", $logFile);
        break;
    }

    foreach ($ids as $fileId) {
        try {
            CFile::Delete($fileId);
            $totalDeleted++;
        } catch (Exception $e) {
            $totalErrors++;
            logMsg("ERROR FILE_ID={$fileId}: " . $e->getMessage(), $logFile);
        }
    }

    logMsg("Пакет: " . count($ids) . " | Всего: {$totalDeleted} | Ошибок: {$totalErrors}", $logFile);

    usleep(100000); // 0.1 сек, чтобы не убить диск/БД
}

logMsg("=== Финал. Удалено: {$totalDeleted}, ошибок: {$totalErrors} ===", $logFile);

Запуск

cd /home/bitrix/www
nohup php -f local/php_interface/cleanup_files.php > /tmp/cleanup.out 2>&1 &
tail -f local/php_interface/cleanup_log_*.log

nohup ... & позволит спокойно отключиться от ssh, скрипт продолжит работать. Прогресс в реальном времени — через tail -f.

Скорость

Грубо 100–300 файлов/сек. На ~934к записей — от 1 до 3 часов.

Если железо позволяет — можно увеличить $batchSize до 1000–2000 и убрать usleep.

Прерывание и продолжение

Если скрипт упал/прервался — просто запусти снова. Каждая итерация делает свежий SELECT, поэтому продолжит с того, что осталось.

5. Финальная зачистка

После завершения скрипта:

# Очистить кеш ресайзов целиком (Bitrix пересоздаст по запросу)
rm -rf /home/bitrix/www/upload/resize_cache/*

# Удалить пустые директории в /upload/iblock
find /home/bitrix/www/upload/iblock/ -type d -empty -delete

# Ужать таблицу b_file
mysql -e "USE DBNAME; OPTIMIZE TABLE b_file;"

После этого таблица b_file_used больше не нужна:

DROP TABLE b_file_used;

Что НЕ покрывает инструкция

Сознательно оставлено за рамками — эти файлы при удалении сохранятся, потому что фильтр MODULE_ID='iblock' их не трогает. Но при необходимости их тоже можно почистить отдельно.

  • Inline-картинки в HTML описаний инфоблоков (PREVIEW_TEXT, DETAIL_TEXT). FILE_ID хранится в HTML как <img src="/upload/iblock/...">. Если в этих текстах есть картинки — нужен парсер HTML, который сматчит пути с b_file.SUBDIR + '/' + FILE_NAME.
  • Модуль landing (b_landing_file хранит явные привязки FILE_ID → блоки лендингов).
  • Модуль sender (HTML тел рассылок в b_sender_message_param).
  • Модуль fileman (медиабиблиотека и картинки из визуального редактора).
  • Файлы без записи в b_file (физический мусор в /upload/iblock, до которого не дошли руки в b_file). Лечится отдельным скриптом, который сравнивает листинг директории с b_file.
  • Сериализованные настройки в b_option (логотипы и т.п. — редко, но бывает).

Полезные ссылки и команды

# Размер /upload/iblock до и после
du -sh /home/bitrix/www/upload/iblock/

# Размер b_file до и после
mysql -e "USE DBNAME; SELECT 
  table_name,
  ROUND(((data_length + index_length) / 1024 / 1024), 2) AS size_mb,
  table_rows
FROM information_schema.tables
WHERE table_schema='DBNAME' AND table_name='b_file';"