Торговая сеть, 12 магазинов, интеграция с маркетплейсом. Заказы приходят через API, обрабатываются в 1С: создаются документы реализации, формируются перемещения по складам, обновляются остатки. Система работает полгода. И вдруг: 47 заказов зависли. Не обработаны, не отклонены. Повторный запуск обработки — ничего не происходит. Заказы как будто заблокированы изнутри.
Я потратил четыре часа на поиск причины. Причина оказалась в одной переменной — флаге «ПеремещенияСозданы», который записывался в отдельной транзакции.
Что случилось: два Write() — две транзакции
Код обработки заказа делал две вещи: создавал документы перемещения товаров между складами и ставил флаг «ПеремещенияСозданы = Истина» в самом заказе. Логически — одна операция. Технически — две записи в базу.
// Упрощённый код (суть бага)
Процедура ОбработатьЗаказМаркетплейса(Заказ)
// Шаг 1: создаём перемещения
Для Каждого Строка Из Заказ.Товары Цикл
Перемещение = Документы.ПеремещениеТоваров.СоздатьДокумент();
// ... заполнение реквизитов ...
Перемещение.Записать(); // <-- первый Write()
КонецЦикла;
// Шаг 2: ставим флаг
Заказ.ПеремещенияСозданы = Истина;
Заказ.Записать(); // <-- второй Write()
КонецПроцедуры
На первый взгляд — нормальный код. Работает. Но только до первого сбоя.
Что произошло: при обработке 47-го заказа второй Записать() упал с ошибкой. Причина — конфликт блокировок. Другой пользователь в этот момент открыл карточку того же заказа. Перемещения создались. Флаг — нет.
Теперь смотрим, что происходит при повторном запуске. Обработка проверяет: Если НЕ Заказ.ПеремещенияСозданы Тогда. Флаг False — значит, перемещения не созданы. Нужно создавать. Код пытается создать перемещения заново. Но документы с такими же реквизитами уже существуют. Контроль уникальности — ошибка. Обработка падает. Заказ остаётся необработанным навсегда.
Deadlock. Не на уровне СУБД — на уровне бизнес-логики. Флаг говорит: «перемещения не созданы». База говорит: «перемещения существуют». Ни вперёд, ни назад.
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. Бизнес-логика и запись
// ...
ЗафиксироватьТранзакцию();
Исключение
ОтменитьТранзакцию();
ВызватьИсключение; // или логирование
КонецПопытки;
Три правила, которые я не нарушаю:
- Блокировка — первое действие после НачатьТранзакцию(). До любого чтения, до любой бизнес-логики. Сначала захватили ресурс, потом работаем.
- ОтменитьТранзакцию() — первое действие в Исключение. Не логирование, не уведомление — сначала откат. Потому что до отката транзакция активна и блокировки удерживаются.
- Никаких внешних вызовов внутри транзакции. HTTP-запросы, отправка email, обращение к внешним сервисам — всё это до или после транзакции. Внутри — только работа с базой данных 1С.
Почему это важно именно сейчас
1С всё чаще работает не изолированно. Интеграции с маркетплейсами, обмен с сайтами, API для мобильных приложений, синхронизация между базами. Каждая интеграция — это параллельные процессы, которые одновременно читают и пишут данные. Отдельный класс проблем — когда API стандартных подсистем ведёт себя непредсказуемо: про это разбор тихих сбоев в БСП API.
Раньше в типичной бухгалтерии работали пять человек. Конфликт блокировок — редкость. Сегодня в ту же базу пишут: пять бухгалтеров, робот обмена с сайтом, обработка заказов маркетплейса, фоновое задание загрузки прайсов, API мобильного приложения. Десять параллельных потоков записи. Без правильных транзакций — хаос.
Я видел базы, где данные рассинхронизировались месяцами. Остатки не сходились, документы противоречили друг другу, регистры содержали «мусорные» записи от наполовину выполненных операций. Причина каждый раз одна: Записать() без НачатьТранзакцию().
Транзакции в 1С — не продвинутая техника. Это базовая гигиена. Как мытьё рук: последствия пренебрежения наступают не сразу, но наступают неизбежно. И обходятся дорого.
Та торговая сеть с 47 зависшими заказами потеряла день работы отдела логистики. Не потому что система сломалась. Не потому что сервер упал. Потому что один разработчик — возможно, в спешке, возможно, по незнанию — написал два Записать() вместо одной транзакции. Маленькая щель в коде, через которую утекла согласованность данных.
Каждый раз, когда я пишу НачатьТранзакцию(), я вспоминаю те 47 заказов. И каждый раз проверяю: блокировка стоит? Чтение после блокировки? ОтменитьТранзакцию() в Исключение — первой строкой? Три секунды проверки, которые экономят часы отладки в production.


