Brian Noyes
ADO.NET позволяет вам легко оформить несколько обращений к БД в
виде одной транзакции. Однако программисты часто неохотно используют такой
подход, поскольку они полностью не понимают взаимодействие между транзакциями,
объявленными в коде ADO.NET и транзакциями, использующимися в хранимых
процедурах (ХП). Ситуация часто усугубляется исключениями, вызванными SQL
Server’ом при использовании вложенных транзакций с ХП, которые сами используют
транзакции. В этой статье мы проясним указанные моменты, и я покажу вам, как
использовать вложенные транзакции и правильно обрабатывать ошибки.
Транзакции на стороне SQL Server
Первым делом необходимо уяснить, каким образом транзакции работают
внутри БД. Транзакции позволяют вам быть уверенным, что изменения, сделанные в
БД в рамках транзакции, будут или все приняты или все отвергнуты. Есть несколько
уровней изоляции, которые определяют, могут ли данные, считанные во время работы
транзакции, быть изменены в то время, пока не закончила работу эта транзакция.
По умолчанию уровень установлен в Read Committed, что говорит о том, что данные
могут быть изменены в контексте незавершенной транзакции, но вы не получите
данных, которые только частично изменены из другой транзакции. Если же вы хотите
убедиться в том, что данные запроса не меняются другой транзакцией до тех пор,
пока не завершена ваша транзакция, вам необходимо установить уровень изоляции в
Repeatable Read или Serializable. Более подробно об этих уровнях можно прочитать
в SQL Books online, также известном как BOL. Каждый индивидуальный запрос,
исполняемый SQL Server’ом, автоматически оформляется как транзакция. Допустим, у
вас есть команда update, модифицирующая 100 записей в таблице. Если что-то пошло
не так на записи номер 99, то все изменения откатываются и, после возникновения
ошибки, никаких в таблице модификаций вы не увидите. Если же вы используете ХП
для доступа к вашей БД, эти ХП также должны оформлять SQL-команды в транзакции
уровня этой ХП. Например, рассмотрим простую ХП:
CREATE PROCEDURE AddOrder @CustomerName
nvarchar(50), @StatusID int AS SET TRANSACTION ISOLATION
LEVEL REPEATABLE READ BEGIN TRANSACTION IF NOT EXISTS
(SELECT StatusID FROM OrderStatus WHERE
StatusID = @StatusID) BEGIN ROLLBACK TRANSACTION
RAISERROR('Вы должны передать существующий Status
ID',11,1) RETURN END
INSERT INTO Orders
(CustomerName, StatusID) VALUES (@CustomerName, @StatusID)
COMMIT TRANSACTION RETURN |
Эта ХП использует свою собственную транзакцию для команд SELECT и INSERT.
SELECT убеждается в том, что передан существующий StatusID (например для того,
чтобы убедиться, что не нарушена ссылочная целостность), а INSERT добавляет
запись к таблице Orders. Если у вас несколько запросов в ХП выполнены виде
транзакции, возможно, вы захотите управлять транзакциями на этом уровне. Откат
транзакции в любом месте перед командой COMMIT TRANSACTION предупредит запись
изменений в БД.
Вы можете подумать, что необязательно включать оператор SELECT в транзакцию,
поскольку он не меняет БД. Во многих ситуациях вы будете не правы. Выполнение
подобных запросов похоже на программирование многопоточных приложений. Вы должны
предполагать, что между выполнением команд SELECT и INSERT кто-то может
модифицировать таблицу OrderStatus и удалить значение, которое вы только что
проверяли на существование, в результате команда INSERT не выполнится. Вам часто
придётся убеждаться в том, что все запросы, которые вы выполняете, выполняются с
одним и тем же состоянием БД и лучший метод убедиться в этом – оформить такие
запросы в виде транзакции и при необходимости установить уровень транзакции в
Repeatable Read или Serializable.
Если оператор INSERT приводит к ошибке, то транзакция автоматически
откатывается. Поэтому нет необходимости явно отслеживать такие ошибки и
откатывать такие транзакции после каждой команды. Однако могут быть ситуации,
когда вы ожидаете результат исполнения такой процедуры для дальнейших действий
и, если они отсутствуют, вам необходимо явно откатывать транзакцию.
Транзакции в ADO.NET
А что если вам надо выполнить несколько ХП из кода на C# согласно
тому же принципу ‘всё или ничего’? Как раз для этих целей ADO.NET содержит класс
SqlTransaction. Технология достаточно проста - вызываете BeginTransaction для
объекта SqlConnection, чтобы получить новый объект транзакции, если всё Ок -
вызываем SqlTransaction.Commit, если не всё так хорошо, как хотелось -
SqlTransaction.Rollback.
// создаём соединение
(conn) // объявляем транзакцию SqlTransaction trans; //
создаём команды try{ conn.Open(); // открываем соединение
trans = conn.BeginTransaction(); // ассоциируем транзакции с
командами // исполняем запросы // если всё ОК - подтверждаем
trans.Commit(); } catch (Exception ex) { // если нет -
откатываем if (trans != null) trans.Rollback(); } finally
{ conn.Close(); } |
В общем, это просто, не так ли? Хитрость заключается в том, чтобы
правильно обрабатывать исключения в блоке catch и понимать те исключения,
которые вызываются. Если вы вызываете ХП, которые не управляют своими
транзакциями, не должно быть никаких проблем. Если ошибка возникает где-то во
время выполнения вашего кода, то будет вызвано исключение SqlException, которое
попадёт в блок catch. Код в этом блоке вызовет Rollback и никакие обновления с
начала транзакции не попадут в вашу БД.
Не пугайтесь своих вложенных инстинктов
А что же происходит, если ХП, которые вы вызываете, сами управляют
своими транзакциями? Не означают ли эти вызовы COMMIT TRANSACTION внутри ХП
того, что изменения будут внесены в БД не смотря на откат в коде на C#? Ответ –
нет, и причина такого ответа в том, как выполняются вложенные транзакции на SQL
Server’e. Когда вы стартуете транзакцию в своём коде, на самом деле вы стартуете
её на сервере, все последующие транзакции являются для неё вложенными, до тех
пор, пока кем-то не будет дана команда Commit или Rollback. Этим кем-то может
быть ваш код на C#, код в ХП или сам SQL Server, если на нём произойдёт ошибка.
Если ХП сама пытается стартовать транзакцию с помощью BEGIN TRANSACTION, то
она просто попадает в область видимости существующей транзакции. Поэтому любой
откат, произошедший внутри ХП, откатит и внешнюю транзакцию, стартовавшую в
вашем коде, чего на самом деле нам и хотелось. Единственно, о чём надо
упомянуть, так это о том, что в случае отката транзакции вызывается SqlException
с таким описанием:
"Transaction count after EXECUTE indicates
that a COMMIT or ROLLBACK TRANSACTION statement is missing. Previous
count = 1, current count = 0." |
Это исключение вызывается SQL Server’ом, который видит, что при
входе в ХП была транзакция, однако при выходе из ХП её не осталось, поскольку
был откат. Поскольку такая ситуация обрабатывается как исключительная, то вы её
отловите. Никакого вреда повторный Rollback не нанесёт, поэтому просто вызывайте
Rollback в обработчике исключений; и не надо его отлавливать по какому-то
условию, основывающемуся на информации об исключении.
Что ещё можно сделать, так это убрать информацию ошибки, содержащую
количество транзакций, особенно если вы собираетесь где-нибудь вести лог ошибок.
По всей вероятности такая информация вызовет у просматривающего недоумение,
особенно если он не понимает механизм действия вложенных транзакций. Путём
итерации через коллекцию Errors можно получить все ошибки, номер ошибки SQL для
счетчика транзакций равен 266, поэтому такую ошибку можно выделить и обработать
отдельно – например, просто ни в какой лог её не писать.
В коде для скачивания находится небольшой проект, с которым можно поиграться
и в котором продемонстрированы основные моменты работы с БД, вышеизложенные в
данной статье.
Код к этой статье можно скачать здесь - скачать.
|
|