Как массово заполнить новое измерение в независимом регистре сведений с большим количеством записей?

Программист 1С v8.3 (Управляемые формы)
← На главную

При разработке и модификации информационных систем на платформе "1С:Предприятие 8" часто возникает задача добавления нового измерения в уже существующий регистр сведений. Если регистр содержит небольшое количество записей, заполнить новое измерение не составляет труда. Однако, когда речь идет о десятках и сотнях тысяч, а порой и миллионах записей, традиционные методы, такие как перебор в цикле с использованием МенеджераЗаписи, становятся крайне неэффективными и могут привести к длительным блокировкам базы данных.

В этой статье мы подробно рассмотрим, как решить задачу массового заполнения нового измерения в независимом регистре сведений, используя наиболее производительные и безопасные подходы, а также разберем распространенные ошибки и ограничения.

Особенности независимого регистра сведений и причины проблем с производительностью

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

Основная проблема при массовом заполнении большого количества записей заключается в том, что каждая операция записи в 1С (через МенеджерЗаписи или НаборЗаписей) инициирует взаимодействие с базой данных (СУБД). Каждая такая операция выполняется в рамках транзакции, которая может блокировать изменяемые данные. Если таких операций очень много и они выполняются последовательно в одной большой транзакции, это приводит к следующим проблемам:

  1. Длительная блокировка базы данных: Одна большая транзакция может заблокировать таблицу регистра на продолжительное время, что сделает регистр недоступным для других пользователей и процессов, существенно снижая работоспособность системы.
  2. Эскалация блокировок: Некоторые СУБД (например, MS SQL Server) могут эскалировать построчные блокировки до блокировки всей таблицы, если количество блокируемых строк превышает определенный порог, что полностью парализует работу с регистром.
  3. Высокое потребление ресурсов: Длительные транзакции потребляют много оперативной памяти и ресурсов СУБД, что может привести к замедлению работы всей системы.
  4. Тайм-ауты и сбои: При очень большом объеме данных и длительной операции возможны тайм-ауты на уровне СУБД или платформы 1С, что приведет к прерыванию операции и необходимости ее повторного запуска.

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

Эффективное решение: Использование объекта НаборЗаписей с порционной обработкой

Метод, который считается одним из самых производительных и безопасных для массовых операций с регистрами сведений, основан на использовании объекта НаборЗаписей в сочетании с порционной обработкой и управлением транзакциями. Давайте разберем его подробнее.

Принцип работы

Объект НаборЗаписей позволяет оперировать сразу целым множеством записей регистра. В отличие от МенеджераЗаписи, который работает с одной записью, НаборЗаписей может быть выгружен в ТаблицуЗначений, модифицирован и затем загружен обратно и записан одной пакетной операцией. Это значительно сокращает количество обращений к базе данных и оптимизирует процесс записи.

Для работы с очень большими объемами данных, чтобы избежать упомянутых выше проблем с блокировками, необходимо использовать порционную обработку. Суть метода заключается в следующем:

  1. Мы не выбираем все записи регистра сразу, а считываем их небольшими частями (порциями).
  2. Каждая порция данных обрабатывается: выгружается в ТаблицуЗначений, в ней заполняется новое измерение, и затем эта ТаблицаЗначений загружается обратно в НаборЗаписей.
  3. Затем НаборЗаписей записывается. Ключевой момент: запись каждой порции производится в отдельной, короткой транзакции. Это предотвращает длительные блокировки.

Оптимальный размер порции обычно находится в диапазоне от 1000 до 10 000 записей, но может варьироваться в зависимости от производительности сервера, количества полей в регистре и общей нагрузки на базу данных.

Пошаговая реализация метода

  1. Определение параметров:

    Начнем с определения имени нашего регистра сведений, имени нового измерения и значения, которым мы хотим его заполнить. Также установим размер порции.

    
    ИмяРегистра = "ВашРегистрСведений"; // Замените на имя вашего регистра
    ИмяНовогоИзмерения = "НовоеИзмерение"; // Замените на имя нового измерения
    ЗначениеДляИзмерения = Перечисления.ЗначенияНовогоИзмерения.Значение1; // Например, ссылка на элемент справочника, перечисление и т.д.
    РазмерПорции = 5000; // Оптимальный размер порции
    
  2. Организация цикла обработки порций:

    Мы будем использовать цикл Пока, который будет продолжаться до тех пор, пока мы не обработаем все записи регистра. В каждой итерации цикла мы будем считывать следующую порцию данных.

    
    КоличествоОбработанных = 0;
    Продолжать = Истина;
    КлючиПоследнейЗаписиПредыдущейПорции = Неопределено; // Для пагинации
            
    Пока Продолжать Цикл
        // ... (дальнейший код для выборки и обработки порции)
    КонецЦикла;
    
  3. Формирование запроса для выборки порции:

    Для выборки порции данных используем объект Запрос. Важно выбрать все ключевые измерения регистра, так как они необходимы для однозначной идентификации записей в НабореЗаписей. Мы также используем конструкцию ВЫБРАТЬ ПЕРВЫЕ <РазмерПорции> для ограничения количества выбираемых записей и условие УПОРЯДОЧИТЬ ПО для обеспечения стабильного порядка выборки, что критично для правильной пагинации.

    Условие для пагинации (ГДЕ) будет зависеть от ключевых измерений вашего регистра. Если у вас несколько ключевых измерений, условие может быть сложным. Для простоты мы будем использовать пример с несколькими измерениями Измерение1, Измерение2, Измерение3 и предположим, что Измерение1 является основным для сортировки.

    
    Запрос = Новый Запрос;
    ТекстЗапроса = "ВЫБРАТЬ ПЕРВЫЕ " + РазмерПорции + "
                  |    РС.Измерение1,
                  |    РС.Измерение2,
                  |    РС.Измерение3, // Все ключевые измерения регистра
                  |    РС." + ИмяНовогоИзмерения + " // Выбираем и новое измерение
                  |ИЗ
                  |    РегистрСведений." + ИмяРегистра + " КАК РС
                  |ГДЕ ИСТИНА"; // Базовое условие
    
    Если КлючиПоследнейЗаписиПредыдущейПорции <> Неопределено Тогда
        // Условие для пагинации. Это пример, который необходимо адаптировать
        // под конкретную структуру ваших ключевых измерений и их порядок сортировки.
        // Для сложных составных ключей условие может быть значительно сложнее.
        // Например, для ключей (Измерение1, Измерение2):
        // (РС.Измерение1 > &Измерение1) ИЛИ
        // (РС.Измерение1 = &Измерение1 И РС.Измерение2 > &Измерение2)
        // И так далее для всех ключевых измерений.
                
        // Здесь мы используем упрощенный подход, полагаясь на то, что Измерение1
        // является основным для сортировки и обеспечивает уникальность в рамках порции.
        ТекстЗапроса = ТекстЗапроса + " И РС.Измерение1 > &ПоследнееИзмерение1";
        Запрос.УстановитьПараметр("ПоследнееИзмерение1", КлючиПоследнейЗаписиПредыдущейПорции.Измерение1);
        // Если Измерение1 может быть неуникальным в последней записи порции,
        // и чтобы избежать пропусков или дублирований, нужно добавлять условия по другим ключевым измерениям.
        // Например: Запрос.УстановитьПараметр("ПоследнееИзмерение2", КлючиПоследнейЗаписиПредыдущейПорции.Измерение2);
    КонецЕсли;
    
    ТекстЗапроса = ТекстЗапроса + "
                  |УПОРЯДОЧИТЬ ПО РС.Измерение1, РС.Измерение2, РС.Измерение3"; // Важно для стабильной пагинации
    
    Запрос.Текст = ТекстЗапроса;
    РезультатЗапроса = Запрос.Выполнить();
    ТаблицаИзмерений = РезультатЗапроса.Выгрузить();
    
  4. Обработка выбранной порции:

    Если в порции есть записи, мы выгружаем их в ТаблицуЗначений, затем используем метод ЗаполнитьЗначения() для быстрого заполнения нужной колонки (нового измерения) одним и тем же значением. После этого создаем НаборЗаписей для нашего регистра и загружаем в него модифицированную ТаблицуЗначений.

    
    Если ТаблицаИзмерений.Количество() = 0 Тогда
        Продолжать = Ложь; // Больше нет записей для обработки
        Прервать;
    КонецЕсли;
    
    // Заполняем новое измерение нужным значением
    ТаблицаИзмерений.ЗаполнитьЗначения(ЗначениеДляИзмерения, ИмяНовогоИзмерения);
    
    // Создаем набор записей и загружаем в него данные
    НаборЗаписей = РегистрыСведений[ИмяРегистра].СоздатьНаборЗаписей();
    НаборЗаписей.Загрузить(ТаблицаИзмерений);
    
  5. Управление транзакциями и запись:

    Перед записью каждой порции мы начинаем транзакцию с помощью НачатьТранзакцию(), а после успешной записи фиксируем ее с помощью ЗафиксироватьТранзакцию(). Это гарантирует, что каждая порция будет записана атомарно, и длительных блокировок не произойдет. В случае ошибки транзакцию можно отменить.

    При записи НабораЗаписей мы используем метод Записать(). Если в регистре уже есть записи с такими же ключевыми измерениями, но пустым новым измерением, то по умолчанию 1С будет обновлять их. Для явного указания режима обновления или замещения, особенно в более новых версиях платформы (8.3.10+), можно использовать свойство РежимЗаписи объекта НаборЗаписей.

    • НаборЗаписей.Записать(Истина): Устаревший, но все еще рабочий метод, который означает "заместить записи". Он может быть менее эффективным, чем новые режимы.
    • НаборЗаписей.РежимЗаписи = РежимЗамещения.Обновление;: Этот режим позволяет только обновить уже существующие записи в регистре, не добавляя новые. Это значительно быстрее, так как выполняется только операция UPDATE на уровне СУБД, без предварительного DELETE.
    • НаборЗаписей.РежимЗаписи = РежимЗамещения.Слияние;: Данный режим позволяет одновременно обновить существующие записи и добавить новые.
    
    // Начинаем транзакцию для каждой порции
    НачатьТранзакцию();
    Попытка
        // Записываем набор записей. 
        // Для версий 8.3.10+ предпочтительнее: НаборЗаписей.РежимЗаписи = РежимЗамещения.Обновление;
        // Тогда просто: НаборЗаписей.Записать();
        НаборЗаписей.Записать(Истина); 
        ЗафиксироватьТранзакцию();
                
        КоличествоОбработанных = КоличествоОбработанных + ТаблицаИзмерений.Количество();
        Сообщить("Обработано записей: " + КоличествоОбработанных);
    
        // Обновляем ключи для следующей порции, берем последние значения из текущей порции
        Если ТаблицаИзмерений.Количество() > 0 Тогда
            КлючиПоследнейЗаписиПредыдущейПорции = ТаблицаИзмерений.Получить(ТаблицаИзмерений.Количество() - 1);
            // Если ключевых измерений несколько, нужно сохранить значения всех ключевых измерений
            // из последней записи, чтобы правильно построить условие для следующей порции.
            // Например, если ключи: Измерение1, Измерение2, Измерение3
            // КлючиПоследнейЗаписиПредыдущейПорции = Новый Структура("Измерение1, Измерение2, Измерение3",
            // ПоследняяЗапись.Измерение1, ПоследняяЗапись.Измерение2, ПоследняяЗапись.Измерение3);
        КонецЕсли;
    
        Если ТаблицаИзмерений.Количество() < РазмерПорции Тогда
            Продолжать = Ложь; // Обработаны все записи
        КонецЕсли;
    
    Исключение
        ОтменитьТранзакцию();
        Сообщить("Ошибка при обработке порции записей: " + ОписаниеОшибки());
        Продолжать = Ложь; // Прекращаем обработку при ошибке
    КонецПопытки;
    

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


Процедура ЗаполнитьНовоеИзмерениеВРегистреСведений(ЗначениеДляИзмерения)

    ИмяРегистра = "ВашРегистрСведений"; // Замените на имя вашего регистра
    ИмяНовогоИзмерения = "НовоеИзмерение"; // Замените на имя нового измерения
    РазмерПорции = 5000; // Оптимальный размер порции для вашей системы

    Сообщить("Начало заполнения измерения '" + ИмяНовогоИзмерения + "' значением '" + ЗначениеДляИзмерения + "'...");

    КоличествоОбработанных = 0;
    Продолжать = Истина;
    КлючиПоследнейЗаписиПредыдущейПорции = Неопределено; 

    Пока Продолжать Цикл
        НачатьТранзакцию();
        Попытка

            Запрос = Новый Запрос;
            ТекстЗапроса = "ВЫБРАТЬ ПЕРВЫЕ " + РазмерПорции + "
                          |    РС.Измерение1,
                          |    РС.Измерение2,
                          |    РС.Измерение3, // Укажите ВСЕ ключевые измерения вашего регистра
                          |    РС." + ИмяНовогоИзмерения + " // Выбираем и само новое измерение
                          |ИЗ
                          |    РегистрСведений." + ИмяРегистра + " КАК РС
                          |ГДЕ ИСТИНА";

            // Условие для пагинации. Это - упрощенный пример, который нужно адаптировать
            // под конкретную структуру ключевых измерений вашего регистра.
            // Если ключевых измерений несколько, и они могут быть неуникальными по отдельности,
            // потребуется более сложное условие для гарантированной выборки "следующей" порции.
            // Например, для ключей (Измерение1, Измерение2):
            // (РС.Измерение1 > &МаксИзмерение1) ИЛИ
            // (РС.Измерение1 = &МаксИзмерение1 И РС.Измерение2 > &МаксИзмерение2)
            // и т.д.
            
            Если КлючиПоследнейЗаписиПредыдущейПорции <> Неопределено Тогда
                ТекстУсловияПагинации = " (РС.Измерение1 > &ПредыдущееИзмерение1) ";
                Запрос.УстановитьПараметр("ПредыдущееИзмерение1", КлючиПоследнейЗаписиПредыдущейПорции.Измерение1);
                
                // Если Измерение1 может быть неуникальным, добавьте дополнительные условия:
                // ТекстУсловияПагинации = ТекстУсловияПагинации + " ИЛИ (РС.Измерение1 = &ПредыдущееИзмерение1 И РС.Измерение2 > &ПредыдущееИзмерение2) ";
                // Запрос.УстановитьПараметр("ПредыдущееИзмерение2", КлючиПоследнейЗаписиПредыдущейПорции.Измерение2);
                // (И так далее для всех ключевых измерений)

                ТекстЗапроса = ТекстЗапроса + " И " + ТекстУсловияПагинации;
            КонецЕсли;

            ТекстЗапроса = ТекстЗапроса + "
                          |УПОРЯДОЧИТЬ ПО РС.Измерение1, РС.Измерение2, РС.Измерение3"; // Укажите ВСЕ ключевые измерения для сортировки

            Запрос.Текст = ТекстЗапроса;

            РезультатЗапроса = Запрос.Выполнить();
            ТаблицаИзмерений = РезультатЗапроса.Выгрузить();

            Если ТаблицаИзмерений.Количество() = 0 Тогда
                Продолжать = Ложь; // Больше нет записей
                Прервать;
            КонецЕсли;

            // Заполняем новое измерение нужным значением
            ТаблицаИзмерений.ЗаполнитьЗначения(ЗначениеДляИзмерения, ИмяНовогоИзмерения);

            // Создаем набор записей и загружаем в него данные
            НаборЗаписей = РегистрыСведений[ИмяРегистра].СоздатьНаборЗаписей();
            НаборЗаписей.Загрузить(ТаблицаИзмерений);

            // Записываем набор записей.
            // Для платформы 8.3.10+ и выше рекомендуется использовать РежимЗаписи:
            // НаборЗаписей.РежимЗаписи = РежимЗамещения.Обновление; // Обновляет только существующие
            // НаборЗаписей.Записать();
            // Для более старых версий или если нужно замещать записи:
            НаборЗаписей.Записать(Истина); // Истина означает "заместить записи"

            ЗафиксироватьТранзакцию(); // Фиксируем транзакцию для текущей порции

            КоличествоОбработанных = КоличествоОбработанных + ТаблицаИзмерений.Количество();
            Сообщить("Обработано записей: " + КоличествоОбработанных + ". Последняя порция: " + ТаблицаИзмерений.Количество());

            // Обновляем ключи для следующей порции
            Если ТаблицаИзмерений.Количество() > 0 Тогда
                // Получаем значения всех ключевых измерений последней записи порции
                ПоследняяЗаписьПорции = ТаблицаИзмерений.Получить(ТаблицаИзмерений.Количество() - 1);
                КлючиПоследнейЗаписиПредыдущейПорции = Новый Структура;
                КлючиПоследнейЗаписиПредыдущейПорции.Вставить("Измерение1", ПоследняяЗаписьПорции.Измерение1);
                // Добавьте сюда другие ключевые измерения вашего регистра
                // КлючиПоследнейЗаписиПредыдущейПорции.Вставить("Измерение2", ПоследняяЗаписьПорции.Измерение2);
                // КлючиПоследнейЗаписиПредыдущейПорции.Вставить("Измерение3", ПоследняяЗаписьПорции.Измерение3);
            КонецЕсли;
            
            Если ТаблицаИзмерений.Количество() < РазмерПорции Тогда
                Продолжать = Ложь; // Текущая порция меньше размера, значит, это была последняя
            КонецЕсли;


        Исключение
            ОтменитьТранзакцию(); // Отменяем транзакцию в случае ошибки
            Сообщить("Ошибка при заполнении измерения: " + ОписаниеОшибки());
            Продолжать = Ложь; // Останавливаем обработку
        КонецПопытки;

    КонецЦикла;

    Если Продолжать = Ложь И КоличествоОбработанных > 0 Тогда
        Сообщить("Заполнение измерения завершено успешно. Всего обработано записей: " + КоличествоОбработанных);
    ИначеЕсли КоличествоОбработанных = 0 Тогда
        Сообщить("Не найдено записей для обработки или произошла ошибка в самом начале.");
    КонецЕсли;

КонецПроцедуры

Неэффективный подход: Использование МенеджераЗаписи в цикле

В рамках обсуждения на форуме был предложен пример кода, использующий МенеджерЗаписи для каждой записи. Давайте рассмотрим этот подход и объясним, почему он не подходит для больших объемов данных.

Пример неэффективного кода


Процедура УстановитьНовоеИзмерениеНеэффективно(Значение)
    Запрос = Новый Запрос;
    Запрос.Текст = "ВЫБРАТЬ
                  | РС.Измерение1,
                  | РС.Измерение2 // Предполагаемые ключевые измерения
                  |ИЗ
                  | РегистрСведений.РС КАК РС";
    Результат = Запрос.Выполнить();
    Выборка = Результат.Выбрать();
    Пока Выборка.Следующий Цикл
        МенеджерЗаписи = РегистрыСведений.РС.СоздатьМенеджерЗаписи();
        МенеджерЗаписи.Измерение1 = Выборка.Измерение1;
        МенеджерЗаписи.Измерение2 = Выборка.Измерение2;
        МенеджерЗаписи.Прочитать(); // Чтение записи по ключу
        Если МенеджерЗаписи.Выбран Тогда
            МенеджерЗаписи.НовоеИзмерение = Значение;
            МенеджерЗаписи.Записать(); // Запись каждой записи по отдельности
        Иначе
            Сообщить("Менеджер записи не выбран: " + МенеджерЗаписи.Измерение1 + ", " + МенеджерЗаписи.Измерение2);
        КонецЕсли;
    КонецЦикла;
КонецПроцедуры

Почему этот подход плох?

Как было справедливо отмечено в сообщении 7, "записей очень много и простым перебором в цикле будет долго". Причины следующие:

  1. Множество транзакций или одна очень длинная транзакция: Если каждая запись происходит в своей транзакции, это приводит к высокой нагрузке на СУБД из-за частых операций фиксации. Если же все записи происходят в одной большой транзакции (например, если обернуть весь цикл в НачатьТранзакцию() и ЗафиксироватьТранзакцию()), то это приведет к длительной блокировке таблицы, что еще хуже для производительности и доступности базы.
  2. Избыточное взаимодействие с СУБД: Каждая операция МенеджерЗаписи.Прочитать() и МенеджерЗаписи.Записать() — это отдельный запрос к базе данных. Для миллиона записей это означает два миллиона обращений к СУБД (миллион чтений и миллион записей), что создает колоссальную нагрузку и замедляет выполнение до неприемлемых значений.
  3. Нагрузка на клиентский компьютер/сервер 1С: Передача каждой записи по отдельности между сервером 1С (или файловой базой) и СУБД, а также создание множества объектов МенеджерЗаписи, потребляет значительные ресурсы памяти и процессора.

Поэтому этот метод категорически не рекомендуется для работы с большими объемами данных.

Дополнительные методы и рекомендации

Прямое изменение данных через SQL (для клиент-серверных баз)

Для клиент-серверных баз данных (например, на MS SQL Server, PostgreSQL) существует наиболее быстрый, но и самый рискованный метод — прямое выполнение SQL-запроса на обновление таблицы регистра сведений. Такой запрос может обновить миллионы записей за считанные секунды.

Пример SQL-запроса (концептуально):

UPDATE _Reference23456 SET _Fld345 = 'ЗНАЧЕНИЕ' WHERE _Fld345 IS NULL;

Где _Reference23456 — это таблица регистра сведений в СУБД, а _Fld345 — колонка, соответствующая новому измерению.

Однако, у этого подхода есть существенные недостатки:

  1. Нарушение целостности и бизнес-логики: Изменение данных в обход платформы 1С не вызывает никаких событий, проверок, триггеров или механизмов регистрации изменений, заложенных в конфигурации. Это может нарушить целостность данных и логику работы системы.
  2. Проблемы с обменом данными: Прямое изменение не регистрирует данные для обмена с другими базами, что может привести к расхождениям.
  3. Сложность и непереносимость: Требует глубокого знания структуры базы данных 1С (имен таблиц и полей в СУБД) и сильно зависит от конкретной СУБД. Код на 1С более универсален.
  4. Высокий риск потери данных: Некорректное использование может привести к необратимой потере данных и нарушению работоспособности информационной базы.

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

Использование готовых универсальных обработок

Как было упомянуто в сообщениях 2 и 5, для решения подобных задач можно использовать готовые универсальные обработки для группового изменения данных. Такие обработки часто уже содержат оптимизированные алгоритмы для работы с большими объемами информации, включая порционную обработку. Они позволяют в пользовательском режиме настроить отборы, указать, какие реквизиты и как изменять, и выполнить операцию без необходимости писать код вручную.

Создание своей внешней обработки (как предложено в сообщении 5) также является хорошим решением, так как позволяет инкапсулировать всю логику в один файл, который легко переносить и использовать в разных базах.

Общие рекомендации и лучшие практики

  1. Всегда тестируйте на копии базы: Перед выполнением любой массовой операции в рабочей базе, обязательно протестируйте ее на актуальной резервной копии. Убедитесь, что операция выполняется корректно и с приемлемой производительностью.
  2. Выполняйте в нерабочее время: Даже с порционной обработкой массовые операции могут создавать нагрузку на базу данных. Планируйте их выполнение в часы минимальной активности пользователей.
  3. Мониторинг производительности: Во время выполнения операции отслеживайте нагрузку на сервер 1С и СУБД. Это поможет определить оптимальный размер порции и выявить потенциальные проблемы.
  4. Индексы: Убедитесь, что ключевые измерения вашего регистра имеют соответствующие индексы в СУБД. Это критично для производительности запросов, используемых для выборки порций данных.
  5. Журнал регистрации: Если требуется детальный контроль, можно записывать информацию о ходе выполнения операции в журнал регистрации 1С.

Выбрав правильный подход и следуя этим рекомендациям, вы сможете эффективно и безопасно заполнить новое измерение в регистре сведений, даже при работе с очень большим объемом данных.

← На главную