Pull to refresh

TransactionScope — заманчивый, но коварный

Reading time4 min
Views31K
Давным-давно вышел ADO.NET 2.0, а вместе с ним и сборка System.Transactions, содержащая класс TransactionScope — путеводитель в мир легкого и непринужденного использования транзакций. В сегодняшней статье я рассмотрю некоторые нюансы, возникающие при использовании этой дырявой, но такой симпатичной абстракции.



Итак, начиная с ADO.NET 2.0, для того чтобы заключить свой код в транзакцию, разработчику достаточно расположить его внутри блока TransactionScope:

using (var transactionScope = new TransactionScope(TransactionScopeOption.Suppress, new TransactionOptions() { IsolationLevel = IsolationLevel.Serializable })
{
   //код внутри транзакции
   transactionScope.Complete();
}


Я использовал в конструкторе наиболее важные параметры — давайте их рассмотрим (в обратном порядке).

IsolationLevel


Старый-добрый IsolationLevel. Enum IsolationLevel включает целых 7 уровней изоляции, но не обольщайтесь — эти значения трактуются лишь как рекомендации ADO.NET провайдеру, а использовать можно лишь те уровни, который поддерживаются вашей СУБД.

По умолчанию используется самый высокий уровень изоляции — Serializable, и мне даже попадалась критика на этот счет: мол, не по-пацански по стандарту это (в стандарте в качестве дефолтного рекомендуется использовать Read Committed). Мне же это решение наоборот по душе: по умолчанию используется самый надежный режим, а при необходимости улучшить производительность или побороть deadlock'и — всегда можно перейти на более мягкий режим.

Кстати, менять Isolation Level в ходе транзакции нельзя.

TransactionScopeOption


Enum TransactionScopeOption содержит три значения: Requires, RequiresNew, Suppress, которые определяют поведение при входе в блок TransactionScope. Поведение при всех возможных случаях TransactionScopeOption отлично описано в msdn, а я лишь обобщу:
  1. Requires (значение по умолчанию) требует транзакции. При входе в блок будет либо использована транзакция родительского TransactionScope (если он есть), либо создана новая транзакция.
  2. RequiresNew всегда требует создания новой транзакции
  3. Suppress выполняет код блока вне транзакции


Обратите внимание на то, что в режимах RequiresNew и Suppress любой TransactionScope является рутовым, тогда как в режиме Requires можно использовать вложенные (nested) TransactionScope. Вложенные TransactionScope визуально очень похожи на классические вложенные транзакции (любителям MySQL известные как Savepoints). Но это ложная аналогия, и следующий пример пояснит, почему:

public void Method1()
{
   using (var transactionScope1 = new TransactionScope(TransactionScopeOption.Requires))
   {
         Method2();
         transactionScope1.Complete();
   }
}

public void Method2()
{
   using (var transactionScope2 = new TransactionScope(TransactionScopeOption.Requires))
   {
      //some code
   }
}


Обратите внимание на то, что в Method2 мы не вызвали transactionScope2.Complete, а значит transactionScope2 откатится. В случае классических вложенных транзакций мы можем откатить внутреннюю транзакцию без отката рутовой. Здесь же оба TransactionScope работают в рамках одной транзакции, а значит если хотя бы один из внутренних transactionScope не вызовет Complete, транзакция будет помечена для отката, а уже при выходе из рутового TransactionScope произойдет rollback (commit/rollback транзакции всегда происходит при выходе из рутового TransactionScope). Причем если в рутовом TransactionScope вы попытаетесь вызвать Complete (как в Method1), а транзакция уже была помечена для отката, будет выкинут TransactionAbortedException.

Касательно вложенных TransactionScope есть одна неприятная особенность: на момент написания кода мы не знаем, будет ли Complete текущего TransactionScope означать commit транзакции. Допустим у нас есть следующий метод:

public void TransactionMethod(TransactionScopeOption.Requires)
{
   using (var transactionScope = new TransactionScope(TransactionScopeOptions.Requires))
   {
      ...
      transactionScope.Complete();
   }
   
   //логика, завязанная на то, что транзакция уже закоммичена
}


, а также метод, который его вызывает:

public void CallingMethod1()
{
   //...
   TransactionMethod();
   //...
}


И все бы ничего, но со временем появляется более высокоуровневый сервис, который вызывает TransactionMethod уже из своего внутреннего TransactionScope:

public void CallingMethod1()
{
   //...
   using (var transactionScope = new TransactionScope(TransactionScopeOptions.Requires))
   {
      //...
      TransactionMethod();
      //...
      transactionScope.Complete();
   }
   //...
}


И тут вызов transactionScope.Complete() внутри TransactionMethod уже не ведет к коммиту транзакции, а значит и нижележащая логика, завязанная на то, что коммит транзакции уже произошел, даст сбой.

Хотя, справедливости ради, стоит отметить, что описанная ситуация довольно специфическая, и, как правило, разработчику все равно, произойдет ли коммит при выходе из текущего transactionScope или одного из вышележащих.

Теперь настало время уделить внимание двум другим значением TransactionScopeOption: RequiresNew и Suppress. Мне крайне редко приходилось использовать эти режимы. Более того, если не ошибаюсь, делал я это лишь один раз, и как раз при решении проблемы, описанной в предыдущей статье.

Вопрос использования или неиспользования RequiresNew и Suppress, безусловно, определяется требованиями алгоритма, но у меня на этот счет есть некоторые предубеждения. Дело в том, что TransactionScope в режимах RequiresNew и Suppress при наличии модифицирующих состояние базы данных операций делает невозможным использование старого трюка, когда код интеграционного теста заключается в транзакцию, которая по окончании теста откатывается, тем самым восстанавливая состояние базы данных:

[Test]
public void void IntegrationTest()
{
   using (new TransactionScope())
   {
      //код теста
   
      //не вызываем Complete
   }
}


Если в тестируемом коде создаются TransactionScope в режиме Requires, то они подцепятся к тестовому TransactionScope, а значит мы сможем откатить все изменения. Если же в коде есть TransactionScope в режиме RequiresNew или Suppress, то откатить результат их работы из тестового TransactionScope мы не сможем. Стоит отметить, что наличие логики, завязанной на момент коммита транзакции (как в предыдущем примере), тоже делает невозможным использование этого приема.

Напоследок отмечу, что TransactionScope локален по отношению к потоку (потому что его реализация базируется на ThreadStatic-переменной). Если же вам необходимо использовать одну транзакцию из нескольких потоков, — воспользуйтесь классом DependentTransaction.

Вот, пожалуй, и все. TransactionScope прекрасен, но коварен — не забывайте об этом :)
Tags:
Hubs:
+26
Comments9

Articles

Change theme settings