Транзакции и обработка ошибок
Учимся записывать данные надёжно: объединять несколько записей в одну транзакцию и грамотно ловить ошибки через Попытка...Исключение.
Транзакция — это группа изменений базы, которая применяется по принципу «всё или ничего»: либо все записи внутри сохраняются успешно, либо при ошибке отменяются все разом, и база остаётся в прежнем состоянии.
Представьте: обработка загружает 500 контрагентов из файла. На 347-м строка оказалась битой, и код падает с ошибкой. Что в базе? Если писать записи по одной без транзакции — там окажутся 346 контрагентов, а остальные нет. Данные загрузились наполовину, и теперь непонятно, с какого места продолжать. Транзакции решают ровно эту проблему: они позволяют сказать базе «считай эти изменения единым целым». А конструкция Попытка...Исключение ловит ошибку, чтобы программа не рушилась, а корректно её обработала.
Зачем это нужно на практике
Любая операция, которая меняет несколько связанных записей, требует транзакции. Провели документ — списали товар со склада и создали проводку: если проводка не удалась, списание тоже должно откатиться, иначе учёт «поедет». Перенесли деньги между счетами — сняли с одного, добавили на другой: полдела здесь недопустимо. Без транзакций база рискует остаться в противоречивом состоянии, а это в учётной системе — катастрофа.
Три метода управления транзакцией
Работа с транзакцией строится на трёх методах. НачатьТранзакцию() открывает её. ЗафиксироватьТранзакцию() подтверждает все изменения — только после неё они окончательно попадают в базу. ОтменитьТранзакцию() откатывает всё, что было сделано с момента начала. Голый каркас выглядит так:
НачатьТранзакцию();
// ... здесь пишем объекты ...
ЗафиксироватьТранзакцию();
Но использовать транзакцию без обработки ошибок опасно: если внутри что-то упадёт, транзакция останется незакрытой. Поэтому в реальном коде транзакцию всегда сочетают с Попытка...Исключение.
Конструкция Попытка...Исключение
Она работает так: код в блоке Попытка выполняется как обычно, но если внутри возникает ошибка, выполнение мгновенно перескакивает в блок Исключение — программа не падает. Там мы можем разобраться с ошибкой: откатить транзакцию, записать в лог, показать понятное сообщение. Функция ИнформацияОбОшибке() внутри Исключение даёт объект с деталями, а ОписаниеОшибки() — готовый текст ошибки:
НачатьТранзакцию();
Попытка
Заказ = Документы.ЗаказПокупателя.СоздатьДокумент();
Заказ.Дата = ТекущаяДата();
Заказ.Контрагент = Справочники.Контрагенты.НайтиПоНаименованию("ООО Ромашка");
Заказ.Записать(РежимЗаписиДокумента.Проведение);
// Если дошли сюда — всё прошло успешно, фиксируем
ЗафиксироватьТранзакцию();
Сообщить("Заказ проведён");
Исключение
// Любая ошибка выше приводит нас сюда
ОтменитьТранзакцию();
ИнфОбОшибке = ИнформацияОбОшибке();
Сообщить("Не удалось провести заказ: " + ИнфОбОшибке.Описание);
КонецПопытки;
Разберём логику по шагам. Мы открыли транзакцию до Попытка. Внутри создаём и проводим документ. Если всё прошло гладко, управление доходит до ЗафиксироватьТранзакцию() — изменения сохраняются. Если же на записи возникла ошибка (скажем, сработала проверка обязательного поля), выполнение прыгает в Исключение, где мы первым делом зовём ОтменитьТранзакцию() — база возвращается в исходное состояние, как будто заказа и не было. Затем через ИнформацияОбОшибке() получаем причину и выводим её пользователю.
Золотое правило:
ЗафиксироватьТранзакцию()— в самом конце блокаПопытка, аОтменитьТранзакцию()— первой строкой блокаИсключение. Тогда транзакция закрывается при любом исходе.
У объекта ИнформацияОбОшибке() есть удобное свойство .Описание с текстом. В блоке Исключение можно также вызвать ВызватьИсключение без аргументов, чтобы «пробросить» ошибку выше по стеку после того, как вы откатили транзакцию — это применяют, когда обработать ошибку до конца на этом уровне нельзя.
Запись в цикле: почему без транзакции — плохо
Вернёмся к загрузке 500 контрагентов. Наивный код записывает каждого отдельно:
// ПЛОХО: каждая запись — отдельная неявная транзакция
Для Каждого СтрокаФайла Из ДанныеФайла Цикл
Контрагент = Справочники.Контрагенты.СоздатьЭлемент();
Контрагент.Наименование = СтрокаФайла.Наименование;
Контрагент.ИНН = СтрокаФайла.ИНН;
Контрагент.Записать(); // 500 отдельных транзакций
КонецЦикла;
Проблем здесь две. Во-первых, производительность: каждый .Записать() — это отдельная неявная транзакция, а фиксация транзакции требует записи на диск. 500 записей = 500 обращений к диску, это медленно. Во-вторых, целостность: если на 347-й записи произойдёт ошибка без обработки, первые 346 останутся в базе, а загрузка прервётся на полпути. Правильнее обернуть весь цикл в одну транзакцию:
// ХОРОШО: весь цикл — одна транзакция
НачатьТранзакцию();
Попытка
Для Каждого СтрокаФайла Из ДанныеФайла Цикл
Контрагент = Справочники.Контрагенты.СоздатьЭлемент();
Контрагент.Наименование = СтрокаФайла.Наименование;
Контрагент.ИНН = СтрокаФайла.ИНН;
Контрагент.Записать();
КонецЦикла;
ЗафиксироватьТранзакцию();
Сообщить("Загружено записей: " + ДанныеФайла.Количество());
Исключение
ОтменитьТранзакцию();
Сообщить("Загрузка прервана, изменения отменены: " + ОписаниеОшибки());
КонецПопытки;
Результат при ошибке на середине:
Загрузка прервана, изменения отменены: Поле объекта не обнаружено (ИНН)
Теперь либо загрузятся все контрагенты, либо ни одного — база не окажется наполовину заполненной. И записывается всё одним пакетом на диск, что в разы быстрее. Обратите внимание: одна транзакция на весь цикл хороша для пакетов в сотни-тысячи записей. Для очень больших объёмов (десятки тысяч) транзакцию иногда фиксируют порциями, чтобы не держать блокировки слишком долго — но это уже тонкая оптимизация.
Как это работает
Когда вы зовёте НачатьТранзакцию(), СУБД начинает копить изменения в специальной области и до фиксации не показывает их другим пользователям. ЗафиксироватьТранзакцию() атомарно применяет весь пакет и сбрасывает его на диск. ОтменитьТранзакцию() просто отбрасывает накопленное. Пока транзакция открыта, платформа удерживает блокировки на затронутые данные, чтобы другой пользователь не изменил их «из-под рук». Именно поэтому долгие транзакции нежелательны: чем дольше открыта транзакция, тем дольше висят блокировки и тем больше рискуют «встать в очередь» другие пользователи.
Транзакции могут быть вложенными: если внутри уже открытой транзакции вызвать НачатьТранзакцию() ещё раз, платформа не создаёт вторую независимую транзакцию, а лишь увеличивает внутренний счётчик. Реальная фиксация в базе произойдёт только когда закроется самая внешняя транзакция. Это важно помнить: ваш ЗафиксироватьТранзакцию() во вложенном коде может ещё не означать окончательного сохранения.
Частые ошибки
- Открыли транзакцию и забыли закрыть при ошибке. Без
Попытка...Исключениеисключение внутри оставит транзакцию незакрытой — последующий код начнёт сыпать ошибками. Всегда закрывайте транзакцию в обеих ветках. - Ставят
ОтменитьТранзакцию()не первой строкой вИсключение. Если перед откатом выполнить другой код, работающий с базой, он может упасть, потому что транзакция уже в ошибочном состоянии. Откат — первым делом. - Пишут в цикле по одной записи без общей транзакции. Это и медленно (много обращений к диску), и небезопасно (частичная загрузка при сбое).
- Глушат ошибку пустым блоком
Исключение. ПустойИсключениебез отката и без сообщения прячет проблему: данные не записались, а пользователь думает, что всё хорошо. Как минимум откатите транзакцию и залогируйтеОписаниеОшибки(). - Держат транзакцию открытой во время долгих операций (запросы к внешним системам, диалоги с пользователем). Это надолго блокирует данные для всех остальных.
Итоги
- Транзакция объединяет изменения по принципу «всё или ничего»:
НачатьТранзакцию()→ изменения →ЗафиксироватьТранзакцию(), а при сбое —ОтменитьТранзакцию(). - Транзакцию всегда сочетают с
Попытка...Исключение: фиксация — в концеПопытка, откат — первой строкойИсключение. ИнформацияОбОшибке()иОписаниеОшибки()внутриИсключениедают причину сбоя для лога и сообщения.- Массовую запись в цикле оборачивайте в одну транзакцию — это быстрее и защищает от частичной загрузки.
- Долгие транзакции держат блокировки на данные, поэтому их нужно закрывать как можно быстрее.