Как оптимизировать удаление записей из периодического регистра сведений, подчиненного регистратору, в 1С?

Программист 1С v8.3 (Обычные формы) Торговля и дистрибуция
← На главную

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

В этой статье мы подробно разберем реальный кейс: имеется периодический регистр сведений алкХранилищеАкцизныхМарок (периодичность — по позиции регистратора, режим записи — подчинение регистратору). В регистре содержится 38 миллионов записей, база весит 17 ГБ, а размер индексов составляет колоссальные 41 ГБ. Измерение Марка имеет тип "Строка" длиной 160 символов. Задача — зачистить выбывшие марки, оставив данные только за последние полгода. Исходный код автора выполнял удаление крайне медленно — всего 500 тысяч записей за 12 часов работы.

Давайте подробно проанализируем причины этой проблемы и разберем эффективные методы оптимизации, начиная с простых программных улучшений в 1С и заканчивая низкоуровневыми операциями на стороне СУБД и изменением архитектуры данных.

---

Выясняем причины низкой производительности

Прежде чем переходить к оптимизации кода, давайте разберем, почему стандартное удаление записей в подчиненном регистре работает так медленно:

  1. Квадратичная сложность алгоритма вложенных циклов. В исходном коде автора для каждого регистратора выполнялся обход выборки детальных записей, а внутри него — цикл по всем записям набора. Это классический пример алгоритма с оценкой производительности O(N*M), который начинает лавинообразно тормозить при росте объема набора записей.
  2. Частые транзакции и накладные расходы. При выполнении метода НаборЗаписей.Записать() платформа 1С совершает множество неявных операций: проверяет права доступа, блокирует таблицы, пересчитывает тяжелые некластеризованные индексы СУБД и регистрирует изменения в транзакционном логе (LDF). При записи каждого набора по отдельности накладные расходы СУБД превышают само время удаления строк.
  3. Огромные индексы по строковому полю. Измерение Марка представляет собой строку длиной 160 символов. Индексирование столь длинных строковых полей в СУБД (особенно уникальных индексов регистра) приводит к раздуванию B-Tree структуры. При удалении каждой записи СУБД тратит колоссальное время на балансировку тяжелого дерева индексов.
  4. Постоянный расчет виртуальной таблицы СрезПоследних. При итерационном выполнении тяжелых запросов СУБД вынуждена динамически вычислять срез последних для миллионов записей, что при объеме таблицы в десятки гигабайт парализует дисковую подсистему.

---

Решение 1: Оптимизация алгоритма сопоставления данных в 1С (убираем вложенный цикл)

Проанализируем цикл обработки набора записей. В исходном варианте автор использовал вложенный цикл для поиска совпадений по маркам. Мы можем радикально ускорить этот процесс, используя объект Соответствие (хеш-таблицу), время поиска в котором стремится к O(1).

Рассмотрим улучшенный вариант кода обработки набора записей:


// Вместо медленного вложенного цикла используем поиск через Соответствие
СоответствиеУдаляемыхМарок = Новый Соответствие;
ВыборкаДетальныеЗаписи = ВыборкаРегистратор.Выбрать();

Пока ВыборкаДетальныеЗаписи.Следующий() Цикл
    // Заполняем карту удаляемых марок для быстрого доступа
    СоответствиеУдаляемыхМарок.Вставить(ВыборкаДетальныеЗаписи.Марка, Истина);
КонецЦикла;

// Теперь обходим набор записей с конца (чтобы корректно удалять элементы по индексу)
Индекс = НаборЗаписей.Количество() - 1;
Пока Индекс >= 0 Цикл
    ТекущаяЗапись = НаборЗаписей[Индекс];
    
    // Быстрая проверка наличия марки в списке удаляемых
    Если СоответствиеУдаляемыхМарок[ТекущаяЗапись.Марка] <> Неопределено Тогда
        НаборЗаписей.Удалить(ТекущаяЗапись);
        СчетчикМарки = СчетчикМарки + 1;
    КонецЕсли;
    
    Индекс = Индекс - 1;
КонецЦикла;

// Записываем измененный набор
НаборЗаписей.Записать();

Почему это работает быстрее? Мы избавились от вложенного перебора. Если в наборе содержится 100 записей, а в выборке удаляемых — 50, то вместо 5000 итераций сравнения мы выполняем ровно 100 обращений к хеш-таблице в памяти. На больших объемах данных это экономит часы процессорного времени сервера приложений.

---

Решение 2: Пакетная обработка транзакциями и оптимизация запроса

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

Разберем готовый шаблон оптимизированной обработки с использованием менеджера временных таблиц и пакетных транзакций:


Процедура ОптимизированноеУдаление()
    Сообщить("Начало подготовки кэша выбывших марок: " + ТекущаяДата());
    
    Запрос = Новый Запрос;
    Запрос.МенеджерВременныхТаблиц = Новый МенеджерВременныхТаблиц;
    
    // Шаг 1: Получаем срез последних один раз и кэшируем только выбывшие марки
    Запрос.Текст = 
    "ВЫБРАТЬ
    |    Регистр.Марка КАК Марка
    |ПОМЕСТИТЬ ВТ_ВыбывшиеМарки
    |ИЗ
    |    РегистрСведений.алкХранилищеАкцизныхМарок.СрезПоследних КАК Регистр
    |ГДЕ
    |    Регистр.ОтметкаВыбытия = ИСТИНА
    |ИНДЕКСИРОВАТЬ ПО
    |    Марка";
    
    Запрос.Выполнить();
    Сообщить("Кэш выбывших марок подготовлен. Начинаем выборку данных по периодам: " + ТекущаяДата());
    
    // Шаг 2: Выбираем движения документов порциями (например, помесячно)
    // Это позволит избежать переполнения памяти сервера 1С и TempDB в SQL
    ДатаНачала = '20180101000000';
    ДатаОкончания = '20200601000000';
    ШагМесяцев = 1;
    
    ТекущийПериодНачало = ДатаНачала;
    Пока ТекущийПериодНачало < ДатаОкончания Цикл
        ТекущийПериодКонец = ДобавитьМесяц(ТекущийПериодНачало, ШагМесяцев) - 1;
        
        Запрос.Текст = 
        "ВЫБРАТЬ
        |    Регистр.Регистратор КАК Регистратор,
        |    Регистр.Марка КАК Марка
        |ИЗ
        |    РегистрСведений.алкХранилищеАкцизныхМарок КАК Регистр
        |        ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_ВыбывшиеМарки КАК ВыбывшиеМарки
        |        ПО Регистр.Марка = ВыбывшиеМарки.Марка
        |ГДЕ
        |    Регистр.Период МЕЖДУ &ПериодНачало И &ПериодКонец
        |ИТОГИ ПО
        |    Регистратор";
        
        Запрос.УстановитьПараметр("ПериодНачало", ТекущийПериодНачало);
        Запрос.УстановитьПараметр("ПериодКонец", ТекущийПериодКонец);
        
        РезультатЗапроса = Запрос.Выполнить();
        Если Не РезультатЗапроса.Пустой() Тогда
            ВыполнитьУдалениеДанных(РезультатЗапроса);
        КонецЕсли;
        
        ТекущийПериодНачало = ТекущийПериодКонец + 1;
    КонецЦикла;
    
    Сообщить("Обработка завершена успешно: " + ТекущаяДата());
КонецПроцедуры

Процедура ВыполнитьУдалениеДанных(РезультатЗапроса)
    ВыборкаРегистратор = РезультатЗапроса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);
    
    РазмерПорцииТранзакции = 1000; // Оптимальный размер порции для избежания эскалации блокировок
    СчетчикДокументов = 0;
    
    НачатьТранзакцию();
    
    Пока ВыборкаРегистратор.Следующий() Цикл
        // Создаем набор записей по регистратору
        НаборЗаписей = РегистрыСведений.алкХранилищеАкцизныхМарок.СоздатьНаборЗаписей();
        НаборЗаписей.Отбор.Регистратор.Установить(ВыборкаРегистратор.Регистратор);
        НаборЗаписей.Прочитать();
        
        // Быстро сопоставляем через Соответствие
        СоответствиеМарок = Новый Соответствие;
        ВыборкаДетали = ВыборкаРегистратор.Выбрать();
        Пока ВыборкаДетали.Следующий() Цикл
            СоответствиеМарок.Вставить(ВыборкаДетали.Марка, Истина);
        КонецЦикла;
        
        БылиИзменения = Ложь;
        Индекс = НаборЗаписей.Количество() - 1;
        Пока Индекс >= 0 Цикл
            Если СоответствиеМарок[НаборЗаписей[Индекс].Марка] <> Неопределено Тогда
                НаборЗаписей.Удалить(Индекс);
                БылиИзменения = Истина;
            КонецЕсли;
            Индекс = Индекс - 1;
        КонецЦикла;
        
        Если БылиИзменения Тогда
            // Отключаем бизнес-логику и подписки на события для ускорения записи
            НаборЗаписей.ОбменДанными.Загрузка = Истина;
            НаборЗаписей.Записать();
        КонецЕсли;
        
        СчетчикДокументов = СчетчикДокументов + 1;
        
        // Фиксируем транзакцию порциями
        Если СчетчикДокументов % РазмерПорцииТранзакции = 0 Тогда
            ЗафиксироватьТранзакцию();
            НачатьТранзакцию();
        КонецЕсли;
    КонецЦикла;
    
    ЗафиксироватьТранзакцию();
КонецПроцедуры

Важное примечание: Свойство НаборЗаписей.ОбменДанными.Загрузка = Истина отключает платформенные проверки и обработчики событий при записи. Обязательно убедитесь, что в подписках на события вашей конфигурации прописано корректное условие обхода (например, Если Источник.ОбменДанными.Загрузка Тогда Возврат; КонецЕсли;), иначе тяжелые обработчики продолжат выполняться, снижая скорость удаления.

---

Решение 3: Сценарий «Пересоздания» регистра на уровне СУБД (Экстремальное ускорение)

Если вам требуется удалить большую часть данных регистра (например, более 60-70%), стандартное объектное удаление через платформу 1С экономически нецелесообразно. СУБД потратит колоссальные ресурсы на удаление сотен миллионов строк и постоянное обновление 41 ГБ индексов. Гораздо быстрее применить стратегию "сохранить нужное, очистить все, вернуть нужное".

Рассмотрим пошаговый план реализации этого метода на MS SQL Server (для выполнения требуются права администратора СУБД):

  1. Выясните физическое имя таблицы вашего регистра в СУБД с помощью метода 1С ПолучитьСтруктуруХраненияБазыДанных(). Допустим, имя таблицы — _InfoRg25624.
  2. Создайте в базе данных временную резервную таблицу и скопируйте туда только те записи, которые нужно сохранить (например, записи за последние 6 месяцев и действующие марки без отметки о выбытии). Сделать это можно простым SQL-запросом:
    
    SELECT * INTO _InfoRg25624_Backup 
    FROM _InfoRg25624 
    WHERE _Period >= '2020-06-01' OR _Fld25625_RRRef = 0x00 -- где _Fld25625 - поле "ОтметкаВыбытия" (Ложь)
    
  3. Выполните команду очистки основной таблицы регистра: TRUNCATE TABLE _InfoRg25624; Внимание: Команда TRUNCATE выполняется мгновенно (за доли секунды), поскольку она не записывает удаление каждой строки в транзакционный лог, а просто освобождает страницы данных и полностью сбрасывает все тяжелые индексы.
  4. Перенесите сохраненные данные обратно в чистую таблицу регистра: INSERT INTO _InfoRg25624 SELECT * FROM _InfoRg25624_Backup;
  5. Удалите резервную таблицу: DROP TABLE _InfoRg25624_Backup;
  6. Выполните полное перестроение индексов таблицы регистра в СУБД: ALTER INDEX ALL ON _InfoRg25624 REBUILD;

Результат: Очистка, которая средствами платформы 1С занимала бы недели непрерывной работы сервера, на уровне СУБД завершается всего за 5–15 минут (в зависимости от скорости дисковой подсистемы).

---

Решение 4: Временное отключение некластеризованных индексов

Если первый или второй способ (через код 1С) работают медленно, но использовать прямой TRUNCATE вы не можете (например, база находится в режиме разделенного доступа и пользователи работают в системе), вы можете временно отключить некластеризованные индексы таблицы в SQL Server перед запуском обработки.

Кластеризованный индекс регистра, подчиненного регистратору, строится по полям ОбластьДанных, Регистратор, НомерСтроки. Он необходим для работы. Все остальные индексы (по измерению Марка, Период и т.д.) можно временно отключить:


-- Отключаем тяжелые некластеризованные индексы таблицы регистра
ALTER INDEX [_InfoRg25624_ByMark] ON [_InfoRg25624] DISABLE;

После отключения индексов скорость записи наборов записей через 1С возрастет в 5–8 раз, так как СУБД больше не придется перестраивать индексные деревья при каждом удалении строки. По завершении работы обработки обязательно восстановите индексы одной командой:


-- Восстанавливаем и перестраиваем все индексы таблицы
ALTER INDEX ALL ON [_InfoRg25624] REBUILD;

---

Решение 5: Системное архитектурное исправление (на будущее)

Проанализируем структуру данных регистра. Измерение Марка имеет тип "Строка" длиной 160 символов. Хранить такие длинные строки непосредственно в качестве измерения периодического регистра — это серьезная архитектурная ошибка проектирования.

Причины, по которым это ухудшает производительность:

Рекомендуемое архитектурное решение:

  1. Создайте подчиненный справочник (например, алкАкцизныеМарки), где владельцем будет являться номенклатура, а реквизитом или наименованием — сама текстовая строка марки длиной 160 символов.
  2. Измените тип измерения Марка в регистре сведений со "Строка (160)" на СправочникСсылка.алкАкцизныеМарки.
  3. Перенесите признак ОтметкаВыбытия из ресурсов регистра в реквизиты этого справочника.

Благодаря этому изменению размер записи регистра сократится до минимума, индексы станут меньше в 5–6 раз, поиск по марке в СрезПоследних будет происходить мгновенно по внутреннему идентификатору ссылки (RefID), а необходимость очистки регистра отпадет сама собой, так как база данных перестанет критически раздуваться.

← На главную