Почему регламентное задание в 1С повторно обрабатывает одни и те же документы и как это исправить?

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

В разработке на платформе 1С часто возникает задача автоматической обработки данных по расписанию. Для этого используются регламентные задания. Типичный сценарий: задание должно выбрать определенные документы или записи, выполнить над ними действие (например, отправить уведомление) и пометить их как обработанные, чтобы не захватывать их при следующем запуске. Однако иногда что-то идет не так, и задание начинает "зацикливаться" — обрабатывать одни и те же данные снова и снова, что может привести к массовым повторным рассылкам, дублированию записей и другим критическим ошибкам.

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

Анализ исходного кода и его очевидные проблемы

Давайте посмотрим на первоначальный код, который выполняет рассылку.


&НаСервере
Процедура Отправить()
    УстановитьПРивилегированныйРежим(Истина);
    МассивКому     = Новый Массив;
    МассивВложений = Новый Массив;
    // ...

    Запрос       = Новый Запрос; 
    Запрос.Текст = "
        |ВЫБРАТЬ
        |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.Ссылка КАК Ссылка,
        |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.Ссылка.Номер КАК Номер,
        |    // ... другие поля
        |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.НомерСтроки КАК НомерСтроки
        |ИЗ
        |    Документ.ХХХ_ПредварительныйЗаказПоставщику.ОтметкиСклада КАК ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада
        |ГДЕ
        |    // ... условия выборки
        |    И НЕ ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.УведомлениеОтправлено = ИСТИНА
        |
        |УПОРЯДОЧИТЬ ПО
        |    РазностьДат";
    // ... установка параметров и выполнение запроса

    Рез = Запрос.Выполнить(); 
    
    НомерПредыдущегоДокумента = Неопределено;
    Если Не Рез.Пустой() Тогда
        Выборка = Рез.Выбрать();
        Пока Выборка.Следующий() Цикл
            // Логика группировки писем по номеру документа
            Если НомерПредыдущегоДокумента = Неопределено ИЛИ Не Выборка.Номер = НомерПредыдущегоДокумента Тогда
                Сообщить("Отправляется " + Выборка.Номер);
                // ... Формирование и отправка письма
                
                РаботаСПочтовымиСообщениями.ОтправитьПочтовоеСообщение(УчетнаяЗаписьПочты, ПараметрыОтправки);

                НомерПредыдущегоДокумента = Выборка.Номер;
            КонецЕсли;
            
            // Установка флага, что строка обработана
            ДокПредварительныйЗаказПоставщику       = Выборка.Ссылка.ПолучитьОбъект();
            НайденнаяСтрока                        = ДокПредварительныйЗаказПоставщику.ОтметкиСклада.Найти(Выборка.НомерСтроки, "НомерСтроки");
            НайденнаяСтрока.УведомлениеОтправлено = Истина;            
            ДокПредварительныйЗаказПоставщику.Записать();
        КонецЦикла;        
    КонецЕсли;
КонецПроцедуры

В этом коде можно выделить несколько проблемных моментов.

  1. Ненадежная группировка писем. Автор пытается отправить одно письмо на один документ, используя проверку `Если ... Не Выборка.Номер = НомерПредыдущегоДокумента`. Эта логика будет работать корректно, только если все строки одного и того же документа идут в результате запроса подряд. В первоначальном коде сортировка была по `РазностьДат`, что не гарантирует такого порядка. Позже автор добавил сортировку по номеру, но и это не является надежным решением: номера документов могут повторяться (например, при начале нумерации с нового года). Правильное решение — группировать по уникальному идентификатору, то есть по полю `Ссылка`.
  2. Накопление получателей. В коде переменная `МассивКому` инициализируется один раз перед циклом. Однако внутри цикла она не очищается перед обработкой нового документа. Это приведет к тому, что при отправке второго письма в списке получателей будут адресаты и от первого, и от второго письма. Для третьего — от первого, второго и третьего. Это и есть причина "диких рассылок", о которых сообщали пользователи. Необходимо очищать массив `МассивКому.Очистить()` перед формированием каждого нового письма.
  3. Неэффективная работа с объектами в цикле. Самая серьезная архитектурная проблема кода — это получение объекта и его запись на каждой итерации цикла (`ПолучитьОбъект()` и `Записать()`). Если в документе 10 строк, которые нужно обработать, то объект документа будет получен из базы данных 10 раз и записан в базу данных тоже 10 раз. Это создает колоссальную избыточную нагрузку на систему и, что важнее, многократно увеличивает риск возникновения блокировок.

Скрытая угроза: транзакционные блокировки

Наиболее вероятной причиной "зацикливания", когда флаг `УведомлениеОтправлено` не устанавливается, являются конфликты блокировок. Разберем механизм их возникновения.

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

Проследим за шагами выполнения кода в цикле:

  1. ДокПредварительныйЗаказПоставщику = Выборка.Ссылка.ПолучитьОбъект(); — получаем объект документа.
  2. НайденнаяСтрока.УведомлениеОтправлено = Истина; — изменяем данные объекта в памяти.
  3. ДокПредварительныйЗаказПоставщику.Записать(); — пытаемся записать измененный объект в базу данных.

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

Ключевой момент: при возникновении такого исключения вся транзакция регламентного задания будет отменена. Это означает, что изменение флага `УведомлениеОтправлено = Истина` не сохранится в базе данных. Письмо при этом уже может быть отправлено. При следующем запуске регламентного задания запрос снова выберет ту же самую строку, так как флаг остался в значении `ЛОЖЬ`, и все повторится.

Как диагностировать? Первым делом нужно изучить Журнал регистрации. Ищите в нем сообщения об ошибках, содержащие слова "Конфликт блокировок", "тайм-аут", "deadlock" в момент времени, когда работало ваше регламентное задание.

Оптимизация и правильная архитектура обработки

Чтобы решить все перечисленные проблемы, код необходимо кардинально переработать, следуя двум основным принципам: минимизировать операции с базой данных и обрабатывать данные порциями (группировками).

Решение 1: Используем обход по группировкам

Вместо того чтобы обрабатывать каждую строку отдельно, мы можем сгруппировать результат запроса по документу (`Ссылка`) и обработать все нужные строки одного документа за один раз.

Для этого изменим запрос и логику обхода результата.


// 1. Изменяем запрос, добавляя ИТОГИ по полю Ссылка
Запрос.Текст = "
    |ВЫБРАТЬ
    |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.Ссылка КАК Ссылка,
    |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.НомерСтроки,
    |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.Ссылка.Номер КАК Номер,
    |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.Ссылка.Дата КАК Дата,
    |    ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.Ссылка.Менеджер КАК Менеджер
    |ИЗ
    |    Документ.ХХХ_ПредварительныйЗаказПоставщику.ОтметкиСклада КАК ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада
    |ГДЕ
    |    НЕ ХХХ_ПредварительныйЗаказПоставщикуОтметкиСклада.УведомлениеОтправлено
    |    // ...прочие условия
    |
    |ИТОГИ ПО
    |    Ссылка";

Рез = Запрос.Выполнить();

// 2. Организуем обход по группировкам
ВыборкаДокументы = Рез.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам);

Пока ВыборкаДокументы.Следующий() Цикл
    // Внешний цикл - по документам
    // Здесь мы работаем с одним документом
    
    // Получаем объект документа ОДИН РАЗ
    ДокОбъект = ВыборкаДокументы.Ссылка.ПолучитьОбъект();
    
    // Готовим одно письмо для всех строк этого документа
    ТекстПисьма = "Срочно предоставить документы по заказу №" + ДокОбъект.Номер + " от " + Формат(ДокОбъект.Дата, "ДЛФ=DD") + ":" + Символы.ПС;
    
    // Внутренний цикл - по строкам текущего документа
    ВыборкаСтроки = ВыборкаДокументы.Выбрать();
    Пока ВыборкаСтроки.Следующий() Цикл
        // Дополняем текст письма информацией из строки
        ТекстПисьма = ТекстПисьма + " - позиция по строке №" + ВыборкаСтроки.НомерСтроки + Символы.ПС;

        // Находим нужную строку в объекте и меняем флаг
        НайденнаяСтрока = ДокОбъект.ОтметкиСклада.Найти(ВыборкаСтроки.НомерСтроки, "НомерСтроки");
        Если НайденнаяСтрока <> Неопределено Тогда
            НайденнаяСтрока.УведомлениеОтправлено = Истина;
        КонецЕсли;
    КонецЦикла;
    
    // Формируем список получателей и отправляем ОДНО письмо
    МассивКому.Очистить(); // Обязательно очищаем массив!
    // ... код добавления адреса менеджера и контролера в МассивКому
    
    // ... Отправка письма ...
    РаботаСПочтовымиСообщениями.ОтправитьПочтовоеСообщение(...);
    
    // Записываем измененный документ ОДИН РАЗ
    Попытка
        ДокОбъект.Записать(РежимЗаписиДокумента.Проведение); // или .Запись
    Исключение
        // Обязательно логируем ошибку, чтобы понять, почему запись не удалась
        ЗаписьЖурналаРегистрации("Ошибка записи документа", УровеньЖурналаРегистрации.Ошибка, , , ОписаниеОшибки());
    КонецПопытки;
    
КонецЦикла;

Что мы изменили и почему это лучше:

  1. Запрос с ИТОГИ ПО Ссылка: Теперь запрос возвращает сгруппированные данные.
  2. Двойной цикл обхода: Внешний цикл `ВыборкаДокументы` проходит по уникальным документам. Внутренний цикл `ВыборкаСтроки` — по всем строкам, относящимся к текущему документу.
  3. Одна операция на документ: ПолучитьОбъект() и Записать() вызываются только один раз для каждого документа, независимо от того, сколько его строк мы обрабатываем. Это кардинально снижает нагрузку и вероятность блокировок.
  4. Единое письмо: Мы формируем одно общее письмо для менеджера со списком всех проблемных позиций в рамках одного документа, что гораздо удобнее для получателя.
  5. Очистка массива: Перед обработкой каждого нового документа массив получателей `МассивКому` очищается.
  6. Обработка исключений: Запись объекта обернута в блок Попытка-Исключение. Если произойдет ошибка блокировки, мы сможем зафиксировать ее в журнале регистрации и проанализировать причину, а работа задания не прервется аварийно.

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

← На главную