При интеграции 1С со сторонними сервисами через REST или HTTP-сервисы часто возникает задача обеспечения идемпотентности. Типичная ситуация: внешний клиент одновременно или с минимальной задержкой отправляет два одинаковых запроса на создание заказа. В коде 1С заложена проверка: сначала выполняется поиск документа по внешнему идентификатору (ID), и если заказ не найден, создается новый. Однако на практике в базе все равно появляются два одинаковых документа с одним и тем же внешним ID.
Проанализируем механику работы СУБД и платформы 1С:Предприятие, выясним истинные причины возникновения дублей и разберем проверенные способы решения этой проблемы.
Рассмотрим, что происходит на уровне СУБД и сервера приложений, когда два параллельных потока выполняют поиск и запись:
READ_COMMITTED_SNAPSHOT (версионирование строк). В этом режиме операции чтения вообще не накладывают блокировок на уровне СУБД, а считывают последний зафиксированный снимок данных. Даже если код обернут в транзакцию с помощью НачатьТранзакцию(), второй поток не увидит изменений первого до момента вызова ФиксироватьТранзакцию().ДЛЯ ИЗМЕНЕНИЯ в управляемом режиме блокировок не накладывает блокировки диапазона (Key-Range) на отсутствующие записи в произвольных таблицах документов.Номер через кластерный сервис нумерации, но для пользовательского реквизита (например, ВнешнийИдентификатор) создать системный уникальный индекс средствами конфигуратора невозможно.Таким образом, простого чтения данных перед записью недостаточно — требуется заставить второй параллельный поток ждать завершения первого с помощью управляемых транзакционных блокировок.
Рассмотрим штатный механизм платформы, доступный начиная с версии платформы 8.3.6. По умолчанию объект БлокировкаДанных для таблиц документов поддерживает блокировку только по одному полю — Ссылка. Однако в свойствах метаданных можно явно расширить этот список.
Разберем реализацию по шагам:
ЗаказКлиента) и откроем его палитру свойств.ВнешнийИдентификатор.БлокировкаДанных.Посмотрим на пример правильной реализации в коде:
НачатьТранзакцию();
Попытка
// 1. Устанавливаем управляемую блокировку по значению внешнего ID
Блокировка = Новый БлокировкаДанных;
ЭлементБлокировки = Блокировка.Добавить("Документ.ЗаказКлиента");
ЭлементБлокировки.Режим = РежимБлокировкиДанных.Исключительный;
ЭлементБлокировки.УстановитьЗначение("ВнешнийИдентификатор", ВходящийID);
Блокировка.Заблокировать();
// 2. Ищем существующий документ
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ ПЕРВЫЕ 1
| Заказ.Ссылка КАК Ссылка
|ИЗ
| Документ.ЗаказКлиента КАК Заказ
|ГДЕ
| Заказ.ВнешнийИдентификатор = &ВнешнийИдентификатор";
Запрос.УстановитьПараметр("ВнешнийИдентификатор", ВходящийID);
Результат = Запрос.Выполнить();
Если НЕ Результат.Пустой() Тогда
// Документ уже создан другим потоком
Выборка = Результат.Выбрать();
Выборка.Следующий();
СсылкаНаЗаказ = Выборка.Ссылка;
ОтменитьТранзакцию();
Иначе
// Документа нет, создаем новый
НовыйЗаказ = Документы.ЗаказКлиента.СоздатьДокумент();
НовыйЗаказ.Дата = ТекущаяДатаСеанса();
НовыйЗаказ.ВнешнийИдентификатор = ВходящийID;
// Заполнение остальных реквизитов документа...
НовыйЗаказ.Записать(РежимЗаписиДокумента.Запись);
СсылкаНаЗаказ = НовыйЗаказ.Ссылка;
ФиксироватьТранзакцию();
КонецЕсли;
Исключение
ОтменитьТранзакцию();
ВызватьИсключение;
КонецПопытки;
Как это работает на уровне платформы: Менеджер блокировок кластера серверов 1С фиксирует исключительную блокировку в оперативной памяти. Когда второй параллельный запрос попытается выполнить строку Блокировка.Заблокировать() с тем же самым ВходящийID, он перейдет в состояние ожидания. Как только первая транзакция зафиксирует данные и освободит блокировку, второй поток продолжит выполнение, выполнит запрос и найдет уже созданный документ, предотвратив появление дубля.
Рассмотрим альтернативный архитектурный паттерн, который особенно удобен, если конфигурация находится на поддержке без возможности изменения свойств документов или если требуется централизованное хранение соответствий внешних ключей.
Создадим новый независимый непериодический регистр сведений ВнешниеИдентификаторыДокументов со следующей структурой:
Идентификатор (Строка или УникальныйИдентификатор).Документ (ОпределяемыйТип или Ссылка на документ).Главное преимущество регистра сведений состоит в том, что в СУБД по набору его измерений автоматически создается первичный ключ и физический уникальный индекс. Кроме того, измерения регистров сведений по умолчанию входят в пространство управляемых блокировок без дополнительных настроек метаданных.
Разберем пример блокировки через измерение регистра:
НачатьТранзакцию();
Попытка
// Блокируем пространство регистра сведений по внешнему ключу
Блокировка = Новый БлокировкаДанных;
ЭлементБлокировки = Блокировка.Добавить("РегистрСведений.ВнешниеИдентификаторыДокументов");
ЭлементБлокировки.Режим = РежимБлокировкиДанных.Исключительный;
ЭлементБлокировки.УстановитьЗначение("Идентификатор", ВходящийID);
Блокировка.Заблокировать();
// Проверяем наличие записи в регистре
МенеджерЗаписи = РегистрыСведений.ВнешниеИдентификаторыДокументов.СоздатьМенеджерЗаписи();
МенеджерЗаписи.Идентификатор = ВходящийID;
МенеджерЗаписи.Прочитать();
Если МенеджерЗаписи.Выбран() Тогда
СсылкаНаЗаказ = МенеджерЗаписи.Документ;
ОтменитьТранзакцию();
Иначе
// Создаем документ
НовыйЗаказ = Документы.ЗаказКлиента.СоздатьДокумент();
НовыйЗаказ.Дата = ТекущаяДатаСеанса();
НовыйЗаказ.Записать(РежимЗаписиДокумента.Запись);
// Регистрируем соответствие
МенеджерЗаписи.Идентификатор = ВходящийID;
МенеджерЗаписи.Документ = НовыйЗаказ.Ссылка;
МенеджерЗаписи.Записать(Истина);
СсылкаНаЗаказ = НовыйЗаказ.Ссылка;
ФиксироватьТранзакцию();
КонецЕсли;
Исключение
ОтменитьТранзакцию();
ВызватьИсключение;
КонецПопытки;
Такой подход обеспечивает двойную надежность: менеджер транзакционных блокировок упорядочивает параллельные вызовы, а уникальный индекс таблицы регистра в СУБД на аппаратном уровне исключает запись двух строк с одинаковым измерением при любых непредвиденных сбоях.