Транзакции и обработка ошибок

Учимся записывать данные надёжно: объединять несколько записей в одну транзакцию и грамотно ловить ошибки через Попытка...Исключение.

Транзакция — это группа изменений базы, которая применяется по принципу «всё или ничего»: либо все записи внутри сохраняются успешно, либо при ошибке отменяются все разом, и база остаётся в прежнем состоянии.

Представьте: обработка загружает 500 контрагентов из файла. На 347-м строка оказалась битой, и код падает с ошибкой. Что в базе? Если писать записи по одной без транзакции — там окажутся 346 контрагентов, а остальные нет. Данные загрузились наполовину, и теперь непонятно, с какого места продолжать. Транзакции решают ровно эту проблему: они позволяют сказать базе «считай эти изменения единым целым». А конструкция Попытка...Исключение ловит ошибку, чтобы программа не рушилась, а корректно её обработала.

Зачем это нужно на практике

Любая операция, которая меняет несколько связанных записей, требует транзакции. Провели документ — списали товар со склада и создали проводку: если проводка не удалась, списание тоже должно откатиться, иначе учёт «поедет». Перенесли деньги между счетами — сняли с одного, добавили на другой: полдела здесь недопустимо. Без транзакций база рискует остаться в противоречивом состоянии, а это в учётной системе — катастрофа.

Три метода управления транзакцией

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

НачатьТранзакцию();
// ... здесь пишем объекты ...
ЗафиксироватьТранзакцию();

Но использовать транзакцию без обработки ошибок опасно: если внутри что-то упадёт, транзакция останется незакрытой. Поэтому в реальном коде транзакцию всегда сочетают с Попытка...Исключение.

Конструкция Попытка...Исключение

Она работает так: код в блоке Попытка выполняется как обычно, но если внутри возникает ошибка, выполнение мгновенно перескакивает в блок Исключение — программа не падает. Там мы можем разобраться с ошибкой: откатить транзакцию, записать в лог, показать понятное сообщение. Функция ИнформацияОбОшибке() внутри Исключение даёт объект с деталями, а ОписаниеОшибки() — готовый текст ошибки:

НачатьТранзакцию();
Попытка
    Заказ = Документы.ЗаказПокупателя.СоздатьДокумент();
    Заказ.Дата = ТекущаяДата();
    Заказ.Контрагент = Справочники.Контрагенты.НайтиПоНаименованию("ООО Ромашка");
    Заказ.Записать(РежимЗаписиДокумента.Проведение);

    // Если дошли сюда — всё прошло успешно, фиксируем
    ЗафиксироватьТранзакцию();
    Сообщить("Заказ проведён");
Исключение
    // Любая ошибка выше приводит нас сюда
    ОтменитьТранзакцию();
    ИнфОбОшибке = ИнформацияОбОшибке();
    Сообщить("Не удалось провести заказ: " + ИнфОбОшибке.Описание);
КонецПопытки;

Разберём логику по шагам. Мы открыли транзакцию до Попытка. Внутри создаём и проводим документ. Если всё прошло гладко, управление доходит до ЗафиксироватьТранзакцию() — изменения сохраняются. Если же на записи возникла ошибка (скажем, сработала проверка обязательного поля), выполнение прыгает в Исключение, где мы первым делом зовём ОтменитьТранзакцию() — база возвращается в исходное состояние, как будто заказа и не было. Затем через ИнформацияОбОшибке() получаем причину и выводим её пользователю.

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

У объекта ИнформацияОбОшибке() есть удобное свойство .Описание с текстом. В блоке Исключение можно также вызвать ВызватьИсключение без аргументов, чтобы «пробросить» ошибку выше по стеку после того, как вы откатили транзакцию — это применяют, когда обработать ошибку до конца на этом уровне нельзя.

Запись в цикле: почему без транзакции — плохо

Вернёмся к загрузке 500 контрагентов. Наивный код записывает каждого отдельно:

// ПЛОХО: каждая запись — отдельная неявная транзакция
Для Каждого СтрокаФайла Из ДанныеФайла Цикл
    Контрагент = Справочники.Контрагенты.СоздатьЭлемент();
    Контрагент.Наименование = СтрокаФайла.Наименование;
    Контрагент.ИНН = СтрокаФайла.ИНН;
    Контрагент.Записать();  // 500 отдельных транзакций
КонецЦикла;

Проблем здесь две. Во-первых, производительность: каждый .Записать() — это отдельная неявная транзакция, а фиксация транзакции требует записи на диск. 500 записей = 500 обращений к диску, это медленно. Во-вторых, целостность: если на 347-й записи произойдёт ошибка без обработки, первые 346 останутся в базе, а загрузка прервётся на полпути. Правильнее обернуть весь цикл в одну транзакцию:

// ХОРОШО: весь цикл — одна транзакция
НачатьТранзакцию();
Попытка
    Для Каждого СтрокаФайла Из ДанныеФайла Цикл
        Контрагент = Справочники.Контрагенты.СоздатьЭлемент();
        Контрагент.Наименование = СтрокаФайла.Наименование;
        Контрагент.ИНН = СтрокаФайла.ИНН;
        Контрагент.Записать();
    КонецЦикла;
    ЗафиксироватьТранзакцию();
    Сообщить("Загружено записей: " + ДанныеФайла.Количество());
Исключение
    ОтменитьТранзакцию();
    Сообщить("Загрузка прервана, изменения отменены: " + ОписаниеОшибки());
КонецПопытки;

Результат при ошибке на середине:

Загрузка прервана, изменения отменены: Поле объекта не обнаружено (ИНН)

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

Как это работает

Когда вы зовёте НачатьТранзакцию(), СУБД начинает копить изменения в специальной области и до фиксации не показывает их другим пользователям. ЗафиксироватьТранзакцию() атомарно применяет весь пакет и сбрасывает его на диск. ОтменитьТранзакцию() просто отбрасывает накопленное. Пока транзакция открыта, платформа удерживает блокировки на затронутые данные, чтобы другой пользователь не изменил их «из-под рук». Именно поэтому долгие транзакции нежелательны: чем дольше открыта транзакция, тем дольше висят блокировки и тем больше рискуют «встать в очередь» другие пользователи.

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

Частые ошибки

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

Итоги

  • Транзакция объединяет изменения по принципу «всё или ничего»: НачатьТранзакцию() → изменения → ЗафиксироватьТранзакцию(), а при сбое — ОтменитьТранзакцию().
  • Транзакцию всегда сочетают с Попытка...Исключение: фиксация — в конце Попытка, откат — первой строкой Исключение.
  • ИнформацияОбОшибке() и ОписаниеОшибки() внутри Исключение дают причину сбоя для лога и сообщения.
  • Массовую запись в цикле оборачивайте в одну транзакцию — это быстрее и защищает от частичной загрузки.
  • Долгие транзакции держат блокировки на данные, поэтому их нужно закрывать как можно быстрее.
Проверьте себя
1. В блоке Исключение вы обрабатываете сбой транзакции. В каком порядке правильно расположить действия?
AСначала записать в лог и показать сообщение, а ОтменитьТранзакцию() — в самом конце
BОтменитьТранзакцию() должна быть первой строкой блока Исключение, до любой другой работы с базой
CПорядок не важен, транзакция откатится сама при выходе из процедуры
DВместо ОтменитьТранзакцию() надо снова вызвать ЗафиксироватьТранзакцию()
2. Почему запись 500 элементов справочника в цикле, где каждый .Записать() вызывается отдельно без общей транзакции, — плохая идея?
A1С вообще запрещает вызывать .Записать() внутри цикла
BКаждая запись — отдельная неявная транзакция (медленно из-за обращений к диску), а при сбое на середине часть записей останется в базе
CМетод .Записать() в цикле всегда перезаписывает предыдущий элемент
DБез транзакции реквизиты не сохраняются вообще