Разработка интеграционных механизмов между 1С и внешними источниками данных, такими как служба каталогов Active Directory (AD), часто сопряжена с серьезными проблемами производительности. Неоптимальный код может работать приемлемо на тестовой базе с двумя пользователями, но при переносе в рабочую среду крупного предприятия (например, торговой сети вроде «Тандер») вызывать зависания системы, взаимоблокировки базы данных (deadlocks) и колоссальные задержки. В этой статье мы подробно разберем, как исправить архитектурные и синтаксические ошибки в процедуре синхронизации данных, избавиться от ресурсоемких вызовов в циклах и ускорить запись в СУБД в десятки раз.
Проанализируем представленный на форуме алгоритм. В нем можно выделить сразу несколько критических узких мест, которые делают код крайне медленным и подверженным сбоям:
1CtoPowerShell и запуск скриптов операционной системы внутри цикла — это тяжелейшая операция. На запуск процесса PowerShell, авторизацию в домене, отправку сетевого RPC-запроса к контроллеру домена и возврат результата тратятся секунды на каждую итерацию. При обработке сотен пользователей процесс растянется на часы.РегистрСведенийМенеджерЗаписи внутри цикла приводит к тому, что на каждую запись СУБД (MS SQL, PostgreSQL) открывает отдельную транзакцию, блокирует таблицы, записывает строку на диск, фиксирует транзакцию и закрывает ее. Это порождает огромные накладные расходы на дисковый ввод-вывод.ТаблицаЗначений типы данных указаны не для всех полей. При передаче такой нетипизированной таблицы в качестве параметра запроса СУБД создает временные таблицы с универсальными тяжелыми типами (например, sql_variant или полями неограниченной длины), что катастрофически замедляет операции соединения (LEFT JOIN).ИЗ ТЗРег КАК ТЗРег вместо ИЗ &ТЗРег КАК ТЗРег). Кроме того, в нем создается бессмысленная временная таблица ТЗРегистр, которая просто дублирует данные, тратя оперативную память и дисковое пространство TempDB.Пользователи.ТекущийПользователь и ТекущаяДата на каждой итерации цикла создает ненужную вычислительную нагрузку. Эти значения статичны в рамках работы процедуры и должны быть кэшированы в переменные заранее.Элементы.ОчередьКоманд.ВыделенныеСтроки (клиентский контекст управляемой формы) в процедуре, где выполняются серверные механизмы (Новый Запрос, запись в БД), неизбежно приведет к ошибкам выполнения.Ниже мы разберем по шагам, как исправить каждую из этих проблем и переписать процедуру по стандартам разработки фирмы «1С».
Самое эффективное решение для получения данных из AD в среде 1С — это отказ от PowerShell-скриптов в пользу прямого сетевого подключения по протоколу LDAP через встроенный системный компонент ADO (ActiveX Data Objects). COM-объект ADODB.Connection с провайдером ADsDSOObject выполняет запросы на уровне ядра операционной системы и возвращает результат практически мгновенно.
Рассмотрим пример функции, которая позволяет быстро получить параметры пользователей напрямую из Active Directory без запуска PowerShell:
Функция ПолучитьДанныеПользователейИзAD(МассивGUID) Экспорт
РезультатТаблица = Новый ТаблицаЗначений;
РезультатТаблица.Колонки.Добавить("GUID", Новый ОписаниеТипов("Строка", , Новый КвалификаторыСтроки(36)));
РезультатТаблица.Колонки.Добавить("ЗначениеСвойства", Новый ОписаниеТипов("Строка", , Новый КвалификаторыСтроки(150)));
// Подключаемся к AD через ADO
Connection = Новый COMОбъект("ADODB.Connection");
Connection.Provider = "ADsDSOObject";
Connection.Open("Active Directory Provider");
Command = Новый COMОбъект("ADODB.Command");
Command.ActiveConnection = Connection;
// Формируем фильтр LDAP для выборки пакета пользователей по их ObjectGUID
СтрокаФильтра = "";
Для Каждого ТекущийGUID Из МассивGUID Цикл
// Преобразуем GUID в понятный для LDAP двоичный формат или используем текстовый поиск
СтрокаФильтра = СтрокаФильтра + "(objectGUID=" + ТекущийGUID + ")";
КонецЦикла;
LDAPФильтр = "(&(objectCategory=person)(objectClass=user)(|" + СтрокаФильтра + "))";
ПутьЗапроса = "GC://DC=corp,DC=tander,DC=ru";
// Запрашиваем только необходимые атрибуты (например, sAMAccountName, mail и т.д.)
Command.CommandText = "SELECT objectGUID, sAMAccountName FROM '" + ПутьЗапроса + "' WHERE " + LDAPФильтр;
Command.Properties("Page Size").Value = 1000;
Command.Properties("Timeout").Value = 30;
Попытка
RecordSet = Command.Execute();
Пока НЕ RecordSet.EOF Цикл
НоваяСтрока = РезультатТаблица.Добавить();
НоваяСтрока.GUID = RecordSet.Fields("objectGUID").Value;
НоваяСтрока.ЗначениеСвойства = RecordSet.Fields("sAMAccountName").Value;
RecordSet.MoveNext();
КонецЦикла;
RecordSet.Close();
Исключение
ЗаписатьЛогОшибки(ОписаниеОшибки());
КонецПопытки;
Возврат РезультатТаблица;
КонецФункции
Если же архитектурные требования компании жестко предписывают использовать именно PowerShell, необходимо переписать скрипт так, чтобы он не вызывался для каждого пользователя отдельно. Передавайте в скрипт массив идентификаторов целиком, формируйте выборку в памяти PowerShell и возвращайте в 1С структурированный JSON-массив, который затем можно обработать в один проход.
Для минимизации обращений к СУБД мы должны отказаться от РегистрСведенийМенеджерЗаписи внутри цикла. Вместо этого мы будем накапливать подготовленные записи в объекте РегистрСведенийНаборЗаписей и записывать их единым пакетом.
Давайте выясним причину, почему это работает быстрее. При записи набора записей 1С отправляет в СУБД один транзакционный блок. Процессор базы данных выполняет оптимизированную вставку (или обновление) всех строк практически одновременно, тратя ресурс жесткого диска на фиксацию транзакции всего один раз. Это исключает фрагментацию транзакционного лога и снимает избыточные блокировки с таблицы регистра.
Схема пакетной записи выглядит следующим образом:
// Создаем набор записей регистра
НаборЗаписей = РегистрыСведений.СинхронизируемыеСвойстваПользователяРесурсов.СоздатьНаборЗаписей();
// Для бесконфликтной пакетной записи (без очистки всего регистра)
// накладываем отбор по ключевым полям, если это необходимо, либо просто добавляем новые записи в режиме замещения.
// В данном случае мы можем подготовить таблицу значений и загрузить её целиком.
ТаблицаДляЗаписи = Новый ТаблицаЗначений;
// Структура колонок таблицы должна полностью соответствовать измерениям и ресурсам регистра
ТаблицаДляЗаписи.Колонки.Добавить("Период", Новый ОписаниеТипов("Дата"));
ТаблицаДляЗаписи.Колонки.Добавить("Ресурс", Новый ОписаниеТипов("СправочникСсылка.РесурсыВнешнихСистем"));
ТаблицаДляЗаписи.Колонки.Добавить("ПользовательРесурсов", Новый ОписаниеТипов("СправочникСсылка.Пользователи"));
ТаблицаДляЗаписи.Колонки.Добавить("Свойство", Новый ОписаниеТипов("СправочникСсылка.СинхронизируемыеСвойства"));
ТаблицаДляЗаписи.Колонки.Добавить("Статус", Новый ОписаниеТипов("ПеречислениеСсылка.РезультатВыполненияКоманды"));
ТаблицаДляЗаписи.Колонки.Добавить("ТекстКоманды", Новый ОписаниеТипов("Строка"));
ТаблицаДляЗаписи.Колонки.Добавить("Ответственный", Новый ОписаниеТипов("СправочникСсылка.Пользователи"));
// Наполняем таблицу в цикле...
Пока Выборка.Следующий() Цикл
НоваяСтрока = ТаблицаДляЗаписи.Добавить();
// Заполнение свойств...
КонецЦикла;
// Записываем всю таблицу одной транзакцией
НаборЗаписей.Загрузить(ТаблицаДляЗаписи);
НаборЗаписей.Записать(Истина); // Истина - перезаписать существующие записи по измерениям
Рассмотрим подробнее работу с временными таблицами. В исходном коде создается ТаблицаЗначений ТЗ, в которую выгружаются данные из интерфейса. Чтобы передать её в запрос, мы обязательно должны:
ОписаниеТипов). Это позволит оптимизатору СУБД построить корректный план выполнения запроса.&) перед именем таблицы в секции ИЗ, сигнализируя платформе о передаче внешнего параметра.ПОМЕСТИТЬ ТЗРегистр без наложения фильтров или группировок является избыточной. Мы можем сразу соединить переданный параметр ТЗ с физической таблицей справочника 1С.Посмотрим на пример оптимизированного запроса, который решает все указанные проблемы:
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| ВременнаяТЗ.Период КАК Период,
| ВременнаяТЗ.Ресурс КАК Ресурс,
| ВременнаяТЗ.ПользовательРесурсов КАК ПользовательРесурсов,
| ВременнаяТЗ.Свойство КАК Свойство,
| ВременнаяТЗ.ДополнительноеСвойство КАК ДополнительноеСвойство,
| ВременнаяТЗ.Значение КАК Значение,
| ВременнаяТЗ.Статус КАК Статус,
| ВременнаяТЗ.ИмяФайла КАК ИмяФайла,
| ВременнаяТЗ.ТекстКоманды КАК ТекстКоманды,
| ВременнаяТЗ.Ответственный КАК Ответственный,
| ВременнаяТЗ.ИспользоватьВстроенныйОбработчикКоманд КАК ИспользоватьВстроенныйОбработчикКоманд,
| ВременнаяТЗ.ДатаВыполнения КАК ДатаВыполнения,
| ВременнаяТЗ.ДатаФормирования КАК ДатаФормирования,
| ВременнаяТЗ.ОписаниеОшибки КАК ОписаниеОшибки,
| СинхронизируемыеСвойства.ИмяРеквизитаВнешнейСистемы КАК ИмяРеквизитаВнешнейСистемы
|ИЗ
| &ВнешняяТаблицаДанных КАК ВременнаяТЗ
| ЛЕВОЕ СОЕДИНЕНИЕ Справочник.СинхронизируемыеСвойства КАК СинхронизируемыеСвойства
| ПО ВременнаяТЗ.Свойство = СинхронизируемыеСвойства.Ссылка";
Запрос.УстановитьПараметр("ВнешняяТаблицаДанных", ТЗ);
РезультатЗапроса = Запрос.Выполнить();
Проанализируем работу циклов. В исходном коде при каждой итерации вызывается метод получения текущего пользователя системы: Пользователи.ТекущийПользователь. Эта процедура выполняет чтение параметров текущего сеанса, что может приводить к неявным запросам к базе данных. Аналогично, получение ТекущаяДата заставляет систему опрашивать аппаратный таймер сервера.
Для исправления этой ситуации достаточно получить эти переменные один раз перед запуском циклов обработки:
// Кэшируем системные значения до начала циклов
ЗначениеТекущейДаты = ТекущаяДата();
ЗначениеТекущегоПользователя = Пользователи.ТекущийПользователь();
// Теперь внутри цикла мы просто присваиваем переменные, что происходит мгновенно:
Пока Выборка.Следующий() Цикл
// ...
НоваяЗапись.Период = ЗначениеТекущейДаты;
НоваяЗапись.Ответственный = ЗначениеТекущегоПользователя;
// ...
КонецЦикла;
С учетом всех разобранных выше шагов, перепишем процедуру синхронизации свойств пользователей. Мы разделим логику на клиентскую часть (сбор выделенных строк интерфейса) и серверную часть (выполнение тяжелых интеграционных запросов и пакетная запись в базу данных).
Код на стороне Клиента (в модуле формы):
&НаКлиенте
Процедура ЧтениеПользователейАД(Команда)
// Собираем необходимые данные из выделенных строк интерфейса в легковесный массив структур
МассивДанныхСтрок = Новый Массив;
Для Каждого ИдентификаторСтроки Из Элементы.ОчередьКоманд.ВыделенныеСтроки Цикл
ДанныеСтроки = Элементы.ОчередьКоманд.ДанныеСтроки(ИдентификаторСтроки);
СтруктураПараметров = Новый Структура;
СтруктураПараметров.Вставить("ПользовательРесурсов", ДанныеСтроки.ПользовательРесурсов);
СтруктураПараметров.Вставить("Свойство", ДанныеСтроки.Svojstvo);
СтруктураПараметров.Вставить("ДополнительноеСвойство", ДанныеСтроки.ДополнительноеСвойство);
СтруктураПараметров.Вставить("Значение", ДанныеСтроки.Значение);
СтруктураПараметров.Вставить("Статус", ДанныеСтроки.Статус);
СтруктураПараметров.Вставить("ИмяФайла", ДанныеСтроки.ИмяФайла);
СтруктураПараметров.Вставить("Команда", ДанныеСтроки.Команда);
СтруктураПараметров.Вставить("Ответственный", ДанныеСтроки.Ответственный);
СтруктураПараметров.Вставить("Дата", ДанныеСтроки.Дата);
СтруктураПараметров.Вставить("ОписаниеОшибки", ДанныеСтроки.ОписаниеОшибки);
МассивДанныхСтрок.Добавить(СтруктураПараметров);
КонецЦикла;
// Вызываем серверную процедуру для выполнения бизнес-логики
ВыполнитьСинхронизациюНаСервере(МассивДанныхСтрок, Объект.Ресурс, ДатаСинхронизации);
КонецПроцедуры
Код на стороне Сервера (в модуле формы или общем модуле):
&НаСервереБезКонтекста
Процедура ВыполнитьСинхронизациюНаСервере(МассивДанныхСтрок, ПараметрРесурс, ДатаСинхронизации)
// Инициализируем кэш системных вызовов
КэшТекущаяДата = ТекущаяДата();
КэшТекущийПользователь = Пользователи.ТекущийПользователь();
// Создаем строго типизированную Таблицу Значений
ТЗ = Новый ТаблицаЗначений;
ТЗ.Колонки.Добавить("Период", Новый ОписаниеТипов("Дата"), "Период");
ТЗ.Колонки.Добавить("Ресурс", Новый ОписаниеТипов("СправочникСсылка.РесурсыВнешнихСистем"), "Ресурс");
ТЗ.Колонки.Добавить("ПользовательРесурсов", Новый ОписаниеТипов("СправочникСсылка.ПользователиРесурсов"), "ПользовательРесурсов");
ТЗ.Колонки.Добавить("Свойство", Новый ОписаниеТипов("СправочникСсылка.СинхронизируемыеСвойства"), "Свойство");
ТЗ.Колонки.Добавить("ДополнительноеСвойство", Новый ОписаниеТипов("СправочникСсылка.ДополнительныеСвойства"), "ДополнительноеСвойство");
ТЗ.Колонки.Добавить("Значение", Новый ОписаниеТипов("Строка", , Новый КвалификаторыСтроки(250)), "Значение");
ТЗ.Колонки.Добавить("Статус", Новый ОписаниеТипов("ПеречислениеСсылка.РезультатВыполненияКоманды"), "Статус");
ТЗ.Колонки.Добавить("ИмяФайла", Новый ОписаниеТипов("Строка", , Новый КвалификаторыСтроки(255)), "ИмяФайла");
ТЗ.Колонки.Добавить("ТекстКоманды", Новый ОписаниеТипов("Строка"), "ТекстКоманды");
ТЗ.Колонки.Добавить("Ответственный", Новый ОписаниеТипов("СправочникСсылка.Пользователи"), "Ответственный");
ТЗ.Колонки.Добавить("ИспользоватьВстроенныйОбработчикКоманд", Новый ОписаниеТипов("Булево"), "ИспользоватьВстроенныйОбработчикКоманд");
ТЗ.Колонки.Добавить("ДатаВыполнения", Новый ОписаниеТипов("Дата"), "ДатаВыполнения");
ТЗ.Колонки.Добавить("ДатаФормирования", Новый ОписаниеТипов("Дата"), "ДатаФормирования");
ТЗ.Колонки.Добавить("ОписаниеОшибки", Новый ОписаниеТипов("Строка"), "ОписаниеОшибки");
// Наполняем таблицу типизированными данными
Для Каждого Стр Из МассивДанныхСтрок Цикл
Строка = ТЗ.Добавить();
Строка.Период = КэшТекущаяДата;
Строка.Ресурс = ПараметрРесурс;
Строка.ПользовательРесурсов = Стр.ПользовательРесурсов;
Строка.Свойство = Стр.Свойство;
Строка.ДополнительноеСвойство = Стр.ДополнительноеСвойство;
Строка.Значение = Стр.Значение;
Строка.Статус = Стр.Статус;
Строка.ИмяФайла = Стр.ИмяФайла;
Строка.ТекстКоманды = Стр.Команда;
Строка.Ответственный = Стр.Ответственный;
Строка.ИспользоватьВстроенныйОбработчикКоманд = Истина;
Строка.ДатаВыполнения = ДатаСинхронизации;
Строка.ДатаФормирования = Стр.Дата;
Строка.ОписаниеОшибки = Стр.ОписаниеОшибки;
КонецЦикла;
// Выполняем оптимальный запрос к СУБД с левым соединением
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| ТЗРег.Период КАК Период,
| ТЗРег.Ресурс КАК Ресурс,
| ТЗРег.ПользовательРесурсов КАК ПользовательРесурсов,
| ТЗРег.Свойство КАК Свойство,
| ТЗРег.ДополнительноеСвойство КАК ДополнительноеСвойство,
| ТЗРег.Значение КАК Значение,
| ТЗРег.Статус КАК Статус,
| ТЗРег.ИмяФайла КАК ИмяФайла,
| ТЗРег.ТекстКоманды КАК ТекстКоманды,
| ТЗРег.Ответственный КАК Ответственный,
| ТЗРег.ИспользоватьВстроенныйОбработчикКоманд КАК ИспользоватьВстроенныйОбработчикКоманд,
| ТЗРег.ДатаВыполнения КАК ДатаВыполнения,
| ТЗРег.ДатаФормирования КАК ДатаФормирования,
| ТЗРег.ОписаниеОшибки КАК ОписаниеОшибки,
| СинхронизируемыеСвойства.ИмяРеквизитаВнешнейСистемы КАК ИмяРеквизитаВнешнейСистемы
|ИЗ
| &ТЗРег КАК ТЗРег
| ЛЕВОЕ СОЕДИНЕНИЕ Справочник.СинхронизируемыеСвойства КАК СинхронизируемыеСвойства
| ПО ТЗРег.Свойство = СинхронизируемыеСвойства.Ссылка";
Запрос.УстановитьПараметр("ТЗРег", ТЗ);
Выборка = Запрос.Perform().Select();
// Инициализируем COM-соединение к Active Directory ОДИН раз
// (Используем прямой LDAP-запрос, как описано в Шаге 1, либо оптимизированный вызов PowerShell)
PowerShell = Новый COMОбъект("1CtoPowerShell");
PowerShell.Инициализировать();
// Создаем коллекцию (Набор Записей) для пакетной записи данных в базу 1С
НаборЗаписейРегистра = РегистрыСведений.СинхронизируемыеСвойстваПользователяРесурсов.СоздатьНаборЗаписей();
ТаблицаЗаписей = НаборЗаписейРегистра.ВыгрузитьКолонки();
Пока Выборка.Следующий() Цикл
// Формируем и выполняем запрос к Active Directory
ТекстКоманды = "Get-ADUser -Filter {GuidMD -eq %1} -SearchBase ""DC=corp,DC=tander,DC=ru"" -Properties %2";
ТекстКоманды = СтроковыеФункцииКлиентСервер.ПодставитьПараметрыВСтроку(
ТекстКоманды,
Выборка.ПользовательРесурсов.УникальныйИдентификатор(),
Выборка.ИмяРеквизитаВнешнейСистемы
);
Результат = PowerShell.ПолучитьТаблицу;
Ошибка = "";
Успех = PowerShell.ЗапуститьСкриптСОбработкойРезультата(ТекстКоманды, , Результат, Ошибка);
Если НЕ Успех Тогда
ОбщегоНазначенияКлиентСервер.СообщитьПользователю(Ошибка);
Прервать;
КонецЕсли;
// Определяем результирующий статус
ТекущийСтатус = Перечисления.РезультатВыполненияКоманды.Отменена;
Если Выборка.Статус = ПредопределенноеЗначение("Перечисление.РезультатВыполненияКоманды.Выполняется") Тогда
Для Каждого rs Из Результат Цикл
Если Врег(rs.Name) = Врег(Выборка.ИмяРеквизитаВнешнейСистемы) Тогда
Если Врег(rs.Value) = Врег(Выборка.Значение) Тогда
ТекущийСтатус = Перечисления.РезультатВыполненияКоманды.Выполнена;
Иначе
ТекущийСтатус = Перечисления.РезультатВыполненияКоманды.ВыполненаСОшибкой;
КонецЕсли;
КонецЕсли;
КонецЦикла;
КонецЕсли;
// Добавляем запись в буферную таблицу для последующей пакетной загрузки
НоваяЗапись = ТаблицаЗаписей.Добавить();
ЗаполнитьЗначенияСвойств(НоваяЗапись, Выборка);
НоваяЗапись.Период = КэшТекущаяДата;
НоваяЗапись.Ресурс = Выборка.Ресурс;
НоваяЗапись.Статус = ТекущийСтатус;
НоваяЗапись.Ответственный = КэшТекущийПользователь;
КонецЦикла;
// Записываем все накопленные данные за одну короткую транзакцию
Если ТаблицаЗаписей.Количество() > 0 Тогда
НаборЗаписейРегистра.Загрузить(ТаблицаЗаписей);
НаборЗаписейРегистра.Записать(Истина);
КонецЕсли;
КонецПроцедуры
Предложенная реорганизация кода кардинально меняет показатели производительности интеграционного процесса:
МенеджерЗаписи.Записать() в пользу пакетного метода НаборЗаписей.Загрузить(). Это полностью исключает возникновение взаимных блокировок СУБД.