Гонка при создании справочников в 1С: как MD5 от ключа заменил блокировки на миграции 28 тысяч позиций
Была миграция из SAP в 1С:ERP. Нужно было разово загрузить около 28 тысяч карточек номенклатуры. Саму номенклатуру мы контролировали очень внимательно, а задвоилось то, на что смотрели меньше всего: номенклатурные группы, виды номенклатуры и другие зависимые справочники, которые создавались по ходу загрузки. Ниже разбор, почему так вышло, почему управляемые блокировки не спасли и как в итоге решили задачу без блокировок вообще.
Весь код в статье — пример. Он упрощён и показывает подход, а не копирует боевую базу. Имена регистров, реквизитов и служебных процедур условные.
Примечание. Имена справочников в коде тоже условные. В типовой УТ 11.5, на которой проверялся код, справочника «Номенклатурные группы» нет, единицы измерения хранятся в справочнике
УпаковкиЕдиницыИзмерения, а ставки НДС есть и справочникомСтавкиНДС, и одноимённым перечислением. В своей конфигурации подставьте реальные объекты.
Как был устроен обмен
Сообщения шли из SAP через 1С-шину и попадали в 1С через веб-сервис. Формат — XML. Одно сообщение несло ровно одну позицию номенклатуры вместе со всем, что ей нужно: видом, группой, единицей измерения, ставкой НДС. Ограничение потока стояло на стороне SAP: не больше 5000 сообщений каждые 15 минут. Эту цифру посчитали пределом нагрузки для сервера.
Схема обработки менялась. Сначала каждое входящее сообщение сразу запускало своё фоновое задание. Это быстро упёрлось в количество одновременных заданий, и мы перешли на журнал обмена: веб-сервис только складывает сообщение в регистр сведений «Журнал обмена» со статусом «Не обработано», а регламентная обработка по настройке берёт порции и раздаёт их фоновым заданиям. Опытным путём остановились на 100 сообщениях на одно фоновое задание.
SAP ──> 1С-шина ──> веб-сервис ──> регистр «Журнал обмена» (статус «Не обработано»)
│
┌──────────────────┼──────────────────┐
Фоновое задание 1 Фоновое задание 2 ... Фоновое задание N
(порция 100) (порция 100) (порция 100)
│ │ │
Номенклатура + создание зависимых справочников «на лету»
У журнала два рабочих статуса для сбойных сообщений. «Ошибка получения» — сообщение действительно не загрузилось, и его нужно разбирать руками. «Не обработано» — сообщение просто попадёт в следующую порцию. Если сообщение падало на конфликте блокировок, статус не менялся на «Ошибка получения», и оно автоматически обрабатывалось повторно. Этот механизм потом стал основой решения.
Самое важное здесь — последняя строка схемы. В SAP большинство связанных сущностей не были отдельными справочниками. Вид, группа, единица измерения и прочие значения приходили текстовыми реквизитами внутри карточки номенклатуры, причём только наименованием: ни кода, ни своего идентификатора у них в сообщении не было. Ставка НДС приходила просто значением. Значит, каждое сообщение само искало нужную группу и, если не находило, создавало её. Одну и ту же группу могли одновременно «хотеть» десятки сообщений в разных фоновых заданиях.
Чтобы было понятно, о чём речь, вот упрощённое сообщение. Структура и имена элементов условные, значения придуманы для примера, но форма данных та же: у позиции есть свой идентификатор и артикул, а зависимые сущности приходят наименованием, иногда с лишними пробелами, кавычками и точками.
<!-- ПРИМЕР сообщения обмена. Структура и имена элементов условные, значения выдуманы для иллюстрации. -->
<!-- Одно сообщение = одна позиция номенклатуры. Зависимые сущности приходят вложенными, -->
<!-- в основном только наименованием; ключ поиска для каждой сущности указан в комментарии. -->
<Message xmlns="urn:example:exchange:material" version="1.0">
<Header>
<MessageId>4f1c2a90-0000-0000-0000-000000000001</MessageId>
<SourceSystem>SAP</SourceSystem>
<ObjectType>Material</ObjectType>
<Created>2026-01-15T10:00:00</Created>
</Header>
<Material>
<!-- Номенклатура: ключ - идентификатор позиции в источнике (или артикул) -->
<SourceId>000000000010045871</SourceId>
<Article>BLT-M10-40</Article>
<Name>Болт М10х40, оцинк.</Name>
<FullName>Болт с шестигранной головкой М10х40 оцинкованный</FullName>
<!-- Вид номенклатуры: ключ - нормализованное наименование -->
<Kind>
<Name>Товар </Name>
</Kind>
<!-- Группа номенклатуры: ключ - нормализованное наименование -->
<Group>
<Name>«Крепёж»</Name>
</Group>
<!-- Единица измерения: ключ - нормализованное наименование -->
<BaseUnit>
<Name>шт.</Name>
</BaseUnit>
<!-- Ставка НДС: ключ - значение ставки -->
<TaxRate>
<Rate>20</Rate>
</TaxRate>
</Material>
</Message>
Для каждой сущности из такого сообщения есть свой ключ поиска. Номенклатура ищется по идентификатору источника или артикулу. Вид, группа и единица измерения — по нормализованному наименованию. Ставка НДС — по значению ставки. Из имени справочника и ключа считается GUID, о нём ниже.
Где возникает гонка
Раз у зависимого значения есть только наименование, ключом может быть только оно. Но сырое наименование для этого не годится: «Крепёж», « крепеж » и «КРЕПЕЖ.» — для человека одно и то же, а для сравнения строк три разных значения. Поэтому сначала наименование нормализуется, и дальше везде используется только нормализованная форма.
// ПРИМЕР. Нормализация наименования для поиска и для ключа GUID
Функция НормализоватьНаименование(Знач Значение) Экспорт
// Кавычки, пунктуация и служебные символы заменяются пробелом,
// а не удаляются, чтобы «1.5» и «15» не слились в одно значение
Разделители = """'«»„“”`.,;:!?()[]{}/\|*_-";
Значение = НРег(Значение);
Значение = СтрЗаменить(Значение, "ё", "е");
Результат = "";
Для Индекс = 1 По СтрДлина(Значение) Цикл
Символ = Сред(Значение, Индекс, 1);
КодСимв = КодСимвола(Символ);
Если КодСимв < 32 Или КодСимв = 160 Или КодСимв = 8203
Или СтрНайти(Разделители, Символ) > 0 Тогда
Результат = Результат + " "; // непечатаемые, неразрывный пробел, спецсимволы
Иначе
Результат = Результат + Символ;
КонецЕсли;
КонецЦикла;
Пока СтрНайти(Результат, " ") > 0 Цикл
Результат = СтрЗаменить(Результат, " ", " ");
КонецЦикла;
Возврат СокрЛП(Результат);
КонецФункции
Набор разделителей здесь примерный, его нужно подбирать под реальные данные источника. Замена «ё» на «е» идёт только в значении строки, в именах переменных её нет.
Поиск сущностей сообщения шёл в два шага. Сначала по GUID через регистр публичных идентификаторов. Если по GUID ничего не находилось, искали в самих справочниках по ключу поиска своего типа — на случай элементов, созданных раньше и не попавших в регистр. Сама гонка классическая, check-then-act, на примере группы:
Поток A: поиск по ГУИД в регистре -> не найдено; поиск по ключу в справочнике -> не найдено
Поток B: поиск по ГУИД в регистре -> не найдено; поиск по ключу в справочнике -> не найдено
Поток A: СоздатьЭлемент(), Записать(), записать ГУИД в регистр -> группа #1
Поток B: СоздатьЭлемент(), Записать(), записать ГУИД в регистр -> группа #2 (дубль)
Проверка и запись разнесены во времени, и между ними успевает влезть соседний поток. Запись в регистр и запись элемента справочника — разные операции, и уникальность GUID в регистре сама по себе ничем не обеспечена. Поток B спокойно создаёт свой элемент с новой случайной ссылкой.
Воспроизвести это на тестовой базе несложно. Берёте пачку сообщений, в которых много карточек ссылаются на одну и ту же группу, и запускаете их не одним, а несколькими фоновыми заданиями. При одном задании дублей не будет никогда. При нескольких они появляются тем чаще, чем больше заданий и чем «популярнее» группа. Это хороший признак того, что проблема именно в параллельности, а не в данных.
Попытка первая: исключительные управляемые блокировки
Очевидное решение — сериализовать доступ к ключу. Чтобы не делать по запросу на каждую сущность, обработка сообщения была построена пакетно:
Разбираем сообщение и собираем таблицу сущностей: вид, группа, единица измерения, ставка НДС и сама номенклатура, для каждой — ключ поиска и GUID.
Накладываем исключительную управляемую блокировку сразу на все GUID — одним элементом блокировки с источником данных, без цикла.
Одним пакетным запросом: сначала во временную таблицу
ВТ_НайденныеПоГуидпопадает всё, что нашлось по GUID в регистре публичных идентификаторов. Остальное уходит вВТ_НеНайденные. Итоговая выборка объединяет черезОБЪЕДИНИТЬ ВСЕнайденное по GUID (приоритет 1) и поиск по ключу в каждом справочнике, но только дляВТ_НеНайденные(приоритет 2). Так по ключу ищется только то, чего нет в регистре.Найденное только по ключу дописываем в регистр. Чего не нашлось нигде, создаём и тоже записываем в регистр.
Второе сообщение, у которого хотя бы один GUID совпадает, ждёт, пока первое не зафиксирует транзакцию. Ниже код этой версии; во второй попытке в нём менялся только режим блокировки.
// ПРИМЕР. Подход «до»: поиск сущностей сообщения одним запросом с блокировкой.
// Имена справочников, реквизитов, регистра и ключи поиска условные.
// Вызывается внутри транзакции загрузки сообщения (см. ниже).
Функция ПолучитьСсылкиСущностейСБлокировкой(Данные)
// 1. Собираем сущности сообщения: тип, ключ поиска, GUID
Сущности = Новый ТаблицаЗначений;
Сущности.Колонки.Добавить("ИмяМетаданных", Новый ОписаниеТипов("Строка", , Новый КвалификаторыСтроки(100)));
Сущности.Колонки.Добавить("КлючСтрока", Новый ОписаниеТипов("Строка", , Новый КвалификаторыСтроки(150)));
Сущности.Колонки.Добавить("КлючЧисло", Новый ОписаниеТипов("Число", Новый КвалификаторыЧисла(10, 2)));
Сущности.Колонки.Добавить("Гуид", Новый ОписаниеТипов("УникальныйИдентификатор"));
Сущности.Колонки.Добавить("Ссылка");
ДобавитьСущность(Сущности, "ВидыНоменклатуры", Данные.Вид); // ключ: наименование
ДобавитьСущность(Сущности, "НоменклатурныеГруппы", Данные.Группа); // ключ: наименование
ДобавитьСущность(Сущности, "ЕдиницыИзмерения", Данные.Единица); // ключ: наименование
ДобавитьСущность(Сущности, "СтавкиНДС", , Данные.СтавкаНДС); // ключ: значение ставки
ДобавитьСущность(Сущности, "Номенклатура", Данные.Артикул); // ключ: артикул (или ИД источника)
// 2. Блокируем GUID всех сущностей сообщения одним элементом через источник данных
Блокировка = Новый БлокировкаДанных;
ЭлементБлокировки = Блокировка.Добавить("РегистрСведений.ПубличныеИдентификаторы");
ЭлементБлокировки.Режим = РежимБлокировкиДанных.Исключительный; // в попытке 2 - Разделяемый
ЭлементБлокировки.ИсточникДанных = Сущности.Скопировать(, "Гуид");
ЭлементБлокировки.ИспользоватьИзИсточникаДанных("Идентификатор", "Гуид");
Блокировка.Заблокировать();
// 3. Один пакетный запрос: найденные по GUID, ненайденные,
// затем итог: по GUID (приоритет 1) + по ключу только для ненайденных (приоритет 2)
Запрос = Новый Запрос;
Запрос.МенеджерВременныхТаблиц = Новый МенеджерВременныхТаблиц;
Запрос.УстановитьПараметр("Сущности", Сущности.Скопировать(, "ИмяМетаданных, КлючСтрока, КлючЧисло, Гуид"));
Запрос.Текст =
"ВЫБРАТЬ
| Т.ИмяМетаданных КАК ИмяМетаданных,
| Т.КлючСтрока КАК КлючСтрока,
| Т.КлючЧисло КАК КлючЧисло,
| Т.Гуид КАК Гуид
|ПОМЕСТИТЬ ВТ_Сущности
|ИЗ
| &Сущности КАК Т
|;
|
|////////////////////////////////////////////////////////////////////////////////
|ВЫБРАТЬ
| Т.Гуид КАК Гуид,
| Рег.Ссылка КАК Ссылка
|ПОМЕСТИТЬ ВТ_НайденныеПоГуид
|ИЗ
| ВТ_Сущности КАК Т
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.ПубличныеИдентификаторы КАК Рег
| ПО (Рег.Идентификатор = Т.Гуид)
|;
|
|////////////////////////////////////////////////////////////////////////////////
|ВЫБРАТЬ
| Т.ИмяМетаданных КАК ИмяМетаданных,
| Т.КлючСтрока КАК КлючСтрока,
| Т.КлючЧисло КАК КлючЧисло,
| Т.Гуид КАК Гуид
|ПОМЕСТИТЬ ВТ_НеНайденные
|ИЗ
| ВТ_Сущности КАК Т
| ЛЕВОЕ СОЕДИНЕНИЕ ВТ_НайденныеПоГуид КАК Найд
| ПО (Найд.Гуид = Т.Гуид)
|ГДЕ
| Найд.Гуид ЕСТЬ NULL
|;
|
|////////////////////////////////////////////////////////////////////////////////
|ВЫБРАТЬ
| Найд.Гуид КАК Гуид,
| Найд.Ссылка КАК Ссылка,
| ВЫРАЗИТЬ("""" КАК СТРОКА(150)) КАК Ключ,
| 1 КАК Приоритет
|ИЗ
| ВТ_НайденныеПоГуид КАК Найд
|
|ОБЪЕДИНИТЬ ВСЕ
|
|ВЫБРАТЬ
| Т.Гуид,
| Спр.Ссылка,
| Спр.Наименование,
| 2
|ИЗ
| ВТ_НеНайденные КАК Т
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.ВидыНоменклатуры КАК Спр
| ПО (Т.ИмяМетаданных = ""ВидыНоменклатуры"")
| И (Спр.Наименование = Т.КлючСтрока)
| И (НЕ Спр.ПометкаУдаления)
|
|ОБЪЕДИНИТЬ ВСЕ
|
|ВЫБРАТЬ
| Т.Гуид,
| Спр.Ссылка,
| Спр.Наименование,
| 2
|ИЗ
| ВТ_НеНайденные КАК Т
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.НоменклатурныеГруппы КАК Спр
| ПО (Т.ИмяМетаданных = ""НоменклатурныеГруппы"")
| И (Спр.Наименование = Т.КлючСтрока)
| И (НЕ Спр.ПометкаУдаления)
|
|ОБЪЕДИНИТЬ ВСЕ
|
|ВЫБРАТЬ
| Т.Гуид,
| Спр.Ссылка,
| Спр.Наименование,
| 2
|ИЗ
| ВТ_НеНайденные КАК Т
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.ЕдиницыИзмерения КАК Спр
| ПО (Т.ИмяМетаданных = ""ЕдиницыИзмерения"")
| И (Спр.Наименование = Т.КлючСтрока)
| И (НЕ Спр.ПометкаУдаления)
|
|ОБЪЕДИНИТЬ ВСЕ
|
|ВЫБРАТЬ
| Т.Гуид,
| Спр.Ссылка,
| """",
| 2
|ИЗ
| ВТ_НеНайденные КАК Т
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.СтавкиНДС КАК Спр
| ПО (Т.ИмяМетаданных = ""СтавкиНДС"")
| И (Спр.Ставка = Т.КлючЧисло)
| И (НЕ Спр.ПометкаУдаления)
|
|ОБЪЕДИНИТЬ ВСЕ
|
|ВЫБРАТЬ
| Т.Гуид,
| Спр.Ссылка,
| Спр.Артикул,
| 2
|ИЗ
| ВТ_НеНайденные КАК Т
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.Номенклатура КАК Спр
| ПО (Т.ИмяМетаданных = ""Номенклатура"")
| И (Спр.Артикул = Т.КлючСтрока)
| И (НЕ Спр.ПометкаУдаления)
|
|УПОРЯДОЧИТЬ ПО
| Приоритет";
// По GUID каждая сущность находится не более одного раза.
// По ключу может найтись несколько кандидатов: берем первый,
// у которого совпала нормализованная форма.
НайденоПоКлючу = Новый Массив;
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
Строка = Сущности.Найти(Выборка.Гуид, "Гуид");
Если Выборка.Приоритет = 1 Тогда
Строка.Ссылка = Выборка.Ссылка;
Продолжить;
КонецЕсли;
Если Строка.Ссылка <> Неопределено Тогда
Продолжить; // уже взят другой кандидат по ключу
КонецЕсли;
Если Строка.ИмяМетаданных = "СтавкиНДС"
Или НормализоватьНаименование(Строка.КлючСтрока) = НормализоватьНаименование(Выборка.Ключ) Тогда
Строка.Ссылка = Выборка.Ссылка;
НайденоПоКлючу.Добавить(Строка);
КонецЕсли;
КонецЦикла;
// 4. Найденное по ключу дописываем в регистр.
// Чего не нашлось нигде - создаем и тоже пишем в регистр.
// Номенклатуру создает вызывающий код, остальное - здесь.
Для Каждого Строка Из НайденоПоКлючу Цикл
ЗаписатьПубличныйИдентификатор(Строка.Гуид, Строка.Ссылка);
КонецЦикла;
Для Каждого Строка Из Сущности Цикл
Если Строка.Ссылка = Неопределено И Строка.ИмяМетаданных <> "Номенклатура" Тогда
Строка.Ссылка = СоздатьЭлементСущности(Строка);
ЗаписатьПубличныйИдентификатор(Строка.Гуид, Строка.Ссылка);
КонецЕсли;
КонецЦикла;
Возврат Сущности;
КонецФункции
Процедура ДобавитьСущность(Сущности, ИмяМетаданных, КлючСтрока = "", КлючЧисло = 0)
Строка = Сущности.Добавить();
Строка.ИмяМетаданных = ИмяМетаданных;
Строка.КлючСтрока = СокрЛП(КлючСтрока);
Строка.КлючЧисло = КлючЧисло;
КлючДляГуид = ?(ИмяМетаданных = "СтавкиНДС", Формат(КлючЧисло, "ЧРД=.; ЧГ=0"), КлючСтрока);
Строка.Гуид = ГуидПоКлючу(ИмяМетаданных, КлючДляГуид);
КонецПроцедуры
Поиск по ключу в запросе намеренно упрощён: соединение находит только элементы, у которых наименование в базе совпадает с исходным из сообщения, а нормализованное сравнение делается уже в памяти. Элементы, записанные в базе в другой форме («КРЕПЕЖ.» вместо «Крепеж»), так не найдутся. Если такие есть, их лучше один раз сопоставить заранее или хранить нормализованную форму отдельным индексируемым реквизитом и соединять по нему. Ставки НДС в зависимости от редакции конфигурации могут быть перечислением, а не справочником, тогда эта ветка запроса не нужна. И ключ поиска должен быть однозначным: при проверке на демо-базе УТ 11.5 по значению 20 нашлись сразу две ставки — «20%» и расчётная «20/120», и цикл взял бы первую попавшуюся. Для ставок в условие соединения нужно добавить отбор, например исключить расчётные ставки.
Дубли пропали. Но около 85% сообщений падали с ошибкой (из 5000 за проход загружалось около 700):
Конфликт блокировок при выполнении транзакции.
Превышено максимальное время ожидания предоставления блокировки.
Причина видна, если посмотреть, где живёт эта функция. Она вызывается не сама по себе, а внутри транзакции загрузки сообщения, и блокирует GUID сразу всех сущностей позиции. Часть этих сущностей общая почти для всех сообщений: вид «Товар», ставка НДС, единица «шт». Значит, почти любые два сообщения пересекаются хотя бы по одному GUID, и одно ждёт другое целиком. Блокировка снимается только при фиксации внешней транзакции, то есть после записи всей карточки со всеми её зависимыми элементами. Популярный вид, единица или ставка нужны почти каждому сообщению. В итоге фоновые задания выстраиваются в очередь на один и тот же ключ, каждое держит его всё время своей длинной транзакции, и ожидание остальных уходит за таймаут. Чем больше параллельных заданий, тем хуже, и параллельность фактически превращается в последовательную загрузку с ошибками.
Сократить транзакцию до создания одной сущности мешало то, что сообщение должно записываться целиком: позиция вместе со всеми зависимыми элементами, либо всё, либо ничего.
Попытка вторая: разделяемые блокировки
Раз исключительная блокировка выстраивает всех в очередь, следующим шагом мы поменяли режим на РежимБлокировкиДанных.Разделяемый. Остальной код остался тем же. Ожиданий стало заметно меньше: падать по таймауту стало около 20% сообщений вместо примерно 85%.
Зато вернулись дубли. Разделяемые блокировки совместимы друг с другом: два потока одновременно получают разделяемую блокировку на один и тот же GUID, оба не находят элемент ни в регистре, ни по ключу и оба его создают. Это ровно та гонка из схемы выше, блокировка её больше не закрывает. Дубли мы нашли глазами, на проверке данных перед сдачей заказчику: задвоилось около 250 элементов зависимых справочников. Номенклатура при этом была чистой — у позиции свой уникальный ключ, и одну позицию два сообщения одновременно почти никогда не несут.
Получилась вилка: исключительная блокировка даёт корректность, но убивает параллельность, разделяемая сохраняет параллельность, но не защищает от дублей. Нужно было решение, при котором уникальность обеспечивается не блокировкой.
Что ещё рассматривали
Регистр соответствия внешних идентификаторов. Он у нас и так был — тот самый регистр публичных идентификаторов, через который шёл массовый поиск всех ссылочных элементов. Проблема в том, что он сам стал точкой конкуренции. Блокировка на нём снималась только после загрузки всех данных сообщения и при одновременном запуске давала ещё больше конфликтов. К тому же регистр не убирает гонку: запись «ключ → ссылка» и запись элемента всё равно две операции, и без блокировки между ними снова появляется окно.
Однопоточная загрузка зависимых справочников. Гонки нет по определению. Но при таком объёме это занимало слишком много времени, а смысл параллельных фоновых заданий терялся.
Предзагрузка справочников отдельным этапом. Не подошла из-за особенностей источника. В системе-отправителе большинство связанных сущностей не существовало как отдельные справочники: они приходили текстовыми реквизитами внутри карточки номенклатуры. Чтобы загрузить их заранее, пришлось бы либо дорабатывать выгрузку на стороне отправителя и собирать уникальные значения из всего массива, либо отправлять их отдельными сообщениями вместе с каждой позицией. Первое требовало изменений в чужой системе. Второе создавало лишние потоки и сообщения, повышало нагрузку на обмен и само порождало гонку при создании одних и тех же значений. Кроме того, предзагрузка закрывает только первичную загрузку: новые значения продолжают появляться в обычном обмене, так что защита от дублей при создании нужна в любом случае.
Решение: GUID из хеша как сама ссылка объекта
Мы уже считали GUID из ключа, но использовали его как значение для поиска в регистре. Решение оказалось в том, чтобы сделать этот GUID ссылкой объекта.
Ссылка элемента справочника в 1С — это и есть первичный ключ его таблицы в СУБД. Если ссылка детерминированно вычисляется из нормализованного наименования, то одни и те же данные всегда дают одну и ту же ссылку, кто бы их ни загружал и в каком потоке. Два элемента с одинаковой ссылкой СУБД записать не даст физически: второй INSERT упадёт на нарушении уникальности. Гонка никуда не исчезает, но её исход меняется. Раньше проигравший поток создавал дубль, теперь он получает ошибку записи. А ошибку можно обработать.
Как получить GUID
Хеш считаем от имени объекта метаданных и ключа поиска сущности в нормализованном виде — той же формы, что и при поиске: нормализованное наименование для вида, группы и единицы, значение ставки для НДС, артикул или идентификатор источника для номенклатуры. Если ключ GUID и ключ поиска нормализуются по-разному, подход ломается молча. MD5 даёт ровно 128 бит, столько же, сколько в GUID, поэтому 32 hex-символа хеша просто раскладываются по формату 8-4-4-4-12. Криптостойкость здесь не нужна: мы не защищаемся от злоумышленника, который подбирает коллизии, нам нужна стабильная функция «строка → 128 бит». Для этого MD5 достаточно.
// ПРИМЕР. Детерминированный GUID из ключа
Функция ГуидПоКлючу(ИмяМетаданных, КлючевоеЗначение) Экспорт
Хеш = Новый ХешированиеДанных(ХешФункция.MD5);
Хеш.Добавить(ИмяМетаданных + "|" + НормализоватьНаименование(Строка(КлючевоеЗначение)));
Строка = НРег(ПолучитьHexСтрокуИзДвоичныхДанных(Хеш.ХешСумма));
Возврат Новый УникальныйИдентификатор(
Сред(Строка, 1, 8) + "-" + Сред(Строка, 9, 4) + "-" + Сред(Строка, 13, 4)
+ "-" + Сред(Строка, 17, 4) + "-" + Сред(Строка, 21, 12));
КонецФункции
Имя метаданных в строке хеша обязательно. Без него группа и вид номенклатуры с одинаковым наименованием получили бы одинаковый GUID. Формально это разные таблицы, и конфликта в СУБД не будет, но при отладке и при сопоставлении данных такие совпадения сильно сбивают с толку. Разделитель | нужен, чтобы пары «А» + «BC» и «AB» + «C» не давали одну строку.
Нормализация внутри ГуидПоКлючу повторная, если на вход уже пришла нормализованная строка, но она идемпотентна: нормализованная строка после повторной нормализации не меняется. Зато функцию нельзя случайно вызвать с сырым значением.
Получение или создание элемента
// ПРИМЕР. Получение или создание элемента по детерминированной ссылке
Функция ПолучитьИлиСоздатьГруппу(Наименование) Экспорт
Ссылка = Справочники.НоменклатурныеГруппы.ПолучитьСсылку(
ГуидПоКлючу("НоменклатурныеГруппы", НормализоватьНаименование(Наименование)));
Объект = Ссылка.ПолучитьОбъект();
Если Объект <> Неопределено Тогда
Возврат Ссылка; // элемент уже есть
КонецЕсли;
Объект = Справочники.НоменклатурныеГруппы.СоздатьЭлемент();
Объект.УстановитьСсылкуНового(Ссылка);
Объект.Наименование = СокрЛП(Наименование); // храним исходный вид, ключ - нормализованный
Объект.Записать(); // при гонке здесь будет исключение, его ловим выше по стеку
Возврат Ссылка;
КонецФункции
Так же, через ПолучитьСсылку и УстановитьСсылкуНового, создаются вид, единица, ставка и сама номенклатура, каждая со своим ключом. Ни регистр, ни поиск по ключам здесь больше не нужны: ссылка вычисляется из того же ключа, что и раньше шёл в регистр. Регистр можно продолжать заполнять для совместимости с остальным обменом, но защищает от дублей уже не он.
ПолучитьСсылку(Новый УникальныйИдентификатор(...)) по GUID создаёт ссылку, даже если такого объекта в базе нет. Поэтому существование проверяем через ПолучитьОбъект(): для несуществующей ссылки он возвращает Неопределено. УстановитьСсылкуНового заставляет новый элемент записаться именно с этой ссылкой, а не со случайной.
Обратите внимание, что исключение при записи здесь не перехватывается. Это сделано намеренно, и причина в транзакциях.
Почему повтор — на уровне сообщения, а не записи
Первое желание — обернуть Записать() в Попытка и при ошибке просто вернуть ссылку: элемент же уже создал соседний поток. Внутри транзакции так делать нельзя. Если в 1С внутри транзакции возникло исключение при записи, вся транзакция помечается как испорченная, и зафиксировать её уже не получится, даже если исключение поймано. Группа создаётся внутри транзакции загрузки карточки, значит, после конфликта карточку в этой транзакции уже не записать.
Поэтому конфликт должен откатить всю обработку сообщения, а сообщение — вернуться в очередь. Тут и пригодился журнал обмена со статусами:
// ПРИМЕР. Обработка сообщения из журнала обмена с повтором
// Конфликт записи не переводит сообщение в ошибку:
// оно остается «Не обработано» и попадает в следующую порцию.
Процедура ОбработатьСообщениеИзЖурнала(Сообщение) Экспорт
НачатьТранзакцию();
Попытка
ЗагрузитьНоменклатуру(Сообщение); // внутри вызывается ПолучитьИлиСоздатьГруппу
ЗафиксироватьТранзакцию();
Исключение
ОтменитьТранзакцию();
ТекстОшибки = ПодробноеПредставлениеОшибки(ИнформацияОбОшибке());
Если ЭтоКонфликтЗаписи(ТекстОшибки) Тогда
// статус не меняем: сообщение будет обработано повторно
ЗаписьЖурналаРегистрации("Загрузка.Номенклатура",
УровеньЖурналаРегистрации.Предупреждение,,, ТекстОшибки);
Возврат;
КонецЕсли;
УстановитьСтатусСообщения(Сообщение, "Ошибка получения");
ЗаписьЖурналаРегистрации("Загрузка.Номенклатура",
УровеньЖурналаРегистрации.Ошибка,,, ТекстОшибки);
Возврат;
КонецПопытки;
УстановитьСтатусСообщения(Сообщение, "Обработано");
КонецПроцедуры
Когда сообщение придёт повторно, группа уже будет в базе с той же самой ссылкой. ПолучитьОбъект() её найдёт, и карточка запишется без конфликта. Повтор идемпотентен: сколько бы раз сообщение ни обрабатывалось, результат одинаковый.
ЭтоКонфликтЗаписи в примере — условная функция. Её задача — отличить гонку (нарушение уникальности ключа, конфликт блокировки) от настоящей ошибки данных. Настоящие ошибки должны уходить в «Ошибка получения», иначе битое сообщение будет крутиться в очереди бесконечно. Если нет надёжного способа распознать конфликт по тексту ошибки, имеет смысл добавить счётчик попыток в запись журнала и после нескольких повторов переводить сообщение в ошибку.
Порядок загрузки
Детерминированный GUID решает проблему корректности, но не убирает саму конкуренцию: при старте загрузки много потоков всё ещё одновременно пытаются создать одни и те же популярные группы, и часть сообщений уходит на повтор. Сейчас я бы снизил это порядком загрузки — от меньшего справочника к большему:
Этап 1: базовые справочники без зависимостей (например, единицы измерения)
↓
Этап 2: номенклатурные группы, виды номенклатуры и прочие значения,
приходившие текстовыми реквизитами карточки
↓
Этап 3: номенклатура (~28 000)
↓
Этап 4: штатный обмен, новые значения создаются по тем же детерминированным GUID
Чем раньше созданы зависимые элементы, тем реже карточкам номенклатуры приходится их создавать и тем меньше конфликтов. При этом GUID остаётся страховкой: если какое-то значение придёт «не в свой» этап, дубля всё равно не будет. Этап 2 в нашем случае пришлось бы собирать из самих карточек, по причинам, описанным выше про предзагрузку, так что это скорее рекомендация на будущее, чем то, что мы сделали.
Для иерархических справочников есть нюанс: родитель должен существовать до записи потомка. Если родитель тоже приходит текстом, его ссылку можно вычислить той же функцией ГуидПоКлючу и создать «на лету» тем же способом. Порядок внутри иерархии тогда не важен.
Что получилось
При том же лимите 5000 сообщений за 15 минут загрузились почти все сообщения порции. С исключительными блокировками падало около 85% сообщений, с разделяемыми около 20%, и появлялись дубли. Ошибки блокировок практически исчезли. Единичные конфликты записи оставались в статусе «Не обработано» и проходили при следующей порции практически сразу. Весь массив около 28 000 позиций прошёл без дублей, полная загрузка стала заметно быстрее, а нагрузка на сервер ровнее: фоновые задания больше не стояли в очереди на одной блокировке.
Исключительные блокировки | Разделяемые блокировки | Детерминированный GUID | |
|---|---|---|---|
Сообщений с ошибкой | около 85% (загружалось около 700 из 5000), таймаут блокировок | около 20%, таймаут блокировок | практически нет, единичные конфликты записи уходили на повтор |
Время загрузки | порция обрабатывалась лишь частично, загрузка растягивалась | около 20% порции уходило в ошибку и на повтор | заметно быстрее |
Нагрузка | потоки ждали блокировок | меньше ожиданий | ровнее |
Дубли | нет | около 250 элементов зависимых справочников | нет |
Ограничения и подводные камни
Для зависимых сущностей ключ — нормализованное наименование, и у этого есть цена. У самой номенклатуры ключ — идентификатор или артикул из источника, так что её переименование ничего не ломает. С зависимыми сложнее: переименование в источнике даёт новый GUID. если в SAP «Крепеж» переименуют в «Крепеж и метизы», следующая загрузка создаст новый элемент, а старый останется. Отличить переименование от действительно нового значения по одному наименованию невозможно, поэтому такие случаи нужно разбирать отдельно: вести таблицу соответствия «старое наименование → новое» и при переименовании явно переносить ссылки.
Обратная сторона нормализации — склейка. Два значения, которые в источнике считались разными, но после нормализации совпали, станут одним элементом. Чаще всего это именно то, что нужно: «Болт, М10» и «болт м10» — одно и то же. Но если в данных встречаются значения, различающиеся только пунктуацией или регистром по смыслу, набор правил нормализации надо сузить. Перед загрузкой полезно прогнать все наименования из выгрузки через НормализоватьНаименование и посмотреть, какие группы исходных строк схлопнулись в одну.
Опечатки нормализация не лечит. «Крепеж» и «Крипеж» дадут два элемента. Это осознанный выбор: нечёткое сравнение в ключе сделало бы GUID непредсказуемым.
Артикул как ключ номенклатуры хорош, только если он в источнике обязателен и уникален. Если артикул может быть пустым или повторяться, ключом должен быть идентификатор позиции в источнике.
Ещё одна мелочь — длина. Если наименование в источнике длиннее реквизита «Наименование» в 1С, в базу запишется обрезанная строка, а GUID будет посчитан от полной. Это нормально, пока GUID считается из данных источника, а не из того, что записано в базу.
Объекты, созданные до загрузки вручную или другим обменом, имеют случайные ссылки. Детерминированный GUID с ними не совпадёт, и загрузка создаст рядом второй элемент. Такие объекты нужно один раз сопоставить заранее по нормализованному наименованию и либо перенести их ссылки в регистр, либо пересоздать, если на них ещё ничего не ссылается.
Помеченный на удаление элемент ПолучитьОбъект() тоже вернёт. Нужно решить, снимать пометку при повторном приходе значения или считать это ошибкой. Если же помеченный элемент удалить окончательно, следующая загрузка создаст его заново с той же ссылкой — иногда это ровно то, что нужно, иногда нет.
Повторная обработка обязательна. Без неё гонка просто превращается из дубля в ошибку записи, и вы теряете сообщения вместо того, чтобы задваивать справочники.
УстановитьСсылкуНового работает с любыми ссылочными объектами, так что подход не привязан к конкретной конфигурации. Он подходит для любого обмена, где несколько потоков параллельно создают одни и те же элементы справочников: первичные миграции, регулярные интеграции, загрузки из нескольких источников с общим ключом.
Вопросы
Интересно, как это решают другие. Используете ли вы детерминированные ссылки или держите внешний идентификатор в регистре и боретесь с блокировками? И был ли у вас случай, когда дубли полезли не из основного справочника, а из зависимых, которые создаются по ходу?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.