ACID в 1С: как одна переменная сломала обработку заказов

ACID в 1С: как одна переменная сломала обработку заказов

Торговая сеть, 12 магазинов, интеграция с маркетплейсом. Заказы приходят через API, обрабатываются в 1С: создаются документы реализации, формируются перемещения по складам, обновляются остатки. Система работает полгода. И вдруг: 47 заказов зависли. Не обработаны, не отклонены. Повторный запуск обработки — ничего не происходит. Заказы как будто заблокированы изнутри.

Я потратил четыре часа на поиск причины. Причина оказалась в одной переменной — флаге «ПеремещенияСозданы», который записывался в отдельной транзакции.

Что случилось: два Write() — две транзакции

Код обработки заказа делал две вещи: создавал документы перемещения товаров между складами и ставил флаг «ПеремещенияСозданы = Истина» в самом заказе. Логически — одна операция. Технически — две записи в базу.

// Упрощённый код (суть бага)
Процедура ОбработатьЗаказМаркетплейса(Заказ)
    
    // Шаг 1: создаём перемещения
    Для Каждого Строка Из Заказ.Товары Цикл
        Перемещение = Документы.ПеремещениеТоваров.СоздатьДокумент();
        // ... заполнение реквизитов ...
        Перемещение.Записать(); // <-- первый Write()
    КонецЦикла;
    
    // Шаг 2: ставим флаг
    Заказ.ПеремещенияСозданы = Истина;
    Заказ.Записать(); // <-- второй Write()
    
КонецПроцедуры

На первый взгляд — нормальный код. Работает. Но только до первого сбоя.

Что произошло: при обработке 47-го заказа второй Записать() упал с ошибкой. Причина — конфликт блокировок. Другой пользователь в этот момент открыл карточку того же заказа. Перемещения создались. Флаг — нет.

Теперь смотрим, что происходит при повторном запуске. Обработка проверяет: Если НЕ Заказ.ПеремещенияСозданы Тогда. Флаг False — значит, перемещения не созданы. Нужно создавать. Код пытается создать перемещения заново. Но документы с такими же реквизитами уже существуют. Контроль уникальности — ошибка. Обработка падает. Заказ остаётся необработанным навсегда.

Deadlock. Не на уровне СУБД — на уровне бизнес-логики. Флаг говорит: «перемещения не созданы». База говорит: «перемещения существуют». Ни вперёд, ни назад.

Схема бага: два Write() без общей транзакции, рассинхронизация флага и реальных данных

ACID: четыре буквы, которые разработчики 1С часто игнорируют

ACID — это не абстрактная теория из учебника по базам данных. Это контракт между приложением и реальностью. Четыре свойства транзакций, каждое из которых решает конкретную практическую проблему.

Atomicity (Атомарность). Транзакция выполняется полностью или не выполняется вообще. Не бывает «наполовину сделано». Именно это свойство нарушил код выше: перемещения создались, а флаг — нет. Половина операции прошла, половина — нет. Атомарность сломана.

Consistency (Согласованность). После завершения транзакции данные находятся в корректном состоянии. Флаг «ПеремещенияСозданы = Ложь» при существующих перемещениях — некорректное состояние. Согласованность нарушена.

Isolation (Изолированность). Параллельные транзакции не мешают друг другу. Пользователь открыл карточку заказа и заблокировал запись — но только потому что код не управлял блокировками явно. Изоляция не обеспечена.

Durability (Долговечность). Зафиксированные данные не пропадут. С этим в 1С обычно всё нормально — платформа и СУБД делают свою работу. Но первые три буквы — зона ответственности разработчика.

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

Правильный паттерн: явные транзакции

Решение бага с заказами было прямолинейным: обернуть всю операцию в одну транзакцию.

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

Что изменилось:

  • Одна транзакция. НачатьТранзакцию() в начале, ЗафиксироватьТранзакцию() в конце. Все записи — перемещения и флаг — либо фиксируются вместе, либо откатываются вместе. Атомарность восстановлена.
  • Явная блокировка. БлокировкаДанных захватывает заказ на запись сразу. Никто другой не сможет его изменить, пока транзакция не завершится. Изоляция обеспечена.
  • Перечитывание внутри транзакции. Заказ читается заново после установки блокировки. Это гарантирует, что мы работаем с актуальными данными, а не с тем, что было закешировано до начала транзакции.
  • Проверка идемпотентности. Если ПеремещенияСозданы = Истина — выходим. Повторный запуск безопасен.

Для 47 зависших заказов пришлось писать отдельную обработку: найти «осиротевшие» перемещения, удалить их, сбросить флаги, запустить обработку заново. Час ручной работы, который не понадобился бы, если бы транзакции были правильными с самого начала.

Правильный паттерн: НачатьТранзакцию, блокировка, запись, ЗафиксироватьТранзакцию

Пять типичных ошибок с транзакциями в 1С

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

1. Несколько Write() без явной транзакции. Самая распространённая ошибка. Два-три вызова Записать() подряд. Разработчик думает, что это одна операция. Платформа думает, что это три отдельных. Если третий упадёт — первые два уже в базе. Откатить нельзя.

2. Попытка-Исключение без ОтменитьТранзакцию(). Код:

НачатьТранзакцию();
Попытка
    // ... работа ...
    ЗафиксироватьТранзакцию();
Исключение
    // Логируем ошибку, но забываем отменить транзакцию
    ЗаписьЖурналаРегистрации(...);
КонецПопытки;

Транзакция осталась активной. Следующий НачатьТранзакцию() создаст вложенную транзакцию. Следующий ЗафиксироватьТранзакцию() не зафиксирует ничего — он уменьшит счётчик вложенности на единицу. Данные не сохранятся, а разработчик не поймёт почему. Видел это в production — регламентное задание «работало» месяц, не записав ни одной строки.

3. Блокировка после чтения, а не до. Код читает данные, потом начинает транзакцию и блокирует. Но между чтением и блокировкой данные мог изменить другой пользователь. Вы работаете с устаревшей копией. Правило: сначала блокировка, потом чтение.

4. Запрос в транзакции без FOR UPDATE. Делаете Запрос.Выполнить() внутри транзакции, но не устанавливаете управляемую блокировку на прочитанные данные. Другой сеанс может их изменить между вашим чтением и записью. В управляемом режиме блокировок платформа не ставит блокировки при чтении автоматически — это ответственность разработчика. Параллельно стоит следить за тем, как транзакции влияют на производительность запросов — практическое руководство по оптимизации запросов в 1С охватывает и этот аспект.

5. Длинные транзакции. Транзакция длится 30 секунд, потому что внутри неё происходит вызов внешнего API, HTTP-запрос к маркетплейсу, ожидание ответа. Всё это время данные заблокированы. Десять пользователей стоят в очереди. Правило: внешние вызовы — за пределами транзакции. Получили данные, обработали, и только потом — быстрая транзакция на запись. Если строите интеграцию с маркетплейсом — отдельная статья о подводных камнях: реальный баг в интеграции 1С с Wildberries.

Как обнаружить сломанные транзакции

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

Вот что я проверяю при аудите чужого кода:

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

Поиск: НачатьТранзакцию() без пары. Каждый НачатьТранзакцию() должен иметь ровно один ЗафиксироватьТранзакцию() и ровно один ОтменитьТранзакцию() в ветке Исключение. Если счёт не сходится — баг.

Журнал регистрации. Фильтр: события с ошибками за последние сутки. Если видите ошибки Конфликт блокировок или Таймаут блокировки — где-то транзакции конкурируют. Само по себе это нормально, но если частота растёт — код неоптимален.

Технологический журнал. Включаем события TLOCK и TTIMEOUT. Видим конкретные таблицы и записи, на которых происходят конфликты. Это самый точный инструмент диагностики, но и самый трудоёмкий: технологический журнал генерирует гигабайты данных, и без автоматизации парсинга работать с ним мучительно. Для первичного анализа кода на предмет проблем с транзакциями неплохо работает 1С:Напарник — AI-ассистент в EDT, который помогает замечать подозрительные паттерны в коде. Для системного мониторинга доступности и нагрузки удобнее связка Prometheus и Grafana — об этом практическое руководство по мониторингу 1С.

Проверка на code review. Я добавил в свой чек-лист код-ревью три пункта, связанных с транзакциями. Первый: если вижу больше одного Записать() в процедуре — проверяю, обёрнуты ли они в транзакцию. Второй: если вижу НачатьТранзакцию() — ищу ОтменитьТранзакцию() в блоке Исключение. Третий: если внутри транзакции есть обращение к внешнему ресурсу (HTTP, email, файловая система) — это красный флаг, транзакция будет долгой, блокировки будут висеть. Три простых правила, которые ловят 90% проблем до того, как код попадёт в production.

Шаблон, который я использую всегда

За годы я выработал стандартный шаблон транзакции, который копирую в каждый новый модуль:

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

Три правила, которые я не нарушаю:

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

Почему это важно именно сейчас

1С всё чаще работает не изолированно. Интеграции с маркетплейсами, обмен с сайтами, API для мобильных приложений, синхронизация между базами. Каждая интеграция — это параллельные процессы, которые одновременно читают и пишут данные. Отдельный класс проблем — когда API стандартных подсистем ведёт себя непредсказуемо: про это разбор тихих сбоев в БСП API.

Раньше в типичной бухгалтерии работали пять человек. Конфликт блокировок — редкость. Сегодня в ту же базу пишут: пять бухгалтеров, робот обмена с сайтом, обработка заказов маркетплейса, фоновое задание загрузки прайсов, API мобильного приложения. Десять параллельных потоков записи. Без правильных транзакций — хаос.

Я видел базы, где данные рассинхронизировались месяцами. Остатки не сходились, документы противоречили друг другу, регистры содержали «мусорные» записи от наполовину выполненных операций. Причина каждый раз одна: Записать() без НачатьТранзакцию().

Транзакции в 1С — не продвинутая техника. Это базовая гигиена. Как мытьё рук: последствия пренебрежения наступают не сразу, но наступают неизбежно. И обходятся дорого.

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

Каждый раз, когда я пишу НачатьТранзакцию(), я вспоминаю те 47 заказов. И каждый раз проверяю: блокировка стоит? Чтение после блокировки? ОтменитьТранзакцию() в Исключение — первой строкой? Три секунды проверки, которые экономят часы отладки в production.